垃圾回收算法你了解过吗?

前言

程序计数器虚拟机栈本地方法栈的内存分配和回收都具有确定性:栈中的栈帧随着方法的进入和退出对应执行出栈和入栈的操作,每个栈帧所分配的内存随着类结构确定下来时就是已知的,当方法结束或线程结束,内存就跟随着被回收;
Java堆方法区,这两个区域不具有确定性:这部分内存的分配和回收是动态的,只有在运行期间内,才能确定创建哪些对象、方法执行时不同的逻辑分支、接口的多个实现类等,GC所关注的也正是这部分的内存如何管理。

哪些内存需要回收

Java堆中,存放着几乎所有的对象实例,GC对堆进行回收前,就需要取判断这些对象是否存活。

引用计数算法

引用计数算法(Reference Counting):在对象中添加一个引用计数器,每当一个地方引用它时,计数器值+1;引用失效时-1;任何时刻计数器为0的对象就是不可能再被使用的。
虽然占用了一些额外的内存空间来进行计数,但它的原理简单、判断高效。

虽然引用计数算法虽然简单高效,但存在很多例外的情况,必须配合大量额外的处理才能保证正确工作,所以主流的Java虚拟机都没有选用计数算法来管理内存。比如:引用计数算法就很难解决对象之间相互循环引用的问题,将导致它们引用计数都不为零,通过引用计数算法也就无法回收它们。

可达性分析算法

可达性分析(Reachability Analysis):以GC Roots作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程中走过的路径称为引用链(Reference Chain),当GC Roots到某个对象不可达时(某个对象到GC Roots间没有任何引用链相连),证明该对象不能再被使用。

可达性算法
那么可以作为GC Roots的对象有哪些呢 ?

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象;
  • 方法区中静态属性引用的对象;
  • 方法区中常量引用的对象;
  • 本地方法栈中JNI(Java Native Interface)引用的对象;
  • 被同步锁持有的对象等;

Reference

强引用(Strongly Reference)

指程序代码中的引用赋值,类似“Object obj=new Object()”这种引用关系,只要强引用关系还存在,垃圾收集器就永远不会回收引用的对象。

软引用(Soft Reference)

指一些还有用但非必须的对象。在系统将要发生内存溢出前,会把这些对象列入回收范围中进行第二次回收,如果这次回收仍然没有足够的内存,就会抛出内存溢出的异常。

弱引用(Weak Reference)

也是用来描述哪些非必须的对象,被弱引用关联的对象只能生存到下次垃圾收集发生为止。

虚引用(Phantom Reference)

也称为幽灵引用或幻影引用,他是最弱的一种引用关系。一个对象是否有需饮用的存在,不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。虚引用的作用只是为了能在这个对象被回收时收到一个系统通知。

finalize()

具体代码可以参考java.lang.ref.Finalizer
GC回收对象至少要经历两次标记的过程:

  1. 如果对象在进行可达性算法分析后发现没有与GC Roots相连接的引用链,将会被第一次标记。
  2. 接着进行筛选此对象是否有必须要执行的finalize()。
  3. 通过筛选判断此对象有必须要执行的finalize(),则将该对象放置在Finalizer-queue队列中,由虚拟机自动建立的地调度优先级的Finalizer线程取执行它们的finalize()(如果某对象的finalize()执行缓慢或死循环,将导致F-Queue队列中的其他对象永久处于等待)。
  4. 然后收集器对Finalizer-queue进行二次标记,若对象在finalize()中与GC Roots的任意引用链关联时,这时他将被移出“即将回收”的集合。

任何一个对象的finalize()只会被系统自动调用一次,若被再次准备回收时,finalize()将不会再次执行,由于finalize()的不确定性与运行代价过高,官方并不推荐使用,我们可以使用try-finally或者其他方式可以做的更好更及时。

方法区的回收

JDK1.7之前(JDK1.7,HotSpot把静态常量池从方法区移到Java堆),方法区主要回收两部分内容:废弃的常量、允许回收不再使用的类型。
判定类型是否属于不再使用的类型需要同时满足以下条件:

  1. 该类的所有实例都已经被回收(Java堆中不存在该类及其任何派生子类的实例)。
  2. 加载该类的类加载器已经被回收。
  3. 该类对应的java.lang.Class对象没有在任何地方引用,且无法在任何地方通过反射访问该类的方法。

垃圾收集算法

垃圾收集算法可以划分为引用计数式垃圾收集(Reference Conuting GC)和追踪式垃圾收集(Tracing GC)两类,分别称为直接垃圾收集和间接垃圾收集。

主流的JVM用的是追踪式垃圾收集,下面主要对这部分进行学习。

分代收集

常用垃圾收集器的设计原则:收集器应将Java堆划分出不同区域,然后将回收对象根据年龄(年龄:对象经历的垃圾收集过程次数)分配到不同的区域中存储。
这样做的优点:

  1. 如果一个区域中的大多对象都是朝生夕灭,在垃圾收集过程中会被回收的情况下,就可以集中将他们放在一起(Young Generation),每次回收时只关注如何保留少量存活而不是去标记这些大量将要被回收的对象,就能以较低代价回收到大量的空间;
  2. 如果剩下的都是难以消亡的对象,把他们集中放在一块区域(Old Generation),虚拟机可以使用较低频率来回收这个区域;

针对不同区域的收集行为有以下几个名词定义:
1 . 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。
2 . 部分收集(Partial GC):指目标不是完整收集整个Java堆的收集,分为:① 新生代收集(Minor GC/Young GC);②老年代收集(Major GC/Old GC);

标记-清除算法

最基础的收集算法,它分为标记清除两个阶段:

  1. 首先标记处所有需要回收的对象。
  2. 标记完成之后,统一回收掉所有被标记的对象。
    缺点:
  3. 执行效率不稳定,若Java堆中包含大量对象,且大部分是需要进行回收的,那么就需要进行大量的标记与清除操作,标记与清除的执行效率随着待回收对象的量增加而降低。
  4. 标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致程序运行过程中需要分配较大对象的时候无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
    标记清除算法

标记-复制算法

标记-复制算法为了解决标记-清除算法中面对大量可回收对象执行效率低下的问题。

半区复制

它将可用内存按照容量大小对半划分,每次仅使用其中一块。当这块内存用完,就讲还存活的对象复制到另外一块上面,然后将已使用过的内存空间一次清理掉。
优点:实现简单,运行高效,需要复制的只是存活对象,每次都是针对半区进行内存回收,分配内存的时候不需要考虑空间碎片的复杂情况,只用移动堆顶指针,按顺序分配即可。
缺点:将可用的内存缩小为原来的一半,如果内存中的多数对象都是存活的,将产生大量的内存间复制的开销。
标记-复制算法

Appel式回收

Appel式回收回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜索时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。

HotSpot虚拟机默认的Eden和Survivor及另一个Survivor的大小比例是8:1:1,也就是每次新生代中可用内存空间为整个新生代容量的90%,另外10%的新生代是一块保留区域。当Survivor空间不足容纳Minor GC之后存活的对象时(没法保证每次回收都只有少于新生代10%的对象存活),就需要依赖其它内存区域进行担保(比如老年代)。

标记-整理算法

标记-复制算法在对象存活率较高时,就要进行较多的复制操作,效率就会降低。如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都是100%存活的极短情况,所以老年代不直接选用此种算法。

标记-整理算法,标记的过程与标记-清除算法一样,但是后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一段移动,然后直接清理掉边界以外的内存。

弊端:如果移动存活对象,必须更新所有引用这些对象的地方,且这种对象移动操作必须全程暂停用户应用程序才能进行。但若采用标记-清除算法,垃圾收集后导致的空间碎片化问题只能依赖更复杂的内存分配器和内存访问器来解决,这样就会间接影响到应用程序的吞吐量。

总结:标记清除算法与标记整理算法的利弊,移动对象则内存回收会复杂但吞吐量高,不移动对象则内存分配会更复杂但停顿时间短,甚至可以不用停顿。
对于这些不同特性,HotSpot里Parallel Scavenge收集器更关注吞吐量,所以它是基于标记-整理算法;CMS收集器关注延迟,所以是基于标记清除算法的。

标记-整理算法

HotSpot的算法

根节点枚举

固定作为GC Roots节点主要在全局性的引用(常量或静态属性)与执行上下文(栈帧中的本地变量表)中,随着Java工程的庞大,逐个获取GC Roots节点将会是一个很耗时的操作,所以所有的收集器在根节点枚举这一步时,都是必须暂停用户线程的,因为根节点枚举必须在一个能保障一致性(枚举期间,执行子系统要保证在分析过程中,根节点集合对象的引用关系不可变化,否则细分结果的准确定无法保证)的快照中才能够进行。

HotSpot是使用一组称为OopMap的数据结构来达到这个目的:当类加载动作完成时,HotSpot就会把栈上本地变量到堆上对象的引用关系记录到OopMap中,通过这通方式收集器在扫描时就可以直接得知这些信息,并不需要一个不漏地从方法区等GC Roots开始查找。

安全点

在OopMap的协助下,HotSpot可以快速准备地完成GC Roots枚举,如果为每一条指令都生成对应的OopMap,那么将会需要大量额外的存储空间。实际上HotSpot只是在特定的位置记录OopMap,这些位置称为安全点(SafePoint)。

安全点的位置的选举基本上是以“是否具有让程序长时间执行的特征”为标准而选定的,“长时间执行”明显的特征就是指令序列的复用,例如:方法调用、循环跳转、异常跳转等都属于指令序列的复用,所以具有这些功能的指令才会产生安全点。

如何在垃圾收集发生时让所有线程都跑到最近的安全点,然后停顿下来?

  1. 抢先式中断:不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把用户的线程全部中断,如果发现存在用户线程中断的地方不在安全点上,恢复此用户线程执行,让它重新中断,直到在安全点上,几乎没有虚拟机采用这种方式。
  2. 主动式中断:不直接对线程进行操作,仅设置一个标志位,各线程执行过程中不停地主动去轮询这个标志,一旦发现终端标志为true时就在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外需要加上所有创建对象和其他需要在Java堆上分配内存的地方,这是为了检查是否即将发生垃圾收集,避免没有足够内存分配新对象。

安全区域

使用安全点的设计似乎可以解决如何停顿用户线程,让虚拟机进入垃圾回收状态的问题了,但是当用户线程处于Sleep状态或者Blocked状态时,就无法响应虚拟机的终端请求,无法执行到安全的地方中断挂起自己。对于这种情况加就需要引入安全区域(Safe Region)来解决。

安全区域:确保在某一段代码片段中,引用关系不会发生变化。

当用户线程执行到安全区域里面的代码时:

  1. 标识自己已经进入安全区域(当这段时间虚拟机发起垃圾收集时不必去管这些已经声明自己在安全区域内的线程)。
  2. 当线程将要离开安全区域时,检查虚拟机是否已经完成了根节点枚举。
  3. 未完成则一直等待,直到收到可以离开安全区的信号为止。

记忆集与卡表

为了解决对象跨代引用的问题,垃圾收集器在新生代中创立了名为记忆集(Remembered Set)的数据结构,用于避免把整个老年代加入GC Roots扫描范围。所有涉及部分区域收集(Partial GC)行为的垃圾收集器(例如G1,ZGC,Shenandoah收集器)都存在跨代引用的问题。

记忆集:记录从非收集区域指向收集区域的指针集合的抽象数据结构(抽象是指只定义了记忆集的行为意图,没有定义其行为的具体实现)。
记忆集的记录精度:

  1. 字长精度:每个记录精确到一个机器字长,该字包含跨代指针。
  2. 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
  3. 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。

卡精度:用一种称为卡表的方式去实现记忆集,卡表就是记忆集的一种具体实现,它定义了记忆集的记忆精度、与堆内存的映射关系等。

HotSpot默认的卡表标记逻辑如下:

CARD_TABLE [this address >> 9] = 0;

字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块称为卡页(card page)。一般来说,卡页的大小都是以2次幂的字节数,上面的代码可以看到HotSpot使用的卡页大小是2的9次幂也就是512字节。
一个卡页的内存通常包含不止一个对象,只要卡页内存在一个或多个对象的字段存在跨代指针,那就将对应卡表的数组元素的值标识为1称为这个元素变脏,没有则标识为0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,即可得出哪些卡页内存块中包含跨代指针,把他们加入GC Roots一并扫描。

写屏障

使用记忆集来缩减GC Roots扫描范围的问题,但是卡表元素什么时候变脏?怎么维护呢 ?
在其他分代区域中对象引用了本地区域对象时,对应的卡表元素就会变脏。如何在对象赋值那一刻去维护卡表呢?

HotSpot虚拟机中是通过写屏障(Write Barrier)来维护卡表状态的。写屏障可以看作在虚拟机层面对引用类型字段赋值这个动作的AOP切面,在引用对象赋值时产生一个Around通知,也就是赋值的前后都在写屏障的覆盖范畴内。所以在赋值的前后部分,分别叫做写前屏障(Pre-Write Barrier)和写后屏障(Post-Wirte Barrier)(在G1收集器之前,其他收集器只用到了写后屏障)。伪代码如下:

void oop_field_store(oop* field, oop new_value) {
// 引用字段赋值操作
*field = new_value;
// 写后屏障, 在这里完成卡表状态更新
post_write_barrier(field, new_value);
}

应用写屏障后,虚拟机会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新就会产生额外的开销,这个开销相比较Minor GC时扫描整个老年代的代价相比还是低很多的。

JDK7之后,HotSpot虚拟机增加了一个新的参数-XX:+UseCondCardMark,用来决定是否开始卡表更新的条件判断。开启后会增加一次额外判断的开销,用来避免伪共享(处理器的缓存系统中以缓存行为单位存储的,多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会彼此影响而导致性能降低)的问题。

并发的可达性分析

从上面我们可以了解到,当前主流的垃圾收集器基本上都是依靠可达性算法来判定对象是否存活,可达性分析算法理论上要求全过程都给予一个能保证一致性的快照中才能进行分析,意味着必须全程冻结用户线程的运行。在根节点枚举这一步,GC Roots对比整个Java堆中对象还是极少的,通过各种优化技巧下,它所带来的的停顿非常短暂且相对固定,但是从GC Roots向下遍历对象停顿的时间,就要与Java堆容量成正比了。

回收算法中我们讲到了标记,标记阶段是所有追踪式垃圾收集算法的共同特征,如果这个阶段随着堆变大而等比例增加停顿时间,将影响到所有的垃圾收集器。

下面我们先搞明白为什么必须要在保障一致性的快照中才能进行对象图的遍历。通过三色标记来推导遍历对象图中遇到的对象,按照“是否访问过”这个对象的条件标记成以下三色:

  • 白色:在可达性分析刚开始时,所有的对象都是白色的;分析结束之后,则代表不可达。
  • 黑色:表示已经被垃圾收集器访问过,并且这个对象所有的引用都已经扫描过,是安全存活的,如果有其他对象引用指向了黑色对象,无需重新扫描。黑色对象不能直接指向某个白色对象。
  • 灰色:表示已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。

如果用户线程此时是冻结的,只有垃圾收集器线程在工作时,不会有任何问题。但如果用户线程与收集器是并发工作,会出现两种结果:1.把原本消亡的对象错误标记为存活。2.把原本存活的对象错误标记为消亡,这就将导致严重的后果,另一种说法叫做“对象消失”。示意图如下:
对象消失
上图我们可以知道,当发生原本应是黑色的对象被误判成了白色,就会产生对象消失的问题(原本应该是黑色的对象被误判为白色)产生对象消失需要同时满足以下两个条件:

  1. 赋值器插入了一条或多条从黑色对象到白色对象的新引用。
  2. 赋值器删除了全部从灰色对象到白色对象的之间或间接引用。

解决并发扫描时对象消失的两种解决方案:

  1. 增量更新破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就讲这次新的插入引用记录下来,等并发扫描结束,再将记录过的引用关系中黑色对象为根,重新扫描一次。黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。
  2. 原始快照破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,将这个要删除的引用记录下来,在并发扫描结束后,再将这个记录过的引用关系中的灰色对象为根,重新扫描一次。

CMS是基于增量更新来做并发标记的,G1、Shenandoah则使用原始快照来实现。

参考文献

《深入理Java虚拟机》周志明

  • 3
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

人生逆旅我亦行人

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值