GC Roots,safePoint安插点,STW,CMS;Davlik/ART垃圾回收

2,905 阅读23分钟

方法区用于存储已被虚拟机加载的类型信息,常量,静态变量,被即时编译器编译后的代码缓存。 也就是存放类型信息、常量、静态变量、即时编译器编译后的代码缓存、域信息、方法信息等。

方法区中清除垃圾常量和垃圾类 1.常量: 常量不被引用,就会从常量池中清除 2.类:

需要满足以下条件:

2.1.该类的所有对象都已被清除

2.2.该类的java.lang.Class对象没有被任何对象或变量引用。只要一个类被虚拟机加载进方法区,那么在堆中就会有一个代表该类的对象:java.lang.Class。这个对象在类被加载进方法区的时候创建,在方法区中该类被删除时清除。

2.3.加载该类的ClassLoader已经被回收

image.png

垃圾回收算法

标记-清除:

image.png

缺点:

1.当堆中的对象大部分是垃圾时,标记和清除的效率会变低,而且会随着内存中垃圾对象的增长,导致效率越来越低。

2.内存碎片化:因为内存分配不是连续的,所以当清除后,内存中会存在大量内存碎片。当遇到大对象分配内存找不到足够的连续的内存来存放时会提前触发GC。

标记-复制:

image.png

缺点

1.当内存中的对象存活率较高时,复制大量存活对象会使得效率变低。 2.如果不想造成严重的内存浪费,就需要有额外的空间进行分配。

标记-整理:

image.png

缺点

移动存活对象: 对于老年代中GC之后大部分都为存活对象,将这些对象都进行移动并且更新引用这些对象的地方是一个比较耗时的操作。而且更新引用需要暂停用户线程来保证用户线程访问对象不会出错,简称STW,“Stop the Word”。

总结

采用标记-整理算法意味着GC的时候要移动对象更新对象的引用,也就是说内存回收的时候会更复杂。

采用标记-清除算法意味着内存碎片化。

采用标记-复制算法意味着内存可用度不高。

“吞吐量”:赋值器(使用垃圾回收器的线程也就是用户线程)与垃圾回收器的效率总和。

因为内存分配和访问(内存碎片化导致过多的分配和访问)比垃圾回收器回收的频率(回收时需要STW,而且比较耗时)要高,因此整理这种方式其实吞吐量要比清除要好。

对于关注吞吐量的收集器Parallel Scabenge基于标记-整理算法, 对于关注STW的收集器CMS来说采用的是标记-清除算法。 其实CMS是一种将两种算法混合起来的收集器,大部分时间采用清除算法,只有当分配内存不足(碎片化特别严重)时用整理算法进行一次收集。

1.GC Roots

以往做法

当垃圾回收器线程进行GC时, 第一步需要找到GC Roots; 第二步通过GC Roots进行遍历堆中引用GC Roots的对象形成引用链; 第三步,将不在引用链中的对象标记进行标记(需要回收的对象),或者标记引用链中的对象(需要复制,整理的对象),具体标记哪种对象根据堆中的分代内存不同和采用的垃圾回收算法来确定。

可优化地方以及优化原理

上述过程第二步中遍历堆中引用GC Roots的对象,这部分随着堆内存的越来越大需要的时间也会逐步增长。如果能够提前知道堆中哪部分内存是引用,来判断是否引用GC Roots这样效率是不是会更高一些。

从Exact VM开始就已经采用了准确式内存管理即知道哪部分内存是引用;而且在即时编译的过程中也会知道栈中或者寄存器里哪部分内存是引用。这个时候用一个数据结构来存储这些信息,在第二步中就不需要遍历整个堆了,只需要遍历没有标识引用内存的地方(也就是刚才数据结构中没有存储的信息)。

在HotSpot中使用OopMap这个数据结构来存储这信息,也就是可以显著提高GC Roots遍历的效率,但是在什么位置放这些信息呢?

2.提升了GC Roots遍历效率却不知道怎么安插?

前面提到过通过一个OopMap数据结构能够提升遍历效率,但是OopMap中的数据在不同的地方内容是不一样的(比如每个方法里面我的局部变量表里面的内容可能是不一样的),所以我为每个指令附近都放一个OopMap

这样未免也太浪费内存了吧~。

没错,所以得先办法把它放到合适的地方!旁白:这个数据结构的出现是为了优化GC第二步的效率出现的,也就是说只有GC时在放这些数据就行了~。思路找到了,但是什么时候发生GC呢?发生GC这个时间不能确定,但是可以确定的是它遍历堆中内存的时候必须要进行STW【否则如果在标记的过程中堆中引用发生变化就会导致标记结果出错】(2.1中讲解),我指定只有代码中执行执行到某个地方才可以进行STW这样我就可以间接的实现我的目的

也就是说当GC发生时,只有执行到某个地方才会进行STW,然后我在这个地方附近放上这么一个OopMap的数据结构,然后加快第二步的效率。

这个某个地方其实名字叫做“safePoint”,顾名思义安全点,只有代码执行到安全点附近才可以进行STW垃圾收集,而只要将OopMap安插到安全点附近就行。

2.1为什么需要STW?

上面提到过:

【否则如果在标记的过程中堆中引用发生变化就会导致标记结果出错】,接下来用三色标记法进行解释如果没有STW会发生什么情况:

一,三色标记法

image.png 二,没有STW出现的情况

image.png

三,解决方案

上面那种异常情况必须同时满足两个条件:

1.灰色对象不引用白色对象 2.黑色读写引用白色对象

因此,当我们希望标记过程减少STW时间并且垃圾回收线程可以与用户线程一起运行,只要让其中一个条件不满足即可,因此出现了两种解决方案: 1.增量更新: 这种方案是让第二个条件不满足,即当黑色对象引用白色对象时,将这个黑色对象保存下来,等扫描结束后,再次取出黑色对象进行扫描,可以简单理解为如果黑色对象引用了百世对象就会被标记为灰色。

2.原始快照: 当灰色对象删除白色对象的引用时,将这个灰色对象记录下来,等到扫描结束后,在对这些灰色对象为根进行扫描,简单理解为:不管是否删除与否都会按照第一次刚开始的引用关系图进行扫描。

CMS垃圾回收器采用增量更新来进行并发标记,G1,Shenandoah采用原始快照.

CMS注重延时,所以这款垃圾回收器采用的回收算法是标记-清除和标记-整理算法两种进行回收,采用标记清除允许有内存碎片产生,但是当碎片不足以分配新对象的内存时采用标记-整理算法将内存变为规整。

3.safePoint

safePoint上面解释过了,但是我该在哪里放置safePoint呢?放的多了会导致GC收集过于频繁增加运行时内存压力,放的少了又会因为堆中不断增加使用的内存而没有及时回收堆里面内存导致垃圾收集器等待时间过长。

这样,我定义一个规则,只有这种**“会让程序长时间运行的指令”**特征我才会进行安插safePoint,但是这个特征“长时间”并没有具体的定义,但是却有“指令序列复用”这样的含义。比如方法调用,循环调整,异常跳转这些,简单说就是只要执行时间是有上限(bounded)就可以了,实时性要求并不是很高,于是进一步假设,向前执行(直线型、带条件分支都算)的代码都会在有限时间内执行完,所以可以不用管;而可能导致长时间执行的代码,要么是循环,要么是方法调用,所以只要在这两种地方插入safepoint就可以保证及时性了。

针对int类型的循环判断数不会进行安插这是JVM进行的优化处理,只有Long类型的循环判断数才会进行安插。只有这些指令附近才会安插safePoint。

参考:www.zhihu.com/question/48…

android中也有类似的机制叫做checkPoint,当发生anr时,SiganlCatcher会给每个线程设置一个标志位,运行时的线程进行上下文切换时会进行检测该标志位进行挂起然后之后进行dump堆栈。

safePoint位置选好了,但是上个问题说过执行到safePoint中需要进行STW,发生GC时,我该如何快速跑到safePoint附近进行STW?还有我这个STW该怎么实现呢?

4.STW

首先解释为什么叫做STW,全称“Stop the Word”,因为通过GC Roots遍历堆中内存的过程其内存里面的引用关系不能发生变化,所以需要暂停所有的用户线程操作来保障Gc Roots形成的引用链是正确的即待会标记过程不会出错。

让所有线程都暂停,这个操作其实有两种方式处理: 一,抢先式中断:

垃圾收集器收集时,系统将所有用户线程都中断。当发现不在safePoint附近的线程时先让他恢复运行直至跑到safePoint附近。这种方式现在几乎没有虚拟机采用这种方式来响应GC。

二,主动式中断:

我不直接对我的用户线程操作,当发生GC时,我给用户线程设立个标志位,用户线程执行的时候不断轮询这个标志位,如果轮询到了那么我将自己中断我自己的运行,由于这种方式是轮询到就立马进行挂起所以将轮询的地方和safePoint的地方重合。

优化

“不断轮询标志位”这句话听起来就很耗时,那么在虚拟机中是怎么优化的呢?还有轮询之后的操作我自己挂起我自己这个又是怎么实现的

轮询标志位这个操作其实就是一条汇编指令,当我轮询到需要中断线程的标志位的时候:我会将其中一个内存页设置为不可读,这会导致产生一个自陷异常信号,异常处理器中接受到后进行主动中断操作。

5.线程不执行

上面说到过现在虚拟机采用的几乎都是主动式中断来中断线程,而其实现又是通过线程执行过程中不断轮询标志位产生自陷异常信号在异常处理表中进行中断线程,

大家有没有发现有个小bug:如果我轮询的操作一直得不到执行呢?这个时候我又该如何让虚拟机进入垃圾回收状态。

其实不一定都需要进行中断线程来保证,回想下STW是为什么:因为如果这个时候用户线程还在执行的话内存中的引用关系可能会发生变化,所以才需要进行STW。如果一个线程没有得到CPU时间片执行,但是我可以确保其中一部分代码区域是不会改变内存引用关系的,这样也可以不用管这些线程。

引入Safe Region(安全区域)解决

“安全区域:这部分代码不会使内存中的引用关系发生变化”,因此只要进入了安全区域,虚拟机就不会管这些线程。当线程离开安全区域后,如果这个时候引用链还没有形成(也就是通过GC Roots遍历堆内存)那么是不能离开的,一直等待直至引用链形成(或者完成了垃圾回收器需要暂停用户线程的阶段)收到信号为止。

6. GC Roots数量

基础知识介绍

根据堆中的不同区域(分代设计)和回收内存空间来判定分为不同的GC名称: 局部回收:Minor GC,MajorGC,..... 整个内存回收:Full GC

如果存在“跨代引用”(最典型的比如老年代对象引用年轻代对象),比如发生Minor GC时,只遍历普通的GC Roots对象其实结果并不准确(某些对象虽然本身不属于GC Roots但是随着经历的GC次数变多成为老年代对象),如果这个时候将这个引用的年轻代对象标记为垃圾清除后,老年代中的对象就会有问题,所以引用链形成的过程中还需要遍历整个老年代来保证结果准确

CPU的缓存行技术及伪共享解决方案

image.png

记忆集

跨域可以理解为跨内存访问或者访问其他分代里面的内存

上面遍历整个老年代这个过程听起来就很耗时哈哈,事实也确实如此。那么我们可以引入这么一个概念: 如果你引用了其他内存里面的对象那么我把你存放到其他内存里面的一个数据结构里面,之后其他内存回收的时候只需要把之前添加到数据结构里面的对象加入到GC Roots中即可。

优化一下: 每个不同的分代中都存着一个数组,这个数组中对堆内存进行一个映射, 我数组中的每一小块对应的元素是分代中固定大小的内存(比如我第一个数组下标表示我引用的是0到100,第二个数组下标表示引用的是100-200以此类推)。当我第一个数组元素对应内存跨域引用了其他分代中的内存,我将把第一个数组元素对应的值标识为1代表脏(Dirty),没有则为0。当垃圾回收时,我就知道哪部分内存是跨代引用并将他们加入到GC Roots进行扫描(将数组中元素为1对应的内存对象加入GC Roots中)。

根据我映射的内存大小精度又可以进行细分: 1.字长精度:只记录一个机器字长(处理器的寻址位数)该字包含跨代指针

2.对象精度:记录一个对象(对象字段中含有跨代指针)

3.卡精度:记录一块内存区域(该区域有对象包含跨代指针)

最常用的精度

采用“卡精度”的记忆集是通过“卡表”这个数据结构来实现的。

使用精度为卡,这个记忆集的实现方式也被称为卡表,卡表中其实是字节数组结构,每个数组中的元素都对应一部分指定大小内存块,这部分内存被称作卡页,当卡页中的内存块中引用了其他的内存块中的一个或多个对象,就会将卡页中的元素值变为一。变为一的就是脏数据,收集时讲这部分内存加入到gc roots中。 也就是这样的:

image.png

可能出现的问题

一,何时进行更新卡表? 赋值操作会进行一个环形通知,可以在写操作之前和写操作之后进行处理,分别成为写前屏障和写后屏障

我在写后屏障中进行更新卡表就可以保证我的卡表记录是正确的。

二,“伪共享引起的问题” 上面刚刚讲过CPU的缓存行技术,简单来说就是如果两个线程中两个独立的变量在同一块缓存行中,那么不管是哪个线程修改,另外一个线程都需要重新从主存中读取,而设置缓存行就是为了加快读取效率,所以这样势必会降低效率。

想想刚刚我们记忆集处理方式,如果卡页对应的内存中发生跨代引用,那么就会对卡表进行更新;上面说的“伪共享”也会在这里出现而且影响性能,比如:一个缓存行六十四个字节;一个卡表中的一个元素是一个字节,每个元素对应的一个卡页存储的是512字节,也就是一个卡表中64个元素在一个缓存行,而这64个元素对应的总卡页内存为32KB(64 X 512字节),如果两个线程中的变量分配到了这部分内存中,之后变量发生跨代引用更新卡表元素时就会导致另一个线程的缓存行失效而从主存中去拿。所以应该减少更新卡表这个操作,如果已经更新过脏数据了就不需要进行更新卡表了。

CMS

imgCMS的回收方式可以分为四大步骤:

- 初始标记: 只标记GC Roots直接关联的对象.需要停止所有线程(Stop The World),但是速度很快. - 并发标记: 进行GC Roots遍历的过程,速度很慢,但是 是和用户线程并发执行的. - 重新标记: 收集浮动垃圾对象,需要Stop The World,比初始标记时间长,但是远比并发标记时间短. - 并发清除: 清除所有标记的垃圾对象,时间长,但是是和用户线程并发执行的.

通过上述过程我们知道,整个GC过程有两次停顿,分别是"初始标记"和"重新标记"阶段,但是速度很快,对用户的影响不大.而两次耗时比较长的"并发标记"和"并发清除",因为是和用户线程并发执行的,所以用户是无感知的,我们可以归纳为:并发收集,低停顿.

我们可以这样: 你在一个房间内吃瓜子,我是垃圾回收线程负责清理瓜子皮,你是用户线程产生瓜子皮。我需要你停止吃瓜子然后我去统计下哪个角落瓜子皮多,统计完你就可以继续吃了,我在你吃瓜子期间去打扫刚刚的角落,你一边吃,我一边打扫房间,打扫完一遍后,我再让你停止,然后再去打扫"在我打扫期间你扔的瓜子皮"(这叫做浮动垃圾),打扫完后你就可以继续吃了,同时我把垃圾清理了。

Davlik

DVM是Android虚拟机的一个版本,主要工作在Android4.4之前. 因为在Android4.4时,Google就引入了ART,而在Android5.0之后, Google就将Android系统虚拟机彻底切换为ART.

也就是说: Android4.4之前,系统是虚拟机DVM,Android5.0之后,系统虚拟机ART.而在Android4.4之后,Android5.0之前这段期间,是两者并存的.

那么,为什么要将DVM替换掉呢? 因为DVM效率低!

DVM的工作原理很简单,它会在app启动后,我们执行到对应功能的时候,就将这部分功能对应的代码 转换为 机器码,保存起来然后执行,可以理解为:用到才转换,所以也被称为JIT(just in time).

Tips: 当Android启动时,Dalvik VM监视所有的程序(APK),并且创建依存关系树,为每个程序优化代码并存储在Dalvik缓存中。Dalvik第一次加载后会生成Cache文件,以提供下次快速加载,所以第一次会很慢。

这样有个优点: 节省内存!

但是,这样有个缺点: app执行速度变慢了,就像"懒汉式"单例一样,省了内存,但是要的时候,就需要临时去创建对象,等于延迟了程序的执行速度.同样的道理,这里要执行功能的时候,才去转换,肯定也延迟了app的执行速度,这也是Android4.4之前的系统卡顿的原因之一.

那么,原因之二就是DVM的垃圾回收机制比较差。

DVM的垃圾回收机制

Davlik采用的回收机制有两处卡顿:枚举GCROOTS的时候还有标记垃圾对象的时候

而且采用的是跟CMS一样的"标记-清除"法来执行垃圾回收,那么,内存碎片是避免不了的. 尤其是在分配大对象时.这就变相增加了回收频率,从而导致app卡顿

ART

ART是Android5.0之后彻底生效的,它主要有两个改善的地方.

  • 1 将转换为机器码的过程提前到了安装apk的时候.(Android7.0之前)
  • 2 内存分配方式和垃圾回收机制做了极大的优化.

前面我们说到,DVM是基于JIT实现的,也就是边编译边执行,在运行到对应功能的时候,才将代码转换为机器码,然后交给CPU去执行.

而ART则不然,ART是在app安装的时候,就提前将所有代码转换为机器码保存下来,等到执行的时候,直接取出来在CPU中执行,也就是说,ART将转换为机器码这件事提前了. 所以叫做AOT(ahead of time).类似于饿汉式单例.

优点很明显,提高了app的运行速度,不用再边加载边执行了.

缺点也很明显,就是耗费内存了,一次加载整个app所有功能的代码 并 转换为机器码,明显费内存. 但是没关系,因为手机的内存越来越大了. 另外,因为在安装时就去转换为机器码,那么安装的时间肯定要变长, 这是无法避免的,但是,虽然安装时间长,但是下载apk的时间更长,于是安装时间就被冲淡了,这是可以接受的.

现如今的ART已经成长了许多。从以前安装阶段进行全量的oat编译,成长为如今的只编译性能有关的代码和类。dex2oat进程也对编译等级进行了区分(安装时的编译等级是最低的)

ART垃圾回收

GC更少的卡顿

ART的整体回收策略跟DVM类似,但是ART只需要停顿一次.

  • 首先,ART的初始标记和并发标记阶段,都是并发执行的,可以理解为只有并发标记这一个阶段.
  • 其次,ART在并发标记过程中,如果有新的对象,则会分配到一个叫做Allocation stack的空间里. 这样,就避免了浮动垃圾.
  • 最后,ART再进行一次整体停顿,去扫描Allocaton stack中分配的对象. 然后再执行清除操作.

这样,ART通过新增一个Allocation stack空间来保存临时新增对象,就避免了多次停顿.这是典型的空间换时间.

新的内存分配器

  1. 粒度更小:L拥有更细粒度的锁定机制,可以锁定对象的单个容器,而不是整个内存分配本身。

以前分配1M,10K都是直接锁住这个内存区域

现在这个分配器通过增加锁的细粒度的方式(1M有1M内存的锁,10K有10K的锁)减少了一把大锁被阻塞的可能

多线程编程里面常用的方式:

  1. 无锁编程:线程局部区域中的小对象可以完全放弃锁定

线程里面的小对象分配直接从线程局部区域进行分配

  1. 号称新的内存分配器可以提升十倍的速度(实际上高通给出的结论是至少在32位平台上只有非常小的变化)

专门的大对象内存空间

专门针对尺寸大的对象搞了一个对象池:以前分配对象可能是从栈里分配,现在不用了直接从池子里分配

而且,ART将Active堆分的更细, ART开辟了一块非连续的、离散的堆空间,叫做Large Object Space,专门用来放大对象.

这样,如果内存空间不够并且还要存放大对象时,就会在这里进行分配,而这个是离散的、非连续的,也就不存在内存碎片,从而变相的降低了GC频率,提高了应用性能,这属于粒度细化的思想

减少了内存碎片

APP退出到后台时会进行内存碎片的整理

ART的内存占用对比

  1. DavlikHeap的PSS减少:由于ART采用的是odex二进制文件在native空间下运行,所以留给Java空间的内存就会变多。
  2. UNKNOWN占用变多:此部分是由于new,malloc分配内存空间时才会增长(增长幅度不固定)

总体来看UNKNOWN的增长要比DavlikHeap和mmaps的减少要多,所以总体来看ART的内存占用会更多

总结

Dalvik的回收过程跟CMS类似,标记过程需要进行两次Stop world,并且内存碎片问题严重,导致分配大对象时频繁触发GC.

ART的回收过程也跟CMS类似,标记过程只需要进行一次Stop world,而且引入了Large Object Space,解决了内存碎片导致的分配大对象频繁GC的问题,变相提高了GC效率.

本文正在参加「金石计划 . 瓜分6万现金大奖」