堆内存管理:新生代、老年代、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)
那为什么老年代不用复制?因为老年代对象多,复制一遍太伤了。而新生代对象少,复制成本低。这就是工程学上的权衡。
G1垃圾收集器就比较聪明,它把堆分成很多小Region,每个Region可以动态扮演Eden、Survivor或老年代。GC时优先回收垃圾最多的Region(G1名字的来源)。它本质上用的是复制算法(把存活对象复制到空闲Region),但为了处理老年代对象,也会部分使用标记整理。
这个图把堆分区和GC算法串起来了。看左下角,三个算法各有归属。复制算法专供新生代,标记清除/标记整理用于老年代。嗯,GC调优其实就是在这三者之间找平衡。
实战中我注意什么
- 对象分配速度 vs GC速度:如果分配速度远大于GC吞吐量,堆迟早爆。我曾经监控到每秒分配上百MB字符串(日志打印过多),Minor GC频率飙到每秒几次。最后加了一行
System.setProperty("jdk.xml.entityExpansionLimit", "0");?不对,其实是把日志级别调高,减少无效输出。 - 晋升阈值调整:默认15,如果老年代增长太快,可以降低阈值
-XX:MaxTenuringThreshold=5,让对象早晋升;或者提高阈值,让它们在新生代多熬几次。 - 大对象直接进老年代:
-XX:PretenureSizeThreshold=1m,超过1MB直接分配到老年代。我遇到过一个大对象反复在新生代复制,浪费CPU。设置后效果好多了。
-Xmn调整。
最后总结一句:堆内存管理没有银弹。分代模型、三种GC算法,都是针对不同场景设计的。你做调优时,先看懂GC日志,再分析对象分布,最后选择算法和参数。别上来就抄别人的配置——我吃过这个亏。