堆内存管理:新生代、老年代、GC算法

说到OOM,堆内存管理是绕不开的核心。说白了,Java堆就像一个大仓库,所有对象都在里面进出。但仓库得分区,不然乱七八糟的,GC都不知道从哪下手。我当年做电商系统时就吃过亏——堆内存配置不合理,一天到晚Full GC,用户下单卡成狗。后来研究了分代模型,才算真正明白怎么回事。

堆内存分代模型

为什么分代?你想想看,大部分对象都是“朝生暮死”——比如一次HTTP请求里new出来的JSON解析对象,请求结束就没人用了。只有少数对象会活很久,比如缓存、常量池。所以JVM把堆分成两代:新生代和老年代。

新生代又分三块:Eden、Survivor0(S0)、Survivor1(S1)。比例通常是8:1:1。新对象都出生在Eden里。Minor GC时,Eden和一块Survivor里存活的会被复制到另一块Survivor,然后清空Eden和第一块Survivor。存活对象每次熬过一次Minor GC,年龄+1,默认到15岁就晋升到老年代。

晋升机制:除了年龄,还有几种情况会提前晋升——对象太大了直接进老年代(-XX:PretenureSizeThreshold),或者Survivor区放不下了,也会“担保”到老年代。我遇到过一个业务,频繁创建大byte数组,结果全跑老年代去了,老年代爆满。

区域作用GC频率典型配置
Eden对象出生地非常高新生代80%
S0/S1存放经历一次GC后还活着的对象中等各占10%
老年代存放长期存活对象低(Full GC)根据应用调整
元空间(非堆)类元数据无直接占用本地内存

注意:这里说的元空间其实是Method Area的JDK8实现,不在堆里。但很多OOM其实是元空间撑爆了,比如CGLIB不停生成类,我碰到过这种情况,查日志发现是反射调用疯狂加载。嗯,跑题了,咱们继续说堆。

GC算法:三剑客

GC本质就是“找垃圾-收垃圾-整理内存”。主流算法就三种:标记清除、复制、标记整理。每种算法都有适用场景。我个人习惯画一张对比表,方便回忆。

算法对比速览

  • 标记清除:先标记存活对象,再清除未被标记的。缺点是会产生内存碎片。
  • 复制:把存活对象复制到另一半空闲区,然后全清。无碎片,但浪费空间。
  • 标记整理:标记存活对象,然后把它们往一端移动,清除边界以外的空间。无碎片,但耗时。

标记清除

原理很简单:mark → sweep。先遍历所有对象,标记出可达的;再遍历堆,把没标记的回收掉。

// 伪代码示意
mark(rootSet) → 标记所有存活对象
sweep(heap) → 清除未标记对象的内存,放入空闲链表

但是,清除后内存不连续。我见过一个应用,内存占用60%,但不断分配大对象失败,就是因为碎片太多,OOM了。解决方案:触发Full GC + 主动压缩,或者干脆换CMS、G1。

复制算法

这就是新生代用的方法。把堆分成两块(实际是Eden+S0/S1),只使用其中一块。GC时把存活对象复制到另一块,然后整块清空。

// 复制算法核心:从from区复制到to区,然后交换
while (from_region has live objects) {
    copy live to to_region;
}
clear from_region;
swap(from, to);

没有碎片!但是空间利用率低(只有一半?其实新生代用Eden+S0+S1,实际可用空间是Eden+S0,S1只作为“to”区,空闲时间约10%浪费)。这个设计非常巧妙,因为新生代对象死得快,复制成本极低。我调优时的经验:如果新生代存活率超过30%,复制成本就高了,可以考虑调整老年代大小或者改用G1。

标记整理

老年代不适合复制算法,因为老年代对象存活率高,复制代价巨大。所以老年代用标记整理。标记阶段和标记清除一样,但整理阶段会把存活对象向一端移动,然后清理另一端全部空间。

// 标记整理 三步走
1. 标记所有存活对象
2. 计算每个对象的新位置(compact)
3. 移动对象并更新引用

这个算法最稳,没有碎片,老年代利用率高。但代价也高——移动对象需要Stop The World,而且更新引用需要遍历所有线程栈。我调过一个金融系统,老年代有30GB,一次Full GC的标记整理花了10秒,用户直接超时。后来我们换了G1,虽然也是标记整理的思想,但它可以部分压缩,STW时间可控。

分代收集:组合拳

JVM不傻,它把新生代和老年代分开处理:

  • 新生代:复制算法,速度快,碎片少
  • 老年代:标记清理(CMS)或标记整理(Serial Old / Parallel Old / G1)

那为什么老年代不用复制?因为老年代对象多,复制一遍太伤了。而新生代对象少,复制成本低。这就是工程学上的权衡。

避坑指南:我曾经给一个项目配置了过大新生代,以为这样可以减少晋升。结果Minor GC反倒频繁了,因为新生代大了存活对象反而变多,复制耗时更长。后来我按“对象平均大小 * 每秒创建量 * GC间隔”来估算新生代大小,才算合理。

G1垃圾收集器就比较聪明,它把堆分成很多小Region,每个Region可以动态扮演Eden、Survivor或老年代。GC时优先回收垃圾最多的Region(G1名字的来源)。它本质上用的是复制算法(把存活对象复制到空闲Region),但为了处理老年代对象,也会部分使用标记整理。

堆内存结构与GC算法 Java堆 新生代 (Young Generation) Eden 对象出生地 S0 第一次 GC存活 S1 (空闲) 老年代 (Old) 长期存活对象 大对象直接进入 年龄满15晋升 GC算法一览 标记清除 有碎片 复制算法 无碎片·浪费空间 标记整理 无碎片·整理耗时 用于老年代(CMS) 用于新生代 用于老年代(Parallel)

这个图把堆分区和GC算法串起来了。看左下角,三个算法各有归属。复制算法专供新生代,标记清除/标记整理用于老年代。嗯,GC调优其实就是在这三者之间找平衡。

实战中我注意什么

  • 对象分配速度 vs GC速度:如果分配速度远大于GC吞吐量,堆迟早爆。我曾经监控到每秒分配上百MB字符串(日志打印过多),Minor GC频率飙到每秒几次。最后加了一行System.setProperty("jdk.xml.entityExpansionLimit", "0");?不对,其实是把日志级别调高,减少无效输出。
  • 晋升阈值调整:默认15,如果老年代增长太快,可以降低阈值-XX:MaxTenuringThreshold=5,让对象早晋升;或者提高阈值,让它们在新生代多熬几次。
  • 大对象直接进老年代:-XX:PretenureSizeThreshold=1m,超过1MB直接分配到老年代。我遇到过一个大对象反复在新生代复制,浪费CPU。设置后效果好多了。
⚠️ 一个让我印象深刻的坑:我曾经把新生代调得特别大(6GB),以为能减少Full GC,结果内存碎片导致老年代晋升阻塞,反而Full GC更频繁了。后来才意识到,新生代大小要结合对象生命周期来定,不是越大越好。建议一般设置为堆的1/3左右,调优时用-Xmn调整。

最后总结一句:堆内存管理没有银弹。分代模型、三种GC算法,都是针对不同场景设计的。你做调优时,先看懂GC日志,再分析对象分布,最后选择算法和参数。别上来就抄别人的配置——我吃过这个亏。