Yujun's Blog
JVM(二):垃圾回收 (GC) 机制
JVM(二):垃圾回收 (GC) 机制
GC垃圾回收器要解决哪些问题?
了解了这个问题之后,我们才能把整个垃圾回收的知识串起来。
- 哪些内存需要回收?
- 如何回收内存空间?
- 什么时候进行回收?
JVM为什么分代?
也就是为什么 JVM 不把所有的对象进行统一管理并清理,而是要分为 年轻代 和 老年代?
分代收集虽然称“理论”,但是却是一套符合大多数程序运行实际情况的经验法则,建立在两个假说之上:
- 绝大多数对象(约90%-99%)在创建后不久就会死亡(变得不可达)。
- 少数对象会重新存活。
新生代区域的对象的特点是,对象死亡率极高,存活率非常低。正是因为如此,JVM可以对新生代进行频繁的GC。为了最大化效率,年轻代通常采用复制算法进行垃圾回收。
因为复制算法的效率与存活对象的数量成正比,年轻代存活对象少,复制开销自然极小,且能够完美避免内存碎片。其结果就是,年轻代的GC(我们称之为Minor GC或Young GC)虽然频繁,但单次停顿时间极短,对应用影响小。
当年轻代的对象经过多次垃圾回收后仍然存活,它们就会被“晋升”到老年代。与年轻代相反,老年代的特点是对象存活率高,因为这里存放的都是那些被证明长期存活的对象。因此,老年代的GC频率相对较低。对于老年代,GC通常会采用标记-整理算法或标记-清除算法。这是因为如果像年轻代一样使用复制算法,由于老年代存活对象多,复制它们的开销将是巨大的。而标记-整理/清除算法更适合这种高存活率的区域,虽然标记-清除可能产生内存碎片,但标记-整理则能有效避免。最终,老年代的GC(Major GC或Full GC)虽然不频繁,但由于需要处理的对象较多,单次耗时通常较长,可能导致更明显的停顿。
JVM分代,是基于“大多数对象短命,少数对象长寿”的经验法则,通过将堆划分为年轻代和老年代,并对它们采用不同的GC算法和策略,实现了高效、低停顿的垃圾回收。
什么情况下新生代对象会晋升到老年代?
主要有以下四种情况:
- 达到年龄阈值 (Tenuring Threshold):
- 机制: 每个对象在新生代中经历一次Minor GC(Young GC)并存活下来,其“年龄”就会增加1。当对象的年龄达到JVM设定的**最大年龄阈值(默认15次,由参数-XX:MaxTenuringThreshold控制)**时,就会被晋升到老年代。
- 理由: 认为能存活这么多次GC的对象,是“长寿”对象,适合长期存放。
- 大对象直接晋升老年代 (Pretenure Large Objects):
- 机制: 对于非常大的对象(例如一个很大的数组),如果它们直接在Eden区分配不下,或者JVM认为在年轻代多次复制会带来巨大开销时,这些对象会直接在老年代中分配。
- 理由: 避免年轻代频繁的复制操作,提高GC效率。
- 参数: -XX:PretenureSizeThreshold(仅对Serial和ParNew收集器有效,G1等更现代的收集器通常不直接使用此参数,而是有其内部优化)。
- 动态年龄判断 (Dynamic Tenuring Threshold):
- 机制: JVM不总是严格遵循MaxTenuringThreshold。在Minor GC时,如果某个Survivor区中相同年龄对象大小的总和,超过了该Survivor区空间的一半,那么比这个年龄更老(或等于这个年龄)的所有对象,都会被立即晋升到老年代,而无需等到MaxTenuringThreshold。
- 理由: 这是JVM为了更灵活地管理内存,避免Survivor区溢出,并提前释放年轻代空间。
- 空间分配担保失败 (Handle Promotion Failure):
- 机制: 在执行Minor GC前,JVM会检查老年代是否有足够的连续空间来容纳年轻代中所有存活的对象(最坏情况)。如果老年代空间不足(或预测不足),就会触发一次Full GC(Major GC),清理老年代。如果Full GC后老年代依然没有足够空间,那么Minor GC可能会因为无法担保空间而失败,并抛出OutOfMemoryError。但在某些情况下,如果老年代经过清理后可以提供空间,年轻代存活对象也会直接晋升到老年代。
- 理由: 这是JVM面对“最坏情况”的一种内存担保机制,确保年轻代存活对象有地方可去。
这些机制都是为了将短期对象(年轻代)和长期对象(老年代)进行有效隔离,并根据它们的生命周期特性,采用最适合的GC算法,从而提高GC效率并减少对应用执行的停顿影响。
三色标记算法是什么?存在什么问题?三色标记漏标问题如何解决?
这是一种重要的对象标记算法,特别在并发垃圾回收器(如CMS和G1)中得到广泛应用。核心目的是在应用线程和GC线程同时执行时,能够正确识别所有存活对象。
三色标记算法(Tri-color Marking Algorithm) 将堆中的所有对象划分为三种颜色,用来表示它们在GC过程中的状 态。 给对象如何标记颜色?很简单,两个比特位就可以实现(00,01,10)
- 白色
- 表示
- 灰色
- 表示
- 黑色
- 表示
接下来谈谈标记的过程: 初始状态所有对象都是白色。GC Roots(根对象)被标记为灰色。
由于在并发标记过程中,由于应用线程也在同时运行,可能会出现两种情况:
- 多标问题:也就是将某些已经死亡的对象标记为存活对象。
- 应用线程将黑色对象引用解除,使其变为不可达。这导致某些对象在标记阶段被错误地标记为黑色(存活),但实际上已死亡。
- 它们会作为“浮动垃圾”保留到下一轮GC再清理。这是可接受的,只是浪费了一点内存。
- 漏标问题:仍然存活的对象被标记为死亡对象。这是更严重的问题,会导致程序错误。
为了应对并发执行带来的“漏标”问题,CMS和G1分别采用增量更新和SATB这两种不同的写屏障技术来确保标记的正确性,从而实现高并发下的垃圾回收。
垃圾回收器知道哪些?
JVM提供了多种GC实现,是我们之前学到的垃圾回收算法的组合和优化,用来适用不同应用场景和性能目标。
具体来说可以分为这几大类:串行,并行,CMS,G1,ZGC。
注意 :CMS 和 G1 这两个垃圾回收器的工作流程在面试中属于必问的知识点。一定要能够使用自己的语言表达出来。
CMS垃圾回收的过程是怎样的?
CMS收集器(并发标记清除 Concurrent Mark Sweep)将垃圾回收过程分为四个主要步骤。分别是:
- 初始标记 (Initial Mark)
- 并发标记 (Concurrent Mark)
- 重新标记 (Remark)
- 并发清除 (Concurrent Sweep)
这里我们先眼熟这四个步骤,保证之后能先流畅地说出来。接下来再来深入每个环节进行分析,为什么要这样设计。
既然Mark Sweep 会产生内存碎片,那为什么不换成Mark Compact呢?
也是从它的设计理念来思考,选择标记-清除算法,因为它相对简单,且易于实现并发清除。它不进行内存整理,是因为内存整理(移动对象)是极其耗时的操作,如果把它也放到STW阶段,就彻底违背了低停顿的初衷;如果放到并发阶段,又会增加并发的复杂度和风险。
CMS主要有以下几个缺点,也是面试中重点,方便引出G1:
- 产生内存碎片
- 可能退化为Full GC
- 无法处理浮动垃圾
CMS处于一个比较尴尬的地位,它曾经是光荣的,是HotSpot JVM中第一个真正意义上实现并发回收、追求低停顿的收集器,但它的内在缺陷以及被更优秀、更完善的G1所取代。目前CMS已经完成了它的历史使命,如今除了维护一些很老的项目,我们基本不可能在新项目中选择它了。CMS诞生于JDK5,在JDK 9中被标记为废弃(deprecated),并在JDK 14中被直接移除(removed)。
-XX:+UseG1GC
有了CMS,为什么还要引入G1?
G1(Garbage-First) 在 JDK 9 及之后才成为默认 GC,在 JDK 8 中需手动启用(-XX:+UseG1GC
)。
G1的垃圾回收过程介绍一下?
故名思义,即“垃圾优先”,G1的设计思想是优先收集那些垃圾最多,回收效率最高的 Region。它打破了传统分代收集器连续的年轻代和老年代概念,引入了区域化(Region-based)堆模型,这是其所有工作的基础。
首先让我们先来看看什么是 Region-based 的区域化堆模型,接下来在来讨论它GC的工作流程。
整个Java堆空间被分割成多个大小相等的独立区域(Region)。每个Region在运行时可以动态地扮演以下角色:
- Eden / Survivor Region:承载新生代对象。
- Old Region:承载老年代对象。
- Humongous Region:专用于存储大小超过Region一半的巨型对象。
- Frrr Region:未使用的空闲区域。
这种设计能够使G1能够进行增量的,有选择性的收集,而不是全盘扫描。 G1 GC的垃圾回收过程主要包括三个环节:
- 年轻代
- 老年代并发标记
- 混合回收
如果并发标记或混合GC的速度无法跟上对象分配的速度,导致老年代或者 Humongous Region的分配空间耗尽时,G1会退化为单线程、全暂停的Full GC。
每次根据允许的收集时间,优先回收价值最大的 Region。
辅助机制:记忆集 (Remembered Set, RSet) 和卡片标记 (Card Table)
首先,在G1的区域化堆模型中,一个Region的对象不可能是孤立的,它可能被其他任意Region中的对象所引用(称为“跨Region引用”)。
那么,当G1需要回收某个特定Region时,如何高效地判断该Region内的对象是否仍被外部Region引用,而无需扫描整个Java堆?
在其他的分代收集器,也存在这样的问题,只是在G1中更突出。但在G1的细粒度Region划分下,这一问题显得尤为突出且关键。例如,在新生代GC时,我们必须知道是否有老年代对象引用了新生代对象,否则将无法准确判断新生代对象的存活性。
G1引入了 记忆集(Remembered Set, RSet)和卡片标记(Card Table)机制来高效解决这一问题。
每个Region都维护了一个独立的 RSet。RSet本质上是一个哈希表,它记录了从该Region外部指向该Region内部的所有引用。
在执行对某个Region的GC时(无论是Young GC还是Mixed GC),GC器不再需要扫描整个Java堆,也无需扫描所有老年代,而只需检查该Region的RSet。RSet中包含了所有指向该Region内部的外部引用,GC器仅需从RSet中列出的引用作为额外的GC Roots,与该Region内部的GC Roots一起进行可达性分析。 极大缩小了扫描范围,显著提高了GC效率,特别是Young GC时,避免了扫描整个老年代的开销。
RSet的建立和维护依赖于卡片标记(Card Table)。卡片标记是JVM为整个堆维护的一个字节数组,将堆内存划分为固定大小的“卡片”(通常为512字节)。
当应用线程修改了堆中对象的引用关系(例如,将一个老年代对象的某个字段指向了一个新生代对象)时,JVM会通过写屏障(Write Barrier)机制拦截这次引用修改操作。写屏障会检查被修改的字段是否为引用类型,如果引用指向了另一个Region,就会将对应的卡片标记为“脏”(Dirty Card)。
GC器在需要时(通常在STW阶段或并发阶段的特定时刻)会扫描这些被标记为“脏”的卡片,将它们对应的跨Region引用记录到目标Region的RSet中,然后将卡片设为“干净”。
也就是,一个Region中的对象可能被其他任意Region中对象所引用,判断对象是否存活时,是否需要扫描整个Java堆才能确保准确?在其他的分代收集其,也存在这样的问题,只是在G1中更突出。 回收新生代是不是也必须扫描老年代,这样会不会降低Minor GC 的效率?
垃圾回收期间发生的动态变化怎么处理,比如一个对象之前是垃圾,现在不是了,会不会回收。
最后讨论一个很多开发者经常忽视的问题,也是面试大厂常问的问题:
😃 JDK 8 的默认 GC 是什么?
很多人觉得是CMS,甚至是G1,但其实都不是。 同学们可以通过以下命令自行去查看:
# 保证电脑中JDK版本为1.8 java -XX:+PrintCommandLineFlags -version
可以看到默认的垃圾收集器(GC)是 Parallel GC。即JDK 8 的 GC 组合:新生代默认是 Parallel Scavenge,老年代是 Parallel Old。