《深入理解JVM》第三章 垃圾收集器与内存分类策略


第三章 垃圾收集器与内存分类策略

框架图

第三章垃圾收集器与内存分配策略

高清图片地址

高清图片地址

高清图片地址


概述

GC的作用:排查各种内存溢出、泄露问题,GC成为并发瓶颈时,要做出调整。

不使用区域:程序计数器、虚拟机栈、本地方法栈这三个区域不需要GC,因为是随着线程生与灭的。

使用区域:Java堆和方法区有很多不确定性,只有运行期才知道那些对象要创建,这部分内存的分配和回收是动态的。


对象已死?

引用计数分析

引用计数算法:给对象添加一个引用计数器,当被引用就+1,引用失效就-1,当引用为0就不可能再被使用,看作死亡。

引用计数算法缺陷
然而java并没有用这个方法,因为存在缺陷,有个循环引用的问题,如下图所示,这样两个对象的引用计数都不为零,就无法回收。


可达性分析

思路:设置一个GC Roots跟对象,然后利用引用关系构建一棵树,树的路径被称为引用链,如果某个对象到GC Roots之间没有引用链,那么就判断为不可能再被使用。

为什么这些可以作为GC Roots的对象?

还可以临时加一些其他对影响,共同构成完整GC Roots集合。


再谈引用

四种,强度递减:强引用、软引用、弱引用、虚引用。

强引用:最常见的引用赋值,只要关系还在,GC就不会回收被引用的对象。(最硬,咋地都一直在)。

软引用:描述一些还有用、但非必须的对象。当系统将要发生内存异常溢出的时候,回对这些对象进行二次回收,如果这次还不够,才会抛出异常。(就是说够用就不管,不够再回收)。

弱引用:描述非必须的对象。只能生存到下一次GC发生为止,下一次GC发生时,无论够不够,都回收。

虚引用:

  • 这个引用和对象本身没啥关系,就是个标记,为了在GC回收这个对象的时候能收到一个系统通知。(那这个怎么回收?直接回收?)
  • 当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在垃圾回收后,将这个虚引用加入引用队列,在其关联的虚引用出队前,不会彻底销毁该对象。 所以可以通过检查引用队列中是否有相应的虚引用来判断对象是否已经被回收了
  • 在清除之前,会调用其finalize方法,如果一个对象已经被调用过finalize方法但是还没有被释放,它就变成了一个虚可达对象。

生存还是死亡

可达性分析中判定死亡的流程

  • 要标记两次,如果没有发现引用链,就进行第一次标记,然后筛选是否有必要执行finalize()方法。
  • 当对象没有覆盖finalize(),或者finalize()已经被调用过(因为只能执行一次),就不用执行,直接GG。
  • 如果要执行的话就放到一个F-Queue队列中,之后虚拟机会自动执行他们的finalize(),到了这个时候还有机会,运行完finalize()之后,过会收集器就会进行第二次标记,即趁着这个空挡又被引用上了,就逃过一劫,如果最后这个机会也没把握住,没人来救的话,就真gg了。

自救案例
看下面的代码,下面的代码第一次自救成功了,通过FinalizeEscapeGC.SAVE_HOOK = this;苟住了,但是第二次就跑不掉了,因为一个对象的finalize()方法只会被调用一次。

然而:并不推荐使用,finalize()能做,try-finally都可以做的更好,所以建议大家完全忘记这个语法。

???


回收方法区

只要回收两部分内容:废弃的常量和不再使用的类型。

废弃的常量:如果已经没有字符串引用常量池的常量,且虚拟机中也没有其他地方引用这个常量,如果发生内存回收,且GC判断有必要的话,清理出常量池。

不再使用的类型:要满足下面三个条件:

  • 该类的所有实例都已经被回收(子类也没有)。
  • 该类的类加载器已经被回收。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法通过放射访问改类方法。

对于频繁自定义类加载器的场景中,通常都需要java虚拟机具备类型卸载能力,以保证不会对方法区造成过大的内存压力。


垃圾收集算法

方法可分为“引用计数式垃圾收集”和“追踪式垃圾收集”,但第一个主流虚拟机都没用,所以书里的都是第二种,也可以叫“间接垃圾收集”。


分代收集理论

三个假说

  • 弱分代假说:绝大多数的对象都是朝生夕灭的。
  • 强分代假说:熬过越多次GC的对象越难以消亡。
  • 跨代引用假说:跨代引用相对于同代引用来说仅占极少数。

根据这几个假说得出了GC设计的一致原则:给Java堆分区,按照年龄分配到不同区域。这样扫描起来就比较方便了,可以设计出多种不同的GC专门扫描特定的区域。

分区:至少分为新生代和老年代两个区域,新生代中存活时间长的也会转移到老年代中。但是有个问题:对象可能存在跨代引用。所以根据第三个原则,在新生代中建立了一个全局的数据结构,被称为“记忆集”,这个结构把老年代分成若干小块,然后标记那一块里有跨代引用,扫描的时候再加上这一小块就行了,就不需要再对整个老年代进行扫描了。

名词解释


标记-清除(Mark-Sweep)算法

方法:先标记需要回收的,再统一回收;或者先标记需要保存的。

缺点:1、效率低,如果对象很多还大部分需要回收的话,很慢;2、内存空间碎片多,删完了会有很多不连续的内存碎片,会导致给大对象分配空间的时候遇到困难。


标记-复制(Mark-Copy)算法

目的:解决面对大量可回收对象时执行效率低的问题。(所以也可以说,在可回收对象少的时候,表现效果也不好,哈哈哈)。

方法:把可用内存分成两部分,一部分用来存放数据,另一部分空白,当使用的内存用完了的时候,就把存活的对象全部移到另一块内存上,然后对使用的空间进行全部清理。

缺点:内存直接小了一半,面对可回收对象少的情况,效率不好。

优点:分配内存时不用考虑空间碎片的问题了,直接移动指针就行了。

发展:现在的java虚拟机(新生代98%都活不过第一轮)基本都用这种算法,不过是改进版的“Appel式”回收。将内存分成了一个较大的Eden空间和两个较小的Survivor(分为A、B吧)空间,(8:1:1),其中Eden和一个Survivor(A)用来存放数据,GC的时候把这两个存活的放到Survivor(B)中,然后直接清理到Eden和Survivor(A)。当Survivor(B)不足以容纳一次GC后存活的对象的时候,就会依赖其他内存区域进行担保分配


标记-整理(Mark-Compact)算法

问题:老年代的对象存活率较高,所以不适合用标记-复制算法。

方法:先标记,然后把所有的存活对象向内存空间一端(前或后)移动,然后清理掉边界外的内存。

比较

权衡:每次移动的话操作负担会很重,而且必须暂停全部用户的应用才行,但是这种方法有利于内存整理;使用标记-清除虽然快,但是碎片空间太多,影响内存分配和访问。总的来讲,即使不移动对象的收集器效率提升了一些,但因内存分配和访问相比GC频率要高得多,而这部分耗时增加了,总的吞吐量还是下降了。

选择:关注吞吐量的Prarllel Scavenge收集器基于标记-整理算法;关注延迟的CMS收集器基于标记-清除算法。

CMS:让虚拟机多数时候采用标记-清除算法,等到内存空间的碎片化程度影响对象分配的时候,再用标记-整理算法收集一次,来获得规整的内存空间。


HotSpot的算法细节实现

根结点枚举

根结点是做什么的?:用作GC Roots的引用链的根结点。

在哪里存放?:在全局性的引用(例如常量或者类静态属性)与执行上下文(如栈帧中的本地变量表)中。

特性:所有的收集器在根结点枚举这一块,都必须暂停用户进程,Stop The World,还必须在一个保障一致性的快照中才能进行。一致性是说根结点下面那些引用在这个时候不会变化,如果引用关系还变的话,结果准确性也没法保证了。因此,那些号称几乎不会发生停顿的CMS、G1、ZGC收集器,在枚举根结点的时候也必须停顿。(能力很大,不停不行,在后面的所有虚拟机中这个阶段都必须暂停进程)。

找引用方法:现在的主流java虚拟机都是用的准确式垃圾收集,所以当线程停下来的时候,不需要挨个的检查所有引用,虚拟机有办法直接找到引用,在hotSpot中用另一了叫OopMap的数据结构。在类加载动作完成时,会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译的过程中,也会在特定位置记录下栈里和寄存器里哪些位置是引用。


安全点

生成:并不是每条指令都会生成OopMap,只有在安全点才会生成。上面提到的“特定位置”就是安全点。

目的:安全点的设定是为了在GC的时候,强制要求线程都执行到达安全点后才能够暂定。(暂停完了如何启动)

选择条件:以“是否具有让程序长时间执行的特征”为标准的,而这种标准的最明显特征就是指令序列的复用,如方法调用、循环跳转、异常跳转。

所以知道了要停的地方,如何让他们跑到最近的安全点,然后停下来?
两种方法,一个是抢先式中断;一个是主动式中断。
抢先式中断:让所有线程全部中断,如果某线程不在安全点上,就恢复,过一会再中断,直到到了安全点(感觉挺蠢的,而且现在几乎没有人用)。
主动式中断:需要做GC的时候,设置一个标志位(标志的地方和安全点是重合的),然后各个线程执行的时候不断轮询(这个轮询因为会频繁出现,所以设计的很高效来加快速度)这个标志,一旦发现中断标志为真时就在最近的安全点上主动中断挂起。(就是每个线程一开始就带了个标志,然后GC控制true或者false,需要的时候就设为true,线程就懂了。)

主动式是提前做好了准备,运行前有了标志;而抢先式临时中断,很粗鲁。应该是这样。


安全区域

问题:安全点已经能解决暂停问题了,但是,如果线程处于睡眠或阻塞状态,就没法响应虚拟机的中断请求了。

解决方法:设置安全区域,这一段代码中引用关系不会发生变化,所以从这个区域中的任何地方放开始垃圾收集都是安全的。

垃圾收集就是为了删除一些没用的引用,之所以暂停是怕引用关系一直在变,而这一段不变,那就无所谓了,随便GC。

流程:当线程进去安全区后,会标记自己已经进去安全区域了,所以这一块就都不用管了;出去的时候,要检查虚拟机其他的需要暂停用户线程工作做完了没(比如根结点枚举,因为安全区域本身是引用不变的,但是出去的时候,自身的引用关系是可能变化的,外面的某个活动要求不能变化,所以没结束不能出去),完了就能出去,没完的话就要一直等,直到收到可以离开安全区域的信号。


记忆集和卡表

位置:记忆集是GC在新生代中建立的

目的:避免把整个老年代加入到GC Roots扫描范围(所以说记录的都是老年代中要扫描的?答:对,老年代中的那些跨代引用)

定义:一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。(老年代指向新生代?答:对)

什么是收集/非收集区域
收集区域应该就是新生代区域。
但是,跨代是不是两个都有,两个都有的话,那只在新生代扫描不行?
答:应是跨代引用是引用跨代了,不是对象本身跨代了,如果新生代指向老生代,那没有问题,扫描的就是新生代。如果是老生代指向新生代,而这个新生代只有这一个引用,就要扫描这个老生代了,看下图:

案例
图片来源
只扫描新生代(Gen 0)的话,下面三个就要丢了,因为在新生代中没与Roots相连。

此时将老生代里有跨代引用的黑色对象的内存区域加入GC Roots一并扫描,然后顺着引用找到新生代的对象,这样就被保留下来了。

然后就会存活下来保存到Gen 1

记忆集的记录粒度
往下精度依次降低,效率高?

  • 字长精度:每个记录精确到一个机械字长,该字包含跨代指针。
  • 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
  • 卡精度:每个记录精确到一个内存区域,该区域内有对象包含跨代指针。
    (那么这个内存区域是如何得到的?)

卡表和记忆集
卡表是记忆集的具体实现,关系如同Java的HashMap和Map。卡表最简单的形式可以只是一个字节数组,而HopSpot虚拟机就是这样的,字节数组每个元素都对应着内存区域中一块特定大小的内存块,这个内存块被称作”卡页“。

卡表和卡页的对应

  • 一般来说卡页的大小都是2的N次幂的字节数,HotSpot大小是512字节。
  • 卡页上通常不止包含一个对象,只要卡页上有一个对象的字段存在跨代指针,就把对应卡表的数组元素的值标记为1,称位这个元素变脏,没有就是0。GC运行的时候只要找出标记为1的就行了,然后把对应的卡页内存块加入GC Roots一起扫描。
    (如何知道卡页中有对象有跨代引用指针?通过指针域判断?)

这个卡表的解释还蛮清晰的,就是记忆集感觉有问题。


写屏障

思路知道了,剩下要看怎么实现了。

何时变脏
在其他分区(老年)的对象中引用了本区域(新生)对象时,其(老年)对应的卡表元素就会变脏,变脏的时间点应该就是引用赋值的那一刻。(所以说,卡表是在程序运行过程中一直维护的?)

如何变脏
如果是解释型字节码,虚拟机有充分的空间接入,但是如果是编译阶段,就变成了纯粹的机器指令流了,所以要有一个机器码层面的手段。
所以,在HotSpot里面就是写屏障来维护卡表状态的,写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形通知。
(写屏障:就是对一个对象引用进行写操作(即引用赋值)之前或之后附加执行的逻辑,相当于为引用赋值挂上的一小段钩子代码)

写前/写后屏障
在赋值前的部分的写屏障叫做写前屏障,在赋值后的叫做写后屏障。G1之前都用的写后屏障。


并发的可达性分析

标记阶段的速度很影响所有追踪式垃圾收集算法的速度。

保证快照一致性的原因
因为不这样做对象可能消失。

标记过程理解
把遇到的对象用三种颜色标记起来:

  • 白色:对象还没被GC访问过,一开始的时候所有的对象都是白色的,如果在结束阶段仍然是白色,就代表不可达。
  • 灰色:对象已经被GC访问过,但这个对象上至少还有一个没被扫描过的引用(白色的)。
  • 黑色:对象已经被GC访问过,且对象上所有的引用都被扫描过了。

所以一个正经的过程结果,应该是只有黑色和白色。

对象消失原因
如果用户进程和收集器是并发的话,就会出现下面的情况:本来A - B - C,A、B已经扫描了,在扫描C之前,把C的引用换到了A上(A指向C),B的引用取消了,因为A不会被扫描两次,所以C引用就不会再被扫描到了,就像丢了一样。

问题发生条件
同时满足以下俩个条件时,问题会发生:

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

两个解决方法,分别对应两个条件

  • 增量更新:把在黑色上新加的引用保存下来,并发扫描结束后把黑色当根结点再扫描一遍。(CMS用的)。
  • 原始快照:把那些灰色删除的也记录下来,并发扫描结束后以灰色为根再扫描一遍。(感觉这个不如上面的好啊,有可能删了就不用呢?这不是容垃圾苟活?答:不是的,因为出问题要同时满足上面两个条件,但是又怎么能知道是否同时满足两个条件呢?)(G1、Shenandoah)。

经典垃圾收集器

经典GC器关系,连线说明可以搭配使用:

Serial收集器

(连续的,可串行的)最基础,最老的

单线程的,但是这个单线程的意思是强调在GC的时候,必须停止所有其他工作线程。

虽然很老,但现在仍然是HopSpot虚拟机运行在客户端模式下的默认新生代收集器,优点就是简单高效,而且是所有收集器里额外内存消耗最小的。(是不是因为没啥精巧的设计,所以额外内存消耗下啊,使用自行车和使用汽车要耗费的脑力是不一样的,哈哈哈哈)


ParNew收集器

特点

  • Serial收集器的多线程并行版本,使用起来几乎和Serial一模一样,是JDK7之前服务器端模式下HotSpot的首选,但选它的原因是因为只有他能和CMS一起后,后来也一直都是绑定着用。
  • ParNew是激活CMS后的默认新生代收集器。
  • ParNew在单核的情况下绝对不会比Serial好,甚至存在线程交互的开销。

名词解释:


Parallel Scavenge

(并行打扫)

属性:新生代、标记-赋值、并行。

特点

  • 和CMS等区别:关注点不同,CMS关注停顿时间,这个适合与用户交互的程序;PS关注吞吐量,适合在后台运算且不需要太多交互的分析任务。

吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 运行垃圾收集时间)

用于控制吞吐量的参数:

还有一个参数,可以自动设置一些参数,虚拟机根据系统性能动态调整,这种方法叫做“自适应的调节策略”。-XX: +UseAdaptiveSizePolicy


Serial Old收集器

属性:是Serial的老年代版本,单线程,使用标记-整理算法,供客户端模式下的HotSpot虚拟机使用。

在服务端有两种用途:1、JDK5之前与Parallel Scavenge搭配使用。2、作为CMS发生失败时的后备预案。


Parallel Old收集器

属性:Parallel Scavenge的老年代版本,多线程,标记-整理,JDK6之后才开始用。

使用:在注重吞吐量或者处理器资源较为稀缺的场合,都可以有先考虑Parallel Scavenge加Parallel Old这个组合。


CMS收集器

(Concurrent Mark Sweep):并发标记清理。

目标:以获取最短回收停顿时间为目标的收集器。

应用:集中应用在互联网网站、基于浏览器的B/S系统的服务端上,因为这些都关注响应速度。

处理步骤
基于标记-清除算法实现的,分为四个步骤:

  1. 初始标记(initial mark)
    需要咋瓦鲁多,这个阶段只是标记GC Roots能直接关联的对象,所以速度很快。
  2. 并发标记(concurrent mark)
    从上个阶段标记的对象开始,对整个图进行遍历,所以耗时很长,但是!不需要停顿用户进程,可以与GC并发运行(此期间大部分引用应该是没动的,所以缩短了暂停时间)
  3. 重新标记(remark)
    这一阶段是可以并发运行的关键,用来修正上个阶段因为标记变动导致的问题,使用增量更新,不过这阶段还是要咋瓦鲁多。
  4. 并发清除(concurrent sweep)
    清理掉判断已经死亡的对象,因为不需要移动,所以也可以并发。时间比第一阶段长,比第二阶段短。

优点:并发收集、低停顿

缺点

  • 对处理资源非常敏感,当处理器核心数量不够四个的时候,对用户程序影响可能变得很大。
  • 无法处理浮动垃圾,可能出现“并发失败”(Concurrent Mode Failure)而导致一次Stop the world的Full GC。浮动垃圾就是并发标记和并发清理之间产生的新的垃圾对象,但是是在标记结束后产生的,所以也没办法消灭,只能留到下一次GC的时候在清理。另外由于是并发的,所以还要预留一些内存给其他线程用,如果不够的话就可能出现“并发失败”,可以用-XX: CMSInitiatingOccu-pancyFraction控制老年代使用了多少空间再上GC。失败的时候就要启动后备预案,临时用Serial Old收集器对老年代进行GC,而这个是要停顿的。
  • 标记-清除会产生大量空间碎片,所以不得不进行Full GC。有两个参数来解决,一个是-XX: +UseCMS-CompactAtFullCollection,在不得不进行Full GC的时候做内存合并整理,移动的话是无法并发的。另一个是-XX: CMSFullGCsBeforeCompaction(这个参数从JDK9开始废弃),执行若干次不整理的Full GC后,下次进入Full GC会进行碎片整理。

Garbage First收集器

(还是有新老生代概念的)
属性:面向服务端应用,JDK 9之后的服务端模式下的默认垃圾收集器。能建立可预测的停顿时间模型。

停顿时间模型:在一段长度为M毫秒的时间片段内,消耗在垃圾收集的时间大概率不超过N毫秒。
这是G1的目标。

Mixed GC模式:为了实现上面的目标,G1对回收区域做出了改进,可以面向堆内存的内核区域组成回收集(Collection Set)进行回收,分代概念弱化了,衡量回收的标准变成了哪块内存存放的垃圾数量最多,收益最大,就回收哪块。

划分操作
模式清楚了,那就要知道怎么具体怎么操作内存的:

  • G1不再把内存划分成固定大小的分代,而是把连续的java堆划分成很多大小相等的独立区域Region,然后根据需要,来让Region扮演新生代或老生代,这个都是可变化的。其中如果Region中有大小超过容器一半大小的对象,那么就判定为大对象,放入Humongous区域。
  • Region大小可以通过-XX: G1HeapRegionSize设置,应为2的N次幂。
  • G1大多数行为把Humongous Region作为老年代的一部分来看待。

能建立可预测的停顿时间模型原因

  • G1将Region看作单次回收的最小单元,每次收集的内存空间都是Region大小的整数倍。G1会跟踪每个Region并计算回收收益,然后列一个优先级列表,根据用户设置的收集停顿参数-XX: MaxGCPauseMillis)来优先处理回收价值受益最大的Region。
  • 以衰减均值为理论基础实现的。在GC过程中,G1收集器汇集了每个Region的回收耗时等各种步骤话费的成本,然后分析出平均值、标准差、置信度等统计信息,然后依据这个来预测哪些Region组成的回收集可以在不超过期望停顿时间的约束下获得最高的收益。

一些处理细节

  • Region里面的跨Region引用对象:每个Region都维护自己的记忆集,这个记忆集会记录下Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。不过和之前的不太一样,G1的记忆集本质上是哈希表,Key是Region的起始地址,Vlaue是一个集合,里面存放的是卡表的索引号,包含“谁指向我”和”我指向谁“两种信息,这是一种双向结构。(之所以双向,是因为每次回收的Region不确定吧?)。

课表索引是别的region地址吧

  • 并发标记阶段和线程的互不干扰:使用原始快照方法。G1为每个Region设计了两个名为TAMS的指针,管新对象的地址分配,如果内存的回收速度赶不上分配速度,也要进入Full GC的Stop The World。

处理步骤

  1. 初始标记:只标记GC Roots能直接关联到的对象,并修改TAMS的值,使进程能正确的在Region中分配新对象。
  2. 并发标记:标记剩下的整个堆的对象图,记录SATB。
  3. 最终标记:对用户线程作短暂暂停,处理并发阶段结束后留下的SATB。
  4. 筛选回收:更新Region的统计数据,对回收成本和价值排序,根据期望的停顿时间指定回收集。先把决定回收的Region的存活对象复制到另一个Region,在清掉旧Region的全部空间,这块也要暂停。(这里是标记-复制)
    (这个Region也挺开的,不能移动一个回收一个吗?好像是不能,因为跨Region引用?还是为什么?)

设计理念

  • G1虽然回收阶段不是并发的,但G1不仅仅是面向低延迟,停顿用户线程能最大幅度提高垃圾回收效率,保证吞吐量。
  • 追求能够应付应用的内存分配速率(Allocation Rate),而不是一次性把java堆全清干净,收集速度赶得上分配速度就是完美。

G1和CMS的比较

  • CMS是标记-清除,G1整体上是标记-整理(整理就是针对总Region,设置回收集的过程,而言吧),局部是标记-复制。G1的这两个方法都不会产生内存空间碎片。
  • G1为了垃圾收集产生的内存占用还有程序运行时的额外执行负载都比CMS更高:
    • 内存问题:CMS只有一个卡表,而G1的每个Region都有一份卡表,所以G1的记忆集可能会占整个堆容量的20%甚至更多。
    • 额外负载问题:相比增量更新算法,原始快照搜索能见少并发标记和重新标记阶段的消耗,避免停顿时间过长的缺点,但是会产生由跟踪引用变化带来的额外负担。由于G1的写屏障的操作更复杂,所以CMS的写屏障实现时直接的同步操作,而G1不得不实现类似消息队列的结构,把写前和写后屏障中要做的事都放到队列里,再异步操作。
  • 小内存应用上CMS表现好,大内存应用上G1表现好。

低延迟垃圾收集器

内存扩大会对延迟带来负面效果。

因为延迟的原因时要做垃圾回收,内存越大,要回收的空间越大。

CMS用的标记-清除算法,只要用了清除算法,那就一定会有停顿的时候(最后整理)。G1相当于把停顿的时间分散了,但终究是停顿了。

垃圾收集器的停顿情况


Shenandoah收集器

介绍:像是G1的下一代继承者,很多地方相似。

与G1的不同之处:1、支持并发整理。2、默认不使用分代收集,所以不会有专门的新生代Region或者老年代Region的存在。3、没有使用记忆集,使用的“连接矩阵”,使用二维数组就能解决跨Region引用问题。

处理步骤

  1. 初始标记(Initial Marking):标记与GC Roots直接关联的对象,停顿时间只与GC Roots的数量相关。
  2. 并发标记(Concurrent Marking):遍历对象图,时间取决于对象多少和图的复杂程度。
  3. 最终标记(Final Marking):处理剩余的SATB扫描,构建回收集,也会停顿。
  4. 并发清理(Concurrent Cleanup):清理那些整个区域连一个存活对象都没有找到的Region。
  5. 并发回收(Concurrent Evacuation):把回收集里面存活的对象复制一份到未被使用的Region中,最困难的地方在于移动对象的时候并行,而Shenandoah是通过读屏障和被称为“Brook Pointers”的转发指针解决的。整个时长取决于回收集的大小。
  6. 初始引用更新(Initial Update Reference):这个阶段没有操作,设这个阶段的目的就是作为一个线程集合点,确保收集器对象的移动任务都完成了。一个很短暂的停顿。
  7. 并发引用更新(Concurrent Update Reference):真正开始引用更新,跟标记不同,不需要按着图来,只需要按照内存物理地址的顺序,线性地搜索出引用类型,把旧值改为新值即可。

为什么这里地址事连续的?难道是在一块连续的地址里保存了引用?就是顺着引用自然的往下走吧

  1. 最终引用更新(Final Update Reference):修正GC Roots的引用,最后一次停顿。
  2. 并发清理(Concurrent Cleanup):回收前面的空Region。

黄色:被选入回收集的Region。
绿色:还存活的对象。
蓝色:用户线程可以用来分配对象的内存Region了。

理解

  • 平时的时候,每个Region都有一部分可以用来分配对象,然后做初始标记
  • 到了并发标记,遍历完了找到了要回收的Region,就是黄色的;
  • 然后做最终标记,橙色代表回收集;并发清理没体现出来;
  • 然后是并发回收,将回收集里的Region的存货部分移动出来,图中是绿色变黄部位,但也没体现移动到哪去了;
  • 然后更新引用,回收集的Region都空了,因为引用没有了;
  • 最后并发清理,第一个被回收了为蓝色,按理讲应该还有好多蓝色。

并行整理核心

  • 老方法:在被移动的对象上设置内存保护陷阱,如果程序访问到了对象的旧地址,就会陷入一个异常,这个异常里有个逻辑可以转发到新位置上,即复制后的新对象的地址。
  • Brooks Pointer:在原有对象的前面加个统一的引用字段,没有并发移动的时候指向自己,移动后指向新地点,这样以后每次访问的就是新对象的地址,缺点是会多一次跳转开销。


ZGC收集器

(Z Garbage Collector)

介绍:基于Region内存布局的,不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法。

Region
从头开始,头就是Region:ZGC的Region具有动态性,可以动态的创建和销毁,而且区域容量也是固定的,容量可以分为三类:

  • 小型Region:固定2MB,用于放置小于256KB的小对象。
  • 中型Region:固定为32MB,存放大于等于256KB,小于4MB的对象。
  • 大型Region:容量不固定,动态变化但必须是2MB的整数倍,用于放置4MB及以上的大对象。
    注意:每个大型Region中只能有一个大对象,而且大型Region在ZGC的实现中不会被重分配。

并发整理
使用了读屏障

染色指针技术
介绍:标志性设计,之前的存储一些对象的额外信息的方法,通常是用对象头。然后对象头在处理移动过的对象并不好用,所以新的思路是从”指针“或者与对象内存无关的地方得到这些信息。(这些信息是不是也是标记信息?答:对)。
比较:HopSpot虚拟机的几种不同标记实现方案:

  • 把标记直接记录在对象头上:Serial收集器。
  • 标记在与对象相互独立的数据结构上:G1、Shenandoah使用的相当于堆内存的1/64大小的,称为BitMap的结构来记录。
  • 把标记信息记在引用对象的指针上:ZGC的染色指针。
    结构:Linux的64位指针的高18位不能用来寻址,ZGC在剩下的46位指针中,提取了高4位来存储四个标志信息。这四个标记信息为:三色标记状态(占两个),是否进入了重分配集,是否只能通过finalize()方法才能被访问到。因此也只剩46位可以用来做地址,所以ZGC能管理的内存不超过4TB。

好处

  • 不需要等所有的引用等修正完再清理Region,只要Region里面不存在存活对象了,就可以直接释放和重用(下面“并发重分配”中会说明)。像Shenandoah要等到引用更新阶段结束以后才能释放“回收集”中的Region。
  • 可大幅度减少垃圾收集过程中内存屏障的使用数量。ZGC没用过任何写屏障,只使用了读屏障(因为ZGC不支持分代收集,不存在跨代引用问题,跨代的时候并发好像要用到写屏障)
  • 有更好的拓展性,还能记录更多信息。方法就是指针位的开发。

(跨代为何要用到写屏障?答:引用变化时,需要用写屏障来改变卡表,这里没用卡表。)

前置问题
如何正确使用指针,处理器不管是标志位还是真正的寻址地址,都把整个指针都是做一个内存地址来对待。
解决方法涉及到虚拟内存映射技术:通俗解释就是内存地址和实际物理空间位置是一对多的关系。比如南京路1号,多个城市都有,那么一个”南京路1号“就能定位到多个实际的地理位置,这个南京路1号就是内存地址。

那一对多,就是一个内存地址能放更多东西?那总地址数量是不是少了?

多重映射
Multi-Mapping:将多个不同的虚拟内存地址映射到同一个物理内存地址上(跟上面相反),是多对一映射,所以ZGC的虚拟空间中的地址空间比实际的堆内存容量大(因为重复利用了一些空间)。把染色指针中的标志位看作是地址的分段符,那只要将这些不同的地址段都映射到同一个物理内存空间, 经过多重映射转换后, 就可以使用染色指针正常进行寻址了。

下图左边就是染色指针和标志,可以看到同样的指针在不同的标志搭配下能指向三个地址。
但,多重映射只是染色指针的伴生物,不是专门做出来的。

处理步骤
全部都可以并发执行,有几个小停顿。

  1. 并发标记(Concurrent Mark):遍历对象图做可达性分析,在初始标记、最终标记的时候会有短暂停顿。在这个时候ZGC会更新染色体指针中的Marked 0,Marked 1标志位。
  2. 并发预备重分配(Concurrent Prepare for Reloc):根据特定条件得出要清理的Region,并做成重分配集(Relocation Set)。这里与G1区别挺大,这里ZGC每次都是扫描所有的Region,虽然耗时,但是省下了G1记忆集的成本。所以这里是对全堆做了标记,所以不能说回收是针对重分配集的,ZGC的重分配集就是把里面存活的对象复制到其他Region。JDK12开始在这里支持类卸载以及弱引用的处理

扫描全堆,有存活对象的叫重分配集,处理重分配集。

  1. 并发重分配(Concurrent Relocate):核心阶段。这个阶段转移重分配集上的存活对象,同时建立一个转发表(Forward Table),记录旧对象和新对象的转向关系。记录完了之后旧的Region就可以回收了,但是转发表必须留着,当并发通过引用访问旧对象的时候,这个访问会被内存屏障获取,就可以通过转发表转到新对象,同时修改旧的引用,这就叫自愈,所以只要有转发表在,Region就可以复制完后立即销毁。自愈过程只有第一次会转发,所以总得来说还是快。
  2. 并发重映射(Concurrent Remap):修正指向重分配集中旧对象的所有引用。这个工作并不着急,所以放到了下一次的GC的并发标记阶段去做,因为反正并发标记也要遍历一遍,这样还省了一次遍历的开销,全部修完后还可以把转发表都释放了,解决空间。

遍历的时候应是先修,修完了再看怎么标记。

缺点

  • 没有使用记忆集和分代,所以能承受的对象分配速率不会太高,分代对于新对象的分配还是有好处的,所以最后还是要引入分代收集,让新生对象都在一个专门的区域内创建,然后专门对这个区域进行更频繁、更快的收集。

选择合适的垃圾收集器

Epilon收集器

一个收集器该做的工作:垃圾收集、堆的布局与管理、对象的分配、与解释器的协作,与编译器的协作、与监控子系统协作等职责。

使用场景:是需要运行数分钟甚至几秒,只要Java虚拟机能正确分配内存,在堆耗尽之前就会退出,那显然负载极小、没有任何回收行为的Epsilon是很恰当的选择。

收集器的权衡(没看)

虚拟机及垃圾收集器日志(回头再看)

垃圾收集器参数总结(回头再看)

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值