JVM:对象是否可以回收及Hotspot算法实现

1、如何确定一个对象是否可以被回收

1.1、引用计数算法:判断对象的引用数量

引用计数算法是通过判断对象的引用数量来决定对象是否可以被回收。

引用计数算法是垃圾收集器中的早期策略。在这种方法中,堆中的每个对象实例都有一个引用计数。当一个对象被创建时,且将该对象实例分配给一个引用变量,该对象实例的引用计数设置为 1。当任何其它变量被赋值为这个对象的引用时,对象实例的引用计数加 1(a = b,则b引用的对象实例的计数器加 1),但当一个对象实例的某个引用超过了生命周期或者被设置为一个新值时,对象实例的引用计数减 1。特别地,当一个对象实例被垃圾收集时,它引用的任何对象实例的引用计数器均减 1。任何引用计数为0的对象实例可以被当作垃圾收集。

PS:缺点:很难解决对象之间相互循环引用的问题

1.2、可达性分析算法:判断对象的引用链是否可达

可达性分析算法是通过判断对象的引用链是否可达来决定对象是否可以被回收

可达性分析算法是从离散数学中的图论引入的,程序把所有的引用关系看作一张图,通过一系列的名为 “GC Roots” 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain)。当一个对象到 GC Roots 没有任何引用链相连(用图论的话来说就是从 GC Roots 到这个对象不可达)时,则证明此对象是不可用的。

Java对象中,可作为GC Roots的对象包括以下几种:

  • 虚拟机栈(栈帧中的局部变量表)中引用的对象;
  • 方法区中类静态属性引用的对象;
  • 方法区中常量引用的对象;
  • 本地方法栈中Native方法引用的对象;
  • Java虚拟机内部的引用, 如基本数据类型对应的Class对象, 一些常驻的异常对象(比如NullPointExcepiton、 OutOfMemoryError) 等, 还有系统类加载器 ;
  • 所有被同步锁(synchronized关键字)持有的对象。

2、Hotspot的算法实现

2.1、枚举根节点

枚举根节点也就是查找GC Roots;

目前主流JVM都是 准确式GC,可以直接得知哪些地方存放着对象引用 ,所以执行系统停顿下来后,并不需要全部、逐个检查完全局性的和执行上下文中的引用位置;

在HotSpot中,是使用一组称为 OopMap 的数据结构来达到这个目的的,在类加载时,计算对象内什么偏移量上是什么类型的数据。在JIT编译时,也会记录栈和寄存器中的哪些位置是引用,这样GC扫描时就可以直接得知这些信息;

2.2、安全点

HotSpot在OopMap的帮助下可以快速且准确的完成GC Roots枚举。
问题: 运行中,非常多的指令都会导致引用关系变化,如果为这些指令都生成对应的OopMap,需要的空间成本太高

问题解决: 只在特定的位置记录OopMap引用关系,这些位置称为 安全点(Safepoint) 即程序执行时并非所有地方都能停顿下来开始GC;

安全点的选定:

不能太少,否则GC等待时间太长;也不能太多,否则GC过于频繁,增大运行时负荷;

所以,基本上是以程序"是否具有让程序长时间执行的特征"为标准选定;

"长时间执行"最明显的特征就是指令序列复用,如:方法调用、循环跳转、循环的末尾、异常跳转等;(for循环 int时i的值很小不会标记为Safepoint)只有具有这些功能的指令才会产生Safepoint;

如何在安全点上停顿:

  • 抢先式中断(Preemptive Suspension)不需要线程主动配合,实现如下:
  1. 在GC发生时,首先中断所有线程;
  2. 如果发现不在Safepoint上的线程,就恢复让其运行到Safepoint上。

现在几乎没有JVM实现采用这种方式

  • 主动式中断(Voluntary Suspension)
  1. 在GC发生时,不直接操作线程中断,而是仅简单设置一个标志;
  2. 让各线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起;
    而轮询标志的地方和Safepoint是重合的

2.3、安全区域

问题: 程序不执行时没有CPU时间(Sleep或Blocked状态),无法运行到Safepoint上再中断挂起。

安全区域(Safe Region): 指一段代码片段中,引用关系不会发生变化,在这个区域中的任意地方开始GC都是安全的;

安全区域(Safe Region)解决问题的思路:

  • 线程执行进入Safe Region,首先标识自己已经进入Safe Region;
  • 线程被唤醒离开Safe Region时,其需要检查系统是否已经完成根节点枚举(或整个GC); 如果已经完成,就继续执行,否则必须等待,直到收到可以安全离开Safe Region的信号通知;

2.4、记忆集与卡表

讲解分代收集理论的时候,提到了为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集(Remembered Set)的数据结构,用以避免把整个老年代加进GC Roots扫描范围。

事实上并不只是新生代、老年代之间才有跨代引用的问题,所有涉及部分区域收集(Partial GC)行为的垃圾收集器,典型的如G1、ZGC和Shenandoah收集器,都会面临相同的问题,因此我们有必要进一步理清记忆集的原理和实现方式。

这种记录全部含跨代引用对象的实现方案,无论是空间占用还是维护成本都相当高昂。而在垃圾收集的场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节

那设计者在实现记忆集的时候,便可以选择更为粗犷的记录粒度来节省记忆集的存储和维护成本,下面列举了一些可供选择的记录精度:

字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。

对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。

卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。

其中,第三种“卡精度”所指的是用一种称为“卡表”(Card Table)的方式去实现记忆集。 前面定义中提到记忆集其实是一种“抽象”的数据结构,抽象的意思是只定义了记忆集的行为意图,并没有定义其行为的具体实现。卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。关于卡表与记忆集的关系,读者不妨按照Java语言中HashMap与Map的关系来类比理解。

卡表最简单的形式可以只是一个字节数组

字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)。
在这里插入图片描述
一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描

2.5、写屏障

我们已经解决了如何使用记忆集来缩减GC Roots扫描范围的问题,但还没有解决卡表元素如何维护的问题,例如它们何时变脏、谁来把它们变脏等。

卡表元素何时变脏的答案是很明确的——有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。

但问题是如何变脏,即如何在对象赋值的那一刻去更新维护卡表呢?假如是解释执行的字节码,那相对好处理,虚拟机负责每条字节码指令的执行,有充分的介入空间;但在编译执行的场景中呢?经过即时编译后的代码已经是纯粹的机器指令流了,这就必须找到一个在机器码层面的手段,把维护卡表的动作放到每一个赋值操作之中。

在HotSpot虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的。先请读者注意将这里提到的“写屏障”,以及后面在低延迟收集器中会提到的“读屏障”与解决并发乱序执行问题中的“内存屏障”区分开来,避免混淆。

写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值后的则叫作写后屏障(Post-Write Barrier)。HotSpot虚拟机的许多收集器中都有使用到写屏障,但直至G1收集器出现之前,其他收集器都只用到了写后屏障。

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

除了写屏障的开销外,卡表在高并发场景下还面临着“伪共享”(False Sharing)问题。伪共享是处理并发底层细节时一种经常需要考虑的问题,现代中央处理器的缓存系统中是以缓存行(Cache Line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题。

在JDK 7之后,HotSpot虚拟机增加了一个新的参数-XX:+UseCondCardMark,用来决定是否开启卡表更新的条件判断。开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损耗,是否打开要根据应用实际运行情况来进行测试权衡。

2.6、并发的可达性分析

前面曾经提到了当前主流编程语言的垃圾收集器基本上都是依靠可达性分析算法来判定对象是否存活的, 可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能够进行分析。

这意味着必须全程冻结用户线程的运行。 在根节点枚举这个步骤中, 由于GC Roots相比起整个Java堆中全部的对象毕竟还算是极少数, 且在各种优化技巧(如OopMap) 的加持下, 它带来的停顿已经是非常短暂且相对固定(不随堆容量而增长) 的了。 可从GC Roots再继续往下遍历对象图, 这一步骤的停顿时间就必定会与Java堆容量直接成正比例关系了: 堆越大, 存储的对象越多, 对象图结构越复杂, 要标记更多对象而产生的停顿时间自然就更长, 这听起来是理所当然的事情。要知道包含“标记”阶段是所有追踪式垃圾收集算法的共同特征, 如果这个阶段会随着堆变大而等比例增加停顿时间, 其影响就会波及几乎所有的垃圾收集器, 同理可知, 如果能够削减这部分停顿时间的话, 那收益也将会是系统性的。

想解决或者降低用户线程的停顿, 就要先搞清楚为什么必须在一个能保障一致性的快照上才能进行对象图的遍历? 为了能解释清楚这个问题, 我们引入三色标记(Tri-color Marking) 作为工具来辅助推导, 把遍历对象图过程中遇到的对象, 按照“是否访问过”这个条件标记成以下三种颜色:

  • 白色:表示对象尚未被垃圾收集器访问过。 显然在可达性分析刚刚开始的阶段, 所有的对象都是白色的, 若在分析结束的阶段, 仍然是白色的对象, 即代表不可达
  • 黑色: 表示对象已经被垃圾收集器访问过, 且这个对象的所有引用都已经扫描过。 黑色的对象代表已经扫描过, 它是安全存活的, 如果有其他对象引用指向了黑色对象, 无须重新扫描一遍。 黑色对象不可能直接(不经过灰色对象) 指向某个白色对象
  • 灰色: 表示对象已经被垃圾收集器访问过, 但这个对象上至少存在一个引用还没有被扫描过

关于可达性分析的扫描过程, 可以把它看作对象图上一股以灰色为波峰的波纹从黑向白推进的过程, 如果用户线程此时是冻结的, 只有收集器线程在工作, 那不会有任何问题。 但如果用户线程与收集器是并发工作呢? 收集器在对象图上标记颜色, 同时用户线程在修改引用关系——即修改对象图的结构, 这样可能出现两种后果。 一种是把原本消亡的对象错误标记为存活,这不是好事, 但其实是可以容忍的, 只不过产生了一点逃过本次收集的浮动垃圾而已, 下次收集清理掉就好。 另一种是把原本存活的对象错误标记为已消亡, 这就是非常致命的后果了, 程序肯定会因此发生错误
并发对象消失示意图
当且仅当以下两个条件同时满足时, 会产生“对象消失”的问题, 即原本应该是黑色的对象被误标为白色

  • 赋值器插入了一条或多条从黑色对象到白色对象的新引用
  • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用

因此, 我们要解决并发扫描时的对象消失问题, 只需破坏这两个条件的任意一个即可。 由此分别产生了两种解决方案: 增量更新(Incremental Update) 和原始快照(Snapshot At The Beginning,SATB)

增量更新要破坏的是第一个条件, 当黑色对象插入新的指向白色对象的引用关系时, 就将这个新插入的引用记录下来, 等并发扫描结束之后, 再将这些记录过的引用关系中的黑色对象为根, 重新扫描一次。 这可以简化理解为, 黑色对象一旦新插入了指向白色对象的引用之后, 它就变回灰色对象了。

原始快照要破坏的是第二个条件, 当灰色对象要删除指向白色对象的引用关系时, 就将这个要删除的引用记录下来, 在并发扫描结束之后, 再将这些记录过的引用关系中的灰色对象为根, 重新扫描一次。 这也可以简化理解为, 无论引用关系删除与否, 都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

以上无论是对引用关系记录的插入还是删除, 虚拟机的记录操作都是通过写屏障实现的。 在HotSpot虚拟机中, 增量更新和原始快照这两种解决方案都有实际应用, 譬如, CMS是基于增量更新来做并发标记的, G1、 Shenandoah则是用原始快照来实现。

3、对象引用类型

强引用: 就是普通的引用,如StringBuffer sb = new StringBuffer(), 只要强引用存在,就不会被垃圾回收;

软引用: 只要内存空间足够,就不会释放软引用的对象;当系统将要发生内存溢出时,才会将软引用的对象列入回收范围,JDK1.2使用SoftReference类实现软引用;

弱引用: 弱引用不会影响对象的生命周期,垃圾回收器对弱引用的对象会当成普通对象处理(只要没有强引用就可以被回收);JDK1.2使用WeakReference类实现弱引用(ThreadLocal);

虚引用: 虚引用不会影响对象的生命周期,无法通过虚引用获取对象实例;创建虚引用的目的是可以在对象被垃圾收集器回收时收到一个系统通知。JDK1.2使用PhantomReference类实现虚引用。

即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历再次标记过程。

标记的前提是对象在进行可达性分析后发现没有与GC Roots相连接的引用链。

  1. 第一次标记并进行一次筛选

筛选的条件是此对象是否有必要执行finalize()方法;
当对象没有覆盖finalize方法,或者finzlize方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”,对象被回收。

  1. 第二次标记

如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个名为:F-Queue的队列之中,并在稍后由一条虚拟机自动建立的、低优先级的Finalizer线程去执行。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束。这样做的原因是,如果一个对象finalize()方法中执行缓慢,或者发生死循环(更极端的情况),将很可能会导致F-Queue队列中的其他对象永久处于等待状态,甚至导致整个内存回收系统崩溃;

PS:finalize() 方法是对象脱逃死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模标记,如果对象要在finalize()中成功拯救自己----只要重新与引用链上的任何的一个对象建立关联即可,譬如把自己赋值给某个类变量或对象的成员变量,那在第二次标记时它将移除出“即将回收”的集合。如果对象这时候还没逃脱,那基本上它就真的被回收了

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值