@

前言

本篇紧接上文,主要讲解垃圾回收算法的实现细节以及对目前最前沿的低延迟GC(Shenandoah、ZGC)做个介绍。

垃圾回收算法实现细节

根节点枚举

我们知道目前的JVM的垃圾回收器都是采用可达性分析算法标记存活对象,该算法首先需要找到GC Roots,然后通过这些根节点向下搜索,能搜索到的就标记为存活对象,未被标记的最后就会被垃圾回收器回收。那你是否想过垃圾回收器怎么找到GC Roots呢?对于在方法区的根节点难道需要将方法区中的类、常量等信息一个不漏的都扫描一遍么?

虚拟机当然不会这么做,否则即使CMS和G1在初始标记这个环节都会停顿较长时间。实际上虚拟机在类加载完成后就会将对象引用维护到一组成为OopMap的数据结构中,在GC进行初始标记这个环节时直接从该数据结构中获取根节点即可。

另外在进行根节点枚举时,这些根节点必然是不能变化的(不可能为每条指令都生成对应的OopMap),即必须冻结在开始扫描之前的某个时间点,这也是为什么初始标记时都会需要STW的原因。

安全点

在上一篇简单提到过安全点的概念,虚拟机开始GC时,不能随时随地立马暂停用户线程,必须跑到合适的位置才能暂停,这个位置就是安全点。那么用户线程应该在何时何点暂停呢?有以下两个原则:

  • 安全点不能太多,太多的话用户线程就会暂停比较频繁,给系统增加负担。
  • 安全点也不能太少,太少的会导致垃圾虚拟机需要等待较长时间才能开始GC标记

从以上两个原则我们可以总结出,安全点的选择应以是否具有让程序长时间执行的特征为标准。什么是长时间执行?这需要从指令角度考量,因为单条指令的执行时间都比较短,不可能以指令流的长度作为标准,只有指令序列的复用才能最明显地体现出程序将要长时间执行,而方法调用(如递归调用)循环跳转(循环次数可能比较多)异常跳转这些指令就属于指令序列复用。

安全点如何确定我们明白了,但是如何让用户线程跑到最近的安全点呢?有两种方案:抢先式中断主动式中断。前者就是系统首先会暂停所有的用户线程,然后挨个检查是否已经在安全点,如果不在就恢复线程让它跑到安全点。而后者则是由线程执行过程中自己去轮询判断是否是安全点,是就暂停,否则继续运行直到跑到安全点。目前虚拟机基本上采用的都是主动式中断

安全区域

在执行过程中的用户线程可以响应系统的中断请求,但是还有些处于SleepBlock等非运行中的线程是无法响应中断请求的,这个就没法用安全点来保证了,因为虚拟机不可能等待线程被系统分配时间片,为此引入了安全区域概念。



如图所示,安全区域指的是一个范围,当用户线程进入该区域时,首先会标记自己进入了安全区域,当执行完该区域内的代码后,需要判断垃圾收集器的STW阶段已经完成(初始标记、重新标记等),如果已完成,线程继续运行即可,否则则需要等待STW的结束。

通过上文我们很容易理解,安全区域需要该区域内不能有引用关系变化。

记忆集和卡表

记忆集的概念在上一篇的也提到过,它是用来解决跨代引用问题的,维护在垃圾收集区域,其中存储了从非收集区域指向收集区域的指针集合(这部分引用需要作为GC Roots)。该指针的精度如果都是指向具体的跨代引用对象的话,维护成本非常高,另外和扫描整个非收集区域是一样的。=指针的精度有以下几种选择:

  • 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个

    精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
  • 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
  • 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。

上面最细粒度的就是字长精度,最粗粒度的是卡精度,目前最常用的实现就是第三种,也被称为卡表。在HotSpot虚拟机中使用的是字节数组来实现的卡表:

CARD_TABLE[this address >> 9] = 0;

上面这段代码的意思是每个数组元素存储的是每个卡页(内存块)的内存起始地址,右移9位即代表除以512,及每个卡表大小为512字节(在HotSpot中是2的9次幂,其它虚拟机中也需要保证是2的N次幂)。为帮助理解,我画了一张对应关系图,图中数字都已转化为十进制数。



当卡页中只要存在至少一个跨代引用对象,对应卡表中的元素就会被标识为1,标识该卡页变“脏”,在进行可达性分析时,就会将变“脏”的内存页加入GC Roots中一并扫描。

在CMS和G1中都使用了卡表,在使用CMS时,只在新生代中维护了一个卡表(老年代中也有可能存在新生代对其的跨代引用,但新生代的对象大都朝生夕死,所以没有必要),而G1是每个Region都需要维护一个卡表,因此G1比CMS更浪费空间,换言之这也是为什么G1更适合堆空间较大的情况。

写屏障

有了卡表,就能很轻松地解决跨代引用的问题,但是卡表在什么时候去维护呢?考虑到并发问题,肯定需要在跨代引用字段赋值完成的那一刻将对应的内存页变“脏”,即字段赋值和卡表的维护应该保证原子性(多个操作是不可分割的一个操作)。那么要如何实现呢?在HotSpot虚拟机中是使用的写屏障技术实现的,可以理解为对字段赋值的AOP环形通知。既然是环形通知,那么就存在写前屏障写后屏障,在维护卡表这一操作上所有的垃圾回收器都使用的是写后屏障,而G1还使用写前屏障实现原始快照(稍后分析)。

写屏障虽好,但也有其缺陷,一是会增加额外的开销,所以的赋值操作都会增加维护卡表的逻辑;二是在高并发场景下卡表会存在伪共享(现代的处理器是以缓存行为单位存储的,如果存储的两个或多个独立的变量位于同一缓存行,就会彼此影响,导致缓存失效,性能大大降低。)问题。

假设处理器的缓存行大小为64字节,由于一个卡表元素占1个字节,64个卡表元素将共享同一个缓存行。这64个卡表元素对应的卡页总的内存为32KB(64×512字节),也就是说如果不同线程更新的对象正好处于这32KB的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。为了避免伪共享问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏,即将卡表更新的逻辑变为以下代码所示:

if (CARD_TABLE [this address >> 9] != 0) CARD_TABLE [this address >> 9] = 0;

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

上面内容引用自《深入理解Java虚拟机》,这里博主仍存在一个疑问:CARD_TABLE[0]~CARD_TABLE[64]处于同一缓存行,任意一个元素的值改变都会导致该缓存行失效,那这里是不是只是解决了同一缓存行所有元素第一次维护完之后的伪共享问题呢?

并发的可达性分析

通过前面的学习我们知道GC停顿最耗时的阶段是在深入遍历对象图的时候,所以CMS和G1都是将该阶段实现为与用户线程并发执行,降低STW的时间,而要降低用户线程的停顿的前提是必须要保证整个可达性分析过程处于一个一致性的快照中。那要如何保证处于一致性快照呢?在非并发垃圾回收器中都是采用让整个回收过程STW实现的,而现在为了降低这个延迟,需要将其中一些过程改为与用户线程并发执行,为此JVM使用了一个三色标记的算法来实现一致性快照三色标记就是将扫描过程中的标记状态分为了三种颜色(以前只有1和0,可以理解为黑色和白色):

  • 黑色:对象和该对象中的所有引用都已经被扫描过,表示存活对象,一开始只有GC Roots是黑色的。
  • 白色:还没有被扫描过的对象,直到整个扫描完成后还是白色的对象就会被回收。
  • 灰色:当前对象已被访问过,但其内至少还有一个引用没被垃圾回收器扫描过的对象就回标记为灰色。黑色和白色不能直接相连,中间必须要有灰色对象。

既然是与用户线程并发执行,那么就必然存在引用变化的问题,所以需要思考怎么正确地标记对象的颜色。这有两种情况,一是多标,将本来应该回收的对象标记为黑色(在扫描过程中有其它线程修改了删除了对黑色对象的引用),这种情况是可以容忍的,只需要在下一次GC时一起回收就可以了;另外还有一个主要要解决的问题——漏标,即本来应该存活的对象没有标记为黑色,导致应存活对象最后被回收,这种情况是非常危险的。



如图所示,当垃圾回收线程扫描到灰色对象的那一刻,突然有其它的用户线程将指向下面白色对象的引用删除掉,并赋值给已经扫描过的黑色对象,那么最终扫描完成后就会漏标一个或多个(此处只列出的最简单的情况)对象,导致被回收。“对象消失”的问题于1994年在Wilson中被证明需要同时满足下面两个条件才会出现:

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

因此只需要破坏这两个条件中的任意一个,就能解决漏标问题。由此产生了下面两种解决方案:

  • 增量更新(Incremental Update):破坏的是第一个条件,每当相黑色的对象插入一个白色对象时,就记录下这个引用,等待并发扫描完成后,再重新扫描一下这些引用,CMS采用的是这种方式。
  • 原始快照(Snapshot At The Beginning,SATB):破坏的是第二个对象,当灰色对象要删除指向白色对象的引用的时候,就会记录下这个引用,在扫描结束后,再逐个扫描这些引用,好比删除时留下了一个快照信息,G1、Shenandoah则是用原始快照来实现。

通过上文就能理解为什么目前并发收集器中都会有一个最终或重新标记的过程,并且这个阶段也是STW的。

低延迟GC

前面所讲的GC在回收阶段都还需要显著的停顿时间,主要问题在于整理阶段还不支持和用户线程并发执行,所以虚拟机的开发者们一直在想方法设法如何让GC的停顿只与根节点数量有关,而不是堆中所有对象的数量,由此产生了几款非常优秀的垃圾回收器,这里主要讨论Shenandoah收集器ZGC,由于它们目前都还不够成熟,实现也非常复杂,所以不会过多的讨论实现细节。

Shenandoah

Shenandoah并非Oracle开发的垃圾收集器,所以受到官方的打压,只能在OpenJDK中使用。相比于G1它有以下区别:

  • 也是采用Region布局
  • Shenandoah目前默认不使用分代回收(以后可能会支持)
  • 整理回收阶段支持与用户线程并发执行
  • 每个Region不再单独维护记忆集,而是维护了一个全局的连接矩阵数据结构。可以看作是一个二维表格,横竖都表示Region的编号,当Region 2引用了Region 3,Region 5引用了Region 1中的对象时,对应表格的2行3列和5行1列就会打上标记。

Shenandoah的运行原理比较复杂,包含了以下9个阶段:

  • 初始标记:标记与GC Roots直接关联的对象,会有极短的STW时间
  • 并发标记:并发的可达性分析
  • 最终标记:处理剩余的SATB记录,并统计出回收价值最高的Region,这个阶段也会有一小段暂停时间。
  • 并发清理:清理整个堆中一个存活对象都没有的Region。
  • 并发回收:这个阶段会把复制存活对象到其它Region。因为与用户线程并发执行,所以需要解决对象引用变动问题,Shenandoah采用的是Brooks Pointers转发指针来解决的(稍后分析)。
  • 初始引用更新:更新回收阶段变动的引用的指针,不过在初始阶段只是设定了一个类似安全点的线程集合点,确保所有并发回收阶段中进行的收集器线程都已完成分配给它们的对象移动任务,这个阶段也会有短暂的STW时间。
  • 并发引用更新:真正执行引用更新操作,时间长短与引用数量有关。
  • 最终引用更新:修正存在于GC Roots中的引用,需要停顿,与GC Roots数量有关。
  • 并发清理:清理掉之前的Region。

了解了Shenandoah的运行原理,再来看转发指针是如何支持并发整理的。转发指针是在对象头中新增了一个引用字段,该字段指向当前对象最新的内存地址,默认情况就是指向自己,一旦对象地址发生改变,即被复制到新的Region中,则需要同时修改头部中的引用指向,注意这两部操作必须保证连续,即中间不能有其它操作,避免并发竞争。

从上面我们可以看到Shenandoah虽然解决了并发清理,但实际运行过程中也有4个需要停顿的地方,另外由于使用转发指针,对于内存地址改变的对象在引用更新完成之前对其访问都会产生额外的开销,所以经测试Shenandoah在总的垃圾回收运行时间上相较以前的垃圾回收器是最长的,但是停顿时间的降低确实有很大的提高。

ZGC

ZGC相较于Shenandoah又是一革命性的垃圾回收器,它的垃圾回收停顿时间只和根节点数量有关,目前任意大小的堆空间回收停顿时间都能控制在10ms内,但是由于它使用染色指针标记对象是否重分配(重分配是ZGC的一种处理动作,用于复制对象的收集器阶段,稍后会介绍到)导致目前ZGC在64位系统最大可管理4TB的堆空间。ZGC同样采用Region布局,不过Region大小分为三种类型:

  • 小型:容量固定为2MB,用于放置小于256KB的小对象。
  • 中型:容量固定为32MB,用于放置大于等于256KB但小于4MB的对象。
  • 大型:容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象。每个大型Region中只会存放一个大对象,最小容量可低至4MB。大型Region在ZGC的实现中是不会被重分配的,因为复制一个大对象的代价非常高昂。

什么是染色指针?ZGC的标记区别于其它的垃圾回收器,既不是单独维护在记忆集中,也不是维护在对象头中,而是直接标记在引用指针上。受限于硬件和操作系统的限制,目前ZGC只能用于64位系统,而64位系统高18位是不能使用的,剩余的46位中ZGC使用了4位来存储三色标记、是否进入了重分配集(即被移动过)、是否只能通过finalize()方法才能被访问等状态信息,这也是为什么目前ZGC最多只能管理4TB的堆空间(2的42次幂)的原因。

ZGC的运行过程包含了4个阶段:

  • 并发标记:对象图的遍历,也存在初始标记和最终标记两个需要短暂停顿的阶段。
  • 并发预备重分配:扫描所有的Region(不再维护记忆集),统计得出需要清理的Region,将这些Region组成重分配集。
  • 并发重分配:将重分配集中存活对象复制到新的Region中,并且会为重分配集中每个Region维护一个转发表,指向对象的新地址,得益于染色指针的支持,ZGC只需要从引用指针上就能得知对象最新的内存地址。如果用户线程此时访问一个移动了的对象,只有第一次会根据转发表找到新地址,并同时修正引用指向,这称为指针的自愈性。另外由于染色指针的存在,任何一个Region中存活对象复制完毕,该Region就可以直接释放并分配新对象,因此在ZGC中至多只会浪费一个空间(需要一个空的Region完成复制对象的存放)。
  • 并发重映射:重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,但这个过程由于染色指针的存在,引用是可以“自愈”的,所以ZGC将这个过程放到下一次GC的并发标记过程中。当所有引用修正后,原先得转发表记录就可以释放掉了。

以上就是ZGC得运行原理,从上面我们可以发现ZGC也是没有分代的,所以它不需要维护记忆集,即少了写屏障带来的运行负担以及没有了记忆集占用大量的内存空间,但同时不分代也带来新的问题,ZGC不适用于高速分配对象的系统中,因为ZGC当对一个较大堆执行一次完整的收集时,会运行较长时间(非停顿时间),这时系统如果创建对象的速度较快,就会产生大量浮动垃圾,堆中可用的空间就会越来越少,目前只能通过增大堆空间来缓解,但终究是治标不治本的方法,这也是为什么ZGC适合较大堆空间的垃圾回收的原因。

总结

垃圾回收算法实现的细节是面试的重点,重要性自然不言而喻,但主要是要理解每个算法要解决的问题以及它的思想,明白我们平时使用的虚拟机在哪些情况下会发生停顿,深刻理解,在进行调优时也才有更好的思路。最后介绍的两款低延迟的垃圾回收器可根据自身情况进行了解,最好是能理解其设计思想,本文也只是简单的介绍了一下,详细细节可翻阅《深入理解JVM虚拟机第三版》。

深入探究JVM之垃圾回收算法实现细节的更多相关文章

  1. jvm详情——3、JVM基本垃圾回收算法回收策略

    JVM基本垃圾回收算法回收策略 引用计数(Reference Counting):比较古老的回收算法.原理是此对象有一个引用,即增加一个计数,删除一个引用则减少一个计数.垃圾回收时,只用收集计数为0的 ...

  2. JVM(九):垃圾回收算法

    JVM(九):垃圾回收算法 在本文中,我们将从概念模型的角度探讨 JVM 是如何回收对象,包括 JVM 是如何判断一个对象已经死亡,什么时候在哪里进行了垃圾回收,垃圾回收有几种核心算法,每个算法优劣是 ...

  3. JVM G1垃圾回收算法简要介绍

    JVM G1垃圾回收算法简要介绍 G1的特点 能够像CMS垃圾回收算法一样并发操作应用线程(潜台词:多核) 无需太长时间即可压缩空闲内存空间(潜台词:不会引起太多的GC停顿时间) 尽可能地让GC时长可 ...

  4. JVM常见垃圾回收算法

    jdk1.7.0_79 众所周知,Java是一门不用程序员手动管理内存的语言,全靠JVM自动管理内存,既然是自动管理,那必然有一个垃圾内存的回收机制或者回收算法.本文将介绍几种常见的垃圾回收(下文简称 ...

  5. jvm的垃圾回收算法

    一.对象存活判断判断对象是否存活一般有两种方式:1.引用计数:每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收.此方法简单,无法解决对象相互循环引用的问题.2 ...

  6. 深入理解JVM一垃圾回收算法

    我们都知道java语言与C语言最大的区别就是内存自动回收,那么JVM是怎么控制内存回收的,这篇文章将介绍JVM垃圾回收的几种算法,从而了解内存回收的基本原理. 一.stop the world 在介绍 ...

  7. jvm学习-垃圾回收算法(三)

     垃圾回收算法  引用计数法 比较古老的一种垃圾回收算法.在java的GC并没有采用 增加一个引用 引用+1 减少一个引用引用减一 每次清除引用为0的的对象 缺点:不能回收循环引用的垃圾对象 标记清除 ...

  8. JVM中垃圾回收算法

    GC 算法与种类 GC的概念 Garbage Collection 垃圾收集1960年 List 使用了GCJava中,GC的对象是堆空间和永久区 引用计数法 老牌垃圾回收算法通过引用计算来回收垃圾使 ...

  9. 深入理解JVM(五) -- 垃圾回收算法

    上篇文章我们了解到哪些内存区域和哪些对象可以被回收,这篇文章我们就来了解一下具体的垃圾回收算法的思路,不讨论具体的实现. 一 最基础算法 标记-清除(Mark-Swap) 为什么说他是最基础的算法,因 ...

随机推荐

  1. js事件入门(4)

    4.表单事件 表单事件处理主要用来验证表单,可以处理用户在表单上所做的任何操作. 4.1.onsubmit事件 当用户点击submit按钮来提交表单时,就会触发onsubmit事件,如果事件处理程序返 ...

  2. P2114 [NOI2014]起床困难综合症【二进制运算+优化】

    起床困难综合症[二进制运算+优化] 题目描述 21世纪,许多人得了一种奇怪的病:起床困难综合症,其临床表现为:起床难,起床后精神不佳.作为一名青春阳光好少年,atm一直坚持与起床困难综合症作斗争.通过 ...

  3. 服务消费者(Feign-上)

    上一篇文章,讲述了Ribbon去做负载请求的服务消费者,本章讲述声明性REST客户端:Feign的简单使用方式 - Feign简介 Feign是一个声明式的Web服务客户端.这使得Web服务客户端的写 ...

  4. 线程的创建方式以及synchronize的使用

    线程的两种创建方式 继承 Thread class Thr extends Thread {    @Override    public void run() {        Thread.cur ...

  5. Java1.7的HashMap源码分析-面试必备技能

    HashMap是现在用的最多的map,HashMap的源码可以说是面试必备技能,今天我们试着分析一下jdk1.7下的源码. 先说结论:数组加链表 一.先看整体的数据结构 首先我们注意到数据是存放在一个 ...

  6. 相邻元素之间的margin合并问题

    任何元素都可以设置border 设置宽高可能无效 行内元素设置padding,margin上下是无效的,左右是有效的 外边距合并:指的是,当两个垂直外边距相遇时,它们将形成一个外边距. 合并后的外边距 ...

  7. Blazor带我重玩前端(二)

    概览 Blazor目前有两种托管模式,一种是Server-Side模式,一种是WebAssembly模式.官方首先支持的是Service-Side模式,使用WebAssembly模式,需要更新到最新版 ...

  8. directive 实例讲解

    http://my.oschina.net/ilivebox/blog/289670 gulp-nodemon http://www.zhihu.com/question/32123388?sort= ...

  9. iOS刨根问底-深入理解GCD

    概述 做过iOS开发的同学相信对于GCD(Grand Central Dispatch)并不陌生,因为在平时多线程开发过程中GCD应该是使用最多的技术甚至它要比它的上层封装NSOperation还要常 ...

  10. Scala 基础(五):Scala变量 (二) 数据类型

    1 scala数据类型介绍 Scala 与 Java有着相同的数据类型,在Scala中数据类型都是对象,也就是说scala没有java中的原生类型 Scala数据类型分为两大类 AnyVal(值类型) ...