Java八股文——JVM「垃圾回收篇」
文章目录
什么是Java里的垃圾回收?如何触发垃圾回收?
面试官您好,Java的垃圾回收(Garbage Collection, GC)是JVM一项非常核心的、实现自动内存管理的机制。
1. GC是做什么的?(What)
- 它的核心职责是自动地识别并回收Java堆内存中那些不再被任何“活”的线程所引用的对象,从而释放它们所占用的内存空间。
- 有了GC,我们开发者就无需像C/C++程序员那样手动地去
free或delete内存。这极大地降低了内存泄漏和野指针等内存管理错误的风险,让我们能更专注于业务逻辑的实现。
2. GC如何判断对象是“垃圾”?(How)
在现代JVM中,主要是通过可达性分析算法(Reachability Analysis) 来判断的。
- 基本思路:
- 首先,确定一系列肯定不能被回收的“根”对象,我们称之为GC Roots。这些根对象包括:虚拟机栈中引用的对象、方法区中静态属性引用的对象、
native方法引用的对象等。 - 然后,从这些GC Roots开始,像一张图一样向下遍历,所有能够被GC Roots直接或间接访问到的对象,都被认为是 “存活对象”。
- 遍历完成后,那些没有任何引用链能从GC Roots到达的对象,就被判定为“垃圾”,即不可达对象,它们就是GC要回收的目标。
- 首先,确定一系列肯定不能被回收的“根”对象,我们称之为GC Roots。这些根对象包括:虚拟机栈中引用的对象、方法区中静态属性引用的对象、
3. GC是如何被触发的?(When)
GC的触发时机,可以分为 “被动触发” 和 “主动建议” 两种情况。
a. 被动/自动触发 (这是最主要的方式)
JVM会根据内存的使用情况,在它认为必要的时候,自动地触发GC。
- 堆内存分配失败:这是最常见、最主要的触发原因。
- Minor GC / Young GC:当程序需要
new一个新对象时,如果在Eden区找不到足够的连续空间来分配,JVM就会触发一次新生代的垃圾回收。 - Full GC / Major GC:如果Minor GC后,存活的对象要晋升到老年代,而老年代空间也不足;或者一个大对象要直接在老年代分配,而老年代空间不足,就可能会触发一次覆盖整个堆的Full GC。
- Minor GC / Young GC:当程序需要
- 老年代空间使用率达到阈值:一些更智能的GC器(如CMS、G1)会持续监控老年代的内存使用情况。当使用率达到一个预设的阈值时(比如70%),为了避免等到内存完全耗尽再回收而导致长时间停顿,它们会主动地触发一次并发的垃圾回收。
- 元空间(Metaspace)不足:当加载的类过多,导致元空间不足时,也会触发Full GC。
b. 主动建议触发 (一般不推荐)
- 手动调用
System.gc():开发者可以在代码中调用System.gc()或Runtime.getRuntime().gc()来“建议”JVM执行一次Full GC。 - 为什么是“建议”? 因为调用这个方法并不保证GC会立即执行,甚至不保证一定会执行。JVM有权忽略这个请求。
- 最佳实践:在生产环境中,强烈不建议手动调用
System.gc()。因为一次Full GC可能会导致长时间的“Stop-The-World”(应用停顿),这会严重影响应用的性能和响应。我们应该相信JVM的自动GC调度机制,并通过合理的内存配置和代码优化来管理内存,而不是粗暴地手动干预。只有在一些非常特殊的、用于诊断或测试的场景下,才可能会用到它。
总结一下,GC是一个由JVM在后台自动运行的“清洁工”。它通过可达性分析来找出垃圾,其工作主要是由内存压力被动触发的。虽然我们可以手动“建议”它工作,但这通常是一种应该避免的不良实践。
判断垃圾的方法有哪些?
面试官您好,在JVM中,判断一个对象是否为“垃圾”(即是否可以被回收),主要有两种经典的算法:引用计数法和可达性分析算法。
1. 引用计数法 (Reference Counting)
-
核心思想:
- 这个算法的思路非常直观。它会为堆中的每一个对象都关联一个引用计数器。
- 每当有一个引用指向这个对象时,计数器就加1。
- 每当一个指向它的引用失效时(比如被置为
null),计数器就减1。 - 任何时刻,只要一个对象的引用计数器为0,就意味着它不再被任何地方所引用,可以被回收了。
-
优点:
- 实现简单,逻辑清晰。
- 效率高:当一个对象的计数器变为0时,可以立即被回收,回收的实时性好。
-
致命缺点:无法解决“循环引用”问题
- 这是导致主流JVM最终放弃使用引用计数法的根本原因。
- 循环引用场景:
Object a = new Object(); Object b = new Object(); a.field = b; // a引用b,b的计数器为1 b.field = a; // b引用a,a的计数器为1 a = null; // a的外部引用断开 b = null; // b的外部引用断开 - 在上面的代码中,当
a和b的外部引用都断开后,从程序的角度看,这两个对象已经无法被访问,是“垃圾”了。但是,由于它们内部互相引用着对方,导致它们的引用计数器都为1,永远不为0。 - 因此,采用引用计数法的垃圾回收器,将永远无法回收这两个对象,造成了内存泄漏。
2. 可达性分析算法 (Reachability Analysis) —— 主流JVM的选择
-
核心思想:
- 这是当前所有主流商用虚拟机(如HotSpot)都在使用的算法。它的思路是,定义一系列被称为 “GC Roots” 的根对象作为起点,然后从这些节点开始,像遍历图一样向下搜索。
- 搜索所走过的路径,称为 “引用链”(Reference Chain)。
- 当一个对象到任何GC Roots之间都不存在任何引用链时(即从GC Roots出发,无论如何都无法访问到这个对象),就证明此对象是不可达的,可以被回收。
-
哪些对象可以作为GC Roots?
- GC Roots是一组必须存活的对象,它们是程序所有活对象的“根源”。主要包括:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象:比如方法里的局部变量。
- 方法区中类静态属性引用的对象:比如一个类的
static字段。 - 方法区中常量引用的对象:比如字符串常量池里的引用。
- 本地方法栈中JNI(即
native方法)引用的对象。 - 所有被
synchronized持有的锁对象。
- GC Roots是一组必须存活的对象,它们是程序所有活对象的“根源”。主要包括:
-
优点:
- 完美解决循环引用问题:在上面的循环引用例子中,虽然
a和b互相引用,但由于没有任何一条引用链能从任何一个GC Root到达它们,所以它们会被判定为不可达,从而被正确地回收。 - 实现精准:逻辑严谨,是目前最成熟、最可靠的垃圾判断方案。
- 完美解决循环引用问题:在上面的循环引用例子中,虽然
-
缺点:
- 实现相对复杂。
- 在分析期间,通常需要 “Stop The World”(STW),即暂停所有应用线程,以保证分析期间引用关系不会发生变化。当然,现代的并发垃圾回收器(如CMS、G1)已经通过各种技术(如三色标记法)极大地缩短了STW的时间。
总结
| 算法 | 核心思想 | 优点 | 缺点 |
|---|---|---|---|
| 引用计数法 | 为每个对象维护一个引用计数器 | 实现简单,回收实时性好 | 无法解决循环引用 |
| 可达性分析 | 从GC Roots出发,搜索引用链 | 精准,能解决循环引用 | 实现复杂,需要STW |
因此,虽然引用计数法简单高效,但因其无法处理循环引用的硬伤而被主流JVM所摒弃。而可达性分析算法,凭借其精准性和对循环引用的完美解决,成为了当今Java世界中判断对象存活与否的事实标准。
垃圾回收算法是什么,是为了解决了什么问题?
面试官您好,GC的核心目的,就是为了将开发者从复杂、易错的手动内存管理中解放出来。
为了实现这个目标,GC 需要通过具体的垃圾回收算法来回收“垃圾”占用的内存。这些算法是逐步演进的,每一种都是为了解决前一种算法的不足。
1. 标记-清除算法 (Mark-Sweep)
这是最基础的垃圾回收算法,后续的算法都是在它的基础上进行改进。
-
工作流程:
- 标记 (Marking):首先,通过可达性分析,从GC Roots开始遍历,所有可达的对象都被标记为“存活对象”。
- 清除 (Sweeping):然后,再次遍历整个堆,将所有未被标记的对象(即垃圾对象)的内存直接回收。
-
解决了什么问题?
- 解决了最基本的问题——“如何回收垃圾”。
-
它带来了什么新问题?
- 效率问题:需要进行两次全堆扫描(标记一次,清除一次),效率较低。
- 空间碎片问题:这是它最致命的缺点。清除后,会产生大量不连续的内存碎片。当后续程序需要分配一个较大的对象时,即使堆的总剩余空间是足够的,也可能因为找不到一块连续的、足够大的空间而不得不提前触发下一次GC。
2. 标记-复制算法 (Mark-Copy / Copying)
这是为了解决“标记-清除”算法的效率和空间碎片问题而生的。
-
工作流程:
- 它将可用的堆内存划分为大小相等的两块,比如A区和B区。在任何时候,只使用其中一块(比如A区)。
- 当A区内存用完时,触发GC。
- 标记与复制:对A区进行可达性分析,将所有存活的对象,整齐地、连续地复制到另一块完全空闲的B区。
- 清空:然后,一次性地将整个A区的内存全部清空。
- 之后,程序就在B区上分配新对象,A和B的角色互换,等待下一次GC。
-
解决了什么问题?
- 解决了空间碎片问题:因为每次都是将存活对象复制到一块新内存上,所以内存始终是连续、规整的。
- 提高了效率(在特定场景下):在对象存活率低的情况下,只需要复制少量存活对象,然后清空整个半区,效率非常高。
-
它带来了什么新问题?
- 空间浪费:代价非常大,它将可用内存缩小为原来的一半。这在对象存活率高的情况下,是不可接受的。
3. 标记-整理算法 (Mark-Compact)
这是为了综合前两种算法的优点,特别是为了解决老年代GC问题而提出的。
-
工作流程:
- 标记 (Marking):第一步与“标记-清除”算法完全一样,标记出所有存活对象。
- 整理 (Compacting):第二步不是直接清除垃圾,而是将所有存活的对象,都向内存空间的一端移动,让它们紧凑地排列在一起。
- 清空:然后,直接清理掉端边界以外的所有内存。
-
解决了什么问题?
- 解决了空间碎片问题,因为它在回收后,会整理内存,使其连续。
- 避免了复制算法的空间浪费问题,不需要额外的半区空间。
-
它带来了什么新问题?
- 它的效率通常最低,因为移动和整理所有存活对象是一个相对耗时的操作。
4. 分代收集算法 (Generational Collection) —— 现代JVM的实践
- 这不是一种具体的算法,而是一种“集大成”的、基于经验的策略。现代商用虚拟机(如HotSpot)并不会只使用上述某一种算法,而是根据不同内存区域的特点,采用最合适的算法。
- 核心思想:绝大多数Java对象的生命周期都符合一个规律——“朝生夕死”。98%的对象都是活不久的。
- 实践:
- 在新生代 (Young Generation):对象存活率极低。因此,非常适合采用复制算法。HotSpot的实现,通过将新生代划分为一个Eden区和两个Survivor区(比例约为8:1:1),每次只浪费10%的Survivor空间,极大地缓解了复制算法的空间浪费问题。
- 在老年代 (Old Generation):对象存活率高,生命周期长。因此,不适合用复制算法。老年代的GC器(如
CMS,G1的部分阶段,Serial Old)通常采用 “标记-清除” 或 “标记-整理” 算法(或它们的混合变体)。
总结一下,垃圾回收算法的演进,就是一个不断发现问题、解决问题的过程:
- 标记-清除 -> 解决了“回收垃圾”的有无问题。
- 标记-复制 -> 解决了“标记-清除”的碎片和效率问题,但带来了空间浪费。
- 标记-整理 -> 解决了“标记-清除”的碎片问题,且比“标记-复制”更节省空间,但效率较低。
- 分代收集 -> 最终,通过对不同生命周期的对象“因材施教”,采用最合适的算法组合,达到了性能和效率的最佳平衡。
垃圾回收器有哪些?
面试官您好,Java的垃圾回收器(GC)经过多年的发展,主要围绕着两个核心目标:提高吞吐量(Throughput)和降低停顿时间(Latency)。
我通常会按照它们的演进脉络,将它们分为以下几个“时代”:
第一代:串行收集器 (Serial Era) —— 单线程GC
这是最古老、最基础的GC。它在进行垃圾回收时,必须暂停所有用户线程(Stop-The-World, STW),并且只使用单条线程去完成GC工作。
- 1.
Serial收集器 (新生代, 复制算法) - 2.
Serial Old收集器 (老年代, 标记-整理算法) - 特点:简单高效(对于单核CPU),但无法利用多核优势,停顿时间较长。
- 适用场景:主要用于客户端模式(Client VM) 下的桌面应用,或者是一些对停顿时间不敏感、硬件配置较低的服务器。
第二代:并行收集器 (Parallel Era) —— 吞吐量优先
为了利用多核CPU的威力,并行收集器应运而生。它们在GC时,会使用多条线程并行地进行垃圾回收,从而大大缩短了STW的时间。但请注意,在GC期间,用户线程仍然是全部暂停的。
- 3.
ParNew收集器 (新生代, 复制算法)- 可以看作是
Serial的多线程版本。一个很重要的特点是,在JDK 8及以前,只有它能和CMS收集器配合工作。
- 可以看作是
- 4.
Parallel Scavenge收集器 (新生代, 复制算法)- 这是JDK 8默认的新生代收集器。它的核心目标是达到一个可控制的吞吐量。
- 5.
Parallel Old收集器 (老年代, 标记-整理算法)- 是
Parallel Scavenge的老年代版本。Parallel Scavenge+Parallel Old的组合,是JDK 8下注重吞吐量场景的默认选择。
- 是
第三代:并发收集器 (Concurrent Era) —— 低停顿优先
并行GC虽然缩短了STW,但停顿依然存在。为了进一步降低停顿时间,并发收集器被设计出来。它的核心思想是让GC线程与用户线程在大部分时间内可以并发执行。
- 6.
CMS(Concurrent Mark Sweep) 收集器 (老年代, 标记-清除算法)- 目标:以获取最短回收停顿时间为目标,是第一款真正意义上的并发收集器。
- 特点:它的工作过程分为多个阶段,其中耗时最长的并发标记和并发清除阶段,GC线程都可以与用户线程一起工作。
- 缺点:
- 采用“标记-清除”算法,会产生内存碎片。
- 对CPU资源敏感,会“抢占”用户线程的CPU。
- 存在“并发失败”(Concurrent Mode Failure)的风险,一旦发生,会触发一次代价高昂的、长时间STW的Full GC。
第四代:区域化、面向全堆的收集器 (G1 & Beyond)
CMS等之前的GC器,都是明确区分新生代和老年代的。而G1开创了一个新的思路。
- 7.
G1(Garbage-First) 收集器 (面向全堆, 复制 + 标记-整理)- 这是JDK 9之后默认的垃圾回收器,也是JDK 8中一个非常重要的可选GC。
- 特点:
- 化整为零:它将整个Java堆划分为多个大小相等的独立区域(Region),每个Region都可以根据需要扮演Eden、Survivor或Old的角色,实现了逻辑上的分代。
- 可预测的停顿:G1会跟踪每个Region的回收价值(回收能释放多少空间,以及预计需要多少时间)。在回收时,它会优先回收那些“价值最高”的Region,这也是“Garbage-First”名字的由来。这使得我们可以为其设定一个期望的停顿时间目标。
- 无内存碎片:从整体上看,它基于“标记-整理”思想;从局部(两个Region之间的数据转移)看,又基于“复制”算法,所以不会产生内存碎片。
第五代:ZGC & Shenandoah —— 面向未来的低延迟GC
- ZGC (JDK 11引入) 和 Shenandoah (JDK 12引入)
- 目标:追求在任何堆大小下(即使是TB级别的堆),都能将GC的停顿时间控制在几毫秒甚至亚毫秒级别。
- 特点:它们使用了更先进的技术,如着色指针(Colored Pointers)和读屏障(Read Barriers),使得几乎所有的GC工作(包括标记、转移、重定位等)都可以并发执行,从而实现了极低的STW时间。
- 适用场景:适用于对延迟极度敏感的、需要超大堆内存的现代应用。
选型总结
| JDK版本 | 默认GC组合 | 核心目标 | 推荐/可选 |
|---|---|---|---|
| JDK 8 | Parallel Scavenge + Parallel Old | 高吞吐量 | 如果追求低延迟,可选ParNew + CMS或 G1 |
| JDK 9及以后 | G1 | 吞吐量与低延迟的平衡 | (默认即推荐) |
| JDK 11+ | (仍然是G1) | 极致低延迟 | 对于超大堆、低延迟场景,可选 ZGC |
垃圾回收算法哪些阶段会 Stop-The-World?
面试官您好,“Stop-The-World”(STW),也就是暂停所有应用线程,是垃圾回收过程中为了保证数据一致性而必须采取的措施。几乎所有的垃圾回收器都或多或少地存在STW阶段,但它们的区别在于STW发生在哪几个阶段,以及停顿时间的长短。
我将按照不同类型的GC器来分别阐述它们的STW阶段:
1. 串行/并行GC器 (Serial, Parallel Scavenge/Old)
这类GC器的特点是,它们的整个垃圾回收过程,都是STW的。
Serial/Serial Old(串行)- STW阶段:从GC开始到结束的整个过程,包括标记和清除/整理,都是在一个单独的GC线程中完成的,期间所有应用线程都会被暂停。
Parallel Scavenge/Parallel Old(并行)- STW阶段:同样,从GC开始到结束的整个过程也都是STW的。
- 与串行的区别:它在STW期间,会使用多条GC线程来并行地执行垃圾回收工作。这使得GC的总耗时大大缩短,从而缩短了STW的时间,但它并没有消除STW本身。
总结:对于串行和并行GC器,我们可以认为它们的垃圾回收过程 = STW。
2. CMS (Concurrent Mark Sweep) 收集器
CMS是第一款真正意义上的并发收集器,它的核心目标就是最大限度地降低STW时间。它通过将耗时的操作并发化来实现这一目标,但仍然保留了两个非常短暂的STW阶段。
- CMS的工作流程与STW阶段:
- 初始标记 (Initial Mark) - STW
- 这是一个非常短暂的STW。它的任务仅仅是标记出GC Roots能直接关联到的对象。这个过程速度非常快。
- 并发标记 (Concurrent Mark)
- 这个阶段不STW,GC线程会与应用线程并发执行。它会从“初始标记”找到的对象开始,遍历整个对象图。
- 重新标记 (Remark) - STW
- 这是另一个短暂的STW。它的任务是修正在“并发标记”阶段,因为用户线程继续运行而导致标记产生变动的那一部分对象的标记记录。这个阶段的停顿时间通常会比初始标记稍长,但远比整个GC过程短。
- 并发清除 (Concurrent Sweep)
- 这个阶段也不STW,GC线程会与应用线程并发执行,清除掉未被标记的垃圾对象。
- 初始标记 (Initial Mark) - STW
总结:CMS通过将耗时最长的“标记”和“清除”过程与用户线程并行执行,成功地将STW限制在了 “初始标记” 和 “重新标记” 这两个非常短暂的阶段。
3. G1 (Garbage-First) 收集器
G1是一款更先进的并发收集器,它同样追求低停顿,其STW分布与CMS有相似之处,但回收策略完全不同。
- G1的工作流程与STW阶段:
- 初始标记 (Initial Mark) - STW
- 与CMS类似,同样是一个短暂的STW,标记GC Roots能直接关联的对象。这个阶段通常是借用 Minor GC的机会来完成的,所以开销很小。
- 并发标记 (Concurrent Marking)
- 与CMS类似,不STW,GC线程与应用线程并发执行,扫描整个堆的对象图。
- 最终标记 (Final Mark / Remark) - STW
- 与CMS的重新标记类似,也是一个短暂的STW,用于处理在并发标记期间产生的引用变化。
- 筛选回收 (Live Data Counting and Evacuation) - STW
- 这是G1与CMS最大的不同。G1不会对整个老年代进行清除,而是根据用户设定的预期停顿时间,选择一部分回收价值最高的Region,进行复制回收。
- 这个复制回收(Evacuation)的过程是完全STW的。但G1的核心优势在于,它通过控制一次回收的Region数量,来努力地将这次STW的时间控制在用户的预期之内。
- 初始标记 (Initial Mark) - STW
总结:G1的STW主要发生在 “初始标记”、“最终标记” 以及 “筛选回收” 这几个阶段。它通过化整为零,将一次大的、不可控的STW,分解成了多次小的、可预测的STW。
4. ZGC & Shenandoah
这两款最新的GC器,目标是实现亚毫秒级的停顿。它们通过使用读屏障、着色指针等更先进的技术,将几乎所有的GC工作(包括对象移动和引用修复)都变成了并发执行。它们的STW阶段极短,通常只在一些根扫描等非常有限的环节存在,几乎可以忽略不计。
结论:可以说,Java GC技术演进的历史,就是一部不断与“Stop-The-World”作斗争,想尽一切办法缩短、拆分、消除STW的历史。
Minor GC、Major GC、Full GC 的区别,什么场景触发full GC
面试官您好,Minor GC、Major GC和Full GC是JVM中对不同范围和目的的垃圾回收活动的不同称呼。理解它们的区别,以及Full GC的触发场景,对于我们进行性能调优和排查问题至关重要。
1. Minor GC / Young GC
- 定义:指的是只发生在新生代(Young Generation) 的垃圾回收。
- 触发时机:非常频繁。主要是当Eden区被写满,无法为新创建的对象分配空间时,就会触发一次Minor GC。
- 工作过程:采用复制算法,将Eden区和From Survivor区中的存活对象,复制到To Survivor区,或者晋升到老年代。
- 特点:
- 速度快、频率高:因为新生代的对象绝大多数都是“朝生夕死”,存活对象很少,所以回收效率非常高。
- 会引发“Stop-The-World”(STW):尽管速度快,但Minor GC期间,所有应用线程仍然是暂停的。
2. Major GC / Old GC
- 定义:这个术语在不同上下文中可能略有不同,但通常指的是只发生在老年代(Old Generation) 的垃圾回收。
- 触发时机:通常是在老年代空间不足时被触发。比如,
CMS收集器就是在老年代空间使用率达到某个阈值时,开始进行并发回收。 - 特点:
- 速度通常比Minor GC慢得多(可能慢10倍以上),因为老年代对象多、存活率高,且通常采用更复杂的标记-清除或标记-整理算法。
- 它的STW时间也可能更长。像CMS这样的并发收集器,其核心目标就是为了缩短Major GC的停顿时间。
3. Full GC
- 定义:这是“最重量级”的垃圾回收,指的是对整个Java堆(包括新生代和老年代),以及方法区(元空间) 进行的一次全面的垃圾回收。
- 特点:
- 速度最慢,因为它要处理的内存范围最大。
- STW时间最长,对应用程序的性能影响最大。
- 与Major GC的关系:Full GC必然会包含对老年代的回收(即包含Major GC),同时通常还会回收新生代和元空间。在很多情况下,人们可能会混用这两个术语,但Full GC的范围更广,影响更大。
什么场景会触发致命的Full GC?
在生产环境中,我们应该极力避免频繁的Full GC。以下是一些最常见的触发场景:
1. 老年代空间不足
- 原因:这是最直接的原因。当有大对象要直接分配到老年代,或者新生代有大量对象要晋升到老年代时,如果老年代的剩余空间不足,就会触发Full GC。
- 背后可能的问题:
- 持续的内存泄漏:大量无用对象占据老年代,无法被回收。
- 新生代配置不合理:比如新生代过小,导致对象过早、过快地晋升到老年代。
2. 方法区(元空间)空间不足
- 原因:当系统需要加载大量新的类、或者使用CGLIB等技术动态生成大量类时,如果元空间(Metaspace)满了,也会触发Full GC来尝试卸载无用的类。
- JVM参数:可以通过
-XX:MaxMetaspaceSize来调整其上限。
3. 显示调用System.gc()
- 原因:如果在代码中手动调用了
System.gc(),JVM默认会执行一次Full GC。 - 最佳实践:强烈不建议在生产代码中这样做。可以通过JVM参数
-XX:+DisableExplicitGC来禁止这种手动的GC调用,以防止因误用而引发性能问题。
4. 空间分配担保失败
- 原因:这是一个相对底层的机制。在进行Minor GC之前,JVM会检查老年代的剩余连续空间,是否大于新生代所有对象的总大小(或大于历次晋升到老年代对象的平均大小)。
- 触发逻辑:如果这个检查失败,JVM会认为这次Minor GC可能会有大量对象晋升,而老年代可能容纳不下。出于安全的考虑,它会提前触发一次Full GC来腾出足够的空间,以确保Minor GC能够安全地进行。
总结一下,Minor GC是常规的、低成本的回收。而Full GC则是重量级的、高成本的“大扫除”。在做性能调优时,一个核心目标就是通过合理的内存配置和代码优化,减少甚至消除不必要的Full GC的发生。
垃圾回收器 CMS 和 G1的区别?
面试官您好,CMS(Concurrent Mark Sweep)和G1(Garbage-First)都是Java并发垃圾回收器发展史上的重要里程碑。它们的核心目标都是为了在保证一定吞吐量的前提下,尽可能地降低GC带来的停顿时间(STW)。
G1可以看作是CMS的继任者和全方位的“升级版”,它在设计理念和实现上,都解决了CMS的一些固有缺陷。
我将从以下几个核心维度来对比它们:
1. 内存布局与回收范围
-
CMS
- 内存布局:基于传统的分代模型,严格划分出物理上连续的新生代和老年代空间。
- 回收范围:是一款只作用于老年代的并发收集器。它必须与一个新生代收集器(通常是
ParNew)配合使用。
-
G1
- 内存布局:彻底颠覆了传统分代模型。它将整个Java堆划分为多个大小相等的独立区域(Region)。每个Region都可以根据需要,动态地扮演Eden、Survivor或Old的角色。这是一种逻辑上的分代。
- 回收范围:是一款面向整个堆的收集器。它在一个统一的框架下,同时管理新生代和老年代的回收,不再需要与其他GC器配合。G1的GC(称为Mixed GC)可以同时回收新生代的Region和一部分“回收价值最高”的老年代Region。
2. 底层垃圾回收算法
-
CMS
- 算法:基于 “标记-清除”(Mark-Sweep) 算法。
- 核心缺陷:这导致CMS在回收后,会产生大量的内存碎片。当碎片过多,无法为大对象找到连续空间时,就会触发一次后备的、STW时间极长的Full GC,这对于追求低延迟的应用是灾难性的。
-
G1
- 算法:从整体来看,它基于 “标记-整理”(Mark-Compact) 思想;从局部(两个Region之间的数据转移)来看,它又是基于 “复制”(Copying) 算法。
- 核心优势:无论是新生代回收还是Mixed GC,G1都是通过将一个或多个Region中的存活对象,复制到其他空的Region中来完成回收的。这个过程天然地就完成了内存的“整理”,所以G1从根本上不会产生内存碎片。
3. 停顿时间的可预测性
-
CMS
- 可预测性:较差。CMS致力于缩短单次GC的停顿时间,但它对于一次GC究竟会停顿多久,无法做出很好的预测。STW的时间会随着堆大小和对象数量的增长而增长。
-
G1
- 可预测性:非常出色。这是G1一个革命性的设计。它引入了 “可预测的停顿时间模型”。
- 实现方式:G1会跟踪每个Region的回收价值(即回收它能释放多少空间,以及预计需要多长时间)。
- 用户可以通过JVM参数
-XX:MaxGCPauseMillis来设定一个期望的最大停顿时间(比如200ms)。 - 在每次GC时,G1会根据这个期望值,有选择性地回收那些“性价比最高”(回收价值大、耗时短)的Region,而不是一次性回收所有垃圾。通过控制一次回收的Region数量,G1就能尽力将STW时间控制在用户的预期之内。
总结对比
| 特性 | CMS (Concurrent Mark Sweep) | G1 (Garbage-First) |
|---|---|---|
| 作用范围 | 仅老年代 (需配合新生代GC) | 整个堆 (统一管理) |
| 内存布局 | 传统物理分代 | Region化 (逻辑分代) |
| 核心算法 | 标记-清除 | 标记-整理 + 复制 |
| 空间碎片 | 会产生 (致命缺陷) | 不会产生 |
| 停顿预测 | 不可预测 | 可预测 (核心优势) |
| JDK默认 | (曾经是低延迟首选) | JDK 9+ 默认GC |
一句话总结:
G1可以被看作是CMS的“完美进化体”。它通过引入Region化的内存布局和可预测的停顿时间模型,不仅解决了CMS最头疼的内存碎片问题,还提供了更灵活、更可控的GC停顿管理能力,是更适合现代大内存、低延迟应用需求的垃圾回收器。这也是为什么它最终取代CMS,成为Java新版本中默认GC的原因。
什么是 ZGC?
面试官您好,ZGC(Z Garbage Collector)是JDK 11中引入的一款实验性、并在后续版本(如JDK 15)中正式可用(Production Ready) 的、可伸缩的、低延迟垃圾收集器。
它的设计目标非常明确和激进:无论堆的大小如何(从几百MB到TB级别),都能将GC的暂停时间(STW)控制在几毫-秒甚至亚毫秒(< 1ms)的级别。
可以说,ZGC是Oracle官方为了应对现代应用中超大内存(Very Large Heaps) 和对延迟极度敏感(Ultra-Low Latency) 这两大挑战,而推出的终极武器。
1. ZGC的核心特性与设计哲学
ZGC的实现,是建立在几项革命性的技术之上的:
a. 全并发的GC周期 (Fully Concurrent)
与之前的CMS或G1这些并发GC器不同,ZGC将“并发”做到了极致。在它的整个工作周期中,几乎所有的阶段,包括标记、对象转移、引用重定位等这些在其他GC器中通常需要STW的重量级操作,都是并发执行的,即GC线程可以与应用线程同时运行。
- 极短的STW:ZGC的STW阶段非常短暂,通常只存在于一些根扫描的初始阶段,其停顿时间不会随着堆大小或存活对象数量的增加而增加。
b. 区域化内存布局 (Region-based)
- 与G1类似,ZGC也将Java堆划分为多个动态的区域(ZPage)。但ZGC的Region大小不是固定的,而是分为小、中、大三种规格,可以更灵活地处理不同大小的对象分配。
c. 着色指针 (Colored Pointers)
这是ZGC实现并发转移的核心黑科技。
- 传统GC的难题:在GC进行对象移动(复制或整理)时,必须更新所有指向这个对象的引用。这个更新过程如果不暂停应用线程,应用线程就可能通过旧的引用,访问到已经被移动的、错误内存地址上的对象。
- ZGC的解决方案:它利用了现代64位处理器的虚拟地址寻址空间巨大的特点。它将一个64位的指针(引用),拆分为两部分:
- 高42-45位:用来存储对象的真实内存地址。
- 低几位(元数据位):不用来寻址,而是像“染色”一样,用来存储与GC相关的状态信息。主要有3个标记位:
Marked0Marked1Remapped
- 通过这几个标记位,ZGC可以在不改变对象真实物理地址的情况下,就知道这个对象当前处于GC的哪个阶段(比如是否已被标记、引用是否已修复等)。
d. 读屏障 (Load Barrier)
- 作用:读屏障是一段由JIT编译器插入到对象读取操作(比如
mov rax, [obj_addr])之前的一小段代码。 - 如何配合着色指针工作:
- 当应用线程尝试通过一个引用,加载一个对象时,读屏障会先检查这个引用指针上的“颜色”(元数据位)。
- 如果发现这个对象的标记是“需要修复的”(比如,对象已经被GC并发移动了,但这个引用还是旧的),读屏障就会触发一个“慢速路径”。
- 在这个慢速路径中,线程会根据GC提供的转发表(Forwarding Table),自我修复这个引用,使其指向对象的新地址,并更新指针的颜色。然后再继续执行原来的加载操作。
- 效果:通过这种“自愈”的方式,ZGC巧妙地解决了在GC并发移动对象时,应用线程如何安全地访问这些对象的问题,从而实现了并发的对象转移和引用重定位。
2. ZGC的工作流程简介
ZGC的回收周期主要也分为几个大的阶段,但几乎都是并发的:
- 并发标记 (Concurrent Mark):与G1类似,找出所有存活对象。
- 并发准备/转移 (Concurrent Prepare for Relocate):准备用于对象转移的Region集合。
- 并发转移 (Concurrent Relocate):将存活对象从一组Region,并发地拷贝到另一组新的Region。
- 并发重映射/修复 (Concurrent Remap):更新整个堆中所有指向被移动对象的引用。这个过程主要就是依赖读屏障来完成的。
总结
| 特性 | ZGC |
|---|---|
| 核心目标 | 超低延迟 (< 1-10ms),不随堆大小增长而增长 |
| 关键技术 | 着色指针 (Colored Pointers) + 读屏障 (Load Barrier) |
| 工作模式 | 几乎完全并发 (标记、转移、重定位都并发) |
| 内存布局 | Region化 (ZPage),支持不同大小的Region |
| 适用场景 | 对延迟极度敏感、且需要超大堆内存 (几十G到TB级别) 的现代应用,如大型微服务、金融交易系统、实时数据分析平台等。 |
ZGC代表了当前Java GC技术的最前沿。它通过颠覆性的设计,为Java应用在超大内存和低延迟这两个看似矛盾的需求之间,提供了一个革命性的解决方案。
GC只会对堆进行GC吗?
面试官您好,这是一个非常好的问题。虽然我们常说GC主要是针对Java堆,但严格来说,垃圾回收器的工作范围并不仅仅局限于堆,它同样也会对方法区进行回收。
不过,方法区的垃圾回收,其内容、条件和性价比都与堆的回收有很大的不同。
1. 堆的垃圾回收 (主要战场)
绝大多数的GC活动都发生在Java堆上,这是GC的“主战场”。其主要目的就是回收那些不再被任何GC Roots引用的对象实例。堆的回收算法和策略(如分代收集)都非常成熟和高效。
2. 方法区的垃圾回收 (次要战场)
方法区(在JDK 8后被称为元空间Metaspace)的垃圾回收,其性价比通常较低。因为这里存放的主要是类的元数据、常量等生命周期很长的数据,回收的频率和效率都远低于堆。
方法区的回收主要针对两大内容:
a. 废弃常量的回收
- 回收对象:主要是运行时常量池中的一些常量,比如字面量。
- 回收条件:相对简单。比如,一个字符串常量
"abc",如果当前系统中没有任何一个String对象的引用指向这个常量池中的"abc",并且也没有其他地方引用这个字面量,那么在GC时,它就可能会被清理出常量池。 - 效果:这部分的回收与堆中对象的回收逻辑类似。
b. 无用类的回收 (Class Unloading)
-
回收对象:对类型(Class) 本身的卸载。
-
回收条件:这个条件极其苛刻,必须同时满足以下三个条件,一个类才会被认为是“无用的类”,才有可能被回收:
- 该类的所有实例都已经被回收。也就是说,Java堆中不存在该类及其任何子类的实例。
- 加载该类的
ClassLoader已经被回收。这个条件通常很难达成。由JVM自带的三个类加载器(启动、扩展、应用)加载的类,在其生命周期内是基本不可能被卸载的。只有在一些动态、可替换的场景下(如OSGi、JSP的热部署),由我们自定义的ClassLoader加载的类,才有可能满足这个条件。 - 该类对应的
java.lang.Class对象,在任何地方都没有被引用,无法通过反射等方式访问到该类的方法。
-
为什么苛-刻? 因为类的卸载是一个非常复杂且有风险的操作,JVM对此非常谨慎。
总结与实践意义
| 回收区域 | 主要回收内容 | 回收条件 | 回收效率/频率 |
|---|---|---|---|
| Java堆 | 对象实例 | 不可达 (从GC Roots) | 高 / 频繁 |
| 方法区 | 废弃常量、无用的类 | 常量无引用、类卸载条件极其苛-刻 | 低 / 不频繁 |
结论:
- 所以,GC确实会回收方法区,但这部分回收的触发频率低,且条件严苛,尤其是类的卸载。因此,我们在讨论GC时,通常关注的焦点都在堆内存上。
- 在实际开发中,如果遇到元空间OOM(
OutOfMemoryError: Metaspace),通常意味着我们加载了过多的类,或者因为动态类生成(如CGLIB)和自定义类加载器的使用不当,导致大量的类元数据无法被卸载,从而撑爆了元空间。
更多推荐



所有评论(0)