JVM垃圾回收详解(重点)
一、前言
当需要排查各种内存溢出问题、当垃圾收集成为系统达到更高并发的瓶颈时,我们就需要对这些“自动化”的技术实施必要的监控和调节。
堆空间的基本结构
Java 的堆空间(GC 堆)是 Java 内存管理中的关键部分。它用于存储由 Java 程序创建的所有对象,是垃圾回收器的主要管理区域。随着垃圾收集器的演变,堆空间的管理方式发生了变化。特别是随着 JDK 8 的发布,永久代(PermGen)被移除,取而代之的是元空间(Metaspace)。
1. 堆的分代模型
Java 堆空间的内存管理采用 分代垃圾回收(Generational Garbage Collection)模型,这意味着堆内存被划分为几个不同的区域,每个区域有不同的垃圾回收策略,适应不同生命周期的对象。主要分为以下几个区域:
新生代(Young Generation):用于存储新创建的对象。新生代的垃圾回收会频繁发生,因为大多数对象在创建后很快变得不可达,因此能够较快被回收。
老生代(Old Generation):用于存储长生命周期的对象。随着对象的存活时间变长,它们会从新生代转移到老生代。老生代的垃圾回收频率较低,回收过程相对较为复杂和耗时。
永久代(PermGen)(JDK 7及以前):存储类的元数据、方法区等与类加载相关的数据。永久代的大小是固定的,如果空间不足,容易导致
java.lang.OutOfMemoryError: PermGen
错误。自 JDK 8 开始,永久代被 Metaspace 取代。元空间(Metaspace)(JDK 8及以后):代替永久代存放类的元数据。不同于永久代,元空间不再占用虚拟机内存,而是使用本地内存,这样可以动态扩展其大小。元空间的大小仅受系统可用内存的限制,极大地减少了
OutOfMemoryError
的发生。
2. 新生代(Young Generation)
新生代存储的是新创建的对象。大多数对象在很短的生命周期内就会被垃圾回收,因此新生代的垃圾回收会非常频繁。
新生代通常被划分为以下几个区域:
- Eden 区:存储刚刚创建的对象。
- Survivor 区(S0 和 S1):Eden 区被清理后,存活的对象会被移动到一个 Survivor 区。通常有两个 Survivor 区,称为 S0 和 S1。对象在这些区域之间复制,直到它们晋升到老生代。
新生代的垃圾回收被称为 Minor GC,其特点是回收速度快,回收对象相对较少。Minor GC 会清理 Eden 区和一个 Survivor 区。
3. 老生代(Old Generation)
老生代用于存放长期存活的对象。随着应用程序的运行,某些对象由于长时间存活而从新生代晋升到老生代。老生代的回收频率较低,但每次回收会涉及大量对象,因此其回收过程比较耗时。
老生代的垃圾回收被称为 Major GC 或 Full GC。由于老生代的对象存活时间较长,因此会有更复杂的回收策略。
4. 永久代(PermGen)与元空间(Metaspace)
JDK 7及以前:永久代(PermGen)用于存储类的元数据,包括类信息、方法信息和静态变量等。由于永久代的内存大小是固定的,因此容易出现 OutOfMemoryError
错误。开发者需要手动配置永久代的大小。
JDK 8及以后:永久代被元空间(Metaspace)取代,元空间不再位于虚拟机内存中,而是使用本地内存(Native Memory)。这样可以动态扩展元空间的大小,不再受到固定内存限制。元空间的大小默认是无限制的,受系统可用内存的限制。
总结
Java 堆空间的内存结构在不同版本中有所变化:
- JDK 7 及之前:堆分为新生代、老生代和永久代(PermGen)。
- JDK 8 及之后:永久代(PermGen)被元空间(Metaspace)取代,堆分为新生代和老生代。
堆的分代模型有助于优化垃圾回收,提高性能,尤其是在新生代对象的回收过程中,减少了老生代回收的频率和回收时的复杂度。
关于堆空间结构更详细的介绍,可以回过头看看 Java 内存区域详解 这篇文章。
二、内存分配和回收原则
内存分配与回收原则
Java 中的内存分配和垃圾回收是通过 堆(Heap) 进行管理的,堆内存分为新生代(Young Generation)和老生代(Old Generation)。垃圾回收的具体行为基于堆的分代收集模型,下面详细分析几个主要的内存分配和回收原则。
1. 对象优先在 Eden 区分配
- Eden 区分配:大多数情况下,Java 的对象会优先在新生代中的 Eden 区 进行分配。Eden 区是新生代内存的主要部分,专门用于存放新创建的对象。
- 触发 Minor GC:当 Eden 区内存不足时,虚拟机会触发 Minor GC(新生代垃圾收集),清理 Eden 区中不再使用的对象,并将存活的对象移到 Survivor 区。如果 Survivor 区无法容纳这些对象,则它们会晋升到老生代。
2. 大对象直接进入老年代
- 大对象定义:大对象是需要大量连续内存空间的对象,如大型数组或字符串。对于这些对象,虚拟机会决定直接将其分配到老生代,以避免将其放入新生代后造成过度的垃圾回收压力。
- 策略:不同垃圾回收器采用不同策略来决定大对象是否直接分配到老生代:
- G1 GC:根据参数
-XX:G1HeapRegionSize
和-XX:G1MixedGCLiveThresholdPercent
来决定大对象是否直接进入老年代。 - Parallel Scavenge GC:没有明确的阈值,而是动态决定,依据堆内存情况和历史数据进行调整。
- G1 GC:根据参数
3. 长期存活的对象将进入老年代
- 对象晋升:对象在经过多次垃圾回收后,如果仍然存活并且能够被 Survivor 区容纳,其年龄会增加,最终晋升到老生代。默认情况下,年龄阈值是 15,超过该年龄的对象会晋升到老生代。
- MaxTenuringThreshold 参数:通过
-XX:MaxTenuringThreshold
可以修改对象晋升到老生代的年龄阈值。这个参数控制着对象晋升的年龄限制,默认是 15。
4. 主要进行垃圾回收的区域
垃圾回收的区域和策略是根据 HotSpot 虚拟机的实现来划分的,主要有以下几种类型:
- Minor GC / Young GC:只对新生代进行垃圾回收。
- Major GC / Old GC:只对老生代进行垃圾回收。某些文献中,Major GC 也被用于指代对整个堆的回收。
- Mixed GC:对新生代和部分老生代进行垃圾回收。
- Full GC:对整个堆和方法区进行垃圾回收,通常是最耗时的操作。
5. 空间分配担保(Allocation Guarantee)
为了避免在 Minor GC 过程中出现内存分配失败,虚拟机会进行 空间分配担保。这个机制确保,在 Minor GC 之前,老生代必须有足够的空间来容纳新生代所有对象。如果老生代空间不足,Minor GC 将无法顺利进行,虚拟机会转而进行 Full GC。
- JDK 6 之前的规则:在进行 Minor GC 之前,虚拟机会检查老生代的最大连续空间是否足够容纳新生代所有对象。如果不够,虚拟机会进行 Full GC。
- JDK 6 Update 24 之后的规则:只要老生代有足够的连续空间,Minor GC 就可以进行。如果空间不足,虚拟机会进行 Full GC。
总结
Java 虚拟机的内存分配和垃圾回收机制通过分代收集(新生代和老生代)优化了内存的管理效率。具体的回收策略和内存分配策略,如 大对象直接进入老年代 和 空间分配担保,都有效地减少了垃圾回收的开销,确保了 Java 程序的高效运行。
死亡对象判断方法
在垃圾回收中,判断哪些对象是不可用的(即死亡的对象)是回收过程中的第一步。主要的对象死亡判断方法有 引用计数法 和 可达性分析算法。此外,Java 中还提供了不同类型的引用,帮助管理对象的生命周期。
1. 引用计数法
基本原理:
- 每个对象维护一个引用计数器。
- 每当有一个地方引用该对象,计数器加 1;引用失效时,计数器减 1。
- 当计数器为 0 时,说明该对象没有任何引用,垃圾回收器可以回收该对象。
缺点:
- 循环引用问题:如果两个对象互相引用,但它们不再被任何外部引用持有,引用计数器却不会为 0,因此无法被回收。这是引用计数法的主要局限性。
示例代码:
public class ReferenceCountingGc {
Object instance = null;
public static void main(String[] args) {
ReferenceCountingGc objA = new ReferenceCountingGc();
ReferenceCountingGc objB = new ReferenceCountingGc();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
}
}
在上面的例子中,objA
和 objB
相互引用,但由于它们不再被外部引用持有,它们的引用计数器依然不为 0,导致无法回收。
2. 可达性分析算法
基本原理:
- 从一组称为 GC Roots 的对象开始,沿着引用链进行搜索。如果一个对象无法通过任何引用链与 GC Roots 连接,则说明该对象是不可达的,应该被回收。
GC Roots 可能包括:
- 虚拟机栈(栈帧中的局部变量表)中引用的对象
- 本地方法栈(Native 方法)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 被锁持有的对象
- JNI(Java Native Interface)引用的对象
标记过程:
- 第一次标记:标记所有不可达的对象。
- 第二次标记:进一步筛选,检查是否需要执行
finalize
方法。 - 如果对象不再引用且不需要执行
finalize
方法,则可以回收。
3. 引用类型总结
Java 提供了不同类型的引用,管理对象的生命周期和垃圾回收。
1. 强引用(StrongReference):
- 最常用的引用类型。如果一个对象具有强引用,垃圾回收器不会回收它。即使内存不足,垃圾回收器也不会回收该对象。
2. 软引用(SoftReference):
- 对象具有软引用时,在内存充足时不会被回收,但在内存不足时会被回收。适用于实现内存敏感的缓存。
- 软引用与引用队列一起使用,当对象被回收时,软引用会被加入到引用队列。
3. 弱引用(WeakReference):
- 对象仅具有弱引用时,垃圾回收器在任何时候都会回收该对象,不管内存是否充足。适用于对象生命周期非常短的场景。
4. 虚引用(PhantomReference):
- 虚引用不会影响对象的生命周期,垃圾回收器在回收对象之前会将虚引用加入到引用队列。虚引用通常用于跟踪对象的回收活动。
4. 常量回收
判断废弃常量:
- 运行时常量池存放字符串常量和其他常量。如果某个常量没有任何引用指向它,它将被视为废弃常量。
- 在 JDK1.7 之前,运行时常量池存放在方法区;JDK1.7 及之后,它被移到堆中。
判断无用类:
- 判断一个类是否无用,需要满足以下 3 个条件:
- 该类的所有实例已经被回收(堆中不再存在该类的对象)。
- 加载该类的
ClassLoader
已经被回收。 java.lang.Class
对象无法通过反射访问该类的方法。
如果一个类满足这 3 个条件,就可以被回收,但这并不意味着它一定会被回收,具体是否回收还取决于垃圾回收器的实现和回收策略。
总结
Java 的垃圾回收机制通过引用计数法和可达性分析算法判断对象是否存活,且通过不同的引用类型(强引用、软引用、弱引用、虚引用)灵活管理对象的生命周期。finalize
方法、常量回收和无用类回收等机制帮助虚拟机更高效地管理内存,避免内存泄漏和过多的垃圾回收。
三、垃圾收集算法
1. 标记-清除算法
标记-清除算法是最基础的垃圾回收算法,它分为两个阶段:
- 标记阶段:遍历所有对象,标记出哪些对象是可回收的(不可达的对象)。
- 清除阶段:清理掉所有未被标记的对象。
缺点:
- 效率低:标记和清除阶段的效率较低,因为需要遍历整个堆并执行标记和清除操作。
- 空间碎片:清除对象后,堆中的内存可能会变得零散,导致内存碎片。这会降低后续内存分配的效率,甚至可能导致需要更多的垃圾回收操作来整理堆中的空闲空间。
2. 复制算法
复制算法是为了解决标记-清除算法中的效率和内存碎片问题而提出的。该算法将内存分为两块,每次只使用其中一块。对象存活时,会将它们复制到另一块内存中,并清理掉当前块的所有对象。
优点:
- 避免内存碎片:通过复制操作,所有存活的对象被整理到内存的一端,从而避免了内存碎片。
缺点:
- 可用内存减少:复制算法需要将内存分为两块,每次只使用一块,这意味着有效内存减少了一半,适用于对象存活数量较少的情况。
- 不适合老年代:由于老年代中的对象大部分存活时间较长,使用复制算法时会导致较大的性能损耗,因此不适合老年代使用。
3. 标记-整理算法
标记-整理算法结合了标记-清除算法和复制算法的优点。标记阶段和标记-清除算法一样,标记出所有的可回收对象。但与清除阶段不同,标记-整理算法会将所有存活的对象向内存的一端移动,然后清理掉端边界以外的内存。
优点:
- 减少内存碎片:通过整理存活对象,解决了内存碎片问题,避免了不连续的内存空间。
- 适合老年代:相比于复制算法,标记-整理算法对于老年代更加适用,因为它不需要减少内存空间。
缺点:
- 效率较低:由于需要额外的整理步骤,整体效率比标记-清除算法低,因此它适合回收频率较低的场景,如老年代。
4. 分代收集算法
分代收集算法是当前虚拟机使用的主流垃圾收集算法。它的基本思路是根据对象的生命周期,将内存划分为不同的区域(如新生代和老年代),并根据不同区域内对象的特点选择不同的垃圾收集算法。
- 新生代:包含生命周期较短的对象。因为新生代中每次垃圾回收时大部分对象会被回收掉,因此适合使用 复制算法,即将存活对象复制到另一块内存区域,回收速度较快。
- 老年代:包含生命周期较长的对象,垃圾回收较少。老年代中的对象通常存活时间较长,内存空间较为紧张,因此适合使用 标记-清除算法 或 标记-整理算法。
分代收集算法的优点:
- 根据对象的生命周期不同,选择不同的回收算法,避免了不必要的性能浪费。
- 通过将内存划分为新生代和老年代,使得回收更加高效和灵活。
5. 延伸问题:HotSpot 为什么要分为新生代和老年代?
原因:
- 新生代:新生代中大部分对象的生命周期较短,回收时大部分对象都会死亡,因此新生代采用 复制算法,其每次回收只需将存活对象复制到另一块内存区域,效率较高。
- 老年代:老年代中的对象存活时间较长,垃圾回收频率较低。由于老年代中的对象大多数存活时间较长且数量相对稳定,内存空间也相对紧张,因此老年代更适合使用 标记-清除 或 标记-整理算法,避免因频繁复制而浪费内存空间。
总结:HotSpot 将堆内存分为新生代和老年代,是为了根据对象的生命周期差异,优化垃圾回收策略。新生代快速回收的高频率特点适合使用复制算法,而老年代则通过标记-清除或标记-整理算法避免了大量的复制,优化了内存的管理和垃圾回收效率。
四、垃圾收集器
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
虽然我们对各个收集器进行比较,但并非要挑选出一个最好的收集器。因为直到现在为止还没有最好的垃圾收集器出现,更加没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器。试想一下:如果有一种四海之内、任何场景下都适用的完美收集器存在,那么我们的 HotSpot 虚拟机就不会实现那么多不同的垃圾收集器了。
JDK 默认垃圾收集器(使用 java -XX:+PrintCommandLineFlags -version
命令查看):
- JDK 8: Parallel Scavenge(新生代)+ Parallel Old(老年代)
- JDK 9 ~ JDK22: G1
1. Serial 收集器
Serial 收集器是最基础、最古老的垃圾收集器,它是 单线程 的,即整个垃圾收集过程由一个线程完成。在收集过程中,Serial 收集器会暂停所有其他线程的执行(Stop The World),直到垃圾收集完成。
- 新生代:标记-复制算法
- 老年代:标记-整理算法
优点:由于没有多线程的开销,它的单线程收集效率相对较高。
缺点:由于是单线程且需要长时间停顿,不适合需要低延迟的应用,通常在 Client 模式 下使用。
2. ParNew 收集器
ParNew 收集器是 Serial 收集器的多线程版本,其行为与 Serial 收集器基本相同,但使用多个线程进行垃圾收集。
- 新生代:标记-复制算法
- 老年代:标记-整理算法
它特别适用于 Server 模式 的虚拟机,能够在多核处理器上提升收集效率。它是 CMS 收集器的前置收集器。
并行和并发概念:
- 并行:多个垃圾收集线程并行工作,但用户线程仍然处于暂停状态。
- 并发:垃圾收集线程与用户线程并发工作,能够同时执行。
3. Parallel Scavenge 收集器
Parallel Scavenge 收集器与 ParNew 类似,也是 多线程标记-复制 收集器,强调通过并行收集提高吞吐量。吞吐量指的是用户代码的执行时间与总的垃圾回收时间的比例。
- 新生代:标记-复制算法
- 老年代:标记-整理算法
它适用于那些注重吞吐量的场景,而不是低停顿时间。JDK 8 默认使用 Parallel Scavenge + Parallel Old。
Parallel Scavenge 收集器还提供了自适应调节策略,可以自动调整停顿时间和吞吐量,适合不太了解垃圾收集器的用户使用。
4. Serial Old 收集器
Serial Old 收集器是 Serial 收集器的老年代版本,使用单线程进行垃圾回收。它主要有两个作用:
- 在 JDK 1.5 之前与 Parallel Scavenge 配合使用。
- 在 CMS 收集器不可用时作为后备方案。
5. Parallel Old 收集器
Parallel Old 收集器是 Parallel Scavenge 收集器的老年代版本,它使用 多线程标记-整理算法 来回收老年代的内存。
它适用于那些对吞吐量有较高要求的应用场景。
6. CMS 收集器
CMS(Concurrent Mark Sweep) 收集器是一个并发收集器,旨在减少 垃圾回收的停顿时间。它的工作流程分为四个步骤:
- 初始标记:短暂停顿,标记与根对象直接连接的对象。
- 并发标记:与应用程序并发运行,标记所有可达对象。
- 重新标记:修正并发标记阶段可能遗漏的对象,停顿时间较短。
- 并发清除:与应用程序并发运行,清除未标记的对象。
优点:低停顿,适用于需要高响应速度的应用。
缺点:对 CPU 资源敏感,可能会产生 浮动垃圾 和内存碎片。
CMS 收集器在 Java 9 中已被弃用,并在 Java 14 中移除。
7. G1 收集器
G1(Garbage-First) 收集器是面向大内存、多处理器服务器的垃圾收集器,旨在以 低停顿时间 和 高吞吐量 为目标。
- 并行与并发:多线程并行工作,减少停顿时间。
- 分代收集:保留分代的概念,但可以独立管理整个堆。
- 空间整理:结合 标记-整理 和 标记-复制 算法,回收空间。
- 可预测停顿:用户可以指定垃圾回收的最大停顿时间。
G1 具有优先级回收区域的机制,称为 Region,它能够根据收集时间选择最优回收区域,保证回收效率和停顿时间的平衡。
G1 是 JDK 9 以后默认的垃圾收集器。
8. ZGC 收集器
ZGC 是一种低延迟的垃圾收集器,能够将垃圾回收的 停顿时间控制在几毫秒以内,而且对 堆内存大小 不敏感。
- 新生代:标记-复制算法
- 老年代:标记-整理算法
ZGC 支持最大 16TB 的堆内存,适合大内存、高吞吐量的应用场景。
ZGC 在 Java 15 后可以正式使用,Java 21 引入了 分代 ZGC,进一步优化了停顿时间。
总结
每种垃圾收集器都有其适用的场景:
- Serial 和 Parallel Scavenge:适合对吞吐量要求高的应用。
- CMS:适合需要低停顿时间的应用,但在 Java 9 后已弃用。
- G1:提供低停顿和高吞吐量的平衡,适合大内存、多处理器环境。
- ZGC:适合大内存环境,能够提供几毫秒的停顿时间,适用于对低延迟要求极高的场景。