垃圾回收机制
分代回收理论
- 新生代:绝大部分的对象都是朝生夕死
- 老年代:熬过多次垃圾回收的对象就越难回收
GC 分类
- 新生代回收(Minor GC/Young GC)
- 老年代回收(Major GC/Old GC)
- 只有 CMS 垃圾回收器会有这个单独的回收老年代的行为
- Major GC 有说指是老年代,有说是做整个堆的收集
- 整堆回收(Full GC):收集整个 Java 堆和方法区(注意包含方法区)
STW (Stop The World)
进行垃圾回收时,必须暂停所有的工作线程,直到它回收结束,这个暂停称之为 STW,JVM 开发团队一直努力消除或降低 STW 的时间。
垃圾回收算法
新生代
复制算法(Copying)- 1:1
将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为了原来的一半。
但是要注意:内存移动是必须实打实的移动(复制),所以对应的引用(直接指针)需要调整。
复制回收算法适合于新生代,因为大部分对象朝生夕死,那么复制过去的对象比较少,效率自然就高,另外一半的一次性清理是很快的。
复制算法 - Appel 式回收 - 8:1:1
一种更加优化的复制回收分代策略:具体做法是分配一块较大的Eden 区和两块较小的Survivor 空间(你可以叫做From 或者To,也可以叫做 Survivor1 和 Survivor2)
专门研究表明,新生代中的对象 98% 是“朝生夕死”的,所以并不需要按照 1:1 的比例来划分内存空间,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。
HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1,也就是每次新生代中可用内存空间为整个新生代容量的 90%(80%+10%),只有10%的内存会被“浪费”。当然,98% 的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于 10% 的对象存活,当 Survivor 空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)
老年代
标记-清除算法(Mark-Sweep)
算法分为“标记”和“清除”两个阶段:首先扫描所有对象标记出需要回收的对象,在标记完成后扫描回收所有被标记的对象,所以需要扫描两遍。回收效率略低,如果大部分对象是朝生夕死,那么回收效率降低,因为需要大量标记对象和回收对象,对比复制回收效率要低。
它的主要问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾回收动作。回收的时候如果需要回收的对象越多,需要做的标记和清除的工作越多,所以标记清除算法适用于老年代。
标记-整理算法(Mark-Compact)
首先标记出所有需要回收的对象,在标记完成后,后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。标记整理算法虽然没有内存碎片,但是效率偏低。
我们看到标记整理与标记清除算法的区别主要在于对象的移动。对象移动不单单会加重系统负担,同时需要全程暂停用户线程才能进行,同时所有引用对象的地方都需要更新(直接指针需要调整)。
三色标记
三色标记最大的好处是可以异步执行,可以以极少的中断时间或者没有中断来进行整个 GC。
三色
- 黑色:根对象,或者该对象与它的子对象都被扫描过
- 灰色:本身被扫描,但是还没扫描完该对象的子对象
- 白色:未被扫描对象,如果扫描完所有对象之后,最终为白色的为不可达对象,既垃圾对象
并发情况下的漏标问题
- CMS 解决方法:Incremental Update 算法
- 当一个白色对象被一个黑色对象引用,将黑色对象重新标记为灰色,让垃圾回收器重新扫描。
- G1 解决方法:SATB(snapshot-at-the-beginning)
- 刚开始做一个快照,当一个灰色对象指向白色的引用消失时要把这个引用推到 GC 的堆栈(GC 方法运行时数据也是来自栈中),保证对象还能被 GC 扫描到,下回直接扫描就行了,白色就不会漏标。
对比:G1 在处理并发标记的过程比 CMS 效率要高
SATB 算法是关注引用的删除。Incremental Update 算法关注引用的增加。
G1 如果使用 Incremental Update 算法,因为变成灰色的成员还要重新扫,重新再来一遍,效率太低了。
所以G1 在处理并发标记的过程比CMS 效率要高,这个主要是解决漏标的算法决定的。
常见的垃圾回收器
- 并行:垃圾收集的多线程的同时进行
- 并发:垃圾收集的多线程和应用的多线程同时进行
- 吞吐量:运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 )
- 垃圾收集时间:垃圾回收频率 * 单次垃圾回收时间
- 垃圾回收器的三项指标:延时、吞吐量、内存占用
- 传统的垃圾回收器一般情况下内存占用、吞吐量、延时只能同时满足两个。但是现在的发展,延迟这项的目标越来越重要。所以就有低延迟的垃圾回收器。
收集器 | 收集对象和算法 | 收集器类型 | 说明 | 场景 |
---|---|---|---|---|
Serial | 新生代 复制算法 |
单线程 | 简单高效;适合内存不大的情况 | |
ParNew | 新生代 复制算法 |
并行的多线程收集器 | 是 Serial 的多线程版 | 与 CMS 搭配 |
Parallel Scavenge | 新生代 复制算法 |
并行的多线程收集器 | 类似 ParNew,面向吞吐量 | 是 Server 级别多 CPU 机器上的默认 GC 方式,主要适合后台运算且不需要太多交互的任务 |
Serial Old | 老年代 标记 - 整理算法 |
单线程 | Client 模式下虚拟机使用 | |
Parrallel Old | 老年代 标记 - 整理算法 |
并行的多线程收集器 | 面向吞吐量 | 在注重吞吐量以及 CPU 资源敏感的场合使用 |
CMS | 老年代 标记 - 清除算法 |
并行与并发收集器 | 尽可能地缩短 STW,缺点: 1. 至少 4 核 CPU 2. 内存碎片,使用 Serial Old 整理碎片 3. 浮动垃圾,需要更大的堆空间,6G 以上 |
重视服务的响应速度、系统停顿时间和用户体验的互联网网站或者B/S系统。互联网后端目前 CMS 是主流的垃圾回收器。 |
G1 | 全部 化整为零 Region 标记 - 整理 & 复制 |
并行与并发收集器 | 采用分区回收的思维,基本不牺牲吞吐量的前提下完成低停顿的内存回收;可预测的停顿是其最大的优势 | 面向服务端应用的垃圾回收器,目标为取代 CMS |
Serial/Serial Old(SerialGC)
开启:-XX:+UseSerialGC
,串行垃圾收集器,几十兆 ~ 一两百兆
JVM 刚诞生就只有这种,最古老的,单线程,独占式,成熟,适合单 CPU,一般用在客户端模式下使用。这种垃圾回收器只适合几十兆到一两百兆的堆空间进行垃圾回收,可以控制停顿时间再100ms 左右,但是对于超过这个大小的内存回收速度很慢。
Parallel Scavenge/Parallel Old(ParallerGC)
开启:-XX:+UseParallelGC
,并行垃圾收集器(吞吐量优先垃圾回收器),上百兆~几G
为了提高回收效率,从 JDK1.3 开始,JVM 使用了多线程的垃圾回收机制,关注吞吐量的垃圾收集器,高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。适合回收堆空间上百兆~几个G。
参数
-
-XX:MaxGCPauseMillis
最大停顿时间- 垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的:系统把新生代调得小一些,收集 300MB 新生代肯定比收集 500MB 快,但这也导致垃圾收集发生得更频繁,原来 10 秒收集一次、每次停顿 100 毫秒,现在变成 5 秒收集一次、每次停顿 70 毫秒。停顿时间的确在下降,但吞吐量也降下来了。
-
-XX:GCTimeRatio
垃圾收集时间占总时间的比率- 参数的值则应当是一个大于 0 小于 100 的整数。垃圾收集时间占总时间的比率,相当于吞吐量的倒数。
- 例如:把此参数设置为 19, 那允许的最大垃圾收集时占用总时间的 5% (即1/(1+19))
- 默认值为 99,即允许最大 1% (即1/(1+99))的垃圾收集时间
-
-XX:+UseAdaptiveSizePolicy
动态调整(默认开启)- 不需要人工指定新生代的大小(
-Xmn
)、Eden 与 Survivor 区的比例(-XX:SurvivorRatio
)、晋升老年代对象大小(-XX:PretenureSizeThreshold
)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。
- 不需要人工指定新生代的大小(
ParNew/Concurrent Mark Sweep(CMSGC)
开启:-XX:+UseConcMarkSweepGC
,第一个并发垃圾收集器,几G ~ 20G
-
ParNew 是多线程垃圾回收器。和 Serial 唯一的区别是多线程,停顿时间比 Serial 少。
- 在 JDK9 以后,把 ParNew 合并到了 CMS 了。
-
CMS 是一种以获取最短回收停顿时间为目标的收集器,重视服务的响应速度。由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以总体上看CMS 的内存回收过程是与用户线程一起并发执行的。
- 在 JDK1.8 以后,CMS 不能与 Serial 配对使用,只能调用 Serial Old。在 JDK9 CMS 进入废弃倒计时,在 JDK14 正式移除 CMS。
流程
- 初始标记(STW):短暂,仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快
- 并发标记:并发处理;三色标记产生漏标
- 标记从 GC Roots 开始关联的所有对象,开始遍历整个可达分析路径的对象。这个时间比较长,所以采用并发处理(垃圾回收器线程和用户线程同时工作)
- 重新标记(STW):短暂
- 为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。就是三色标记产生的漏标,使用 Incremental Update 算法来修正。
- 这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
- 并发清除
缺点
- CPU 敏感:至少 4 核
- 浮动垃圾
- 由于CMS 并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS 无法在当次收集中处理掉它们,只好留待下一次 GC 时再清理掉。这一部分垃圾就称为“浮动垃圾”。
- 因此需要预留出一部分内存,意味着CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。在1.6 的版本中老年代空间使用率阈值(92%)
- 如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代CMS。
- 空间碎片:标记 - 清除算法会导致产生不连续的空间碎片
参数
-
-XX:+UseCMSCompactAtFullCollection
无法分配大对象时使用 Serial Old 进行碎片整理(默认开启)- CMS 会有内存碎片,给大对象的分配带来很大的麻烦
- 如果分配不了大对象,就进行内存碎片的整理过程。这个地方一般会使用 Serial Old ,因为 Serial Old 是一个单线程,所以如果内存空间很大、且对象较多时,CMS 发生这样的情况会很卡。
为什么 CMS 采用标记 -清除
在实现并发的垃圾回收时,如果采用标记整理算法,那么还涉及到对象的移动,对象的移动必定涉及到引用的变化,这个需要暂停业务线程来处理栈信息,这样使得并发收集的暂停时间更长,所以使用简单的标记-清除算法才可以降低 CMS 的 STW 的时间。
Garbage First(G1)
开启:-XX:+UseG1GC
,并发垃圾收集器,6G ~ 上百G
大于 6G 直接用 G1,不用 CMS,省事,没那么多烦恼。
设计思想(采用 Region)
为了实现 STW 的时间可预测,G1 将堆内存“化整为零”,将堆内存划分成多个大小相等独立区域(Region),每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、Survivor 空间,或者老年代空间。回收器能够对扮演不同角色的 Region 采用不同的策略去处理。
Region
- 新生代(Eden,Survivor)
- 老年代
- Old
- Humongous
- 存储大对象,大小超过了一个 Region 容量一半的对象即可判定为大对象
- 超过了 Region 容量的大对象,会被存放在 N 个连续的 Humongous Region 之中
流程
- 初始标记(Initial Marking),STW:短暂;TAMS
- 仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的 Region 中分配新对象。
- 这个阶段需要停顿线程,但耗时很短,而且是借用进行 Minor GC 的时候同步完成的,所以 G1 收集器在这个阶段实际并没有额外的停顿。
- TAMS:要达到 GC 与用户线程并发运行,必须要解决回收过程中新对象的分配,所以 G1 为每一个 Region 区域设计了两个名 TAMS (Top at Mark Start)的指针,从 Region 区域划出一部分空间用于记录并发回收过程中的新对象。这样的对象认为它们是存活的,不纳入垃圾回收范围。
- 并发标记( Concurrent Marking):三色标记产生漏标
- 从 GC Root 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。
- 当对象图扫描完成以后, 并发时有引用变动的对象, 这些对象会漏标 , 漏标的对象会被一个叫做 SATB (snapshot-at-the-beginning) 算法来解决。
- 最终标记( Final Marking),STW:短暂;处理 SATB 记录
- 处理并发阶段结后仍遗留下来的最后那少量的 SATB 记录(漏标对象)。
- 筛选回收( Live Data Counting and Evacuation),STW:根据用户所期望的停顿时间来制定回收计划
- 负责更新 Region 的统计数据,对各个Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划
- 可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。
特点
- 并行与并发
- 分代收集
- 空间整合
- 与 CMS 的“标记—清理”算法不同,G1 从整体来看是基于“标记—整理”算法实现的收集器,从局部(两个Region 之间)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着 G1 运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。
- 这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 GC。
- 追求停顿时间
- G1 尝试调整新生代和老年代的比例,堆大小,晋升年龄来达到这个目标时间。
参数
-
-XX:G1HeapRegionSize
: Region 大小,1MB ~ 32MB ,2 的 N 次幂- 一般建议逐渐增大该值,随着 size 增加,垃圾的存活时间更长,GC 间隔更长,但每次 GC 的时间也会更长。
-
-XX:MaxGCPauseMillis
: 最大 GC 暂停时间
低延迟的垃圾回收器
- Eplison:不能进行垃圾回收
- 这个垃圾回收器不能进行垃圾回收,是一个“不干活”的垃圾回收器,由 RedHat 退出,它还要负责堆的管理与布局、对象的分配、与解释器的协作、与编译器的协作、与监控子系统协作等职责,主要用于需要剥离垃圾收集器影响的性能测试和压力测试。
- ZGC:类似于G1 的 Region;染色指针;STW 小于 10ms
- 有类似于G1 的Region,但是没有分代。标志性的设计是染色指针 ColoredPointers(这个概念了解即可),染色指针有 4TB 的内存限制,但是效率极高,它是一种将少量额外的信息存储在指针上的技术。
- 它可以做到几乎整个收集过程全程可并发,短暂的 STW 也只与 GC Roots 大小相关而与堆空间内存大小无关,因此可以实现任何堆空间 STW 的时间小于十毫秒的目标。
- JDK 13 支持的内存增大到 16TB
- JDK 14 支持在 Windows 和 MAC 上使用
- JDK 15 正式上线
- Shenandoah:类似于G1 的 Region;染色指针;STW 几十毫秒
- 第一款非 Oracle 公司开发的垃圾回收器,有类似于 G1 的 Region,但是没有分代。也用到了染色指针 ColoredPointers。效率没有 ZGC 高,大概几十毫秒的目标。
- JDK 15 上线
RSet/CardTable
处理跨代引用或 G1 中的跨 Region 引用的问题。
RSet(记忆集)
RSet 本身就是一个 Hash 表。RSet 的价值在于使得垃圾收集器不需要扫描整个堆,找到谁引用了当前分区中的对象,只需要扫描 RSet 即可。
- G1:每个 Region 都有,非常消耗空间
- 记录了其他 Region 中的对象到本 Region 的引用, 在一个 Region 区里面。
- 每一个 Region 都需要一个 RSet 的内存区域,导致有 G1 的 RSet 可能会占据整个堆容量的 20% 乃至更多。
- CMS:只有一份
CardTable(卡表)
如果一个老年代 CardTable 中有对象指向新生代, 就将它设为 Dirty(标志位1), 下次扫描时,只需要扫描 CardTable 上是 Dirty 的内存区域即可。
字节数组 CARDTABLE 的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)。一般来说,卡页大小都是以 2 的 N 次幂的字节数,假设使用的卡页是 2 的 10 次幂,即 1K,内存区域的起始地址是 0x0000 的话,数组 CARD_TABLE 的第 0、1、2 号元素,分别对应了地址范围为 0x0000~0x03FF、0x0400 ~ 0x07FF 0x0800~0x011FF 的卡页内存。
- G1:按 Region 划分,所以要求 Region 大小为 2 的 N 次幂
- CMS:按 2 的 N 次幂划分
安全点与安全区域
安全点
用户线程暂停,GC 线程要开始工作,但是要确保用户线程暂停的这行字节码指令是不会导致引用关系的变化。所以 JVM 会在字节码指令中,选一些指令,作为“安全点”,比如方法调用、循环跳转、异常跳转等,一般是这些指令才会产生安全点。
为什么它叫安全点,是这样的,GC 时要暂停业务线程,并不是抢占式中断(立马把业务线程中断)而是主动是中断。
主动式中断是设置中断标志,各业务线程在运行过程中会不停的主动去轮询这个标志,一旦发现中断标志为True,就会在自己最近的“安全点”上主动中断挂起。
安全区域
要是业务线程都不执行(业务线程处于Sleep 或者是Blocked 状态),那么程序就没办法进入安全点,对于这种情况,就必须引入安全区域。
安全区域是指能够确保在某一段代码片段之中, 引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区城看作被扩展拉伸了的安全点。
当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,这段时间里 JVM 要发起 GC 就不必去管这个线程了。 当线程要离开安全区域时,它要看 JVM 是否已经完成了(根节点枚举,或者其他 GC 中需要暂停用户线程的阶段)
- 如果完成了,那线程就当作没事发生过,继续执行。
- 否则它就必须一直等待, 直到收到可以离开安全区域的信号为止。