前言
在 JVM 专栏章节里,有讲解 Java 中四大引用类型以及如何判定对象是否存活,它们是前置知识也是作为学习 JVM 必经之路,从此文中我们会详细分析 JVM 有哪些垃圾回收算法、垃圾收集器
垃圾回收算法
垃圾回收算法是一种用于确定哪些对象是 “垃圾”,它们通过检测不再被引用的对象来标记、识别可以释放的内存,这些算法使用不同的策略和技术,例如:引用计数、标记-清除、标记-复制、标记-整理等,以确保有效地回收内存并提高应用程序的性能
分代收集
当前大部分的垃圾收集器,大多数都遵循了 “分代收集”(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 参数来限制垃圾收集的线程数