JVM 垃圾回收相关知识概要
如何判断对象是否死亡(两种方法)?
- 引用计数法
- 可达性分析算法
引用类型简介
- 强引用
- 软引用
- 弱引用
- 虚引用
垃圾回收算法
- 标记-清除算法
- 复制算法
- 标记-整理算法
- 分代收集算法
内存分配与回收原则
- 对象优先在 Eden 区分配
大多数情况下,对象会在新生代 Eden 区分配,当空间不足时触发 Minor GC。 - 空间分配担保
在 Minor GC 之前,检查老年代是否有足够空间存放新生代对象。
判断对象是否死亡
- 引用计数法
通过计数器判断引用状态,不适用解决循环引用问题。 - 可达性分析算法
从 GC Roots 开始,遍历引用链判断对象是否可达。
垃圾回收算法
- 标记-清除算法
- 标记不需要回收的对象,回收未标记对象。
- 存在效率低和内存碎片问题。
- 复制算法
- 将内存划分为两部分,每次使用一部分,回收后将存活对象复制到另一部分。
- 缺点是可用内存减少一半。
- 标记-整理算法
- 将存活对象移至一端,清理无用内存。
- 适用于老年代,效率较低。
- 分代收集算法
- 新生代采用复制算法,老年代采用标记-清除或标记-整理算法。
常见垃圾收集器
- Serial 收集器
单线程,适用于 Client 模式,停顿时间长。 - ParNew 收集器
Serial 的多线程版本,配合 CMS 使用。 - Parallel Scavenge 收集器
关注吞吐量,适合高效率利用 CPU 的场景。 - CMS 收集器
以最短停顿时间为目标,适用于注重用户体验的场景。 - G1 收集器
面向服务器,高并发,停顿时间可控。 - ZGC 收集器
引入于 Java 11,支持大内存,暂停时间控制在几毫秒以内。
引用类型总结
- 强引用:内存不足时,绝不会被回收。
- 软引用:内存不足时会被回收,常用于缓存。
- 弱引用:一旦被 GC 发现,立即回收。
- 虚引用:用于跟踪对象的回收状态。
如何判断无用的类
需满足以下条件:
- 类的所有实例已被回收。
- 加载该类的 ClassLoader 已被回收。
- 该类对应的 java.lang.Class 对象没有被引用,且无法通过反射访问。
即便满足这些条件,虚拟机也不一定会立即回收该类,但它具备被回收的条件。
堆内存的基本结构
Java 的自动内存管理主要负责对象内存的分配和回收,核心是堆内存中的操作。堆内存是垃圾收集器管理的重点区域,因此也称为 GC 堆(Garbage Collected Heap)。
现代垃圾收集器普遍采用分代收集算法,基于此,Java 堆通常被划分为多个区域,以便针对不同区域使用适合的收集算法。
- 在 JDK 7 及更早版本中,堆内存一般分为三部分:新生代(Young Generation)、老年代(Old Generation)和永久代(Permanent Generation)。新生代包括 Eden 区和两个 Survivor 区(S0 和 S1),老年代位于中间层,最底层是永久代。
- 自 JDK 8 起,永久代(PermGen)被元空间(Metaspace)取代,元空间使用直接内存。
内存分配和回收的原则
对象优先在 Eden 区分配
大多数情况下,对象在新生代中 Eden 区分配。当 Eden 区空间不足时,虚拟机会触发一次 Minor GC。我们可以通过以下代码进行测试:
1 | public class GCTest { |
通过添加 JVM 参数:-XX:+PrintGCDetails,可以查看 GC 详情。从输出中可以看到 Eden 区的内存几乎被完全占用(即使程序不做任何操作,新生代仍然会使用 2000 多 KB 的内存)。如果再为 allocation2 分配内存,情况会如何呢?
1 | allocation2 = new byte[900 * 1024]; |
因为 Eden 区几乎已经满了,当再为 allocation2 分配内存时,虚拟机会触发 Minor GC。GC 过程中,虚拟机发现 allocation1 无法放入 Survivor 区,所以只能通过分配担保机制将新生代的对象提前转移到老年代。老年代有足够的空间来存放 allocation1,所以不会触发 Full GC。执行 Minor GC 后,后面新分配的对象如果能放入 Eden 区,还是会在 Eden 区分配。以下代码可以验证这个过程:
1 | public class GCTest { |
大对象直接进入老年代
大对象是指需要大量连续内存空间的对象(如字符串、数组)。
大对象直接进入老年代的行为是由虚拟机动态决定的,具体取决于所使用的垃圾收集器和相关参数。将大对象直接分配到老年代是一种优化策略,旨在避免它们占用新生代,从而减少新生代的垃圾回收频率和成本。
- 对于 G1 垃圾收集器,虚拟机会根据 -XX:G1HeapRegionSize 参数设置的堆区域大小,以及 -XX:G1MixedGCLiveThresholdPercent 参数设置的阈值,来决定哪些对象会直接进入老年代。
- 在 Parallel Scavenge 垃圾收集器中,默认情况下没有一个固定的阈值来决定大对象何时进入老年代(XX:ThresholdTolerance是动态调整的),虚拟机会根据当前堆内存情况和历史数据来动态决定。
长期存活的对象将进入老年代
由于虚拟机采用了分代回收的策略来管理内存,所以在回收内存时就需要区分哪些对象应该继续留在新生代,哪些对象应该被移动到老年代。为此,虚拟机给每个对象分配了一个对象年龄(Age)计数器。
通常对象会先在 Eden 区域分配。如果对象在 Eden 区生成并经过第一次 Minor GC 后仍然存活,并且 Survivor 区有足够的空间容纳它,那么它会被移动到 Survivor 区(S0 或 S1),同时对象年龄设为 1(即对象从 Eden 区移动到 Survivor 区时,初始年龄为 1)。
对象每在 Survivor 区经历一次 MinorGC,年龄就会加 1,当它的年龄增加到一定阈值就会被晋升到老年代中。对象晋升到老年代的年龄阈值可以通过参数 -XX:MaxTenuringThreshold 来调整。(很多说法说默认为 15 岁,其实这个默认阈值并非固定是 15。根据 Oracle 官方文档,-XX:MaxTenuringThreshold 的最大值为 15,且默认值在并行收集器(Parallel GC)中为 15,而在 CMS 收集器中则为 6。)
另外,Hotspot 虚拟机在遍历所有对象时,会按照年龄对象从小到大累计它们的内存占用大小,当某个年龄的累积大小超过了 Survivor 区的 50% 时(默认值,可以通过 -XX:TargetSurvivorRatio=percent 来设置),虚拟机会取这个年龄和 MaxTenuringThreshold 中较小的值作为新的晋升年龄阈值。
需要说明的是,默认的对象晋升年龄并不固定为 15。尽管《深入理解 Java 虚拟机》一书中提到 15 岁为默认值,但实际上不同的垃圾收集器有所不同。根据 Oracle 官方文档,-XX:MaxTenuringThreshold 的最大值为 15,且默认值在并行收集器(Parallel GC)中为 15,而在 CMS 收集器中则为 6。
主要进行 GC 的区域
针对 HotSpot VM 的实现,GC 可以分为两大类:Partial GC 和 Full GC。
- 部分收集 (Partial GC):
- 新生代收集(Minor GC / Young GC):只对新生代进行垃圾回收
- 老年代收集(Major GC / Old GC):只对老年代进行垃圾回收。(注意:在有的语境中 Major GC 也用于指代 Full GC)
- 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾回收
- 整堆收集 (Full GC):回收整个 Java 堆和方法区的垃圾
空间分配担保
空间分配担保的目的是在 Minor GC 之前,确保老年代有足够的空间来容纳新生代中所有可能晋升的对象。
(根据《深入理解 Java 虚拟机》第三章描述)在 JDK 6 Update 24 之前,虚拟机会在 Minor GC 发生之前检查老年代最大可用的连续空间是否大于新生代所有对象总大小。如果老年代的空间足够,则能够确保这次 Minor GC 可以安全进行;如果空间不足,虚拟机会根据 -XX:HandlePromotionFailure 参数来决定是否允许担保失败(Handle Promotion Failure)。如果允许担保失败,虚拟机会进一步检查老年代的最大连续可用空间是否大于历次晋升到老年代对象的平均大小。如果大于,将尝试进行一次 Minor GC,即使这会存在一定的风险;如果小于,或者 -XX:HandlePromotionFailure 设置为不允许担保失败,则会改为执行一次 Full GC。
死亡对象判断方法
在堆中存储着几乎所有的对象实例,因此对堆进行垃圾回收的第一步就是判断哪些对象已经死亡(即不再会被任何途径使用)。
引用计数法
引用计数法给每个对象添加一个引用计数器:每当有一个地方引用它时,计数器就加 1;当引用失效时,计数器就减 1。计数器为 0 的对象就被认为不可能再被使用,可以被回收。这个方法实现简单且效率较高,但无法解决对象之间循环引用的问题,所以目前主流的虚拟机并不采用这种算法。
例如以下代码,展示了对象之间相互引用的问题。objA 和 objB 这两个对象除了相互引用对方以外没有其他任何引用,但是因为他们互相引用对方,导致它们的引用计数器都不为 0,所以引用计数算法无法通知 GC 收集器回收他们。
1 | public class ReferenceCountingGc { |
可达性分析算法
基本思想就是通过一组被称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,走过的路径称为引用链,如果一个对象与 GC Roots 之间没有任何引用链相连,就证明该对象是不可达的,需要被回收。
哪些对象可以作为 GC Roots:
- 虚拟机栈中局部变量表引用的对象
- 本地方法栈(Native 方法中)引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 所有被同步锁持有的对象
- JNI(Java Native Interface)引用的对象对象
即使对象在可达性分析中被判断为不可达,也不达标它一定会立即被回收。对象需要经过两次标记,只有在对象没有覆盖 finalize() 方法或该方法已被虚拟机调用之后,才会真正被回收。JDK 9 之后,finalize() 方法逐渐被启用,因为它影响了 GC 性能并被认为是一个糟糕的设计。
引用类型总结
无论是引用计数法还是可达性分析算法,都是通过“引用”来判断对象的存活。JDK 1.2 之前,Java 的引用定义很传统:如果引用类型(reference)的数据存储的数值表示另一块内存的起始地址,则称为引用。JDK 1.2 之后,Java 扩展了引用的概念,将引用分为以下四种类型(按强度递减):强引用、软引用、弱引用、虚引用。
如何判断一个常量是废弃常量?
运行时常量池主要回收废弃的常量。JDK 1.7 之后,字符串常量池被移到堆中,但运行时常量池的其他部分仍在方法区中。JDK 1.8 移除了永久代,使用元空间(Metaspace)代替。
如果字符串常量池中的某个字符串(如 “abc”)没有任何 String 对象引用,它会被认为是废弃常量,如果这时发生内存回收并且有必要的话,”abc”就会被系统清理出常量池。
如何判断一个类是无用的类?
方法区主要回收无用的类。判定无用类的条件相对比较严格了,必须同时满足以下三个条件:
- 该类的所有实例都已被回收
- 加载该类的 ClassLoader 已被回收
- 该类对应的 java.lang.Class 对象未被引用,且无法通过反射访问
即使满足这些条件,虚拟机也不一定会立即回收该类,但它具备被回收的条件。
垃圾回收算法
标记-清除算法
标记-清除(Mark-and-Sweep)算法分为两个阶段:
- 标记(Mark)阶段:标记出所有不需要回收的对象
- 清除(Sweep)阶段:在标记完成后,统一回收未被标记的对象。
该算法是最基础的垃圾回收算法,许多后续的算法都是在此基础上进行改进的。然而,标记-清除算法有两个明显的问题:
- 效率问题:标记和清除两个阶段的效率都不高
- 空间问题:标记-清除后会产生大量不连续的内存碎片。
(关于具体是标记可回收对象还是不可回收对象,存在两种说法。无论标记的是可回收对象还是不可回收对象,都是合理的。)如果前者的理解,标记-清除过程大致是:在标记阶段,将所有可达对象的标记位设置为 1(true),然后在清除阶段,清除标记仍为 0(false)的对象。
复制算法
复制算法的出现是为了解决标记-清除算法的效率和内存碎片问题。该算法将内存分为大小相同的两块,每次只使用其中的一块。当一块内存使用完毕时,将存活的对象复制到另一块内存区域,然后清理已使用的那块空间。这使得每次内存回收只针对内存的一半进行,从而有效避免了内存碎片问题。
尽管复制算法提高了效率,但仍然有以下问题:
- 可用内存减少:由于内存被分为两块,实际可用内存只有原来的一半。
- 不适合老年代:在老年代,如果存活对象数量较多,复制性能会变差。
标记-整理算法
标记-整理(Mark-and-Compact)算法针对老年代的特点进行了优化。该算法的标记过程与标记-清除算法相同,但在清理阶段,不是直接清除可回收对象,而是让所有存活对象向一端移动,最后清理掉端边界以外的内存。
由于增加了对象整理的步骤,标记-整理算法的效率相对较低,但它适用于老年代,这种情况下垃圾回收频率较低。
分代回收算法
当前的虚拟机大多采用分代回收算法,根据对象的存活周期将内存分为几大区域。一般将 Java 堆分为新生代和老年代,根据各自的特点选择合适的垃圾回收算法:
- 新生代:对象生命周期较短,每次垃圾回收都会有大量对象死亡,因此可以采用复制算法,只需付出少量存活对象的复制成本。
- 老年代:对象存活率较高,没有足够的空间进行分配担保,因此采用标记-清除或标记-整理算法。
HotSpot 为什么要将内存分为新生代和老年代?
HotSpt 将内存分为新生代和老年代是为了根据对象的生命周期长短,使用不同的垃圾回收策略:
- 新生代:对象创建频繁,生命周期短,因此使用复制算法更高效。
- 老年代:对象存活时间较长,存活率较高,使用标记-清除或标记-整理算法更适合处理大量存活对象。
垃圾收集器
如果将垃圾回收算法视为内存回收的方法论,那么垃圾收集器就是这些方法论的具体体现。尽管我们可以对不同的垃圾收集器进行比较,但这并不是为了找出最好的垃圾收集器,因为目前还没有一个“最优”或“万能”的垃圾收集器。我们能做的是根据具体的应用场景选择最合适的垃圾收集器。如果有一种适用于所有场景的完美收集器,HotSpot 虚拟机就不会实现那么多不同的垃圾收集器了。
JDK 默认垃圾收集器
- JDK 8:新生代使用 Parallel Scavenge,老年代使用 Parallel Old
- JDK 9 ~ JDK 20:默认使用 G1 收集器
可以通过 java -XX:+PrintCommandLineFlags -version 命令查看当前的默认垃圾收集器。
Serial 收集器
Serial(并行)收集器是最基础、历史最悠久的垃圾收集器。它是单线程的,且在进行垃圾回收时必须暂停其他所有工作线程(即 Stop-The-World 机制)。新生代采用标记-复制算法,老年代采用标记-整理算法。
虽然 Stop-The-World 影响用户体验,但 Serial 收集器的优势在于其简单且高效,尤其是没有线程交互的开销。它非常适合运行在 Client 模式下的虚拟机。
ParNew 收集器
ParNew 是 Serial 收集器的多线程版本,其他行为与 Serial 收集器完全一致。新生代使用标记-复制算法,老年代使用标记-整理算法。只有它能与 CMS 收集器配合使用,因此在 Server 模式下常被优先选择。
Parallel Scavenge 收集器
Parallel Scavenge 收集器也是基于标记-复制算法的多线程收集器。它的关注点是吞吐量(高效率利用 CPU)。所谓吞吐量就是 CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值。相较于关注用户线程停顿时间的垃圾收集器,Parall Scavenge 更适合需要高效利用 CPU 的场景(CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间,即提高用户体验)。
通过参数 -XX:+UseParallelGC 使用 Parallel 收集器+老年代串行,通过参数 -XX:+UseParallelOldGC 启用Parallel 收集器+老年代并行。
新生代采用标记-复制算法,老年代采用标记-整理算法。
JDK1.8 默认使用的是 Parallel Scavenge + Parallel Old,如果指定了-XX:+UseParallelGC 参数,则默认指定了-XX:+UseParallelOldGC,可以使用-XX:-UseParallelOldGC 来禁用该功能Serial Old 收集器。(使用 java -XX:+PrintCommandLineFlags -version 命令查看)
Serial Old 收集器
Serial Old 收集器是 Serial 收集器的老年代版本,同样是单线程,主要用途:1. 与 Parallel Scavenge 收集器搭配使用;2. 作为 CMS 收集器的后备方案。
Parallel Old 收集器
Parallel Old 收集器是 Parallel Scavenge 的老年代版本,使用多线程和标记-整理算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。
CMS 收集器
CMS(Concurrent Mark Sweep)收集器是以最短回收停顿时间为目标的收集器,主要面向注重用户体验的应用。它是HotSpot 虚拟机第一款真正意义上的并发收集器,第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。从名字可以看出 CMS 采用标记-清除算法,并通过并发机制让用户线程和垃圾收集线程同时工作。
它的运作过程相比前面几种垃圾收集器来说更复杂一些。整个过程分为四个步骤:
- 初始标记:暂停所有用户线程,并记录从 GC Roots 直接可达的对象。这一步骤非常快速,因为只涉及直接的引用关系。
- 并发标记;在初始标记后,GC 线程和用户线程同时运行。CMS 通过并发阶段来标记其他可达的对象。此时,垃圾收集器无法保证记录所有可达对象,因为用户线程可能在运行期间修改对象的引用。
- 重新标记:这个阶段需要暂停用户线程,但停顿时间相对较短。重新标记的目的是修正并发标记阶段由于用户线程更新引用而导致的对象标记不一致的问题。
- 并发清除:标记完成后,GC 线程再次与用户线程并发运行,开始清除未标记的对象。这一过程无需再暂停用户线程,清理过程是并发进行的。
CMS 的优点:并发收集、低停顿。有三个主要缺点:
- 对 CPU 资源敏感
- 无法处理浮动垃圾
- 产生大量内存碎片
因此,尽管 CMS 提供了较低的停顿时间,它的缺点也使得它在 Java 9 之后被逐步废弃,最终在 Java 14 中移除。
G1 收集器
G1 (Garbage-First) 是一款为服务器优化的垃圾收集器,主要用于多处理器、大内存的机器,尤其适合需要高吞吐量和低停顿时间的应用场景。在 JDK1.7 中首次引入,被视为 HotSpot 虚拟机垃圾收集器的重要进化。G1 结合了并发、并行、分代收集、空间整合与可预测的停顿控制等优点,能够在大内存、低延迟场景下高效运行,从 JDK 9 开始成为默认的垃圾收集器。G1 垃圾收集器的特点如下:
并行与并发:G1 收集器能充分利用多核 CPU 的硬件优势,在垃圾收集时使用多个线程并行工作,从而减少 STW 时间。与传统的垃圾收集器不同,G1 允许在某些 GC 阶段与 Java 应用线程并发运行,即在垃圾回收时用户程序也能继续执行,极大减少了停顿对应用的影响。
分代收集:虽然 G1 可以独立管理整个 Java 堆,不再严格依赖新生代和老年代的分代,但它仍然保留了分代的概念,根据对象的生命周期和存活率来优化垃圾收集的效率。
空间整合:G1 采用的是标记-整理算法,不像 CMS 收集器那样产生内存碎片。通过移动对象,使得内存空间在 GC 后更加紧凑。局部上,G1 使用标记-复制算法来实现对区域的回收,这避免了碎片化,并且提升了内存分配效率。
可预测的停顿:G1 的一个核心优势是对停顿时间的控制能力,它允许用户通过设置参数来指定垃圾收集所占用的最大时间。例如,可以设定在一个特定时间片段内,GC 不得超过一定时间(如 N 毫秒)。这一点使得 G1 对延迟敏感的应用特别有吸引力。
G1 垃圾收集器的工作步骤:
- 初始标记:暂停所有应用线程,标记 GC Roots 直接可达的对象,时间较短。
- 并发标记:与因共用线程并发执行,标记所有可达的对象。此阶段应用程序可以继续运行。
- 最终标记:修正并发标记期间可能发生变化的对象引用,时间较短,但需要再次暂停应用线程。
- 筛选回收:根据可回收空间的价值优先回收最值得回收的区域。
ZGC 收集器
ZGC(Z Garbage Collector)与 CMS 中的 ParNew 和 G1 类似,也采用了标记-复制算法,不过 ZGC 对该算法进行了显著的改进。ZGC 的一个核心优势是将暂停时间控制在几毫秒以内,并且这种暂停时间不受堆内存大小的影响,极大减少了 STW 事件的发生,但代价是牺牲了一些吞吐量。ZGC 最大可以支持 16TB 的堆内存。
ZGC 在 Java 11 首次引入,并在 Java 15 中可正式使用。然而,默认的垃圾收集器仍然是 G1。启用 ZGC 收集器参数: java -XX:+UseZGC。
在 Java 21 中引入了分代 ZGC,进一步缩短了暂停时间至 1 毫秒以内。启用 ZGC 收集器参数: java -XX:+UseZGC。