JVM 内存区域与对象创建:一次 GC 从哪来

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 的路径大致是:

  1. 类加载检查:常量池里能否定位到类的符号引用,且类已完成加载、链接、初始化。
  2. 分配内存:在堆里划出一块确定大小的内存。
  3. 零值初始化:把这块内存清零(这就是为什么字段有默认值)。
  4. 设置对象头:Mark Word、类型指针(Klass Pointer)。
  5. 执行 <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 是为了让日志更直观:单线程、分代清晰。观察日志时重点看三件事:

  1. Young GC 的频率:由 Eden 大小和分配速率共同决定。
  2. 晋升量:每轮有多少对象被复制进老年代。
  3. 是否出现 Full GC:一旦出现,看是 Metadata GC Threshold、Ergonomics 还是 Promotion Failed。

需要强调:上面参数只是为了让行为可观察,不是"最佳实践"。真实系统里 Eden、Survivor 比例、收集器选型都要结合分配速率、对象生命周期分布来定,不能照抄。

五、什么时候别用 / 别踩的坑

几条反直觉清单,按"最容易想当然"排序:

  1. 别把 System.gc() 当"手动清理" 。它只是建议,HotSpot 里是否真的执行取决于 -XX:+DisableExplicitGC(默认 false,即允许)。生产环境常把它设为 true,避免第三方库误调触发 Full GC。
  2. 别迷信 -XX:MaxTenuringThreshold 设大就能让对象多在新生代待。动态年龄判定会绕过它,Survivor 空间不足也会绕过它。
  3. 别以为调大堆就一定能减少 GC。堆越大,单次 GC 的停顿可能越长,且老年代回收成本上升。分代收集器下,堆大小和停顿时间是权衡关系。
  4. 别把 TLAB 当成"线程私有堆"。TLAB 只是分配缓冲区,里面的对象仍然在 Eden 里,仍然参与 GC。
  5. 别用"对象一定分配在堆上"当铁律。逃逸分析生效时,标量替换可能让对象根本不落地;但这是 JIT 优化,不可依赖。
  6. 别忽略元空间 。JDK 8 之后方法区由 Metaspace 实现,位于本地内存,-XX:MaxMetaspaceSize 不设时理论上可无限增长(受物理内存限制),动态生成类多的场景要警惕。

六、小结与系列预告

把链路收束一下:

  • 规范定义的是运行时数据区,HotSpot 的堆分代是实现选择
  • new 的快路径是 TLAB 指针碰撞,慢路径才可能触发 GC;
  • GC 的触发条件是分配失败后的决策,不是独立定时事件;
  • 晋升老年代有多条路径,动态年龄判定和空间担保是常被忽略的两条;
  • 观察胜过猜测,用统一日志(-Xlog:gc*)看真实行为。

系列后续会继续往 JVM 侧深入,聊一聊对象头的 Mark Word 与锁状态迁移,以及它和本系列前面讲过的 AQS、偏向锁撤销之间的真实联系。如果这篇对你有帮助,欢迎关注,后面会保持同样的源码深度。

参考来源:

相关推荐
事圆则缓1 小时前
Java 转 Kotlin 的 Android 迁移路线
java
凤山老林1 小时前
Spring Boot 应用 JVM 性能调优实战:GC 日志分析、堆内存规划与容器环境避坑
jvm·spring boot·gc·堆内存
计算机毕设定制辅导-无忧学长1 小时前
《基于Vue的流浪动物救助中心管理系统的设计与实现》
java·vue.js·spring boot·流浪动物救助中心管理系统
Mikko71 小时前
jackson-databind 升到 2.21.6 就安全了吗?jackson-core 是另一个坐标,它那条 high 全局库至今没收
java·后端·安全·json
Java_AI工程师1 小时前
90%的人写Function Calling,只写了"把参数传给工具执行"这一步,剩下的参数校验、错误重试、超时控制、结果格式化,全是空白。
java·人工智能·程序员
斯维赤1 小时前
LangChain4j 入门教学(Java 后端狂喜版
java·后端
Java_2017_csdn1 小时前
Java 8 Stream API 中的 map 和 flatMap 详解
java
AI深栈2 小时前
第 15 章 · 第一个 AI 工作流:Hello Graph 从 START 跑到 END
java·人工智能
搜狐技术产品小编20232 小时前
解锁Kotlin Serialization高阶玩法:详解4种自定义序列化器与动态上下文策略
java·人工智能