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的触发时机,可以分为 “被动触发” 和 “主动建议” 两种情况。
-
情况一:被动/自动触发(这是最主要的方式)
- 堆内存分配失败:这是最常见、最主要的触发原因。当程序需要
new
一个新对象时,如果在堆的Eden区找不到足够的连续空间来分配,JVM就会触发一次Minor GC(新生代GC)。如果Minor GC后内存仍然不足(比如对象太大),或者老年代空间也不足,就可能会触发一次Full GC(全堆GC)。 - 老年代空间使用率达到阈值:一些GC器(如CMS、G1)会持续监控老年代的内存使用情况。当使用率达到一个预设的阈值时(比如70%),为了避免等到内存完全耗尽再回收而导致长时间停顿,它们会主动地触发一次并发的垃圾回收。
- 元空间(Metaspace)不足:当加载的类过多,导致元空间不足时,也会触发Full GC。
- 堆内存分配失败:这是最常见、最主要的触发原因。当程序需要
-
情况二:主动建议触发(一般不推荐)
- 手动调用
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时,可以立即被回收,无需等待一个全局的GC周期。
-
致命缺点:无法解决“循环引用”问题
-
这是导致主流JVM放弃使用引用计数法作为主要垃圾判断算法的根本原因。
-
循环引用场景:
public class CircularReference {public Object instance = null;public static void main(String[] args) {CircularReference objA = new CircularReference();CircularReference objB = new CircularReference();// 相互引用objA.instance = objB;objB.instance = objA;// 断开外部引用objA = null;objB = null;// 在这里,objA和objB实际上已经无用了,但它们的引用计数器都不为0// ... 手动触发GC ...} }
-
在上面的代码中,当
objA
和objB
被置为null
后,从程序的角度看,这两个对象已经无法被访问,是“垃圾”了。但是,由于它们内部互相引用着对方,导致它们的引用计数器都为1,永远不为0。 -
因此,采用引用计数法的垃圾回收器,将永远无法回收这两个对象,造成了内存泄漏。
-
2. 可达性分析算法 (Reachability Analysis) —— 主流JVM的选择
-
核心思想:
- 这是当前所有主流商用虚拟机(如HotSpot)都在使用的算法。它的思路是,通过一系列被称为 “GC Roots” 的根对象作为起点,从这些节点开始向下搜索,搜索所走过的路径称为 “引用链”(Reference Chain)。
- 当一个对象到任何GC Roots之间都不存在任何引用链时(即从GC Roots出发,无论如何都无法访问到这个对象),就证明此对象是不可达的,可以被回收。
-
什么是GC Roots?
- GC Roots是一组必须存活的对象,它们是程序所有活对象的“根源”。主要包括:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象:比如方法里的局部变量。
- 方法区中类静态属性引用的对象:比如一个类的
static
字段。 - 方法区中常量引用的对象:比如字符串常量池里的引用。
- 本地方法栈中JNI(即
native
方法)引用的对象。 - 所有被
synchronized
持有的对象(即锁对象)。 - JVM内部的一些特殊引用,如类加载器等。
- GC Roots是一组必须存活的对象,它们是程序所有活对象的“根源”。主要包括:
-
优点:
- 完美解决循环引用问题:在上面的循环引用例子中,虽然
objA
和objB
互相引用,但由于没有任何一条引用链能从任何一个GC Root到达它们,所以它们会被判定为不可达,从而被正确地回收。 - 实现精准:逻辑严谨,是目前最成熟、最可靠的垃圾判断方案。
- 完美解决循环引用问题:在上面的循环引用例子中,虽然
-
缺点:
- 实现相对复杂。
- 在分析期间,通常需要 “Stop The World”(STW),即暂停所有应用线程,以保证分析期间引用关系不会发生变化。当然,现代的并发垃圾回收器(如CMS、G1)已经通过各种技术(如三色标记法)极大地缩短了STW的时间。
总结一下,虽然引用计数法简单高效,但因其无法处理循环引用的硬伤而被主流JVM所摒弃。而可达性分析算法,凭借其精准性和对循环引用的完美解决,成为了当今Java世界中判断对象存活与否的事实标准。
垃圾回收算法是什么,是为了解决了什么问题?
面试官您好,GC的核心目的,就是为了将开发者从复杂、易错的手动内存管理中解放出来。
为了实现这个目标,JVM的“垃圾回收器”在判断出哪些是“垃圾对象”之后,就需要通过具体的垃圾回收算法来回收它们占用的内存。这些算法主要有以下几种,它们是逐步演进的,每一种都是为了解决前一种算法的不足。
1. 标记-清除算法 (Mark-Sweep)
这是最基础的垃圾回收算法。
-
工作流程:
- 标记 (Marking):首先,通过可达性分析,从GC Roots开始遍历,所有可达的对象都被标记为“存活对象”。
- 清除 (Sweeping):然后,再次遍历整个堆,将所有未被标记的对象(即垃圾对象)进行回收,直接抹掉它们占用的内存。
-
解决了什么问题?
- 解决了最基本的问题——“如何回收垃圾”。
-
它带来了什么新问题?
- 效率问题:需要进行两次全堆扫描(标记一次,清除一次),效率较低。
- 空间碎片问题:这是它最致命的缺点。清除后,会产生大量不连续的内存碎片。当后续程序需要分配一个较大的对象时,即使堆的总剩余空间是足够的,也可能因为找不到一块连续的、足够大的空间而不得不提前触发下一次GC,甚至导致OOM。
2. 标记-复制算法 (Mark-Copy) / 复制算法
这是为了解决“标记-清除”算法的效率和空间碎片问题而生的。
-
工作流程:
- 它将可用的堆内存划分为大小相等的两块,比如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的实现(
Serial
,ParNew
,Parallel Scavenge
等GC器)通常将新生代划分为一个Eden区和两个Survivor区(比例约为8:1:1),每次只浪费10%的Survivor空间,大大缓解了复制算法的空间浪费问题。 - 老年代 (Old Generation):对象存活率高,生命周期长。因此,不适合用复制算法。老年代的GC器(如
CMS
,G1
的部分阶段,Serial Old
)通常采用**“标记-清除”或“标记-整理”**算法(或它们的混合变体)。
- 新生代 (Young Generation):对象存活率极低。因此,非常适合采用复制算法。HotSpot的实现(
总结一下,垃圾回收算法的演进,就是一个不断发现问题、解决问题的过程:
- 标记-清除 -> 解决了“回收垃圾”的有无问题。
- 标记-复制 -> 解决了“标记-清除”的碎片和效率问题,但带来了空间浪费。
- 标记-整理 -> 解决了“标记-清除”的碎片问题,且比“标记-复制”更节省空间。
- 分代收集 -> 最终,通过对不同生命周期的对象“因材施教”,采用最合适的算法,达到了性能和效率的最佳平衡。
垃圾回收器有哪些?
面试官您好,Java的垃圾回收器(GC)经过多年的发展,已经演进出了一个非常丰富的“家族”。它们的演进,主要围绕着两个核心目标:提高吞吐量(Throughput)和降低停顿时间(Latency)。
我通常会按照它们的演进脉络,将它们分为以下几个时代:
第一代:串行收集器 (Serial Era) —— 单线程GC
这是最古老、最基础的GC,它在进行垃圾回收时,必须暂停所有用户线程(Stop-The-World, STW),并且只使用单条线程去完成GC工作。
- 1.
Serial
收集器 (新生代, 复制算法) - 2.
Serial Old
收集器 (老年代, 标记-整理算法) - 特点:简单高效(对于单核CPU和少量内存),但因为是单线程且STW,无法利用多核CPU,停顿时间较长。
- 适用场景:主要用于 客户端模式(Client VM) 下的桌面应用,或者是一些对停顿时间不敏感、硬件配置较低的服务器。
第二代:并行收集器 (Parallel Era) —— 吞吐量优先
为了利用多核CPU的威力,并行收集器应运而生。它们在GC时,会使用多条线程并行地进行垃圾回收,从而大大缩短了STW的时间。但请注意,在GC期间,用户线程仍然是全部暂停的。
- 3.
ParNew
收集器 (新生代, 复制算法)- 可以看作是
Serial
的多线程版本,是很多运行在Server模式下虚拟机的首选新生代收集器,一个很重要的原因就是只有它能和CMS收集器配合工作。
- 可以看作是
- 4.
Parallel Scavenge
收集器 (新生代, 复制算法)- 这是JDK 8默认的新生代收集器。它的目标是达到一个可控制的吞吐量。它提供了参数来精确控制最大GC停顿时间和吞吐量大小。
- 5.
Parallel Old
收集器 (老年代, 标记-整理算法)- 是
Parallel Scavenge
的老年代版本,在注重吞吐量和CPU资源敏感的场合,通常会使用它们俩的组合。
- 是
第三代:并发收集器 (Concurrent Era) —— 低停顿优先
并行GC虽然缩短了STW,但停顿依然存在。为了进一步降低停顿时间,特别是老年代GC带来的长停顿,并发收集器被设计出来。它的核心思想是让GC线程与用户线程在大部分时间内可以并发执行。
- 6.
CMS
(Concurrent Mark Sweep) 收集器 (老年代, 标记-清除算法)- 目标:以获取最短回收停顿时间为目标。
- 特点:它的工作过程分为初始标记、并发标记、重新标记、并发清除等多个阶段。其中,耗时最长的并发标记和并发清除阶段,GC线程都可以与用户线程一起工作。
- 缺点:
- 采用“标记-清除”算法,会产生内存碎片。
- 对CPU资源非常敏感,会“抢占”用户线程的CPU。
- 无法处理“浮动垃圾”,可能导致并发失败(Concurrent Mode Failure),从而触发一次代价高昂的Full GC。
- CMS是GC发展史上的一个重要里程碑,是第一款真正意义上的并发收集器。
第四代:区域化、面向全堆的收集器 (G1 & Beyond)
CMS等之前的GC器,都是明确区分新生代和老年代的。而G1开创了一个新的思路。
- 7.
G1
(Garbage-First) 收集器 (面向全堆, 复制 + 标记-整理)- 这是JDK 9之后默认的垃圾回收器。
- 特点:
- 不再划分固定的新生代和老年代。它将整个Java堆划分为多个大小相等的独立区域(Region),每个Region都可以根据需要扮演Eden、Survivor或Old的角色。
- 它引入了可预测的停顿时间模型。G1会跟踪每个Region的回收价值(回收能释放多少空间,以及预计需要多少时间),并维护一个优先级列表。在回收时,它会优先回收那些“价值最高”的Region,这也是“Garbage-First”名字的由来。
- 从整体上看,它基于“标记-整理”思想,从局部(两个Region之间)看,又基于“复制”算法,所以不会产生内存碎片。
第五代:ZGC & Shenandoah —— 面向未来的低延迟GC
- ZGC (JDK 11引入, JDK 15生产可用) 和 Shenandoah (JDK 12引入)
- 目标:追求在任何堆大小下,都能将GC的停顿时间控制在几毫秒甚至亚毫秒级别。
- 特点:它们使用了更先进的技术,如着色指针(Colored Pointers)、读屏障(Read Barriers)等,使得几乎所有的GC工作(包括标记、转移、重定位等)都可以并发执行,从而实现了极低的STW时间。
- 适用场景:适用于对延迟极度敏感的、需要超大堆内存(几十G甚至上百G)的现代应用。
选型总结:
- JDK 8:默认是
Parallel Scavenge
+Parallel Old
,追求高吞吐量。如果对停顿时间有要求,ParNew
+CMS
是一个常见的选择。 - JDK 9及以后:
G1
是官方推荐和默认的选择,它在吞吐量和低延迟之间取得了很好的平衡。 - 未来与超大堆:对于需要极致低延迟和巨大堆内存的应用,
ZGC
是未来的发展方向。
标记清除算法的缺点是什么?
面试官您好,标记-清除(Mark-Sweep)算法是垃圾回收中最基础的算法之一,但它存在两个非常致命的缺点,这也促使了后续更先进算法的诞生。
正如您所说,这两个缺点是:执行效率低下和空间碎片化。
1. 缺点一:执行效率低下
- 为什么效率低? 因为它的整个工作流程需要对堆内存进行两次大规模的扫描。
- 第一次扫描(标记阶段):需要从GC Roots出发,遍历整个对象图,找出并标记所有存活的对象。
- 第二次扫描(清除阶段):需要再次遍历整个堆,找到所有未被标记的“垃圾”对象,并将它们占用的内存回收。
- 当堆中对象数量庞大时,这两次扫描会消耗大量时间,导致较长的 “Stop-The-World”(STW) 停顿,影响应用程序的响应。
2. 缺点二:空间碎片化(这是最严重的问题)
-
什么是空间碎片? 标记-清除算法在回收内存时,并不是将垃圾对象挪走,而是像在地图上“抹掉”它们一样,直接原地释放。这就导致回收后的可用内存,变成了许多零散的、不连续的小块。
-
一个生动的比喻:电影院的空座位
- 想象一个电影院,总共有100个座位。现在已经卖出去了70张票,但这些观众是随机坐的,座位零零散散。
- 此时,影院的总空位数是30个,看起来还很充裕。
- 但如果这时来了一个5个人的家庭,他们想坐在一起。售票员找遍了全场,也找不到连续的5个空位,最终只能告诉他们“抱歉,没票了”。
- 在这个比喻里:
- 电影院就是堆内存。
- 零散的空位就是内存碎片。
- 5个人的家庭就是一个需要分配的大对象。
-
带来的危害:
- 这种空间碎片化,会导致内存利用率下降。
- 最严重的是,它可能导致无法为大对象分配内存。当程序需要分配一个较大的对象时(比如一个大数组),即使堆的总剩余空间是足够的,也可能因为找不到一块连续的、足够大的空间而分配失败。
- 这种失败会被迫提前触发下一次更昂贵的垃圾回收(Full GC),希望能腾出连续空间。如果Full GC后仍然无法满足,最终就会导致
OutOfMemoryError
。
后续算法如何解决这些问题?
正是为了解决标记-清除算法的这两个缺点,后续的算法才被提了出来:
- 复制算法:通过将存活对象复制到另一块内存,完美地解决了空间碎片问题,并且在对象存活率低时效率很高。
- 标记-整理算法:通过在标记后,将存活对象向一端移动,也解决了空间碎片问题,并且比复制算法更节省空间。
所以,我们可以把标记-清除算法看作是GC算法演进的“起点”,它虽然有缺陷,但为后续更优算法的设计提供了基础和方向。
垃圾回收算法哪些阶段会stop the world?
面试官您好,“Stop-The-World”(STW),也就是暂停所有应用线程,是垃圾回收过程中为了保证数据一致性而必须采取的措施。几乎所有的垃圾回收算法都或多或少地存在STW阶段,但它们的区别在于STW发生在哪几个阶段,以及停顿时间的长短。
我将按照不同类型的GC器来分别阐述它们的STW阶段:
1. 串行/并行GC器 (Serial, Parallel Scavenge/Old)
这类GC器的特点是,它们的整个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的回收价值进行排序。
- 然后,它会根据用户设定的预期停顿时间 (
-XX:MaxGCPauseMillis
),选择一部分回收价值最高的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的历史。
minorGC、majorGC、fullGC的区别,什么场景触发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区。如果对象的年龄达到阈值,则直接晋升到老年代。
- 清空Eden区和From Survivor区。
- 特点:
- 速度快、频率高:因为新生代的对象绝大多数都是“朝生夕死”,存活对象很少,所以采用复制算法的Minor GC效率非常高。
- 会引发“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必然会包含对老年代的回收,所以一次Full GC通常也伴随着一次Major GC(但反之不成立)。在很多情况下,人们可能会混用这两个术语,但Full GC的范围更广。
什么场景会触发致命的Full GC?
在生产环境中,我们应该极力避免频繁的Full GC。以下是一些最常见的触发场景:
-
老年代空间不足
- 原因:这是最直接的原因。当有大对象要直接分配到老年代,或者新生代有大量对象要晋升到老年代时,如果老年代的剩余空间不足,就会触发Full GC。
- 背后可能的问题:
- 持续的内存泄漏:大量无用对象占据老年代,无法被回收。
- 新生代配置不合理:比如新生代过小,导致对象过早、过快地晋升到老年代。
-
方法区(元空间)空间不足
- 原因:当系统需要加载大量新的类、或者使用CGLIB等技术动态生成大量类时,如果元空间(Metaspace)满了,也会触发Full GC来尝试卸载无用的类。
- JVM参数:可以通过
-XX:MetaspaceSize
和-XX:MaxMetaspaceSize
来调整。
-
显示调用
System.gc()
- 原因:如果在代码中手动调用了
System.gc()
,JVM默认会执行一次Full GC。 - 最佳实践:强烈不建议在生产代码中这样做。可以通过JVM参数
-XX:+DisableExplicitGC
来禁止这种手动的GC调用。
- 原因:如果在代码中手动调用了
-
新生代晋升到老年代的平均大小大于老年代的剩余空间
- 原因:这是由一种叫做 “空间分配担保” 的机制触发的。在进行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 (Concurrent Mark Sweep)
- 内存布局:基于传统的分代模型,严格划分新生代和老年代。
- 回收范围:是一款只作用于老年代的并发收集器。它需要与一个新生代收集器(通常是
ParNew
)配合使用。它无法处理新生代的垃圾回收。
-
G1 (Garbage-First)
- 内存布局:彻底颠覆了传统分代模型。它将整个Java堆划分为多个大小相等的独立区域(Region)。每个Region都可以根据需要,动态地扮演Eden、Survivor或Old的角色。
- 回收范围:是一款面向整个堆的收集器。它在一个统一的框架下,同时管理新生代和老年代的回收,不再需要与其他GC器配合。G1的GC(称为Mixed GC)可以同时回收新生代的Region和一部分“回收价值最高”的老年代Region。
2. 底层垃圾回收算法
-
CMS
- 算法:基于 “标记-清除”(Mark-Sweep) 算法。
- 核心缺陷:这导致CMS在回收后,会产生大量的内存碎片。当碎片过多,无法为大对象找到连续空间时,就会触发一次后备的、STW时间极长的Serial Old 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时间控制在用户的预期之内。
4. 其他差异
- 浮动垃圾:CMS在并发清除阶段,用户线程还在运行,会产生新的垃圾,这部分垃圾只能等到下一次GC再清理,被称为“浮动垃圾”。CMS需要预留一部分空间来存放这些浮动垃圾。而G1通过其Region化的管理,能更有效地处理这个问题。
总结对比
特性 | CMS (Concurrent Mark Sweep) | G1 (Garbage-First) |
---|---|---|
作用范围 | 仅老年代 (需配合新生代GC) | 整个堆 (新生代+老年代) |
内存布局 | 传统分代 (连续的新生代/老年代) | Region化 (不连续,动态角色) |
核心算法 | 标记-清除 | 标记-整理 + 复制 |
空间碎片 | 会产生 (致命缺陷) | 不会产生 |
停顿预测 | 不可预测 | 可预测 (核心优势) |
JDK默认 | (曾经是低延迟首选) | JDK 9+ 默认GC |
一句话总结:
G1可以被看作是CMS的“完美进化体”。它通过引入Region化的内存布局和可预测的停顿时间模型,不仅解决了CMS最头疼的内存碎片问题,还提供了更灵活、更可控的GC停顿管理能力,是更适合现代大内存、低延迟应用需求的垃圾回收器。这也是为什么它最终取代CMS,成为Java新版本中默认GC的原因。
GC只会对堆进行GC吗?
面试官您好,这是一个非常好的问题。虽然我们常说GC主要是针对Java堆,但严格来说,垃圾回收器的工作范围并不仅仅局限于堆,它同样也会对方法区进行回收。
不过,方法区的垃圾回收,其内容、条件和性价比都与堆的回收有很大的不同。
1. 堆的垃圾回收 (主要战场)
正如您所说,堆是GC的“主战场”。绝大多数的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)和自定义类加载器的使用不当,导致大量的类元数据无法被卸载,从而撑爆了元空间。
参考小林coding和JavaGuide