
前言我们在写 Java 时随手new一个对象几乎从不关心它到底存活多久、在内存里搬过几次家。很多人只知道堆被划成了新生代和老年代但一个对象从出生到最终进老年代中间到底经历了什么本文把对象在各区域的完整流转过程讲透。文章目录前言一、新对象刚出生为什么全挤在伊甸园区1.1 大多数对象其实活不过三秒1.2 伊甸园区满了会发生什么二、存活下来的对象为什么要在两个幸存区来回跳2.1 标记复制算法与双幸存区的配合2.2 年龄计数器与四位二进制限制三、对象进入老年代的三条硬核路线3.1 活过15次垃圾回收的常规晋升3.2 幸存区放不下的动态提前晋升3.3 大对象直接跨过新生代进入老年代四、写在最后一、新对象刚出生为什么全挤在伊甸园区写业务代码时绝大多数对象都是在方法内部创建的局部变量packagecom.crayontech.jvm;publicclassObjectAllocationDemo{publicvoidhandleOrder(){OrderContextcontextnewOrderContext();context.validate();}}执行这几行代码时new OrderContext()产生的对象实体绝大多数情况下都会直接进入新生代的 Eden伊甸园区。1.1 大多数对象其实活不过三秒JVM 之所以特意划分出一个 Eden 区是因为弱分代假说Weak Generational Hypothesis指出的客观规律绝大多数 Java 对象的生命周期都极其短暂朝生夕死。比如你在一个方法里循环查库、拼接 JSON、处理业务 DTO方法执行完毕退出栈帧这些对象立刻失去所有的引用链变成了垃圾。如果每次new对象都直接混放在一整块大内存里垃圾回收器扫描和整理内存的开销会难以承受。JVM 的设计者干脆把新生代划出单独一块区域专门用来接纳这些新生的短命对象。JVM 堆内存 (Heap)老年代 (Old Generation)新生代 (Young Generation)绝大多数新对象存活对象转移复制轮转晋升晋升Eden 区 (伊甸园区)新对象优先在此分配Survivor 0 (From)Survivor 1 (To)Old 区存放长期存活或大对象代码执行: new Object()1.2 伊甸园区满了会发生什么Eden 区的容量是有限的。随着业务线程持续不断地创建对象Eden 区很快就会被填满。当又有新对象申请分配内存而 Eden 区剩余空间不足时JVM 就会触发一次 Minor GC也叫 Young GC。这次 GC 的首要任务就是快速清理新生代找出 Eden 区中所有失去根引用的垃圾对象。将这些垃圾对象占用的内存空间就地释放。找出少量依然存活的对象准备把它们转移到幸存者区Survivor。这个过程速度极快因为新生代里存活的对象通常不足 10%GC 只需要关注并拷贝那 10% 的活对象剩下的 90% 垃圾直接覆盖清空。二、存活下来的对象为什么要在两个幸存区来回跳经历了第一次 Minor GC 依然存活的对象并不会立刻被送到老年代而是先被复制到了 Survivor 区。Survivor 区又被平分成了两块S0From Survivor和 S1To Survivor。2.1 标记复制算法与双幸存区的配合新生代采用的是标记-复制Mark-Copy算法。这种算法的最大优点是运行效率高、没有内存碎片但代价是必须有一块空闲内存用来接收存活对象。S0 和 S1 的空间大小始终完全一致而在任意时刻它们当中必定有一块是保持完全空白的第一次 Minor GCEden 区满了存活的对象被复制到 S0此时 S1 是空的。存活下来的对象年龄被记为 1。Eden 区被瞬间清空。第二次 Minor GC经过一段时间运行Eden 区又满了。此时 GC 会同时扫描Eden 区与 S0 区。将两处所有存活的对象统一复制到此前完全空置的 S1 区。原先在 S0 里的对象如果还活着用年龄累加变成 2从 Eden 刚搬过来的新对象年龄记为 1。随后 Eden 区和 S0 区被整体清空。后续轮转此时 S0 变成了空的S1 存放着存活对象。下一次 GC 时存活对象又会从 S1 复制回 S0。老年代S1 (To)S0 (From)Eden 区业务线程老年代S1 (To)S0 (From)Eden 区业务线程触发第 1 次 Minor GC (S1 当前为空)Eden 垃圾被清空触发第 2 次 Minor GC (S0 变 FromS1 变 To)Eden 与 S0 全部清空归零持续创建对象直到 Eden 满1存活对象拷贝到 S0 (Age1)2继续创建对象Eden 再次满3Eden 存活对象拷贝到 S1 (Age1)4S0 存活对象拷贝到 S1 (Age2)5两个幸存区就像两个倒换的抽屉一个负责装东西另一个必须腾空待命。只要不断在两者之间对调拷贝新生代永远不会产生任何内存碎片空间利用非常规整。2.2 年龄计数器与四位二进制限制对象每次成功熬过一轮 Minor GC并在 Survivor 区之间完成一次拷贝它内部记录的年龄就会增加 1。这个年龄存放在哪里它就记录在 Java 对象头Object Header的 Mark Word 里面。在 HotSpot 虚拟机中Mark Word 只给对象分代年龄分配了4 位4 bits的存储空间。64位 Mark Word (无锁状态)未使用 (25 bit)对象哈希码 (31 bit)分代年龄 (4 bit)0000 ~ 1111偏向锁位 (1 bit)锁标志位 (2 bit)4 位无符号二进制数的最大值是2 4 − 1 15 ( 1111 2 ) 2^4 - 1 15 \quad (1111_2)24−115(11112)这就是无论你怎么设置 JVM 启动参数-XX:MaxTenuringThreshold的最大值绝对不能超过 15 的根本原因。底层硬件和数据结构已经把上限卡死在 15 了。三、对象进入老年代的三条硬核路线对象一直在两个 Survivor 区跳来跳去不可能无限期跳下去。老年代就是为了存放长生命周期对象而设计的。一个对象进入老年代主要通过以下三种方式。3.1 活过15次垃圾回收的常规晋升这是最经典的晋升路线。如果一个对象在 S0 和 S1 之间来回倒腾了 15 次历经 15 轮 Minor GC 依然没有被回收说明它大概率是系统常驻的核心组件比如 Spring 容器中的单例 Bean、数据库连接池、全局静态配置缓存等。当它的年龄达到阈值默认 15后下一次 Minor GC 如果它依然存活垃圾回收器就不会再把它拷到另一个 Survivor 区而是直接送入老年代。packagecom.crayontech.jvm;// 这类生命周期极长的对象经过多次 GC 最终会常驻老年代publicclassGlobalConfigHolder{privatestaticfinalGlobalConfigHolderINSTANCEnewGlobalConfigHolder();privateGlobalConfigHolder(){}publicstaticGlobalConfigHoldergetInstance(){returnINSTANCE;}}3.2 幸存区放不下的动态提前晋升实际生产环境中很多对象根本不需要等满 15 岁就会被提前送进老年代。这涉及两个核心机制Survivor 空间放不下Survivor 区的容量通常只有 Eden 的八分之一左右。如果某次 Minor GC 之后Eden 和 From Survivor 存活下来的对象体积比较大目标 To Survivor 根本装不下。此时 JVM 的分配担保机制会启动装不下的这部分存活对象会被直接送到老年代。动态对象年龄判断Dynamic Age CalculationHotSpot 虚拟机有一条规则如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半可以通过-XX:TargetSurvivorRatio调整默认 50%那么年龄大于或等于该年龄的对象就可以直接进入老年代完全不用等 15 岁。这样做是为了防止 Survivor 区被过快撑爆提前为年轻代腾出周转空间。是否否是装不下 / 超过担保容量装得下未达到达到 15创建新对象对象是否巨大(是否超过 PretenureSizeThreshold)路线三大对象直接进老年代(绕过 Eden 与 Survivor)优先在 Eden 区分配Eden 区空间不足正常运行工作触发 Minor GC (Young GC)Survivor (To) 装得下存活对象吗路线二空间担保/动态年龄超标直接提前晋升老年代拷贝到 Survivor (To) 区年龄计数 Age 1Age 是否达到阈值(默认 15 岁)在 S0 与 S1 间继续轮转路线一满 15 岁常规晋升老年代老年代 (Old Gen)3.3 大对象直接跨过新生代进入老年代还有一种最霸道的路线对象刚被new出来连 Eden 区的门都还没进就直接落到了老年代。典型的大对象包括超长的数组、大字符串或者大文件字节缓冲区packagecom.crayontech.jvm;publicclassLargeObjectDemo{publicvoidallocateBuffer(){// 分配一个占用 8MB 的连续大字节数组byte[]largeBuffernewbyte[8*1024*1024];}}如果在 JVM 中配置了参数-XX:PretenureSizeThreshold只在 Serial 和 ParNew 收集器下生效只要创建的对象体积超过了这个设定值JVM 就会把它直接分配在老年代。这么设计的原因很直接大对象需要大块连续的内存空间。如果把大对象放在 Eden 区在 Survivor 之间来回拷贝时会带来巨大的内存复制性能开销更糟的是它很容易直接把小巧的 Survivor 区挤爆导致本不该进老年代的短命对象跟着提前晋升破坏分代垃圾回收的平衡。四、写在最后弄清楚对象在堆中的流转过程平时看 GC 日志和排查内存问题时思路就会清晰很多Eden 区是高频产生垃圾、高频清空的第一现场。双 Survivor 区用极高的规整度为存活对象提供了缓冲周转用时间换空间筛选出真正的长寿对象。老年代则是压舱石只有真正历经考验的长寿对象、或者天生巨大的特殊对象才有资格进驻。如果这篇文章对你理解 JVM 堆内存流转有所帮助欢迎关注作者后续我们继续把垃圾回收算法和 GC 调优实战彻底拆透。