Java垃圾收集器原理详解

Java垃圾收集器原理详解

  • 垃圾收集器是如何工作的?

关于垃圾收集器,我们可以想想,垃圾收集器的作用是什么,回收无用对象,释放内存,让程序稳定运行下去,所以,垃圾收集器需要考虑以下几点

哪些内存是需要回收的
什么时候才能知道它需要回收
怎么去回收

我们根据这三个问题,来逐步了解垃圾收集器的工作流程,及细节实现等

首先,我们必须确定哪些内存需要回收。

在java内存运行时区域的各个部分,其中程序计数器,虚拟机栈,本地方法栈三个区域跟着线程的生命周期走,线程在新建状态时开始,占用一部分内存,在线程销毁时,会释放这部分内存。而栈中的栈帧随着方法的进入和退出而有条不紊的执行着出栈和入栈的操作,每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的,因此这几个区域的内存分配和回收都具备确定性,当方法或者线程结束时,内存也就跟着回收了。

而java堆和方法区这两个区域有着很显著的不确定性,一个接口的多个实现类可能不一样,一个方法所执行的不同条件内的操作所需要的内存可能也不一样,只有在运行期间,我们才能知道程序究竟会创建哪些对象,创建多少对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的也正是这部分内存该如何管理,这次介绍的分配与回收也是指这部分内存。

在堆里面,存放着java几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还存活着,哪些可以回收的对象。

而判断对象是否存活的算法有两种

第一种:引用计数算法
用这种算法判断对象是否存活时,会在对象中加一个引用计数器,当对象被引用一次时,计数器加一,当引用已经失效时,计数器减一。
这种算法的原理简单,判定效率很高,但是在java主流的虚拟机里面,都没有使用引用计算算法来管理内存。这种算法看似简单,其实有很多例外情况需要考虑,必须要配合大量额外处理才能保证正确的工作,而java是一门面向对象的语言,对象的互相引用是再正常不过了,这时,单纯的引用计数算法就很难解决对象之间互相引用的问题了,如代码:

public class ReferenceCountingGC {

    public Object instance=null;

    public static void main(String [] args){
        ReferenceCountingGC objA=new ReferenceCountingGC();
        ReferenceCountingGC objB=new ReferenceCountingGC();
        //这两个对象都有instance字段,赋值让这两个对象都在相互引用,再从代码上看,其实这两个对象都是无用对象
        //但是由于他们都在互相引用对方,所以会导致他们的引用计数器都不会为零,这就会导致垃圾收集器无法回收这两个对象
        objA.instance=objB;
        objB.instance=objA;
        objA=null;
        objB=null;
    }
}

第二种:可达性分析算法

可达性分析算法是java里广泛使用的一种算法,这种算法是从一系列根节点(GC Roots)开始,一直往下扫描引用的对象,这扫描走过的路径被称为引用链,如果一个对象的引用链没有链接到根节点,就说明这个对象已经不会再用到了,是可以回收的状态了。

如示例图中的例子:

X
GC Roots
对象一
对象二
对象三
对象四
对象五

当从根节点开始扫描时,对象一的引用链是可以连接到GC Roots的
所以这是对象一及以下的节点是存活的,不可回收的对象。

而对象四和对象五虽然有关联,但是他们的引用链并没有连接到GC Roots,所以这时,垃圾收集器会将对象四标记为可回收状态。

在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,比如各个线程被调用的方法堆栈中使用到的参数,局部变量,临时变量等
  • 在方法区中类静态属性引用的对象,比如java类的引用类型静态变量
  • 在方法区中常量引用的对象,如字符串常量池里的引用
  • 在本地方法栈中JNI(Native方法)引用的对象
  • java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(NullPointException)等,还有系统类加载器
  • 所有被同步鎖持有的对象
  • 反应java虚拟机内部情况的JMXBean,JVMTI中注册的回调,本地代码缓存等

除了这些固定的GC Roots外,根据用户所选的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象临时性加入,共同构成完整的GC Roots集合。

被标记了就一定会被回收吗?

要正式回收一个对象的话,该对象至少要被标记两次才可以被回收。被可达性算法标记了为可回收对象时,对象还有一次自救机会,条件为该对象是否有必要执行一次finalize()方法,如果对象没有覆盖finalize方法,或者finalize方法已经被虚拟机调用过,那么虚拟机将会将这两种情况都视为“没有必要回收”。

如代码;:

public class FinalizeEscapeGC {

    public static FinalizeEscapeGC SAVE_HOOK=null;

    public void isAlive(){
        System.out.println("没想到吧程太郎!这就是我的逃跑路线!");
    }

    @Override
    protected void finalize() throws Throwable {
        System.out.println("finalize方法执行中");
        super.finalize();
        FinalizeEscapeGC.SAVE_HOOK=this;
    }

    public static void main(String [] args) throws InterruptedException {
        SAVE_HOOK=new FinalizeEscapeGC();
        //对象第一次成功自救
        SAVE_HOOK=null;
        System.gc();
        //因为finalize方法优先级很低,先暂停一会,等待它
        Thread.sleep(500);
        if(SAVE_HOOK !=null){
            SAVE_HOOK.isAlive();
        }else {
            System.out.println("没想到吧程太郎!这就是我的逃跑路。。。。");
            System.out.println("啊!");
        }
        //下面这段代码和上面的完全相同,但是这次自救却失败了
        SAVE_HOOK=null;
        System.gc();
        //因为finalize方法优先级很低,先暂停一会,等待它
        Thread.sleep(500);
        if(SAVE_HOOK !=null){
            SAVE_HOOK.isAlive();
        }else {
            System.out.println("没想到吧程太郎!这就是我的逃跑路。。。。");
            System.out.println("啊!");
        }
    }

无论是引用计数器算法,还是可达性分析算法,都离不开引用二字,
而在jdk的定义中,引用的概念有四种,分别为:

强引用(Strongly Reference)
指最传统的引用定义,即直接的new对象类似的这种引用关系。
软引用(Soft Reference)
用来描述一些还有用,但非必须的对象。
弱引用(Weak Reference)
弱引用也是用来描述那些非必须的对象,但是它的引用强度比软引用还小一些,被弱引用关联的对象只能活到下一次垃圾收集器执行。
虚引用(Phantom Reference)
也被称为幽灵引用和幻影引用,它是最弱的一种引用关系。

虚拟机在这几种引用中,每种引用都会采取不同的回收策略。

  • 分代收集理论

当前的垃圾收集器大多数都遵循了“分代收集”理论进行设计,它建立在两个分代假说上:
强分代假说(Weak Generational Hypothesis)

熬过越多次垃圾收集的对象,就越难以回收

弱分代假说(Strong Generational Hypothesis)

大部分对象都是朝生夕灭的

这两个假说奠定了多个常用垃圾收集器的设计原则:
收集器应该将java堆分成几个不同的区域:
新生代
大部分对象都会在新生代中,垃圾收集器扫描新生代的频率会比较高,
而熬过多次收集的对象则会转移到老年代中。
老年代
这里的对象通常是较难回收的,垃圾收集器会以较低的频率来扫描老年代。

正因为java堆有了区域之分,垃圾收集器可以一次只回收其中某一个区域,才有了:Young GC(新生代收集)Old GC(老年代收集)Full GC(整堆收集)

但是对象之间并不是孤立的,对象之间会有跨代引用。

比如现在只进行一次新生代的收集,但是这个新生代对象已经被老年代对象所引用,为了找出这个区域存活的对象,收集器不得不再进行一次扫描整个老年代的所有对象来确保可达性分析结果的正确性。但是这明显会为内存带来很大的负担,所以就需要对分代收集理论添加第三条经验法则:
跨代引用假说(Intergenerational Reference Hypothesis)

跨代引用相对于同代引用来说仅占少数。这其实是可以根据前两条假说逻辑推理得出的隐含理论:存在于互相引用的对象应该是倾向于同时生存或者同时死亡的。
比如说,如果某个新时代对象出现了跨代引用,由于老年代对象很难被回收,该引用会使新生代对象在收集时同样存活,而收集次数多了这个新生代对象也会晋升到老年代中。

根据上面的描述,我们也不需要为了一个跨代引用而去扫描整个老年代,我们只需要在新生代中建立一个记忆集(Remembered Set),这个结构把老年代分为若干个小块,来标出老年代的那个区域是存放跨代引用的对象的。
当发生新生代收集(Young GC)时,如果有跨代收集的话,只需要扫描部分老年代区域就可以了。

  • 内存回收算法

标记-清除

该算法是先将对象扫描一遍,标记出可回收对象和不可回收对象,如图:

它有两个缺点
一个是它的执行效率不稳定,当有大量的,需要回收的对象时,他将会进行大量的标记和清除行为,导致它的执行效率会随着可回收的对象数量而变,
第二是清除完对象后,会产生大量不连续的内存碎片,如果有大的对象需要分配内存时,由于不连续的内存碎片太多,无法分配内存,会引起一次额外的一次垃圾收集

标记-复制

这种算法会将内存一分为二,标记出不需要回收的对象,再将他复制到另一块没有使用过的内存中去,然后把另一块内存区域全部清除,如图:

这种方式,对象收集完后另一块区域的内存会被全部清除,从而不会产生内存碎片。
但是在执行时有大量的存活对象时,会花费大量资源去进行对象的复制,而且内存划区的话会浪费很多的内存,针对这个问题,有一个优化的策略,被称为Appel式回收
Appel式回收会将内存划分为三个区域,一个较大的Eden空间和两个较小的Survivor空间,每次回收时只扫描Eden空间和一个Survivor空间,存活对象会被转移到另一个Survivor空间中,由于新生代中的对象在正常情况下大部分都是朝生夕灭的,所以新生代只会留下很少的对象转移到Survivor中,但是也有极端情况Survivor空间容纳不下存活对象,所以Apple式回收有一个担保机制,如果发生了这种情况时,那些放不下的对象会被放入其他的内存区域中(大多数是放入老年代中)

标记-整理

标记-复制算法会进行一次复制对象的操作,更重要的是,会浪费50%的内存,标记复制算法是一种非移动性的算法,而标记整理是移动性的算法,它会将不可回收的对象移动到某个区域中,然后在将这个区域外的对象全部进行回收,如图:

但是这种算法如果在进行回收时,有大量存活对象时,移动将是一种极为耗费资源的操作,而且这种操作必须暂停用户线程才能进行

HotSpot算法细节实现

  • 根节点枚举

固定可作为GC Roots的节点主要是可作为全局性的引用中,尽管目标明确,但是现在java应用越做越大,如果要逐一检查以这里为起源的的引用肯定要消耗非常多的时间。

迄今为止,所有获取根节点枚举的收集器都必须暂停用户的线程,根节点枚举必须要能在保存一致性的时候才能进行分析,在这个时候,对象的引用关系不能变化,如果变化了的话,分析的准确性就不能够保证了。

目前主流java虚拟机使用的都是准确式垃圾收集,所以当用户线程停顿下来之后,并不需要一个不漏的检查完所有执行上下文和全局引用的位置,在HotSpot中,,是使用一组称为OopMap的数据结构来达到这个目的的。
一旦类加载动作完成后,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译的过程中,也会在特定的位置记录下来栈里和寄存器里哪些位置是引用。
这样收集器就不需要一个不漏的从GC Roots开始查找。

在OopMap的协助下,HotSpot可以快速准确的完成GC Roots枚举,
但是导致引用关系变化,导致OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的存储空间。

实际上HotSpot也没有对每一条指令都生成OopMap,而只是在特定位置记录了这些信息,这些位置被称为安全点。它决定了用户不允许随意的停顿下来开始进行垃圾收集,只能在安全点进行停顿。安全点不能太少,让收集器等待时间过长,也不能太多,增加内存负担,安全点的位置选取基本上是以是否具有让程序长时间继续的特征为标准进行选定的,而它最明显的特征就是指令序列的复用,方法调用,循环跳转,异常跳转等都属于指令复用,所以只有这种的才会产生安全点。

而如何在垃圾收集器执行的时候,让所有的线程都到安全点上去呢?
这里有两种方法:

抢先式中断(Preemptive Suspension)
在垃圾收集时暂停线程,如果有线程没有到达安全点,就先让他到安全点,再将它暂停。
主动式中断(Voluntary Suspension)
用户线程在运行时,会不断轮询一个标记,这个标记会在垃圾收集器要进行收集时标记出来,线程一旦轮询到标记为真,就会立马在离自己最近的一个安全点挂起。

由于轮询操作的出现会十分频繁,所以HotSpot使用了内存保护陷阱的方式来精简操作,如用户一直轮询查询一个标记,在进行即将进行垃圾收集时,虚拟机将那个设置为不可读,这时会抛出一个异常,再对这个异常进行捕获,在异常处理中将线程在安全点中挂起。

在有了安全点之后看起来似乎已经可以解决如何停顿线程,让虚拟机进入垃圾回收状态了,但是实际情况并不一定,当程序执行时,会一段时间遇到一个安全点,如果程序“不执行”呢?比如当线程处于Sleep或者Blocked状态时,线程就无法响应虚拟机的请求,这时,就必须引入**安全区域(Safe Region)**来解决,可以将安全区域理解为一个范围性的安全点,当线程进入安全区域的时候会给自己一个标识,在进行垃圾收集时,垃圾收集器可以不用理会那些在安全区域中的线程,当线程要离开安全区域时,线程会先查询一下虚拟机是否已经完成了根节点枚举,如果已经完成,线程可以继续执行,否则它就必须一直等待,直到收到它可以离开安全区域的信号为止。

记忆集与卡表

上面说分代收集的时候,讲到了解决跨代引用的问题,垃圾收集器为了避免扫描整个老年代,在新生代创建了一个**记忆集(Remembered Set)**的数据结构。其实并不只有新生代老年代中才有跨代引用,在任何有区域收集行为的收集器,都会有这个问题。

记忆集是一种用于记录从非收集区域里指向收集区域里的指针集合的抽象数据结构,最简单的实现可以用非收集区域中所有含跨代引用的对象数组来实现这个数据结构,伪代码为:

public class rememberedSet{
	Object [] set [对象的新,老代间的引用大小];
}

这种记录全部含跨代引用对象的实现方案,成本十分高昂。
而在垃圾收集器中,收集器只需要通过记忆集判断出某一块非收集区域是否有指向了收集区域的指针就可以了,并不需要了解这些跨代引用的全部细节。
而下面这几个选择,可以在设计者实现记忆集的时候来节省记忆集的存储和维护成本:

  • 字长精度
    每个记录精确到一个机器字长(处理器的寻址位数,如32位和64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含了跨代指针
  • 对象精度
    每个记录精确到一个对象,该对象里有字段包含跨代指针
  • 卡精度
    每个记录精确到一块内存区域,该区域内有对象含有跨代指针

卡精度所指的是一种被称为**卡表(Gard Table)**的方法实现记忆集,这也是最常用的一种记忆集实现形式,卡表是记忆集的一种实现,他定义了记忆集的记录精度,与堆内存的映射关系等

卡表最简单的形式可以是一个字节数组,如以下形式:

CARD_TABLE [this address >> 9] = 0 ;

首先,计算对象引用所在卡页的卡表索引号。将地址右移9位,相当于用地址除以512(2的9次方)。可以这么理解,假设卡表卡页的起始地址为0,那么卡表项0、1、2对应的卡页起始地址分别为0、512、1024(卡表项索引号乘以卡页512字节)。

字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作卡页(Card Page),一般来说卡页的大小都是2的n次幂,就如上面的代码,地址是向右位移九位,就是2的9次幂。

如果卡表标识内存区域的其实地址是0x0000的话,数组CARD_TABLE的第0,1,2号元素,分别对应了地址范围为0x0000 ~ 0x01FF,0x0200 ~ 0x3FF,0x0400~0x05FF的内存块(十六进制数200,400分别为十进制的512,1024,这三个内存块为从0开始,512字节容量的相邻区域)如图:

卡表
卡页
dirty
Page-0x0000-512byte
Card1-1byte
Page-0x0200-512byte
Card2-1byte
Page-0x0400-512byte
Card3-1byte

一个卡页通常包含不止一个对象,只要卡页内有一个或者更多对象的字段存在着跨代指针,那就将对应的卡表的数组元素值标为1,表示这个元素变脏dirty,在进行垃圾收集时,就会很容易识别出有跨代引用的对象

写屏障

上面讲了如何使用记忆集来缩减GC Roots的扫描范围的问题,但是,卡表怎么维护呢?卡表是什么时候变脏的,怎么变脏的?

卡表什么时候变脏是很明确的,即在对象存在跨代引用,给引用对象赋值的那一刻。

而写屏障,就是用来维护卡表状态的一种技术,可以把写屏障理解成Spring的AOP即面向切面技术,但是是在虚拟机层面的,在给引用类型字段赋值的那个步骤时进行操作,而在引用类型字段赋值的时候会有一个环形(Around)通知,来给程序提供额外的动作,赋值的前后都在写屏障的范围内,所以写屏障又分为两种屏障:
写前屏障(Pre-Write Barrier)

赋值前进行

写后屏障(Post-Write Barrier)

赋值后进行

这就是一段更新卡表状态的伪代码:

void oop_field_store(oop* field,oop new_value){
	//引用字段赋值操作
	*field=new _value;
	//写后屏障,在这里完成卡表状态更新
	post_write_barrier(field,new_value);
}

在应用写屏障后,虚拟机会对所有变量赋值操作生成相应的指令,如果在写屏障中增加了更新卡表的操作后,那么不管是给新生代或者是老年代引用赋值,都会更新卡表状态,不过这个也比进行 Young GC(新生代收集)时,扫描整个老年代要好了。

除了写屏障的开销外,卡表还有一个伪共享(False Sharing) 的问题。

伪共享是处理并发底层细节时经常需要考虑到的一个问题,现代中央处理器是以缓存行(Cache Line) 为单位存储的,如果在并发时,修改互相独立的数据时,如果这些数据恰巧在同一个缓存行中,就会彼此影响(回退,无效化,同步)而导致性能降低,这就是伪共享问题。

假设一个缓存行为64字节,而卡表元素占一个字节,64个卡表元素要共享同一个缓存行,而这64个卡表元素对应的卡页为32kb(64*512),也就是说,如果在并发情况下修改这32kb范围内的数据时,会导致不同线程同时更新同一个缓存行而影响性能。

一种简单的解决方案为,不采用无条件的写屏障,而是在标记卡表元素时,先判断一下这个卡表有没有标记过,卡表更新的代码逻辑变为一下所示:

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

在jdk7以后,HotSpot虚拟机增加了一个 -XX:+UseCondCardMark,用来解决是否开启卡表更新的条件判断。

开启会产生一次额外的判断开销,但是可以解决伪共享的问题,具体要根据实际情况来判断。

并发下的可达性分析

前面说过,java主流的收集器基本上都是用的可达性分析来判断对象是否存活的,可达性分析要求对象必须在保持一致性的状态下进行分析,这也意味着进行分析时,需要暂停用户线程。

在根节点枚举中,GC Roots相比于整个堆中的对象来说占比还是很少的,最主要的是往下遍历的对象图,这就与堆容量成正比例关系了,堆越大,对象越多,对象图结构越复杂,需要暂停的时间就越长。

包含标记是所有追踪式垃圾收集器算法的共同特征,如果这个阶段会随着堆的变大而增加暂停时间,就会影响所有追踪式收集器,如果能消减部分时间,那收益将是系统性的。

想降低用户线程的暂停时间,就必须搞懂为什么要在一个能保持一致性的快照上才能进行遍历,为了理解这个问题,我们引入三色标记(Tri-Color Marking) 来作为工具进行辅助推导,把遍历对象图过程中遇到的对象,用“是否访问过”这个条件标记成以下三种颜色:

黑色
该对象已经被扫描过,而且这个对象的所有引用都已经扫描完成,它是可以存活的对象,如果有其他对象引用了这个对象,无需重新扫描一遍。(黑色对象不可能不经过灰色对象直接引用白色对象)

灰色
这个对象已经被扫描过一次了,但是他还有至少一个引用没有被扫描到

白色
还没有扫描到的对象,如果在结束时还是白色的,那说明这个对象是不可达的,可以回收

如果是暂停用户线程来进行扫描,那是没有任何问题的,但是这样显然会使用户线程暂停时间过长,如果是并发扫描呢?用户线程在收集器工作的时候改变了对象的引用关系——即改变了对象图结构,这样可能出现两种后果:

  • 原本要回收的对象被标记成了存活
  • 原本存活的对象被标记成可回收

第一个问题还可以忍受,这个对象可以在下一轮标记中被回收
但是第二个问题就是非常致命的了,程序肯定会因此发生错误,下面图演示了这个致命错误是如果产生的:

初始状态,只有GC Roots是黑色的,对象只有被标记为黑色的对象引用才能够存活,否则就会被回收

扫描过程中,以灰色为波峰的波纹从黑向白推进,灰色是黑白两色的分界线


扫描顺利完成,白色的是可回收对象。

但是如果用户线程在标记进行时并发修改了引用关系,扫描就不会如此顺利了。

譬如在波纹推进过程中,正在扫描的灰色对象的一个引用被切断了,同时原来引用的对象又与已经扫描过的黑色对象建立起了引用关系
在这里插入图片描述
又假如,这种切断后重新被黑色对象引用的对象可能是原有引用链的一部分。

由于黑色对象不会重新扫描,这将导致扫描结束后出现两个被黑色对象引用的对象仍然是白色的,这个对象就会消失,这个时候就危险了。

Wilson于1994年在理论上证明了,以下两个条件同时满足时,会产生对象消失的问题,即原本是黑色的对象被误标记为白色:

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

因此,我们要解决并发扫描时对象消失的问题,只需要破坏这两个条件的任意一个就可以了。

所以分别产生了两种解决方案,增量更新(Incremental Update)原始快照(Snapshot At The Beginning,SATB)

增量更新破坏的是第一个条件,当黑色对象引用了白色对象时,会将这个记录下来,在扫描结束后对记录中的引用从黑色对象开始,再扫描一遍。这可以简单理解为,当黑色对象插入了只想白色对象的引用后,它会变成灰色对象。

而原始快照要破坏的是第二个条件,灰色对象要删除到白色对象的引用时,将这个删除记录下来,之后再将这个记录过的引用关系中的灰色对象为根,重新扫描一次。可以简单理解为无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

以上无论是对引用关系记录的插入和删除,都是根据写屏障实现的。

在Hotspot虚拟机中,增量更新和原始快照这两个解决方案都有应用,GMS是基于增量更新来进行并发标记的。G1,Shenandoah则是用原始快照来实现的。

结尾

以上就是HotSpot虚拟机如何法器内存回收,如何加速内存回收,和如何保持回收正确性等问题了。

本文章是参考 周志明 老师所著的 《深入理解Java虚拟机》 一书来写的。更多内容可购买该书阅读,这本书超棒的!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值