细说jvm(四)、垃圾回收算法

2,252 阅读10分钟

之前的文章

1、细说jvm(一)、jvm运行时的数据区域

2、细说jvm(二)、java对象创建过程

3、细说jvm(三)、对象创建的内存分配

从本篇开始说一说垃圾回收,这玩意是个大话题,也是我们应用性能优化中非常重要的一环,如果很擅长诊断jvm的GC问题,不仅能让你在工作中出彩,也可以让你在面试中更容易面试官的青睐。GC这部分我将会说常见的垃圾回收算法,垃圾回收算法有基础的算法,也有复杂一些的增量算法和分代算法等,然后还有常见的垃圾回收器的工作过程和优化细节,收集器我会重点说CMS,ParNew,G1,ZGC这几个,还会手把手的带你读GC log,以及会针对应用类型以及常见的一些场景做一定的优化总结,还会介绍几个排查问题的工具。这里面有的东西比如垃圾回收器工作过程这里是有一定的难度的,有什么问题可以在文章下方留言,只要我看到,我都会回复你。

GC涉及到的点会非常多,这里我将会说的比较细,我的目标是让你做个能实战的人,所以在后边的GC篇会经常展示各种排查问题的工具以及我排查的过程,因此不可避免文章也会变得长一些。

这章先来说说基础的垃圾回收算法,这是我们去理解后边一个大的前提。

一、判断对象可以被回收的依据

判断对象是否可以被回收,总共有两种方式,我们分别说一下

1、引用计数法

这是一种很古老的算法,具体是:在堆内存中分配对象时,会为对象分配一段额外的空间,这个空间用于维护一个计数器,如果有一个新的引用指向这个对象,则计数器的值加1;如果指向该对象的引用被置空或指向其它对象,则计数器的值减1。每次有一个新的引用指向这个对象时,计数器加1;反之,如果指向该对象的引用被置空或指向其它对象,则计数器减1;当计数器的值为0时,则自动删除这个对象。

2、可达性分析法

可达性分析法是以根集合(GCRoot)作为起始点,从这些节点出发,根据引用关系开始搜索,所经过的路径称为引用链,当一个对象没有被任何引用链访问到时,则证明此对象是不活跃的,可以被回收。jvm正是使用的这种方法。

从根本上来说,两种算法没有孰优孰劣之分,只有用得多和用得少的区别,用的多的是可达性分析算法,大概原因如下:1、引用计数法很难解决循环引用的问题,虽说有Recycler算法可以解决(再去面试的时候别再说解决不了呦),但是这又带来了难以预测的性能消耗问题。2、在多线程的环境下更改引用次数是一件性能消耗比较大事情。

名词解释:GCRoot 指的是执行可达性分析的起点,在java中,可以作为GCRoot的有:
1、虚拟机栈中(栈帧中的本地变量表)引用的对象
2、方法区中的类静态属性引用的对象
3、方法区中常量引用的对象
4、本地方法栈中 JNI(Native 方法) 引用的对象

上面说的两种方法是用来判断对象能否被回收的最基本的方法,很多的算法如标记算法都要基于此来做。下面我们来看看具体的垃圾回收算算法。

二、基础垃圾回收算法

这里要介绍的是三种基础的垃圾回收算法,这些是理解后边垃圾回收器工作原理的基础

(1)标记清除算法

此算法分为两个阶段:标记阶段和清除阶段。标记阶段的任务是标记出所有需要被回收的对象,清除阶段就是回收被标记的对象所占用的空间。流程如下: 我们从图中也可以看出来,标记清除算法有着比较严重的内存碎片问题,这会导致创建对象时候内存分配效率变低,并且太多的内存碎片还会导致额外的垃圾回收动作。

(2)标记压缩算法

此算法是为了解决上面的内存碎片问题,在标记之后,会将存活对象移动到内存的一端,过程如下: 我们先不说这种算法的优缺点,先说说另外一种算法

(3)标记复制算法

这个算法是将内存分为了两块,每次只使用其中一块,标记动作也只是发生在使用的这块内存中,然后将存活对象全部复制到另外一块内存中,再将之前的内存块全部清空,过程如下: 我们结合标记压缩算法和标记复制算法一起来看下:这两种算法都解决了内存碎片的问题,但是,标记复制算法浪费了一半的内存,也就是存在内存使用率低的问题,那是不是说标记压缩算法就是适用所有场景了呢?显然不是的,如果这块内存中存活对象很多的话,使用标记压缩算法就涉及到大量对象复制并且修改引用地址的问题,那样会减少分配给用户线程的执行时间(实际上标记复制算法在存活对象多的时候也有这个问题)。

三、对垃圾回收算法的改进

下面介绍的两种垃圾回收算法,会对基础算法中内存碎片化、暂停时间过长、空间利用率不高等不足进行改进。

(1)分代算法

分代算法是基于这样一个假说:绝大多数对象都是朝生夕灭的。这个假说其实已经被证实了,这一点也不难以想象,绝大多数OLTP应用中一个对象创建出来很快就会被回收掉。分代算法把对象分类成几代,针对不同的代使用不同的 GC 算法:刚生成的对象称为新生代对象,对新对象执行的 GC 称为新生代 GC(minor GC),到达一定年龄的对象则称为老年代对象,面向老年代对象的 GC 称为老年代 GC(major GC),新生代对象转为为老年代对象的情况称为晋升。但是代数也不是越多越好,综合来看,代数是两代或者三代是最好的。

这里引进了一个新的概念,OLTP和OLAP,这是两种不同的类型的应用,OLAP一般是数据分析型应用,OLTP应用是普通的实时类型
业务的应用。这两种应用的jvm优化是不一样的,这里你先记住这个概念,后边我们再具体讲优化。

在经过新生代 GC 而晋升的对象把老年代空间填满之前,老年代 GC 都不会被执行。因此,老年代 GC 的执行频率要比新生代 GC 低。通过使用分代垃圾回收,可以减少 GC 所花费的时间。

(2)增量算法

增量算法主要是通过并发的方式来控制STW(stop the world即应用停顿)时间。具体体现在垃圾回收线程工作和用户线程工作是以并发的方式来执行的。这点是因为jvm采用的是可达性分析算法,基于可达性分析的标记算法的工作过程一般都会分为数个阶段,并不是每个阶段都需要停止用户线程,所以这些不需要停止用户线程的阶段只需要和用户线程并发执行即可(增量如果不明白不着急,到后边讲垃圾回收器的时候会让你更好的理解)

四、使用改进的垃圾回收算法带来的问题及解决方式

上面的改进也是有代价的,因为这类算法的本质其实就是在时间和空间之间做权衡,如果速度变快了,那就得牺牲一定的内存,如果占的内存小了,那速度肯定就会相对慢一些。

(1)标记过程中对象引用关系发生变化

我们先来看看标记算法中的标记过程,这个过程可以用三色算法来抽象概括,即根据标记的不同程度将对象分成三类,白色:未被垃圾回收器标记的对象,灰色:自身已经被标记,但其拥有的成员变量还未被标记,黑色:自身已经被标记,且对象本身所有的成员变量也已经被标记。

在GC开始的时候所有对象都是白色如状态1,首先从GCRoot扫描的时候,将图中A标为灰色,E标为黑色如状态2,然后从灰色对象出发(不再扫描黑色),将灰色引用标记为黑色(A对象),再将B标记为灰色,如状态3,再递归循环从灰色出发扫描的过程,到了最后,只有D不会被扫描到,所以D就是垃圾,垃圾将在清理过程中被清理掉。

问题1:假如在状态3, jvm准备进行下一步标记的时候,A和B的引用关系被解除了,那么在下次标记的时候依然会从B出发把B标为黑色,把C标为灰色,最后B和C都会被标记上,但是其实我们根据图很容易推断出A和B解除了引用关系之后B和C从GCRoot是不可达的,因此B和C在这轮回收中是无法被回收的,于是B和C就变成了浮动垃圾。

问题2:另外一种情况发生在状态2结束,jvm准备进行下一步之前,E引用了B,而A和B的引用关系被解除,然后B和C也就无法在下一轮扫描中被扫描到了(因为下次扫描只会从灰色对象出发),接下来B和C就变成了垃圾,这就很严重了,因为影响到了程序的正确性。jvm为了解决这两个问题引入了读写屏障,读写屏障发生的条件如下

问题2这个场景的代码如下:
void test(A a,E e) {    
    B b = a.b;                  		// 触发读屏障    
    a.b = null;
    e.b = b;   					// 触发写屏障
}

具体是:(1)写屏障:在e.b=b的时候,把e对象或者b对象标为灰色,这样下次扫描就可以扫描到了,(2)读屏障:在给B b赋值a.b的时候,立刻将a.b标记为了灰色,保证扫描的正确性,这种也叫做增量更新。

(2)跨代引用

使用分代也是有代价的,新生代中的对象不一定仅仅只是被GCRoot引用,还有可能被老年代对象引用,如图,想要知道B能否被回收,就必须扫描一下老年代,但这样就失去了分代的意义,jvm必须做到在安全的回收新生代的同时不扫描老年代,不然的话还不如不分代。

上图中,我们可以把C也作为一个GCRoot,从而每次扫描的时候可以从C出发,或者是将C标为灰色(写屏障)。将C作为GCRoot的这个方法具体是每当发生跨代引用的时候,就将老年代对象记录进一个名为Remember Set的集合中,然后扫描的时候也会以Remember Set中对象作为GCRoot,这样就避免了扫描整个老年代的问题。