在第 2 章中,我们讨论了内存中“引用(reference)”与“对象(object)”的区别。引用与其所指向的对象密切相关。我们发现,Java 的“按值传递(call-by-value)”机制再加上可变对象,可能引发一种名为“逃逸引用(escaping references)”的安全问题。借助示例代码与示意图,我们分析了这些问题,并展示了如何通过“防御性拷贝(defensive copying)”来解决。
我们已经知道,原始类型(primitive)与引用既可能存在于栈上,也可能存在于堆上;而对象只会存在于**堆(heap)中。现在,我们准备更近一步地审视堆,为下一章的垃圾回收(Garbage Collection,GC)**做铺垫。本章将涵盖以下主题:
- 了解堆上的不同代(generation)
- 学习各个空间的使用方式
探索堆上的不同代
堆空间由两个不同的内存区域构成:
- **年轻代(young generation)**空间
- **老年代(old generation / tenured)**空间
虽然本章不会深入 GC 的具体流程,但我们需要先说明什么是存活对象(live object) 。存活对象指的是**从 GC Roots 可达(reachable)**的对象。
垃圾回收根(GC roots)
GC Root 是一种特殊的存活对象,因此它不会被回收。所有从 GC Roots 可达的对象也都被视为存活对象,因此同样不会被回收。GC Roots 在 GC 过程中充当起始节点:从这些根开始,标记所有可达对象为存活。常见的 GC Roots 包括:
- 栈上的本地变量(local variables)
- 所有活跃的 Java 线程
- 静态变量(因为可以通过其类被引用)
- JNI 引用:由 Java Native Interface 调用中的本地代码创建的对象。此类 GC Root 较为特殊,因为 JVM 无法得知这些对象在本地代码中是否仍被引用。
下面来看这些空间在内存中的示意(见图 3.1):
图 3.1 – 堆的各代(generations)
到这里,我们先给出简要定义,便于随后讨论各空间如何被使用:
-
年轻代(Young Generation)空间 —— 有时也称为 nursery / new space,包含两个子区域:Eden(伊甸区)与Survivor(幸存者)区。二者职责不同,但共同目标是提升内存管理效率,具体如下:
- Eden 区:新对象首先分配在 Eden。当 Eden 被填满且无法再为新对象分配空间时,就会触发一次年轻代(Minor)GC。
- Survivor 区:有两个等大区域,通常记为 S0 与 S1。Minor GC 会交替使用这两个区域,具体机制稍后会进一步介绍。
-
老年代(Old Generation / Tenured)空间 —— 存放生命周期较长的对象。也就是说,在年轻代中经历了一定次数 GC 仍存活的对象会被**提升(promote)**到老年代。当老年代空间被填满时,将触发一次“Major GC(老年代 GC)”。
至此,我们已对各个空间有了整体认识。接下来,我们将进一步说明这些空间的具体用法。
学习各空间的使用方式
要理解这些不同内存空间如何被使用,我们分两个阶段来说明。首先,介绍 Minor GC(年轻代回收) 算法如何使用这些空间;随后,再用一个示例展示该算法的实际运行过程。
了解 Minor GC 算法
先从 Minor GC 算法开始。图 3.2 给出了 Minor GC 过程的高层伪代码:
图 3.2 – 年轻代垃圾回收算法的伪代码
我们用一个 Given-When-Then 的场景来理解上述流程:
Given: 初始时将 S0 作为目标(target)幸存区,将 S1 作为源(source)幸存区。
When: 发生一次 Minor GC。也就是说,当 JVM 想要在 Eden 分配一个对象,但 Eden 没有足够空间时。
Then:
- 将 Eden 中所有存活对象复制到 S0 幸存区;这些对象的**年龄(age)**设为 1,因为它们刚刚经历了第一次 GC 并幸存。
- 扫描 S1,将其中年龄达到阈值(tenuring threshold)的存活对象复制到老年代(Old/Tenured)中,即晋升(tenure) 。这意味着它们是更长寿的对象,应放入老年代,有助于后续 Minor GC 更高效(避免对这些对象反复检查)。
- 将 S1 中其余未晋升的存活对象复制到 S0,并将其年龄加 1(又经历了一次 GC)。
注意:晋升阈值可通过 JVM 参数 -XX:MaxTenuringThreshold 配置。该参数控制对象在幸存区中经历多少次 GC 后晋升至老年代。但要小心,超过 15 的值表示对象永不晋升,这会让幸存区被老对象长期占满。
图 3.3 展示了上述过程:
图 3.3 – 以 S0 为目标幸存区的 Minor GC
小结:
- 将 Eden 的存活对象复制到 S0(年龄设为 1)
- 将 S1 中“老”的存活对象复制到老年代
- 将 S1 中“年轻”的存活对象复制到 S0(年龄 +1)
现在 Eden 与 S1 的存活对象都已复制(“保护”)到 S0,因此 Eden 与 S1 可以被回收。
当 Minor GC 再次运行时,由于上次的目标幸存区是 S0,这次的目标幸存区就变为 S1。因此:
- 所有 Eden 的存活对象复制到 S1,年龄设为 1;
- 此时 S0 成为源幸存区;GC 会检查 S0,将“老”的对象晋升到老年代,将“年轻”的对象复制到 S1。
图 3.4 展示了这一过程:
图 3.4 – 以 S1 为目标幸存区的 Minor GC
小结:
- 将 Eden 的存活对象复制到 S1(年龄设为 1)
- 将 S0 中“老”的存活对象复制到老年代
- 将 S0 中“年轻”的存活对象复制到 S1(年龄 +1)
完成后,Eden 与 S0 的对象已被复制,Eden 与 S0 可被回收。
用示例演示 Minor GC 的运行
图 3.5 展示了第一次 Minor GC 运行之前的内存状态:
图 3.5 – 第 1 次 Minor GC 前的初始堆状态
在该图中,对象 H 代表 JVM 试图在 Eden 中分配的新对象。Eden 的情况如下:
- 红色对象:从 GC Roots 不可达,可被回收;
- 绿色对象:存活对象(是 GC Roots 或可经由 GC Roots 到达),不可回收;
- 白色空洞:Eden 中的空闲碎片。若连续空闲空间足够,则对象可直接放入 Eden 并返回其引用;若因碎片化导致没有足够的连续空间,就会触发一次 Minor GC。
幸存区状况:
- S0:初始为空;假定 JVM 初始将其作为目标幸存区;
- S1:初始也为空;既然 S0 为目标,S1 就是源幸存区(第一次由于 S1 为空,这一步没有实际影响)。
老年代存放长寿对象——即在年轻代经历了若干次 Minor GC 仍存活的对象。该“若干次”的阈值可通过 -XX:MaxTenuringThreshold 自定义。
如图 3.5 所示,JVM 需要分配对象 H,但 Eden 空间不足,触发 Minor GC。Eden 中对象 A、D、G 可被回收;对象 B、C、E、F 复制到 S0。随后回收 Eden,并在 Eden 中分配 H。
图 3.6 展示了第一次 Minor GC 后的堆:
图 3.6 – 第 1 次 Minor GC 后的堆状态
在该图中,对象 H 已分配到 Eden,而 B、C、E、F 位于 S0。注意:S0 中这些对象的年龄为 1(它们第一次在 Minor GC 中存活)。
图 3.7 展示了第二次 Minor GC 前的堆:
图 3.7 – 第 2 次 Minor GC 前的堆状态
此时 JVM 想要分配对象 N,但 Eden 无足够空间,触发第二次 Minor GC。Eden 中 H、L、M 可回收,I、J、K 存活。幸存区 S0 中,对象 B 现在可回收,而 C、E、F 存活。
图 3.8 展示了第二次 Minor GC 后的堆:
图 3.8 – 第 2 次 Minor GC 后的堆状态
在该图中,S1 现在成为目标幸存区,因此 S0 成为源。GC 将 S0 中存活对象 C、E、F 复制到 S1,并将其年龄从 1 增加到 2;随后回收 S0。
接着,将 Eden 中的 I、J、K 复制到 S1,其年龄设为 1(它们第一次在 Minor GC 中幸存)。随后回收 Eden,并分配对象 N。
最后我们展示对象晋升到老年代的情形,如图 3.9:
图 3.9 – 对象晋升到老年代
图 3.9 表示在经历 15 次 Minor GC 后的堆状态。对象 E、F 的年龄达到 15(默认阈值为 15),因此晋升到老年代。下一次 Minor GC 时,它们将不再参与年轻代检查,使 GC 更高效。
本轮触发 Minor GC 的是对象 X;这一轮中 S1 是源,S0 是目标幸存区。对象 J、P、S 仍然存活,从 S1 复制到 S0,其年龄分别变为 14、8、3。
在结束本章前,再提一些相关 JVM 参数:
-Xms与-Xmx:分别指定堆的最小/最大大小。-XX:NewSize与-XX:MaxNewSize:分别指定年轻代的最小/最大大小。-XX:SurvivorRatio:指定Eden 与单个幸存区的相对大小。例如-XX:SurvivorRatio=6表示 Eden:单个幸存区 = 6:1,因此每个幸存区为 Eden 的 1/6,进而年轻代总大小为 Eden + 2×Survivor,即每个幸存区约占年轻代的 1/8(不是 1/7,因为有两个幸存区)。-XX:NewRatio:指定新生代与老年代的相对大小。例如-XX:NewRatio=3表示 新生代:老年代 = 1:3,即新生代占25%堆空间,老年代占75% 。-XX:PretenureSizeThreshold:若对象大小超过该阈值,对象将直接在老年代分配(立即“晋升”)。默认 0 表示无对象会直接分配到老年代。
一般建议:让年轻代占总堆的 25%~33% 。这样老年代更大,更合适,因为Full GC 的代价远高于 Minor GC。
本章到此结束,下面做个回顾。
总结
本章我们聚焦于 堆空间(heap) 。首先考察了堆上的不同“代”,即 新生代(young generation) 和 老年代(tenured/old generation) 。
新生代被划分为两个区域:Eden 与 幸存区(survivor spaces) 。Eden 用于分配新对象;幸存区由两个等大小的空间 S0 与 S1 组成。Minor GC(年轻代回收) 在回收内存时会使用这两个幸存区。当 Eden 中没有足够的连续空间来分配新对象时,就会触发 Minor GC。我们通过伪代码与示意图讲解了 Minor GC 如何利用这些“代”和“空间”,随后又用包含多种情形的示例巩固了相关概念。
老年代 用于存放存活时间更长的对象。我们看到:当对象经历多次 GC 仍然存活时,会被晋升到老年代,从而让后续的 Minor GC 更高效。最后,我们还回顾了若干相关的 JVM 参数。
至此,我们已经理解了堆的结构,并对 Minor GC 有了宏观认识。下一章将对 垃圾回收(GC) 进行深入剖析。