GC算法以及垃圾收集器的一些个人理解

    之前有“(updated)Allocation Failure引发的一些GC知识梳理”这篇文章中简单介绍过GC相关的一些知识点,但是总结的并不是很全面,本片文章希望能尽量分析下垃圾回收机制相关的各个方面的知识点。

再次提一下,怎样判断哪些对象需要回收:

    一般使用的是可达性分析法(Reachability Analysis),从GC Roots开始向下搜索,搜索走过的路径称为引用链(Reference Chain),当一个对象没有任何引用链和它连接的时候,证明对象是不可用的,那这些对象就可以回收了。

    GC Roots主要是栈和方法区中的对象(堆外指向堆内的引用),包括:

  1. JVM栈中引用的对象
  2. 本地方法栈中引用的对象
  3. 方法区中静态属性引用的对象
  4. 方法区中常量引用的对象

 

垃圾收集算法简介:

    主要有如下几种:标记-清除、复制、标记-整理、分代收集算法

    响应能力和吞吐量是不一样的,响应能力指的是一个程序对系统的请求能够在多少时间内返回,而吞吐量关注的是一个指定时间内,一个系统能处理多少请求。

    对于关注吞吐量的系统,卡顿是可以接受的,因为这个系统并不会关注单次响应要在多长时间内返回。

 

标记-清除:

    分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,完成标记之后然后统一回收掉。

有两个缺点:一是这两个阶段的效率都不高,二是会产生大量不连续的内存碎片,导致无法为大对象分配足够连续的内存从而触发Full GC。

GC算法以及垃圾收集器的一些个人理解

 

复制:

    将内存划分为两块相同的区域,每次只使用其中一块。当这一块用完时,将这一块上海存活的对象复制到另外一块上,在把当前这块内存的内存空间一次性清理掉。

    优点是不用考虑内存碎片的情况了,而且分配的时候寻找空闲的内存只要移动下堆顶指针即可,内存分配简单。

    缺点是只使用一般导致利用率不高,并且如有对象是长期存活的话,复制这种对象会导致效率变低。

GC算法以及垃圾收集器的一些个人理解

 

标记-整理:

    过程和“标记-清除”差不多,标记挖之后,算法会让所有仍然存活的对象向一段移动,然后直接清理掉端边界以外的内存。

GC算法以及垃圾收集器的一些个人理解

 

分代收集算法:

    把Java堆分成了新生代和老年代,新生代主要都是一些朝生夕死的对象,而老年代都是一些存活时间很久的对象。所以新生代中一般选用复制算法,而老年代因为对象存活率高,没有额外的空间对它进行分配担保,必须使用“标记-清理”或者“标记-整理”算法。

 

JMV的Client与Server模式:

    JVM在运行的时候有两种模式,一种是Client模式,另外一种是Server模式。两种的区别在于前者启动快,但是在进入稳定运行之后,后者的速度比前者要快很多,原因是当虚拟机运行在-client模式的时候,使用的是一个代号为C1的轻量级编译器, 而-server模式启动的虚拟机采用相对重量级,代号为C2的编译器。C2比C1编译器编译的相对彻底,服务起来之后,性能更高。

    JVM虚拟机启动的时候,会根据机器的CPU核数以及内存大小自动选择使用哪种模式,当然也可以自己制定,Linux上对应的路径为:

jdk1.8.0_162/jre/lib/amd64/jvm.cfg

GC算法以及垃圾收集器的一些个人理解

可以使用java –version查看当前是运行在什么模式下:

GC算法以及垃圾收集器的一些个人理解

 

垃圾收集器的具体实现:

GC算法以及垃圾收集器的一些个人理解

    如上图所示,垃圾回收算法一共有7个,3个属于年轻代、3个属于年老代,还有一个G1和其他6个有所不同,它抛弃了新生代和老年代的概念,具体后续细说。

JVM会从年轻代和年老代各选出一个算法进行组合,连线表示哪些算法可以组合使用,逐一简单介绍下这些垃圾收集器。

 

Serial:

    该收集器是一款单线程的新生代收集器,采用的是复制算法,是JVM在Client模式下默认的垃圾收集器,回收时会导致STW。单线程是指它只用单个线程去完成垃圾回收的动作,图示如下:

GC算法以及垃圾收集器的一些个人理解

ParNew:

    和上面的Serial差不多,也是在新生代,也是复制算法,回收的时候也会STW。不同的是它是一款多线程的垃圾收集器,可以理解为它是Serial的多线程版本。它是JVM在Server模式下默认的垃圾收集器,图示如下:

GC算法以及垃圾收集器的一些个人理解

Paralle Scavenge:

    它的GC机制和上面的ParNew是一样的,不同的是这款垃圾收集器可以控制吞吐量,它不能和CMS组合使用。吞吐量=运行代码时间/(运行代码时间+垃圾收集时间)。较高的吞吐量可以最好的利用CPU的效率。

    可以通过参数进行设置吞吐量以及在多少时间范围内完成垃圾回收。通过-XX:UseAdaptiveSizePolicy:参数开关,启动后系统动态自适应调节各参数,如-Xmn、-XX:SurvivorRatio等参数,这是和ParNew收集器重要的区别

Serial Old:

    在老年代中使用,仍然使用单线程进行垃圾回收,使用的是“标记-整理”算法,会导致STW。图示如下:

GC算法以及垃圾收集器的一些个人理解

它主要是在Client模式下使用。

在Server模式有两大用途:JDK1.5之前与Parallel Scavenge收集器搭配使用。它作为CMS收集器的后备预案,如果CMS失败的话会使用它进行垃圾回收

Parallel Old:

Parallel Old是Parallel Scavenge收集器的老年代版本,也采用“标记-整理”算法,但只能配合Parallel Scavenge使用,适用在注重吞吐量以及CPU资源敏感的场合采用。

GC算法以及垃圾收集器的一些个人理解

CMS:(Concurrent Mark Sweep)

    它是一种以最短STW为目的的一种垃圾收集器,CMS只会回收老年代和永久代(1.8开始为元数据区,不归JVM管了)(默认是老年代或者永久代达到92%会进行触发一次Full GC),它的GC会分成四个步骤进行:

  1. 初始标记 会STW,标记下GC Roots能直接关联到的对象,速度很快
  2. 并发标记 即GC Tracing的过程,在三个标记中最慢
  3. 重新标记 会STW,修正“2”执行期间标记产生变动的那一部分的对象记录,时间比“1”稍长,但是远比“2”短
  4. 并发清除

GC算法以及垃圾收集器的一些个人理解

缺点:

1、对CPU资源非常敏感,因为它在并发期间会占用一部分的CPU资源,(CPU数量+3)/4,即至少是25%

2、CMS收集器无法处理浮动垃圾,即清除时用户进程同时产生的垃圾,只能等到下次GC时回收

3、因为是使用“标记-清除”算法,所以会产生大量内存碎片,从而可能会触发一次Full GC,使用STW(Serial Old垃圾回收期)整理内存碎片。

 

G1:

    OpenJdk9中,G1取代了CMS成为了默认的垃圾收集器,它的目的是缩短STW时间(几乎不需要STW),即它是一个低延迟的垃圾收集器。一般对于用户体验来说,低延迟要优先于高吞吐。

    G1将堆划分成了若干个Region(各Region物理上可以不连续),它仍然属于分带收集器,不过只有一部分Region包含了新生代,新生代和老年代不再是要求物理上连续的了。G1中的堆结构划分如下:

GC算法以及垃圾收集器的一些个人理解

    每个方块称为一个Region,每个Region的大小限制在1M~32M之间(可以通过G1HeapRegionSize参数设置大小,但必须是2的整次幂),整个堆最多会被划分成2048个Region。

每个Region在运行时都会被定义一种角色(角色在GC后可能会变),这个角色和之前的分代思想一样,但它们只是一个逻辑上的表示。还有一点不同的是它多了一个humongous区域,当新建的对象大小超过一个Region容量的50%,会专门放到一个或者多个连续的H区域。

    G1提供了“可预测暂停时间”机制,它可以避免对整个Java堆的全区域扫描。G1跟踪各个Region获得其收集价值大小,在后台维护一个优先列表。每次根据允许的收集时间,优先回收价值最大的Region(名称Garbage-First的由来),这就保证了在有限的时间内可以获取尽可能高的收集效率。

    当然它只是进行动态调整,尽可能在你指定的时间内完成GC,真实情况下不一定保证能在规定的时候内完成GC回收。

G1中的对象分配机制:

1.TLAB(Thread Local Allocation Buffer)

默认使用TLAB加速内存分配,TLAB是线程本地分配缓冲区,每个线程均可以“认领”某个分区用于本地线程的内存分配,这样就可以减少同步时间,提升GC效率。

2.Eden区中分配

如果TLAB不够用,则在Eden中分配内存生成对象。 

3.Humongous区分配

如果对象需要的内存超过一个region的50%以上,会忽略前两个步骤直接在老年代的humongous中分配(连续的Region)。

 

老年代中有指向新生代的引用,怎么避免扫描整个老年代:

CMS中是使用卡表(Card Table)+写屏障技术来解决老年代到新生代的引用问题。

卡表(Card Table)技术是将堆空间划分为一系列2次幂大小的卡页(Card Page),然后创建一个卡表(Card Table,其实就是一个数组),用于标记卡页的状态,每个卡表项对应一个卡页。HotSpot JVM中的卡页(Card Page)大小为512字节。

当对一个对象引用进行写操作时(对象引用改变),写屏障逻辑将会标记对象所在的卡页为dirty,这样只要扫描dirty的卡也就好了,避免了扫描整个老年代。这种结构被称为point-out,老年代point-out到新生代。

而G1不一样,G1有很多的老年代,如果使用了上面的这种point-out机制,还是要扫描很多老年代,所以G1使用的是一种叫point-in的机制。

G1中每个区域内部有一个结构叫Remembered Sets(RSets,已记忆集合)并且每个分区只有一个RSet,存储着其他分区中的对象对本分区对象的引用。GC的时候,只要扫描RSet中的其他old区对象对于本young区的引用,不需要扫描所有old区。

(ps:GC的时候新生代对象都是要扫描的,所以只要规避掉上面的这个老年代有指向新生代引用这个问题就好了)

 

G1中的垃圾回收机制:

G1中提供了三种垃圾回收模式,Young GC、Mixed GC和 Full GC,在不同的条件下被触发。它们都是STW的

Young GC:

主要是对Eden区域进行区域,在所有的Eden空间耗尽时被触发。

和之前的Young GC差不多,执行完一次Young GC,活跃对象会被拷贝到survivor region或者晋升到old region中,空闲的region会被放入空闲列表中,等待下次被使用。

此时会有计算出Eden和Survivor大小,用于下次Young GC。统计信息会被保存下来用于辅助计算size,然后调整各代区域大小,便于下次可以在规定时间内完成GC。

Mixed GC:

 既可能只收集年轻代,也可能在收集年轻代的同时,包含部分老年代的收集,它在老年代大小达到一定阈值时被触发,默认是80%,也可以用-XX:InitiatingHeapOccupancyPercent参数进行配置。

Mixed GC的执行过程和CMS的超级像,主要分为以下几个步骤:

initial mark: 初始标记过程,整个过程STW,标记了从GC Root可达的对象

concurrent marking: 并发标记过程,整个过程gc collector线程与应用线程可以并行执行,标记出GC Root可达对象衍生出去的存活对象,并收集各个Region的存活对象信息

remark: 最终标记过程,整个过程STW,标记出那些在并发标记过程中遗漏的,或者内部引用发生变化的对象

clean up: 垃圾清除以及拷贝过程,整个过程STW(这里和CMS不一样,CMS使用的是“标记-清除”,所以CMS无法处理浮动垃圾,而这里使用的是“复制”算法),如果发现一个Region中没有存活对象,则把该Region加入到空闲列表中

Full GC:

如果对象内存分配速度过快,Mixed GC来不及回收,导致老年代被填满,就会触发一次full gc,G1的full gc算法就是单线程执行的serial old gc,会导致异常长时间的暂停时间,需要进行不断的调优,尽可能的避免full gc.

 

总结:

    所以G1的GC机制,从局部上看是“复制”算法,从整体上看可以说是“标记-整理”算法,从而没有内存碎片的问题。

 

 

参考:

《深入理解JVM虚拟机》

https://www.cnblogs.com/ityouknow/p/5614961.html(GC算法示例图)

https://www.cnblogs.com/huzi007/p/6728328.html(JVM的client与server模式)

http://openjdk.java.net/jeps/248(JDK9中,G1变成默认的垃圾收集器了)

http://www.importnew.com/27793.html(G1相关的一些源码)

https://juejin.im/post/5c39920b6fb9a049e82bbf94(JVM卡表技术)