垃圾回收相关技术

垃圾收集的三大假设

这是三个统计上的经验法则。

  1. 弱分代假说,绝大多数对象都是朝生夕灭。
  2. 强分代假说,熬过越多次GC过程的对象就越难以消亡
  3. 跨代引用假说,跨代引用只占少数。这一条实际上是前两条的推论,因为大多数对象都是同生共死的,所以一般都会位于通过区域。这也是后期实现各种分代/分区收集算法的基础——正因为少数,所以开销不至于太大。

垃圾回收算法回顾

经典算法

从缺点演进方面来梳理一下三种算法。
标记清楚算法的缺点在于内存碎片,空闲列表的维护使得整体的效率处于中等。
复制算法解决了该问题,复制算法在存活对象少的时候效率最快,复制算法的问题在于浪费了一半的空间。
标记整理算法是一种折衷的方案,它不再需要划分内存,也没有内存碎片,但是它整体下来效率是最低的。

此外,复制算法和标记整理算法,共同的缺点是由于对象的移动,都需要某种机制去维护现有的引用,这也是垃圾回收中的一个重要问题。

增量收集算法

增量收集算法意在解决STW时间过长的缺陷,它是一种并发收集的算法。例如CMS就使用了该算法。如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成。

总的来说,增量收集算法的基础仍是传统的标记-清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作。

分区算法

G1收集器中引入了分区的概念,对每个分区衡量回收价值,选择高价值的进行回收。这里是我一直以来的问题:分区算法和内存划分(新生代老年代等)能够共存吗?答案是肯定的。
请添加图片描述

Finalization机制与GC过程

finalization机制

Java提供了finalization的机制——一个对象被GC之前,会调用finalize()方法。它很像c++的析构方法,但由于Java是由虚拟机管理GC,使得它在功能上和析构方法完全不同。

  • finalize的执行时机完全无法保证,它只和GC的时机有关
  • finalize方法之后对象不一定回收,它可以复活某个对象
  • 糟糕的finalize实现会极大地影响GC效率

所以GC整个过程是怎么样的呢?

对象三种状态

首先需要来了解对象可能的三种状态:

  • 可触及的:从根节点开始,可以到达这个对象。
  • 可复活的:对象的所有引用都被释放,但是对象有可能在finalize()中复活。
  • 不可触及的:对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize()只会被调用一次。

GC过程

  1. 如果对象objA到GC Roots没有引用链,则进行第一次标记。
  2. 进行筛选,判断此对象是否有必要执行finalize()方法
  3. 如果对象objA没有重写finalize()方法,或者finalize()方法已经被虚拟机调用过,则虚拟机视为“没有必要执行”,objA被判定为不可触及的。
  4. 如果对象objA重写了finalize()方法,且还未执行过,那么objA会被插入到F-Queue队列中,由一个虚拟机自动创建的、低优先级的Finalizer线程触发其finalize()方法执行。
  5. finalize()方法是对象逃脱死亡的最后机会,稍后GC会对F-Queue队列中的对象进行第二次标记。如果objA在finalize()方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA会被移出“即将回收”集合。之后,对象会再次出现没有引用存在的情况。在这个情况下,finalize方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的finalize方法只会被调用一次。

根节点枚举

引用分析算法的基础是GCRoots,虽然我们知道可以作为GC Roots的对象只有有限的类型。但是,事实上,Java的应用越来越大,方法区、常量池、栈帧等可能有数百兆,这个时候枚举找到GC Roots的开销就非常大。
Hotspot虚拟机通过OopMap数据结构记录字节码文件中引用的位置,相当于提前索引好,以空间换时间,当然这个是非常值得的,这种优化也非常重要。
目前而言,所有的垃圾收集器找到GC Roots都必须STW,但是由于OopMap的存在,使得这件事与堆大小无关,只和GC Roots的数量相关。

安全点和安全区

如果每条指令都生成OopMap,那会造成巨大的空间成本。另一方面,GC的时候需要暂停程序,但是程序并不是任意时刻都能停的。很自然的,Java设定了一些可以停顿的位置,这就是安全点和安全区。
安全点的选取一般是基于“是否具有让指令序列长时间执行”,这种情况,一般包括方法调用,循环跳转,异常跳转。
程序需要执行到安全点才可以放心暂停,但有时候,线程处于Sleep 状态或Blocked 状态,这时候线程无法响应JVM的中断请求,“走”到安全点去中断挂起,JVM也不太可能等待线程被唤醒。解决这个问题,Java又提出了安全区的概念:指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始Gc都是安全的。

如何控制中断

  • 一种方式是抢先式的,直接中断所有用户线程,然后再逐一让它们运行到安全点,这显然实现是很麻烦的。
  • 另一种是主动式的,设定好标志,线程运行经过时会轮询是否中断。

执行过程

这里可以体会安全点和安全区的概念,一般在垃圾收集器中STW被使用。

  1. 当线程运行到Safe Region的代码时,首先标识已经进入了Safe Relgion,如果这段时间内发生GC,JVM会忽略标识为Safe Region状态的线程
  2. 当线程即将离开Safe Region时,会检查JVM是否已经完成GC,如果完成了,则继续运行,否则线程必须等待直到收到可以安全离开Safe Region的信号为止;

记忆集和卡表

问题

从GC Roots进行全图遍历,可以遍历到全堆的存活对象,但问题在于,这种操作非常耗时。现代垃圾收集器一般不会进行全堆扫描,而只会进行区域扫描,例如只扫描老年代/新生代,或者分区垃圾收集器,只扫描部分区域。
在垃圾收集器中,会遇到跨代引用或跨区域引用的问题——例如,只回收新生代,但是新生代中一些对象却有来自老年代的引用。

记忆集和卡表

跨代引用对于“通过部分收集来降低整体STW时间”的垃圾收集器来说,都非常关键。试想一下,在回收新生代的时候为了知道都有什么跨代引用,把老年代枚举一遍是完全无法接受的。所以很自然地,我们会想要提前把这些跨代引用记录下来,空间换时间。这就是记忆集。
记忆集记录了跨代的映射,这里还有一个粒度选择的问题。如果是选择以对象为粒度,那么可想而知,记忆集的开销会非常大,所以划分为更大区块进行记录就理所当然,这样的区块的索引叫卡表。

写屏障

记忆集肯定需要维护,那么如何在跨代引用发生的时候进行维护呢?最简单的方式,是不去管分代,在所有赋值操作前后进行操作,这很像编程模型AOP,在底层则是通过写屏障实现的——写屏障,通过硬件,让赋值指令执行前后进行某些操作,这时候可以维护记忆集。

并发标记与重新标记的原理

问题

上面也提到,事实上,全图的可达性分析(可能是局部的)的开销非常大——它和堆的大小正相关;如果在STW中进行,当然实现非常简单直接,但是那样一来延迟就无法优化,所以我们需要想办法让这个过程可以和用户线程并发地执行。
和用户线程一同执行并发标记的问题在于,可能在并发标记的过程中,用户线程会改动已经扫描过的标记。有两种情况,一是原来存活的对象,后续引用消失,变成浮动垃圾,这种可以在下次GC解决。二是,原来不可达的对象,有了新的引用,而可能被错误回收。

增量更新和原始快照

在《深入理解Java虚拟机》中有详细分析(P87,并发的可达性分析),如果想要理解,可以参考书中描述,非常清除。
假如将对象分为黑、灰、白三种状态,分别代表对象所有引用都扫描过且确定存活、有引用未扫描过、未被扫描或不可达,出现这种情况有两个必要条件:

  • 新加入的黑色对象到白色对象的引用
  • 删除灰色对象到白色对象的引用

经典的解决思路——找出必要条件,破坏其中一个必要条件。
增量更新破坏第一个必要条件,它会将并发标记过程中的新增的符合条件的引用记录下来,然后再重新标记阶段进行修正。CMS收集器使用了这种方式。
原始快照破坏第二个必要条件,它会记录这种删除的引用,并再重新标记阶段进行修正。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值