
JVM 内存区域与对象创建:一次 GC 从哪来
一个常被忽略的事实:对象分配本身并不"触发"GC,真正触发 GC 的是分配失败后那条"要不要扩容/要不要回收"的决策路径。换句话说,GC 不是定时器敲响的钟,而是分配器走到死胡同时被迫回头做的选择。理解这句话,需要把 JVM 内存区域、TLAB 分配、以及"什么时候才会走到那条慢路径"串成一条线。本系列前面讲过线程池、AQS、CompletableFuture 这些并发侧的东西,这一篇换到 JVM 侧,把"一次 GC 从哪来"这条链路拆开。
关键词标签:JVM 内存模型 TLAB 对象分配 GC 触发条件 HotSpot 源码
一、先把"内存区域"和"分配路径"分开看
JVM 规范里定义的是运行时数据区(Run-Time Data Areas),而 HotSpot 的实现又在此基础上做了很多具体化。二者不能混为一谈。
| 规范中的区域 | 线程私有 | HotSpot 实现要点 |
|---|---|---|
| PC 寄存器 | 是 | 每个线程一份,指向当前字节码指令 |
| Java 虚拟机栈 | 是 | 栈帧存局部变量表、操作数栈等 |
| 本地方法栈 | 是 | 服务于 native 方法 |
| 堆 | 否 | 对象主要分配区,分代实现 |
| 方法区 | 否 | JDK 8 起由元空间(Metaspace)实现,位于本地内存 |
| 运行时常量池 | 否 | 逻辑上属于方法区 |
关键点:"堆"是规范概念,"新生代/老年代/Eden/Survivor"是 HotSpot 的分代实现。规范并不要求分代,只是分代在实践中对"大多数对象朝生夕死"这一经验规律更友好。
另一个常见误区是把"栈上分配"当成默认行为。HotSpot 的逃逸分析(Escape Analysis)确实能带来标量替换 和栈上分配 的优化,但它是 JIT 编译期的优化,且默认行为随版本和编译阈值变化,不能作为设计前提。真正稳定、默认生效的分配快路径是 TLAB。
二、一次 new 到底走了哪几步
假设有这样一个 order-service 里的普通对象(仅为讲解示例):
public class Order {
private long orderId;
private long userId;
private int amount;
private OrderStatus status; // 引用类型
}
当字节码执行到 new 时,HotSpot 的路径大致是:
- 类加载检查:常量池里能否定位到类的符号引用,且类已完成加载、链接、初始化。
- 分配内存:在堆里划出一块确定大小的内存。
- 零值初始化:把这块内存清零(这就是为什么字段有默认值)。
- 设置对象头:Mark Word、类型指针(Klass Pointer)。
- 执行
<init>:调用构造方法,做显式初始化。
第 2 步是本文的重点。分配内存有两种方式:
- 指针碰撞(Bump the Pointer):如果堆是规整的,分配就是把指针往空闲方向挪动对象大小。
- 空闲列表(Free List):如果堆碎片化,需要从空闲列表里找一块够大的。
HotSpot 用哪种,取决于垃圾收集器是否具备压缩能力。Serial、ParNew、Parallel Scavenge 这类带压缩/复制的收集器,通常用指针碰撞;CMS 这类基于标记-清除的收集器,堆不规整,就用空闲列表。这是实现细节层面的差异,不是规范要求。
并发分配与 TLAB
多线程同时 new,如果都在共享的 Eden 上挪指针,必然要同步。HotSpot 的做法是给每个线程划一块私有缓冲区------TLAB(Thread Local Allocation Buffer),分配先在 TLAB 里做,避免全局锁。
相关参数是真实存在的:
-XX:+UseTLAB:启用 TLAB(默认开启)。-XX:TLABSize:指定 TLAB 初始大小。-XX:TLABWasteTargetPercent:TLAB 允许的浪费比例,默认 1。
分配逻辑可以简化为(示意,非源码原文):
// 伪代码,用于说明 TLAB 分配的快路径
Object allocate(Thread thread, int size) {
TLAB tlab = thread.tlab();
if (tlab.remaining() >= size) {
// 快路径:指针碰撞,无锁
return tlab.bump(size);
}
// 慢路径:TLAB 不够,走共享分配或换新 TLAB
return allocateSlow(thread, size);
}
只有当快路径失败时,才会进入慢路径 。慢路径里可能重新申请 TLAB、可能直接在 Eden 分配、也可能触发 GC。这就是"一次 GC 从哪来"的第一层答案:GC 是分配快路径失败的后果,而不是独立事件。
三、GC 触发的真实条件
不同收集器的触发条件不同,这里说几个真实存在的机制。
3.1 Minor GC 的触发
对分代收集器来说,Eden 空间不足是 Minor GC 最常见的触发条件。当新对象在 Eden 分配失败,且无法通过其他方式腾挪时,就会触发一次 Young GC。
但要注意:并不是 Eden 一满就立刻 GC。HotSpot 里有一系列判断,比如是否还有可用的 TLAB、是否可以扩容、是否达到某些阈值。具体行为随收集器实现变化,不宜一概而论。
3.2 对象何时进入老年代
这是另一个常被简化过头的地方。对象晋升老年代不止"熬过 N 次 GC"这一条路:
- 年龄达到阈值 :由
-XX:MaxTenuringThreshold控制,默认 15(实际生效值受对象头年龄位宽限制)。 - 动态年龄判定:Survivor 中相同年龄对象大小总和超过 Survivor 空间一半时,大于等于该年龄的对象直接晋升。
- 大对象直接进老年代 :由
-XX:PretenureSizeThreshold控制(注意:该参数对部分收集器不生效,比如 G1 中它被忽略)。 - Survivor 放不下:晋升时 Survivor 空间不足,对象直接进老年代。
最后一条尤其重要:"Survivor 放不下"会引发空间担保(Handle Promotion Failure),进而可能触发 Full GC。这是很多"莫名其妙 Full GC"的来源之一。
3.3 一个可复现的观察方式
与其猜,不如让 JVM 自己说。以下参数都是真实存在的:
# 打印 GC 详情,JDK 9+ 推荐统一日志
java -Xlog:gc*:file=gc.log:time,uptime,level,tags \
-XX:+HeapDumpOnOutOfMemoryError \
-jar order-service.jar
JDK 8 时代常用 -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log,JDK 9 起被统一日志框架(Unified Logging,JEP 158)取代。别在新版本上继续用老参数,它们要么被忽略,要么直接报错。
四、用一段代码把"分配---晋升---回收"串起来
下面这段代码真实可运行,用来观察对象分配与晋升行为(示意用途,不代表任何真实系统数据):
import java.util.ArrayList;
import java.util.List;
public class AllocationDemo {
// 每个对象约 16 字节对象头 + 字段,实际大小随压缩指针开关变化
static class Payload {
long a;
long b;
}
public static void main(String[] args) throws InterruptedException {
List<Payload> survivors = new ArrayList<>();
// 持续分配短命对象,制造 Eden 压力
for (int round = 0; round < 20; round++) {
for (int i = 0; i < 100_000; i++) {
Payload p = new Payload();
p.a = i;
}
// 每轮保留一小部分,制造晋升
survivors.add(new Payload());
Thread.sleep(50);
}
System.out.println("retained=" + survivors.size());
}
}
配合下面的启动参数(示意配置,非调优建议):
java -Xms64m -Xmx64m \
-Xmn32m \
-XX:SurvivorRatio=8 \
-XX:+UseSerialGC \
-Xlog:gc \
AllocationDemo
用 Serial GC 是为了让日志更直观:单线程、分代清晰。观察日志时重点看三件事:
- Young GC 的频率:由 Eden 大小和分配速率共同决定。
- 晋升量:每轮有多少对象被复制进老年代。
- 是否出现 Full GC:一旦出现,看是 Metadata GC Threshold、Ergonomics 还是 Promotion Failed。
需要强调:上面参数只是为了让行为可观察,不是"最佳实践"。真实系统里 Eden、Survivor 比例、收集器选型都要结合分配速率、对象生命周期分布来定,不能照抄。
五、什么时候别用 / 别踩的坑
几条反直觉清单,按"最容易想当然"排序:
- 别把
System.gc()当"手动清理" 。它只是建议,HotSpot 里是否真的执行取决于-XX:+DisableExplicitGC(默认 false,即允许)。生产环境常把它设为 true,避免第三方库误调触发 Full GC。 - 别迷信
-XX:MaxTenuringThreshold设大就能让对象多在新生代待。动态年龄判定会绕过它,Survivor 空间不足也会绕过它。 - 别以为调大堆就一定能减少 GC。堆越大,单次 GC 的停顿可能越长,且老年代回收成本上升。分代收集器下,堆大小和停顿时间是权衡关系。
- 别把 TLAB 当成"线程私有堆"。TLAB 只是分配缓冲区,里面的对象仍然在 Eden 里,仍然参与 GC。
- 别用"对象一定分配在堆上"当铁律。逃逸分析生效时,标量替换可能让对象根本不落地;但这是 JIT 优化,不可依赖。
- 别忽略元空间 。JDK 8 之后方法区由 Metaspace 实现,位于本地内存,
-XX:MaxMetaspaceSize不设时理论上可无限增长(受物理内存限制),动态生成类多的场景要警惕。
六、小结与系列预告
把链路收束一下:
- 规范定义的是运行时数据区,HotSpot 的堆分代是实现选择;
new的快路径是 TLAB 指针碰撞,慢路径才可能触发 GC;- GC 的触发条件是分配失败后的决策,不是独立定时事件;
- 晋升老年代有多条路径,动态年龄判定和空间担保是常被忽略的两条;
- 观察胜过猜测,用统一日志(
-Xlog:gc*)看真实行为。
系列后续会继续往 JVM 侧深入,聊一聊对象头的 Mark Word 与锁状态迁移,以及它和本系列前面讲过的 AQS、偏向锁撤销之间的真实联系。如果这篇对你有帮助,欢迎关注,后面会保持同样的源码深度。
参考来源:
- Oracle《Java Virtual Machine Specification》运行时数据区章节:https://docs.oracle.com/javase/specs/jvms/se21/html/jvms-2.html
- Oracle《Java HotSpot VM 选项与统一日志》官方文档:https://docs.oracle.com/en/java/javase/21/docs/specs/man/java.html
- OpenJDK JEP 158(Unified JVM Logging):https://openjdk.org/jeps/158