从垃圾识别到收集器:详细聊聊Java的GC

个人博客

从垃圾识别到收集器:详细聊聊Java的GC | iwts’s blog

前言

聊GC,自然离不开JVM内存模型,建议先了解JVM内存模型相关内容,或者最起码了解堆相关的内容,GC主要处理的就是堆。

这里会从垃圾识别算法->GC算法->JVM 垃圾收集器,从发展演进的过程详细聊聊,内容会比较长。

垃圾识别算法

引用计数法

简单地说,就是对象被引用一次,在它的对象头上加一次引用次数,如果没有被引用(引用次数为 0),则认为此对象可回收。

看起来比较简单,只要没有变量引用这个具体的对象,那么就认为这个对象是没有用的,可以被GC回收掉。

但是这样可能会造成一个问题:循环引用。

循环引用问题

给个代码:

public class TestRC {

    TestRC instance;
    
    public TestRC(String name) {
    }

    public static  void main(String[] args) {
        // 第一步
        TestRC a = new TestRC("a");
        TestRC b = new TestRC("b");

        // 第二步
        a.instance = b;
        b.instance = a;

        // 第三步
        a = null;
        b = null;
    }
}

那么变量的引用过程:

最后导致了这样的情况,A和B互相引用,但是本质上这两个对象都应该是垃圾,因为完全没有被正经使用了。

采用引用计数法时,这个问题无解,所以目前GC算法都不会使用这个算法。

可达性算法

目前JVM一般都是采用这个方法来判断对象是否存活。

可达性算法的原理是以一系列叫做 GC Root 的对象为起点出发,引出它们指向的下一个节点,再以下个节点为起点,引出此节点指向的下一个结点。

这样通过 GC Root 串成的一条线就叫引用链,直到所有的结点都遍历完毕。

如果相关对象不在任意一个以 GC Root 为起点的引用链中,则这些对象会被判断为垃圾,会被 GC 回收。

文字可能略有难懂,看图:

等于说JVM本身维护了一个GC Roots的集合,每个GC Roots都有一条具体的引用链,只有在引用链上的对象才不会被回收。

那么对于循环引用的问题,虽然两个变量在循环引用,但是由于已经不在GC Roots的引用链上,所以依旧会被回收掉。

finalize 方法

算是一次误杀补偿的机会。当一次GC扫描中,发现一个对象没有在引用链上,那么就会先对这个对象执行一次finalize方法,看是否有GC Roots能和对象进行关联。如果有的话,就不对该对象执行GC。

但是finalize对于每个对象是只能执行一次的。如果该对象执行方法后没有被回收,那么第二次GC的时候又发现了这个对象,那么这次就不给机会了,直接回收掉。

GC Roots的选择

综上,GC Roots的选择就非常重要了。而GC Roots本身其实也是一个对象,但是这个对象的引用方,即变量,要级别够高,最好是方法/应用从最开始就出现的对象。所以GC Roots的选择有以下几种:

虚拟机栈中引用的对象

即栈帧中的本地变量表中的变量。会随着弹栈自己消失,消失前基本保证整个方法都在用。

public class Test {
    public void test() {
        Test a = new Test();
        a = null;
    }
}

变量a就是本地变量,设置为null后,这个对象在整个test方法中,就不会再用到了,所以可以被回收掉。

方法区中类静态属性引用的对象
public class Test {

    public static Test s;
    
    public void test() {
        Test a = new Test();
        a.s = new Test();
        a = null;
    }
}

由于静态属性存储在运行时常量池中,全对象可见,所以基本上可以认为整个应用存活期间,s对象都是存在的。

方法区中常量引用的对象

同上。

本地方法栈中JNI(即native方法)引用的对象

native方法是由C++实现的,Java中想要请求非Java实现的接口,就需要使用JNI来调用。

这个调用在JNI中,会产生一个引用,只有在native方法执行完毕后才会被释放,所以也适合来当作GC Roots,例如下面的jc对象:

JNIEXPORT void JNICALL Java_com_pecuyu_jnirefdemo_MainActivity_newStringNative(JNIEnv *env, jobject instance,jstring jmsg) 
{
   // 缓存String的class
   jclass jc = (*env)->FindClass(env, STRING_PATH);
}

GC 算法

GC算法的发展历程与基础

下面几个算法虽然是演进流程,但是各有优缺点,现代GC算法基本是混合下面的算法类型的,所以还是要了解。

标记清除算法

  1. 根据可达性算法标记出可回收对象
  2. 回收

简单粗暴快捷。那么一看就能发现有个比较严重的问题:内存碎片。回收是回收了,但是多次回收之后,整个内存利用率非常的低,大量的内存碎片占用空间,需要进行整理。

位图标记法

主要是标记&清除的时候到底怎么定位的问题。

将堆的内存某个块用一个位来标记。

就像我们的内存是一页一页的,堆中的内存可以分成一块一块,而对象就是在一块,或者多块内存上。

根据对象所在的地址和堆的起始地址就可以算出对象是在第几块上,然后用一个位图中的第几位在置为1,表明这块地址上的对象被标记了。

相比与标记在对象头上,通过扫描位图就可以快速清除对象,非常快速。

复制算法

复制算法算是标记清除算法的升级版,增加了整理的过程以解决内存碎片问题。

  1. 将堆分为两个区域:A&B。

    a> A负责分配对象。

    b> B啥也不干。

  2. A区域执行标记清除算法。

  3. A区域全部存活对象复制到B区域,并且是紧挨着放。

  4. 清除A区域。

这样啥事没有了。

虽然碎片没有了,但是有个大问题:50%的内存空间是被完全浪费的。

标记整理法

用时间换空间,回收后不复制,而是直接整理。从而解决复制算法的浪费空间问题。

但是性能实在太烂。

引用类型的寻找

上述的算法都是利用可达性分析,来分析这个对象是否有在引用链上,注意这个前提:对象。

当JVM触发GC的时候,在从堆上扫描的时候,怎么知道这块内存代表了啥,到底是对象数据还是对象引用,或者说看到这块内存里的一坨东西,怎么确定这个到底是哈。

其实GC扫描的不是堆,扫描堆没有意义。

比如学校大扫除,需要挑选二年级同学过来大扫除,同时排除哪些身体虚弱的同学不让他们参加。那么现在有两个队列:

  1. 学校大操场,有全部一到六年级的学生。
  2. 教导处,有全部一到六年级的各个班级班主任。

那么有两种扫描方式:

  1. 扫描学校大操场,问每个学生,是不是二年级的,身子骨咋样。

  2. 扫描教导处,问每个班主任,是不是二年级的。

    a> 然后问班主任要花名册,一个一个问这个学生身子骨咋样。

自然是第二种效率更高。

扫描大操场就好比扫描堆,而扫描教导处可以理解为扫描栈。

所以说,JVM在执行GC的时候,本质上是扫描栈,根据栈上的变量类型,来区分这个变量是不是引用,如果是引用,再执行可达性分析,看需不需要被GC掉。

那么核心在于区分变量是否是引用。根据不同的策略分为保守式 GC、半保守式 GC、和准确式 GC。

保守式 GC

JVM 不会记录数据的类型,也就是无法区分内存上的某个位置的数据到底是引用类型还是非引用类型。

所以只能靠一些条件来猜测是否有指针指向:

  1. 栈上扫描的时候看变量指向地址是否在 GC 堆的上下界之内。
  2. 是否字节对齐,从而确定是指向 GC 堆中的指针。

保守式说明判定条件比较保守,似是而非的都认为不是,忽略掉。所以不会出现误杀对象,但是可能会留下较多垃圾。

保守式 GC 只能使用标记-清除算法。因为对象都是不能移动的。

半保守式 GC

只在对象上会记录类型信息,而其他地方还是没有记录,因此从根扫描的话还是跟保守式GC一样,得靠猜测。

但是到堆内对象了之后,就能准确知晓对象中变量所包含的信息了。因此后续的链路查找都是准确的。

开始是保守,后面是准确,所以称为半保守式 GC。

半保守式 GC 中,根直接扫描的对象是无法移动的,因为保存在栈中,而后续从根对象再追溯出去的对象都在堆,所以可以移动。所以半保守式 GC 可以使用移动部分对象的算法,也可以使用标记-清除这种不移动对象的算法。

准确式 GC

准确意味 JVM 需要清晰的知晓对象的类型,包括在栈上的引用也能得知类型等。

解决方案可能有这几种:

  1. 在指针上打标记,来表明类型。
  2. 在外部记录类型信息形成一张映射表。
HotSpot解决方案

HotSpot 用的就是映射表,这个表叫 OopMap。

在 HotSpot 中,每一个对象的类型信息里,会单独记录自己的 OopMap,记录了在该类型的对象的内存上,什么偏移量上是什么类型的数据。而在解释器中执行的方法可以通过解释器里的功能自动生成出 OopMap 出来给 GC 用。

被 JIT 编译过的方法,也会在特定的位置生成 OopMap,记录了执行到该方法的某条指令时,栈上和寄存器里哪些内存地址是对象引用。

上面说的这几个记录的位置称为安全点。

OopMap记录安全点

如果对执行的每条指令都记录一个OopMap,那么开销太大了,所以是在指令执行到某个节点的时候记录一次,而这个节点的前后,最好是没有什么对象的操作,数据变更基本成型,不存在新增和删除的对象。所以称为安全点。一般是:

  1. 循环的末尾。
  2. 方法返回前。
  3. 调用方法的 call 之后。
  4. 可能抛出异常的位置。

在安全点上,对象基本趋于稳定,很少产生和删除,所以也恰好适合当作GC执行的位置。HotSpot限定在安全点才能执行GC,发生STW。具体参考下面的安全点内容。

句柄

JNI调用native方法的时候,走的是C++,所以无法获得native方法引用的OopMap。那么HotSpot本质是利用句柄(handle)包装了一层,利用句柄访问真正的对象。

此时GC不再扫描JNI的native方法栈,而是扫描句柄表。

分代收集算法

基本是现在GC首选策略了,准确说是按照一定策略,融合了上述几种算法,在空间和效率上达到平衡。

对象存活时间统计学数据

IBM的统计,98%的对象都是非常短命的,一次Minor GC就会被回收。

x轴为运行时间,y轴为GC的对象的内存大小和。

所以本质上说,大部分对象都会很快就变成垃圾,只有很少的对象会被长期使用。

堆分代

结合上面的统计学数据,JVM希望能将堆分为多个区域,让大部分对象都在小内存中不断GC,而真正常用的对象持久化存储起来,不被持续的变动。

所以分代收集算法根据对象存活周期的不同将堆分成:

  1. 新生代,1/3。

    a> Eden 区(伊甸区),8/10。

    b> Survivor 区(幸存者区),2/10。

    1. From Survivor 区,1/10。
    2. To Survivor 区,1/10。
  2. 老年代,2/3。

新生代:老年代内存比例:1:2

Eden:From Survivor:To Survivor=8:1:1

大部分对象一般都是直接在Eden区进行分配,对于实在太大的,则是直接丢到老年代分配。

不同分代上GC类型

在新生代上的GC:Minor GC,也称为Young GC。

在老年代上的GC:Full GC。

Minor GC(Young GC)

  1. 对象初始化时,一般分配到Eden区。

  1. Eden区满,触发Minor GC。

此时,进入第一次GC循环:

  1. 垃圾收集器使用具体垃圾识别算法开始识别垃圾(一般是可达性算法),并且采用具体的GC算法处理垃圾,算法的选择跟不同的垃圾收集器有关。

    a> 此时只有Eden区有数据,所以只清除Eden区。

  2. 根据上面的统计,此时绝大部分对象都直接被GC掉了,只剩下很少的对象未被回收。

  3. 剩余对象通过复制算法,从Eden区移动到Survivor区。

    a> Survivor区中,From和To区都是空的,先移到From区。

    b> 清空Eden区。

    c> 被移动的对象年龄+1。

  4. 然后继续第二次循环。

第二次循环中,前面的不说,只看GC循环:

  1. 垃圾收集器使用具体垃圾识别算法开始识别垃圾(一般是可达性算法)。

    a> 此时GC扫描的范围不仅仅是Eden区,因为此时From Surviver区也有数据,所以触发范围是Eden区+From Surviver区。

    b> 垃圾收集器采用具体的GC算法处理垃圾,算法的选择跟不同的垃圾收集器有关。

  2. 根据上面的统计,此时绝大部分对象都直接被GC掉了,只剩下很少的对象未被回收。

  3. 剩余对象通过复制算法,从Eden区移动到Survivor区。

    a> 此时,From Survivor区中有数据,而To区是空的,那么就全部移动到To区。

    b> 清空Eden区+From Survior区。

    c> 被移动对象年龄+1。

  4. 然后继续循环。

这样,两个循环,实际上是保证:

  1. 一次Minor GC,所有的存活数据都存放Survivor区的From区或者To区。
  2. 清空的时候保证From区和To区至少有一个空的。
  3. From区和To区循环存放数据。

老年代数据来源(晋升时机)

上面有聊过:对象初始化一般都是在Eden区(大对象不是,下面聊),那么老年代本身是空着的。所以自然是有一些时机,会把数据丢到老年代。可以理解为老年代中对象的来源/新生代晋升老年代的时机。

新生代对象晋升

上面流程中有个重要的细节:每次Minor GC,都会导致对象的年龄+1。

当一次Minor GC后,对象年龄+1,此时如果对象年龄超过了阈值,在执行复制算法的时候就不再放到From/To区了,而是直接晋升老年代。JVM设定了一个阈值,一般是15。

大对象内存分配

当某个对象分配需要大量的连续内存时,此时对象的创建不会分配在 Eden 区,会直接分配在老年代。

因为如果把大对象分配在 Eden 区,Minor GC 后再移动到 Survivor 区会有很大的开销(对象比较大,复制会比较慢,也占空间),并且也很快会占满 Survivor 区,所以干脆就直接在老年代创建。

Minor GC流程中晋升

在From/To区相同年龄的对象大小之和大于From/To区空间一半时,则年龄大于等于该年龄的对象也会晋升到老年代。

例如From区的大小为100MB。

此时Minor GC流程结束,当前From区有:

  1. 10MB-1岁的对象。
  2. 55MB-2岁的对象。
  3. 10MB-6岁的对象。

2岁的对象超过了From/To区的50%即50MB。

那么2岁的对象和6岁的对象,总计65MB的对象就会晋升到老年代。

所以说老年代真的都是大龄么?不一定,有可能都是一群1岁的小老弟。

空间分配担保

上面聊的,除了直接分配大对象,老年代数据的主要来源是Minor GC,那么此时就有这样的可能:Minor GC需要将100MB的对象晋升到老年代,但是老年代只剩下80MB的空间了。

这里其实有个空间分配担保机制,来处理这种情况下GC如何继续:

  1. Minor GC执行前,判断老年代中最大可用连续空间是否>新生代所有对象的和。

    a. 大于,确保GC安全,正常执行Minor GC。

    b. 小于,GC不安全,开始寻找处理策略。

    1. JVM检查HandlePromotionFailure的设置是true/false。

      a. true,开始担保流程,检查老年代最大可用连续空间是否>历次晋升到老年代的对象的平均大小。

      1. 大于,那么从概率上来说,这次大概率是安全的,正常执行Minor GC。
      2. 小于,那么从概率上来说,这次大概率是不安全的,先执行一次Full GC。

      b. false,先执行一次Full GC。

Full GC

聊full gc前先聊一个机制:Stop The Word,STW。

Stop The World

跟Dio的The World一样,全方位的暂停时间,时间还很长。

STW,即在 GC(Minor GC 或 Full GC)期间,只有垃圾回收器线程在工作,其他工作线程则被挂起。

这不仅是出于安全,也是出于方便整理。因为一边回收对象,一边分配对象,这样的管理机制太乱了,也容易产生大量内存碎片。同时此时垃圾收集器也需要获取一个一致性上下文快照来枚举所有的根对象。索性在GC期间直接全部挂起得了。

那么对于一个正常的运行情况良好的应用来说,Minor GC虽然相对频繁(但也没有那么频繁),但每次执行时间非常的短,现代CPU的性能,完全不care这点时间的阻塞。Full GC虽然慢,但是一万年执行一次,慢就慢了,忍一下就好。

但是对于有问题的应用,比如频繁Minor GC甚至Full GC,GC时间巨长,就会导致系统RT飙高乃至各种拒绝服务出现不可用了。这也是各种GC问题的主要来源。

STW安全点

由于STW会影响性能,所以我们要在一个合适的时间点发起 GC,那么结合上面的内容,GC需要使用OopMap来标记对象引用,那么这个时间点选择安全点也是恰好合适的。

这个时间点上下文基本结束,没啥对象变化,栈和堆趋于稳定,同时业务已经跑完或者处于中间停留状态,STW执行时影响相对偏小。

同时在安全点,存在JIT的OopMap和解释器生成的OopMap,能准确标记对象。

而安全点并不是一个独有概念,GC的安全点和OopMap生成的安全点也不完全一致,只能说GC安全点包括OopMap的安全点。

如果安全点太多,也会导致执行的时候check次数太多,check一次也是一个很大的开销。

编译执行时,在进入安全点时就把这个内存页设为不可访问,然后编译代码访问就会发生异常,然后捕获这个异常挂起即暂停,开始STW。

那么这时候其实对线程影响是非常大的,运行一半被挂起了。所以又提出了安全区域的概念。

安全区域

即引用关系不会发生变化的代码段中的区域。在这个区域内的任意地方开始 GC 都是安全的,这些执行到安全区域的线程也会标识自己进入了安全区域。

所以工作线程就需要执行的时候查看是否在GC,并且这些线程如果要出安全区域的时候也会查看此时是否在 GC ,如果在就阻塞等着,如果 GC 结束了那就继续执行。

Full GC流程

其实没啥好说的了,老年代没有像新生代一样划分区,就说明Full GC是比较简单粗暴的。由于Full GC对象很多,占用空间极大,所以再分区会浪费比较多的时间。

因为Full GC数量大,还用时间换空间,所以Full GC流程相对是慢很多的。

由于Full GC操作空间大,还比较慢,所以Full GC的垃圾收集器优化也是相当多的,包括也会产生“就为了这瓶醋,包了这顿饺子”的情况,例如业务后端很多情况下,就为了能用CMS来降低Full GC STW时间,就只能选择ParNew来搭配CMS。

跨代引用

上面聊的内容,现代JVM一般是根据对象存活的特性进行了分代,提高了垃圾收集的效率。

但是像在回收新生代的时候,有可能有老年代的对象引用了新生代对象,所以老年代也需要作为根,但是如果扫描整个老年代的话效率就又降低了。

这个就是跨代引用的问题。

记忆集

记忆集,Remembered Set,用来记录跨代之间的引用而避免整体扫描非收集区域。

因此记忆集就是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。根据记录的精度分为:

  1. 字长精度,每条记录精确到机器字长。
  2. 对象精度,每条记录精确到对象。
  3. 卡精度,每条记录精确到一块内存区域。

一般是到卡精度,所以记忆集也被称为卡表。

假设新生代对象 A 被老年代对象 D 引用了,那么就需要记录老年代 D 所在的地址引用了新生代对象。这是对象精度。

那卡的意思就是将内存空间分成很多卡片。假设新生代对象 A 被老年代 D 引用了,那么就需要记录老年代 D 所在的那一块内存片有引用新生代对象。

也就是说堆被卡切割了,假设卡的大小是 2,堆是 20,那么堆一共可以划分成 10 个卡。

因为卡的范围大,如果此时 D 旁边在同一个卡内的对象也有引用新生代对象的话,那么就只需要一条记录。

一般会用字节数组来实现卡表,卡的范围也是设为 2 的 N 次幂的大小:

那么在Minor GC的时候,因为有卡表的标记,所以只需要扫描卡表,将卡表节点中对应的堆内存块加入到GC Roots中进行扫描。

多卡表

非常类似计网中的子网,将卡表再用卡表记录,分成多级结构,寻址范围更大更详细一点。

卡表的维护

当引用字段赋值的时候判断下当前对象是老年代对象,所引用对象是新生代对象,于是就在老年代对象所对应的卡表位置置为 1。

不过这种将老年代作为根来扫描会有浮动垃圾的情况,因为老年代的对象可能已经成为垃圾,所以拿垃圾来作为根扫描出来的新生代对象也很有可能是垃圾。

但是这也是分代收集必须做出的牺牲。

垃圾收集器

上面的一套套算法,其实只是理论,具体的细节实现,都是由各个垃圾收集器来的,同时不同的垃圾收集器也在上面的流程中会做一些独特的性能优化,尽量减少STW对应用性能的影响。

不同的垃圾收集器有不同的特性,适合不同的应用场景,所以JVM也是提供相关参数,允许用户自主选择不同的垃圾收集器。

G1是新生代老年代都能用的,而其他的单独只在其中一个代中使用,所以需要组合。而这个组合也不是随便组的,需要适配。这些连线代表着配合,有连线说明可以配合使用。

先给个对比大纲:

SerialParNewParallel ScavengeCMSSerial OldParallel OldG1
回收哪个代新&老
GC算法复制复制复制标记清除标记整理分代
线程类型
特点简单高效Serial多线程版自适应调节策略,吞吐量大低停顿低停顿低停顿

新生代垃圾收集器

简而言之,除了G1,新生代和老年代各有三种垃圾收集器,分别三个特性:

  1. 单线程。
  2. 多线程。
  3. 可配置吞吐量的多线程,提供算法支持吞吐量的配置。

其中CMS是多线程的老年代回收器,但是算是真正意义上实现了高并发,所以和Parallel Old相比,性能要好很多,大量减少STW时间。

Serial

单线程的垃圾收集器,采用复制的GC算法。

那么它只会使用一个 CPU 或一个收集线程来完成垃圾回收,那么由于STW,在 GC 期间,此时的应用完全不可用。

看起来很垃,但是非常适合Client模式。Client模式限定单CPU,那Serial的优势就凸显出来了。

单线程处理,没有其他干扰,减小开销,性能拉满。同时Client场景下,内存一般不大,所以STW时间相对也会短很多。

ParNew

Serial的多线程版本,其他和Serial完全一致。

多线程就是快,STW时间短很多。

同时因为CMS太nb了,而想要用CMS就只能用ParNew,所以有时候也是不得已而为之,选择ParNew。

Parallel Scavenge

基本和ParNew一样,多线程,复制GC算法。

最主要的不同是Parallel Scavenge更关注吞吐量,比较适合后台计算类的服务。

吞吐量 = 运行用户代码时间 运行用户代码时间 + G C 时间 吞吐量 = \frac{运行用户代码时间}{运行用户代码时间 + GC时间} 吞吐量=运行用户代码时间+GC时间运行用户代码时间

等于说尽量让大部分时间花在运行代码上,所以可能存在多次的阻塞,但是本质上速度是快的。

同时也提供了一系列参数来控制吞吐量,会由垃圾回收器自适应控制。

老年代垃圾收集器

CMS

对于后台开发而言,算是个非常nb的收集器,详情看:CMS Full GC流程以及调优配置

Serial Old

同样也是个单线程的,所以一般和Serial搭配工作在Client服务中。

GC算法选用的标记整理。

此外有一个重要意义:作为CMS的后备预案,具体在CMS中已经有描述。

Parallel Old

Parallel Scavenge的老年代版本。以吞吐量为目标。

G1

JDK9的默认垃圾收集器,还没了解呢,未来可能会更新。

  • 22
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值