🔭 大家好,我是 vnjohn,在互联网企业担任 Java 开发,CSDN 优质创作者
📖 推荐专栏:Spring、MySQL、Nacos、Java,后续其他专栏会持续优化更新迭代
🌲文章所在专栏:JVM
🤔 我当前正在学习微服务领域、云原生领域、消息中间件等架构、原理知识
💬 向我询问任何您想要的东西,ID:vnjohn
🔥觉得博主文章写的还 OK,能够帮助到您的,感谢三连支持博客🙏
😄 代词: vnjohn
⚡ 有趣的事实:音乐、跑步、电影、游戏
目录
- 前言
- 垃圾回收算法
- 分代收集
- 标记-清除算法
- 标记-复制算法
- 标记-整理算法
- 垃圾收集器
- Serial 收集器
- ParNew 收集器
- Parallel Scavenge 收集器
- CMS 收集器
- Garbage First 收集器
- 低延迟垃圾收集器
- 并行、并发
- 总结
前言
在 JVM 专栏章节里,有讲解 Java 中四大引用类型以及如何判定对象是否存活,它们是前置知识也是作为学习 JVM 必经之路,从此文中我们会详细分析 JVM 有哪些垃圾回收算法、垃圾收集器
深入理解 Java 引用类型:强壮、柔软、脆弱、虚无的力量
引用计数 vs 根可达算法:深入比较对象存活判定
垃圾回收算法
垃圾回收算法是一种用于确定哪些对象是 “垃圾”,它们通过检测不再被引用的对象来标记、识别可以释放的内存,这些算法使用不同的策略和技术,例如:引用计数、标记-清除、标记-复制、标记-整理等,以确保有效地回收内存并提高应用程序的性能
分代收集
当前大部分的垃圾收集器,大多数都遵循了 “分代收集”(Generational Collection)理论进行设计,分代收集将内存划分为不同的代,通过分代假说设计和选择适当的垃圾回收算法和策略来处理不同代的对象;分代收集理论建立在两个分代假说之上,如下:
- 弱分代假说:绝大多数的对象都是朝生夕死的
- 强分代假说:熬过了多次垃圾收集过程的对象就越难以消亡
弱分代假说、强分代假说共同鉴定了多款常用垃圾收集器的一致设计原则:收集器应当将 Java 堆划分出不同的区域,然后将回收对象根据其年龄(年龄即对象熬过垃圾收集过程的次数:-XX:MaxTenuringThreshold)分配到不同的代中存储;显而易见,若一个区域中大多数对象都是朝生夕死,难以熬过垃圾收集过程的话,那么就将它们集中在一起(年轻代),每次回收时只需要关注如何保留少量对象而无须去标记哪些大量要被回收的对象,就能以较低代价回收大量的空间;若剩下的都是难以消亡的对象,就将它们集中在另外一块区域(老年代),JVM 便可使用较低的频率来回收这个区域的对象,也就同时兼顾了垃圾收集的时间开销以及内存空间的有效利用
分代收集并非只是简单如上所述划分一下内存区域那么容易,它至少存在一个明显的困难:对象不是孤立的,对象之间会存在跨代引用
- 跨代引用假说:跨代引用相对于同代引用来说占极少数
基于弱分代、强分代假说,得出隐式推论:互相引用关系的两个对象,应该倾向于同时生存或同时消亡的;举例:若新生代引用 A 存在老年代 B 引用的引用链,由于老年代 B 引用难以消亡,会使得新生代引用 A 在垃圾收集时无法被清理,进而新生代引用 A 在年龄增长到一定数时会晋升到老年代,此时跨代引用随机也就被消除了
B 对象所指向的引用可能是个特别大的对象或动态年龄担保机制又或是分配担保机制,直接就进入到老年代了,这里不对这些作过多分析
Java 堆划分出了不同区域,垃圾收集器才可以每次只回收其中某一个或一部分的区域,因而有了以下几种 GC 方式
- Minor GC:年轻代 GC
- Major GC:老年代 GC,目前只有 CMS 垃圾收集器会有单独收集老年代的行为,所以其他垃圾收集器会升华为 Full GC
- Full GC:整个堆 GC
标记-清除算法
最早出现也是最基础的垃圾收集算法就是 “标记-清除”(Mark-Sweep)算法,如它的名字,算法分为 “标记”、“清除” 两个阶段:首先会标记出所有需要回收的对象,在标记完成以后,统一回收掉所有被标记的对象,也可反过来,标记存活的对象,统一回收掉所有未被标记的对象;标记过程就是对象是否属于垃圾的判定过程
大多数都以标记清除算法作为基础,对其缺点进行改进而得到的,主要缺点有两个,如下:
- 执行效率不稳定,若 Java 堆中包含大量对象,而且其中大部分都是需要被回收的,这时候必须进行大量标记、清除动作,导致标记、清除这两个过程的执行过程都会随着对象数量增长而降低;反而言之,在存活对象较多的情况下,它的执行效率是比较高的
- 内存空间碎片化问题,标品、清除之后会产生大量不连续的内存碎片,
空间碎片太多可能会导致在程序运行过程中需要分配较大对象时无法找到足够的连续空间存储
,从而不得不提前触发一次垃圾收集工作
CMS 中为了提高吞吐量,采用标记-清除作为老年代的垃圾回收算法,从而说明了 CMS 会产生内存碎片的问题,故而之要调整一些 CMS 参数来调整回收的频率!
标记-复制算法
标记-复制算法通常被简称为复制算法,为了解决标记-清除面对大量可回收对象时执行效率低的问题;它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块;当这一块内存用完了,就将还存活着的对象复制到另外一块上面去,然后再把已使用过的内存空间一次性清理掉;若内存中多数对象都是存活的,这种算法将会产生大量的内存空间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按地址顺序分配即可
复制算法的缺陷也显而易见,其代价会将可用内存大小缩小为原来的一半,空间浪费的多了一点
新生代中的对象 98% 熬不过第一轮垃圾收集,因此并不是需要按照 1:1 比例来划分新生代的内存空间
HotSpot 虚拟机的 Serial、ParNew 等新生代垃圾收集器均采用了这种策略来设计新生代的内存布局
由于其 “朝生夕死” 特点,将新生代分为一块较大的 Eden 空间以及两块较小的 Survivor 空间,每次分配内存只使用 Eden 和其中一块 Survivor 空间;当发生垃圾收集时,将 Eden、Survivor 区中仍然存活的对象一次性复制到另外一块 Survivor 空间上,然后清理掉 Eden 区以及已用过的那块 Survivor 区空间
Eden:伊甸区、Survivor From:幸存者 1 区、Survivor To:幸存者 2 区
HotSpot 虚拟机默认 Eden、Survivor From、Survivor To 区大小比例是 8:1:1,也就是每次新生代可用内存空间为整个新生代容量的 90%(Eden 区 80% 占比加上一个 Survivor 区的占比)只有一个 Survivor 区空间,即 10% 新生代空间是会被浪费的
新生代中的对象 98% 熬不过第一轮垃圾收集,这可能是普通的场景,特殊情况下,例如:一直在循环中持有多个对象引用不释放,与其他对象共同并入时,就没办法保证每次发生 Minor GC 时,存活对象少于 10%;因此,当 Survivor 空间不足以容纳一次 Minior GC 后存活的对象时,就需要依赖其他内存区域(大多数是老年代)进行分配担保机制
年轻代采用复制算法回收过程:将 Eden 伊甸区、Survivor From 区存活的对象放入到 Survivor To 区,然后再将 Eden 伊甸区、Survivor From 区进行清理,此时原来的 Survivor To 区调换身份变为 Survivor From 区用于作用于下次回收的 Survivor 区,而原来被清理的 Survivor From 调换身份变为 Survivor To 区,以此类推,Survivor 通过不断的变换身份采用复制算法结合占比 80% 的伊甸区一起完成年轻代的回收旅程!!
标记-整理算法
标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低;更关键的是,若不想浪费 50% 空间,就需要有额外的空间进行分配担保,以应对被使用的内存所有对象在新年代中都是 100% 存活的极端情况,所以在老年代中一般不能直接选用这种算法
标记清除、标记整理算法的本质差异在于前者是一种非移动式的回收算法(直接对回收对象进行清理)而后者是移动式的回收算法(让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存)
标记-整理算法中,是否移动回收后的存活对象是一项优缺点并存的存在,如下:
- 若移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方,将会是一种极为负重的操作,而且这种对象移动操作必须是全程暂停用户应用程序才能进行,这就更加让使用者不得不小心翼翼地权衡其弊端了,像这样的全程暂停被描述为 “Stop The World” > STW
ZGC、Shenandoah 收集器使用读屏障(Read Barrier)技术实现了整理过程与用户线程的并发执行
- 若像标记-清除算法那样完全不考虑移动、整理存活对象的话,堆中分散开来的存活对象会造成空间碎片化问题,从而只能依赖更为复杂的内存分配器、内存访问器来解决;内存的访问是用户最频繁的操作,假设在这个环节上增加了负担,势必会直接影响到应用程序的吞吐量~
基于以上两点,是否移动对象都存在弊端,移动对象则内存回收时会更复杂,不移动对象时则内存分配时会更复杂
从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要移动;若从整个程序的吞吐量来看,移动对象会更划算。
吞吐量 = 用户代码时间 /(用户代码执行时间+垃圾回收时间)
HotSpot 虚拟机里关注吞吐量的 Parallel Old 垃圾收集器是基于标记-整理算法的,而关注提供程序响应时间的 CMS 垃圾收集器是基于标记-清除算法的
“和稀泥式” 解决方案:让 JVM 大多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,当内存碎片化程度已经大到影响对象正常分配时,再采用标记-整理算法进行一次垃圾收集,以获得可连续存储的内存空间
CMS 基于标记-清除,会产生内存碎片,当内存碎片到达无法收拾的地步时,会退化为 Serial Old 垃圾收集器进行垃圾收集,采用的是单线程的标记-整理算法,后续再详细介 绍!!
垃圾收集器
如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的实践者
如上图,这是本文会介绍到的一些垃圾收集器,展示了七种作用于不同分代的收集器,若两个收集器之间存在连线,则说明它们可以组合使用
JDK 诞生以后第一个垃圾收集器就是采用 Serial+Serial Old(基于单线程),为了提高效率,诞生了 PS(Parallel Scavenge > 基于多线程),为了配合 CMS 使用诞生了 ParNew
CMS 是里程碑式的一个垃圾收集器,它开启了并发回收的先行,但 CMS 内存碎片化问题,导致目前没有任何一个 JDK 版本的默认收集器是 CMS
常见的垃圾回收器组合:Serial+SerialOld、Parallel Scavenge+Parallel Old、ParNew+CMS
JDK8 默认组合是 PS+PO,自 JDK9 默认的垃圾收集器变为了 G1
由于维护、兼容性测试的成本,在 JDK8 时将 Serial+CMS、ParNew+Serial Old 这两个组合声明为废弃,并在 JDK9 中完全取消了这些组合的支持
Serial 收集器
Serial+Serial Old 收集器是最基础、历史最悠久的恶收集器,它是一个单线程工作的收集器,但它的 “单线程” 含义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程(STW),直到它的收集工作结束。
Serial 收集器目前已经老而无用,成为食之无味、弃之可惜的 “鸡肋” 了;Serial 收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择,由于在用户桌面这种场景下一般分配的内存不会特别大
若在服务端模式下,Serial Old 老年代单线程垃圾收集器,它也可能有两种用途,如下:
- JDK5 以及之前的版本中于 Parallel Scavenge 收集器搭配使用
- 作为 CMS 收集器发生失败时的后备收集器,在并发收集时发生
Concurrent Mode Failure
ParNew 收集器
ParNew 收集器实质上是 Serial 收集器额度多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括 Serial 收集器所有可用的控制参数(例如:-XX:SurvivorRatio、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure 等)、收集算法、对象分配规则、回收策略都与 Serial 收集器完全一致,在实现上这两种收集器也共用了相当多的代码;ParNew 收集器工作流程如下图所示:
ParNew 收集器除了支持多线程并行收集之外,其他与 Serial 收集器相比并无太多创新之处,作为老年代搭配的收集器 > 除了 Serial 收集器以外,目前它只能与 CMS 收集器一起配合工作
遗憾的是,CMS 作为老年代的收集器,却无法与 JDK 4 已经存在的新生代收集器 Parallel Scavenge 配合工作,所以在 JDK 5 使用 CMS 来收集老年代时,与之搭配的新生代收集器只能选择 ParNew 或 Serial 收集器其中一个
两者所追求的目标不一致,Parallel Scavenge 追求的是高吞吐量,CMS 追求的是低延迟
两者所使用的分代框架不一致,ParNew 是复用 Serial 代码在其基础上以多线程并行方式处理,而 Parallel Scavenge、G1 都是另外独立去实现了分代框架
ParNew 收集器是当激活了 CMS 收集器后(-XX:+UseConcMarkSweepGC)默认选用的新生代收集器,也可以使用 -XX:+/-UseParNewGC 来强制选用或禁用它
ParNew 收集器在单核心处理器的环境中,绝对不会有比 Serial 收集器更好的效果,甚至由于存在线程之间交互、切换的开销,效率会低于 Serial;以此类推,在处理器核心数量的增加,ParNew 对于垃圾收集时系统资源的高效利用还是很有益的;ParNew 默认开启的收集线程数与处理器核心数量相同,在处理器核心非常多的情况下,可以通过:-XX:ParallelGCThreads 参数来限制垃圾收集的线程数
Parallel Scavenge 收集器
Parallel Scavenge 作为一款新生代收集器,它同样是基于标记-复制算法实现的收集器,也能够并行收集的多线程收集器
Parallel Scavenge 收集器通常会用来与 ParNew 收集器作比较,CMS 老年代收集器是选用的 ParNew 作为它的年轻代收集器,主要在于它们的关注点不同,CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的关注点是达到一个可控制的吞吐量(Throughput)
所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值,即:
用户代码运行时间 / (用户代码运行时间 + 垃圾收集运行时间)
若虚拟机完成某项任务,用户代码+垃圾收集总共耗时了 100 分钟,其中垃圾收集花掉了 1 分钟,那么吞吐量就是 99%
停顿时间越短,越适合需要与用户交互或需要保证服务响应质量高的程序,良好的响应速度能够提升用户的体验度
吞吐量越高,则可以很高效率的利用服务器的资源,尽快完成程序的运算任务,主要适合在后台进行运算而不需要太多交互的分析任务`
Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间:-XX:MaxGCPauseMillis 参数以及直接设置吞吐量大小:-XX:GCTimeRatio 参数
- -XX:MaxGCPauseMillis:允许值是一个大于 0 的毫秒数,收集器将尽量保证内存回收花费的时间不超过用户设定的值
内存回收花费时间缩短是以牺牲吞吐量、新生代空间为代价换取的,系统把新生代大小调整的少一点,收集 300MB 新生代肯定比收集 500MB 新生代速度要快,但这也直接会导致垃圾收集的发生过于频繁,原来 10 秒收集一次,停顿 100 毫秒,现在变成 5 秒收集一次,停顿 70 毫秒;停顿时间的确在下降,但吞吐量也下降了
- -XX:GCTimeRatio:值应当是一个大于 0 小于 100 的整数,也就是垃圾收集时间占总时间的比率
若设置这个参数设为 N,表示用户代码执行时间与总执行时间之比为 N:N+1;譬如将此参数设置为 19,那允许的最大垃圾收集时间就占总时间的 5%(即:1 / (1+19) )默认值为 99,即允许最大 1%(1 / (1+99) )的垃圾收集时间
由于该收集器与吞吐量密切相关,Parallel Scavenge 收集器也经常被称作为 “吞吐量优先收集器”;除了上述两个参数之外,还提供了一个参数:-XX:+UseAdaptiveSizePolicy
该参数是一个开关参数,默认是开启的,当参数开启之后,就不需要人工指定新生代的大小(-Xmn)、Eden 与 Survivor 区比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了;虚拟机会根据当前系统的运行情况收集性能的监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量
Parallel Old 是 Parallel Scavenge 收集器的老年代版本,支持多线程并行收集,基于标记-整理算法实现
Parallel Old 收集器直到 JDK 6 才开始提供,在此之前,新生代 Parallel Scavenge 收集器,老年代除了 Serial Old 收集器别无选择,其他表现良好的老年代收集器无法与其配合工作,如:CMS
由于 Serial Old 收集器采用单线程无法充分利用多核服务器的并行处理能力,故 Parallel Scavenge 收集器未必能在整体上获得吞吐量的最大化;直到 Parallel Old 收集器出现,“吞吐量优先” 才有了名副其实的搭配组合,在注重吞吐量或处理器资源较为稀缺的场合下,都可以优先考虑 PS+PO
CMS 收集器
CMS(Concurrent Mark Sweep)是一款以获取最短回收停顿时间为目标的收集器,它非常适用于要求服务响应速度,希望停顿时间尽可能短,以给用户带来良好的交互体验
CMS:Mark Sweep,它是基于标记-清除算法实现的,开启并发回收的第一款垃圾收集器 > 里程碑,它出现的目的是因为无法忍受前面收集器在开启垃圾回收时 > 所有用户线程都不可用以及 STW 时长过长
CMS 工作过程相比前面几种收集器会复杂一些,整个过程分为几个阶段,如下:
- CMS initial Mark:初始标记,会发生 STW,仅仅是标记一下 GC Roots 能直接关联到的对象,速度很快
- CMS Concurrent Mark:并发标记,从 GC Roots 直接关联对象开始遍历整个对象图的过程,该过程虽然过长但不会暂停用户线程,可以与垃圾收集线程一起并发运行
- CMS Remark:重新标记,会发生 STW,为了修正并发标记阶段,因用户线程继续运行的情况下而导致引用产生变化的那一部分记录,该阶段停顿时间稍微比初始化阶段长一些,但也远比并发标记阶段花费的时间短
- CMS Concurrent Sweep:并发清除,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以该阶段也是可以与用户线程同时并发的
CMS 是一款优秀的收集器 > 并发收集、低停顿,它是 HotSpot 虚拟机中追加低停顿的第一次成功尝试,但它还远远达不到完美的程度,它存在一些明显的缺点,如下:
- 资源占用问题,在执行并发处理阶段,它虽然不会造成用户线程停顿,但却因此会占用一部分线程 > 处理器的计算能力,而导致应用程序变慢,降低总吞吐量;CMS 默认启动的回收线程数(处理器核心数量 + 3)/ 4,也就是说,若处理器核心数在 4 个或以上,并发回收时垃圾收集线程只占用不少于 25% 的处理器运算资源
- 浮动垃圾问题,无法处理每次回收阶段时产生的 “浮动垃圾”,有可能出现 “Concurrent Mode Failure” 失败而导致另一次完全 STW Full GC 的产生;在 CMS 并发标记、并发清理阶段,用户线程还是在继续运行的,用户程序在运行必然就会有新的垃圾对象不断产生,但这部分垃圾对象的产生是出现在标记过程结束过后的,CMS 也就无法在当次收集中处理掉它们,只能留在下一次垃圾收集时再行清理,这一部分就称之为 “浮动垃圾”
由于在垃圾收集阶段用户线程要持续运行,那就还得预留足够的内存空间提供给用户线程使用,因此 CMS 收集器不能像其他收集器那样等待到老年代几乎完全填满了再进行垃圾收集,必须预留一部分内存空间供并发收集时的用户线程运行时使用
在 JDK5 默认设置下,CMS 收集器当老年代使用了 68% 空间就会被开启收集,该值设置得过于保守,若在实际工作中,对象增长的速度不过于快,可以适当调高:-XX:CMSInitiatingOccupancyFraction 参数值来提高 CMS 触发的百分比,降低内存回收的频率,以获取更好的性能;在 JDK6 时,CMS 收集器启动阈值就默认提升到了 92%,这也就会容易引发出另外一种风险 > 要是 CMS 运行期间预留的内存无法满足程序分配新对象的需求,就会出现一次
“Concurrent Mode Failure”
,此时虚拟机就不得不开启后备预案:冻结用户线程的运行,临时启用 Serial Old 单线程收集器来重新进行老年代的垃圾收集,从而停顿时间就会变得很长了!
综合以上所述,-XX:CMSInitiatingOccupancyFraction 参数值大小的调整应当结合实际项目的情况,Trade Off 权衡利弊之下进行调整
- 内存碎片问题,CMS 基于标记-清除算法实现的收集器,也就意味着收集结束时会产生大量的空间碎片,空间碎片过多时,在分配大对象时会带来很大的麻烦,往往会出现在老年代明明还有很多剩余空间,但就是无法找到有足够大的连续空间分配给当前的大对象,从而不得不提前触发一次 Full GC
CMS 为了解决内存碎片过多产生的问题,提供了:-XX:UseCMSCompactAtFullCollection 参数(默认是开启的),用于在 CMS 收集器不得不进行 Full GC 时开启内存碎片的合并整理,由于该内存整理必须移动存活对象,所以是无法并发的,虽然内存碎片问题加以解决,但停顿时间随即变长了
基于以上停顿时间变长问题,CMS 提供了:-XX:CMSFullGCsBeforeCompaction 参数(默认值为 0,代表每次进入 Full GC 时都会进行碎片整理),其作用是要求 CMS 收集器在执行若干次(数量由参数值指定)不整理空间的 Full GC 之后,下一次进行 Full GC 前会先进行碎片整理
以上两个参数,在 JDK9 版本中,开始废弃
Garbage First 收集器
Garbage First,简称 G1 收集器,它是一款面向服务端应用的垃圾收集器,它开创了收集器面向局部收集的设计思路和基于 Region 的内存布局形式
从 JDK9 版本开始,G1 宣告取代 JDK8 PS+PO 收集器组合,成为服务端模式下的默认垃圾收集器,而 CMS 则沦落为不推荐使用的收集器,在 JDK9 版本及以上开启 CMS 收集器的话,会提示 CMS 将会在未来被废弃
作为 CMS 收集器的替代者、继承人,设计者们希望作出一款能够建立起 “停顿时间模型“(Pause Prediction Model)收集器
停顿时间模型:能够支持指定在一个长度为 M 毫秒的时间片内,消耗在垃圾收集上的时间大概率尽可能保证不超过 N 毫秒这样的目标 >
通过 -XX:MaxGCPauseMillis 参数指定,默认值为 200 毫秒
该参数若设置的过于低,G1 根据进行垃圾收集时过于倾向该时间,那么回收的 Region 区域过小,从而导致垃圾收集速度逐渐跟不上分配对象内存的速度,导致垃圾慢慢堆积,最终造成堆占满引发 Full GC 反而降低性能,所以通常该参数一般会使用默认的或上下可再微调 100 毫秒
在 G1 设计初衷,思想上就有了巨大的改变,在 G1 收集器出现之前的其他收集器,包括 CMS 在内,垃圾收集的目标要么是整个新生代(Minor GC)要么是整个老年代(Major GC)要么就是整个堆(Full GC)而 G1 跳出了这个囚笼,它可以面向堆内存任何部分来组成回收集(Collection Set,简称 CSet)进行回收,衡量回收的标准不再是看它属于哪个代,而是考量那块的内存中存放的垃圾最多,回收收益内存最大,这就是 G1 收集器中的 Mixed GC 模式,也就是 “Grabage First” 名字的由来
G1 开创的基于 Region 堆内存布局是它能够实现该目标的关键,虽然 G1 仍然是遵循分代收集理论进行设计的,但其堆内存布局与其他收集器有非常明显的差异:G1 不再坚持固定内存大小以及固定数量的分代区域划分,而是把连续的 Java 堆内存划分为多个大小相等的独立区域(Region)
每一个 Region 都可以根据其需要,扮演新生代的 Eden 区、Survivor 区或者老年代;收集器能够对扮演不同角色的 Region 采用不同的策略去处理,如此而来,无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果
Region 中还有一类特殊的 Humongous 区域,专门用来存储大对象;G1 认为只要大小超过了一个 Region 50% 容量的对象;每个Region 大小可以通过参数:-XX:G1HeapRegionSize 设定,取值范围为 2~32 MB,且应为 2 的 N 次幂
;对于那些超过了整个 Region 容量的超级大对象,将会被存放在 N 个连续的 Humongous Region 区域中,作为老年代的一部分来进行看待
在 G1 中仍然保留新生代、老年代的概念(逻辑分代,物理不分代),但新生代、老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合;G1 收集器之所以能建立可预测的停顿时间模型,是因为它将 Region 作为单位回收的最小单元,即每次收集到的内存空间都是 Region 大小的整数倍,这样可以有计划地避免在整个堆中进行全区域的垃圾收集工作
任何一款垃圾收集器出来都必然会有更大的优化空间,G1 收集器至少有一些关键的细节问题需要妥善解决,如下:
- 在前面 「垃圾回收算法 > 分代收集 > 跨代引用假说」 有提到新生代对象会引用老年代对象,导致新年代的这部分对象会一直无法回收,最终会膨胀进入到老年代;在 G1 中,同样会存在跨 Region 的引用对象,解决思路:使用记忆集避免全堆作为 GC Roots 扫描,但在 G1 收集器上记忆集应用要复杂很多,它的每个 Region 都维护自己的记忆集,这些记忆集会记录下别的 Region 指向自己的指针,并标记这些指针分别在哪些卡页的范围之内
以前的垃圾收集器为了解决跨代引用问题,建立了名为记忆集(Remembered Set)数据结构,用于避免再回收新生代时把整个老年代加进 GC Roots 扫描范围中
记忆集其实只是一种 “抽象” 数据结构,只单独定义了记忆集的行为意图,并没有定义其行为的具体实现,而卡表就是记忆集的一种具体实现,卡表定义了记忆集中的记录精度、与堆内存之间的映射关系;它们之间的关系就类比于接口 > Map、实现类 > HashMap 关系
HotSpot 将卡表比作是一个字节数组,卡表数组中每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作为 “卡页”(Card Page)在一个卡页内存中通常包含不止一个对象,只要卡页中存在一个或多说对象的字段存在跨代指针引用,那就将对应卡表中该卡页的元素标识为 1(Dirty)没有则标识为 0,随即在垃圾收集时,只需要将这些标识为 Dirty 卡页元素,就能知道哪些卡页内存块中包含跨代指针,将它们一并加入到 GC Roots 中一并扫描
G1 记忆集在存储结构的本质上是一种哈希表,Key > Region 起始地址,Value > 多个卡表索引号集合;这种 “双向” 卡表结构(上面说的卡表是单向的,这种结构还记录了谁指向我)比原来的卡表实现起来更复杂,同时由于 Region 数量比传统收集器的分代数量明显要多得多,因此也就说明了为什么 G1 收集器比其他的传统垃圾收集器要更高的内存占用
- 在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?首先要解决的是用户线程在改变对象引用关系时,必须保证其不能打破原本对象的图结构,导致标记结果出现错误(多标、漏标)
CMS 收集器中采用的增量更新(Incremental Update)算法实现的
G1 收集器则是通过原始快照(Snapshot At The Beginning > SATB)算法实现的
这两种算法是如何实现以及其原理图在下篇文章详细拆解
- 用户通过 -XX:MaxGCPauseMillis 参数指定的停顿时间只意味着在垃圾收集发生之前的期望值,G1 收集器的停顿预测模型是以 “衰减均值”(Decaying Average)作为理论基础去实现的,在垃圾收集过程中,G1 收集器会记录每个 Region 回收耗时、每个 Region 记忆集里的脏卡数量等各个可测量的步骤花费成本,并分析得出平均值、标准偏差、置信度等统计信息
通过对这些信息进行预测,开始回收的话,由哪些 Region 组成回收集才可以不超过期望停顿时间的约束下获取最短的回收时长
G1 收集器的运作过程大致分为几个阶段,如下:
- Initial Marking:初始标记,仅仅只是标记一下 GC Roots 能直接关联到的对象,该阶段需要 STW,但耗时很短,而且是借用在进行 Minor GC 的时刻同步完成的,所以 G1 收集器在该阶段实际上并没有额外的开销
- Concurrent Marking:并发标记,从 GC Roots 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户线程并发执行,无须 STW;当对象图扫描完成以后,还需要重新处理 SATB 记录下在并发时有引用变动的对象
- Final Marking:最终标记,对用户线程做另一个短暂的暂停,该阶段需要 STW,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录
- Live Data Counting and Evacuation:筛选回收,负责更新 Region 统计数据,对各个 Region 回收价值、成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 还剩下的存活对象复制到空的 Region 中,再清理掉整个旧 Region 全部空间
在这里的操作涉及到存活对象的移动,是必须暂停用户线程,由多条收集线程
并行完成
的
从以上四阶段来看,G1 收集器除了并发标记以外,其余阶段也是要完全暂停用户线程的,换言之,它并非纯粹追求低延迟,它目标在于延迟可控的情况下获取尽可能高的吞吐量,所以才能担当起 “全功能收集器” 重任与期望
It meets garbage collection pause time goals with a high probability,while achieving high throughput
最后,CMS、G1 这两款垃圾收集器在我们目前的工作中是比较常用的(JDK8),通过以下对比 CMS、G1 这两垃圾收集器的区别及优劣,来着重选用哪款垃圾收集器
- CMS 使用标记-清除算法实现垃圾收集,G1 从整体来看是基于标记-整理算法实现的垃圾收集,因为它可用的内存空间是连续的,但从局部(两个 Region 之间)上看又是标记-复制算法实现;这两者意味着 G1 不会产生内存碎片,在为分配大对象时有卓越的优势
- 使用 G1 作为垃圾收集,它为了垃圾收集能够保持低延迟、高吞吐特征,产生的内存占用还是程序运行时的额外执行负载都要比 CMS 要高,在机器内存不足以支撑时,只能取舍选用 CMS 作为我们的首选垃圾收集器了
内存占用:G1、CMS 都使用卡表(Card Table)来处理跨代指针
G1 卡表实现更为复杂,而且堆中每个 Region,无论是扮演新生代还是老年代角色,都必须有一份卡表,这导致记忆集以及其他内存消耗可能会占整个堆容量的 20% 乃至更多的内存空间
相比之下,CMS 卡表比较简单,只有唯一一份,而且只需要处理老年代到新年代的引用,反过来则不需要,因为在所有垃圾收集器中,只有 CMS 是专门只针对老年代进行回收,也就是 Major GC
- CMS、G1 两者都有并发标记这个阶段,导致了它们两者在使用用户线程运行时负载会有所不同
1、写屏障使用方式不同
两者都使用到了写屏障,CMS 用写后屏障来更新及维护卡表;G1 除了使用写后屏障来进行同样的卡表维护操作外,为了实现原始快照搜索(SATB)算法,还需要使用写前屏障来跟踪并发时的指针变化情况
2、处理标记产生的多标、漏标问题方式不同
CMS 采用增量更新算法解决,G1 采用原始快照算法解决
低延迟垃圾收集器
Shenandoah、ZGC 收集器,几乎整个过程全部都是并发的,只有初始标记、最终标记阶段会有短暂暂停,这部分停顿时间基本上是固定的(堆容量足够下,实现垃圾收集停顿时间都不会超过 10 毫秒),与堆容量、堆中对象数量没有正比例关系,这两款在 JDK11 后续版本有被开始使用,被官方命名为 “低延迟垃圾收集器”
在这里,只是浅谈还有这两款低延迟垃圾收集器,由于在工作中暂未接触到这两款收集器,故不作过多介绍
并行、并发
在介绍以上不同的收集器时,有经常提到并行回收、并发回收等词;并行(Parallel)、并发(Concurrent)在并发编程中是很专业的名词
并发编程
1、并行:多个工作任务在同一时刻在同一个 CPU 上交替执行
2、并发:多个工作任务在同一时刻分散给机器中多个 CPU 同时执行
但在垃圾收集器上下文中,它们所对应的语义又有所不同, 如下:
并行: 描述的是多条垃圾收集线程之间的关系,说明在同一时间又多条这样的收集线程在协同工作,而此时用户线程是处于阻塞/等待状态的
并发: 描述的是垃圾收集线程与用户线程之间的关系,说明在同一时间垃圾收集线程与用户线程都在运行;由于用户线程未处于阻塞/等待状态,所以应用程序仍然能够响应用户请求,但由于垃圾收集线程占用了一部分的系统资源,此时应用程序处理的吞吐量必然会受到一定的影响
总结
该篇博文是从周志明教授编写的 《深入理解 Java 虚拟机》巨著里面的内容结合自身的一些理解,整理出来的一些内容,垃圾收集算法是内存回收的方法论:分代收集理论(弱分代学说、强分代学说、跨代引用学说)标记-清除算法、标记-复制算法、标记-整理算法这三种算法之间的优劣势;垃圾收集器就是内存回收的实践者,从 Serial 系列收集器到 Parallel 系列收集器,为 CMS 老年代收集器搭配而生的 ParNew 新生代收集器,最重要莫过于 CMS、G1 这两种并发收集器了,里程碑式意义的存在;在最后,介绍了在垃圾收集领域内,并行、并发的区别,希望书中的精髓以及自身的理解能够帮助到您!
参考文献:《深入理解 Java 虚拟机》周志明著
博文放在 JVM 专栏里,欢迎订阅,会持续更新!
如果觉得博文不错,关注我 vnjohn,后续会有更多实战、源码、架构干货分享!
推荐专栏:Spring、MySQL,订阅一波不再迷路
大家的「关注❤️ + 点赞👍 + 收藏⭐」就是我创作的最大动力!谢谢大家的支持,我们下文见!