3、堆内存分配机制:对象优先在Eden分配、大对象直接进入老年代、长期存活对象进入老年代
聊到堆内存分配,很多人第一反应就是「new出来的对象放堆里」。这话没错,但太笼统了。堆内部其实分了好几个区域,对象到底放哪,JVM有一套自己的规则。我刚开始学的时候也觉得这玩意儿记不住,后来调了几个线上问题,才真正理解这些规则背后的设计意图。
说白了,JVM的堆内存分配策略,核心就三条:
- 对象优先在Eden区分配
- 大对象直接进入老年代
- 长期存活的对象进入老年代
咱们一条一条拆开讲。
3.1 对象优先在Eden分配
大多数情况下,新创建的对象都会分配到新生代的Eden区。为什么是Eden?因为大部分对象都是「朝生夕死」的,活不过几轮GC。把它们集中放在Eden区,Minor GC的时候一次性清理掉,效率最高。
我给你们看个例子:
// JVM参数:-Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8
// 意思是堆大小20M,新生代10M,Eden:Survivor = 8:1
// 所以Eden约8M,每个Survivor约1M
public class EdenAllocation {
private static final int _1MB = 1024 * 1024;
public static void main(String[] args) {
byte[] a1, a2, a3, a4;
a1 = new byte[2 * _1MB];
a2 = new byte[2 * _1MB];
a3 = new byte[2 * _1MB];
// 这里a4分配时,Eden已经用了6M,不够了
a4 = new byte[2 * _1MB]; // 触发Minor GC
}
}
运行这段代码,你会发现a4分配的时候触发了Minor GC。为什么?因为Eden区只剩不到2M了,放不下a4。JVM会先尝试做一次Minor GC,把a1、a2、a3里那些没引用的对象清掉。如果清完还不够,那就得走分配担保机制了。
关键点:Minor GC触发条件是Eden区空间不足,不是整个新生代不足。很多新手以为Survivor区满了也会触发,其实不会。
3.2 大对象直接进入老年代
什么叫大对象?就是需要大量连续内存空间的Java对象。比如很长的数组、很大的字符串。这类对象有个特点:在Eden区分配时容易导致提前触发GC,而且复制到Survivor区时成本很高。
JVM提供了一个参数:-XX:PretenureSizeThreshold。设置了这个值之后,任何超过这个大小的对象,直接分配到老年代,不走新生代。
举个例子:
// JVM参数:-Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8
// 加上 -XX:PretenureSizeThreshold=3M
// 意思是超过3M的对象直接进老年代
public class BigObjectAllocation {
private static final int _1MB = 1024 * 1024;
public static void main(String[] args) {
byte[] big = new byte[4 * _1MB]; // 4M > 3M,直接进老年代
}
}
我在项目中遇到过一个问题:有个业务系统频繁Full GC,查了半天发现是某个定时任务每次生成一个10M的缓存数组。这个数组在新生代里反复复制,从Eden到Survivor,再从Survivor到另一个Survivor,折腾了好几次才进老年代。后来我加上了PretenureSizeThreshold,大对象直接进老年代,Minor GC的压力瞬间降下来了。
注意:PretenureSizeThreshold只对Serial和ParNew两款收集器有效。如果你用的是Parallel Scavenge或者G1,这个参数就不起作用了。G1有自己的大对象分配策略,叫Humongous区域。
3.3 长期存活的对象进入老年代
JVM给每个对象定义了一个「年龄计数器」。对象在Eden区出生,经过一次Minor GC后存活下来,就被移到Survivor区,年龄加1。以后每熬过一次Minor GC,年龄就加1。当年龄达到某个阈值(默认15),就晋升到老年代。
这个阈值可以通过参数调整:
-XX:MaxTenuringThreshold=15 // 默认值,最大可以设到15
为什么要设这个阈值?说白了就是给对象一个「考察期」。有些对象虽然一开始看起来挺能活,但可能过几轮GC就死了。如果太早晋升到老年代,老年代空间被占满,就会触发Full GC,代价很大。
我给你们画个流程图(用文字描述):
- 对象在Eden区出生
- 第一次Minor GC存活 → 移到Survivor区,年龄=1
- 第二次Minor GC存活 → 仍在Survivor区,年龄=2
- ...以此类推
- 年龄达到MaxTenuringThreshold → 晋升到老年代
不过这里有个细节:JVM并不是严格等到年龄到了才晋升。它还会动态计算Survivor区的空间使用情况。如果Survivor区里同龄对象的总大小超过了Survivor空间的一半,那这些对象就会提前晋升。这个机制叫「动态年龄判定」。
经验之谈:我曾经调过一个应用,它的MaxTenuringThreshold设成了1。你想想看,这意味着对象只要熬过一次Minor GC就直接进老年代。结果老年代涨得飞快,每半小时就来一次Full GC。后来我改回默认值15,配合调整新生代大小,Full GC频率降到了每天一次。所以这个阈值不是越小越好,得根据对象的存活特征来调。
3.4 三种策略的配合关系
这三种策略不是孤立的,它们共同决定了对象在堆里的「人生轨迹」。我整理了一张表:
| 策略 | 触发条件 | 目标区域 | 主要目的 |
|---|---|---|---|
| 优先Eden分配 | 新对象创建 | Eden区 | 利用局部性,提高Minor GC效率 |
| 大对象直接进老年代 | 对象大小 > PretenureSizeThreshold | 老年代 | 避免在新生代复制大对象的开销 |
| 长期存活进老年代 | 年龄达到阈值或动态判定 | 老年代 | 让稳定对象进入老年代,减少复制 |
实际运行时,一个对象的分配路径可能是这样的:
- 如果对象很大 → 直接进老年代
- 如果对象不大 → 先放Eden区
- Eden区不够 → 触发Minor GC
- Minor GC后存活的对象 → 移到Survivor区,年龄+1
- 年龄到了 → 晋升到老年代
嗯,这里要注意一点:如果Minor GC之后Eden区还是不够放新对象,那就得走「分配担保」机制了。简单说就是把老年代的空间借来用用,但这是有风险的,搞不好就会触发Full GC。
我个人习惯在调优时先观察对象的分配模式。用-XX:+PrintGCDetails和-XX:+PrintTenuringDistribution这两个参数,可以清楚地看到对象在各个年龄段的分布情况。如果发现某个年龄段的对象特别多,那就说明阈值可能需要调整。
最后总结一句:堆内存分配策略的设计,本质上是在「空间利用率」和「GC效率」之间找平衡。理解了这一点,你再看那些参数设置,就不会觉得是死记硬背了。