JVM学习笔记(二)——垃圾回收机制

上一节JVM学习笔记主要学习了关于JVM的介绍以及内存结构JVM学习笔记(一)——内存结构,现在更新后面的章节,本节首先介绍垃圾回收机制(GC)。(本学习笔记大多数概念都来自于《深入理解Java虚拟机:JVM高级特性与最佳实践》以及黑马家的JVM课程,感兴趣的小伙伴可以查阅相关资料)

目录

一、概述

二、哪些对象需要回收?

2.1 引用计数算法

 2.2 可达性分析算法

2.3谈谈引用

2.3.1强引用

2.3.2软引用

2.3.3弱引用

2.3.4虚引用

三、垃圾回收算法(How)

3.1 标记-清除算法

3.2 标记-整理算法

3.3 标记-复制算法

3.4分代垃圾回收

3.5跨代引用造成的问题

四、垃圾收集器

4.1 Serial收集器

4.2 ParNew收集器

4.3 Parallel Scavenge收集器

4.4 CMS收集器

4.5 Garbage First(G1)收集器

4.5.1 G1垃圾回收阶段


一、概述

        Java与C、C++等语言的最大不同就是不需要自己手动回收对象,而是使用JVM提供的垃圾收集(Garbage Collection,简称GC)。其具动态分配内存和垃圾收集奇数,故垃圾收集需要完成三件核心的事情:

  • 哪些对象需要回收?(who)
  • 什么时候进行回收?(when)
  • 如何回收?(how)

二、哪些对象需要回收?

        Java堆中存放着很多对象实例,那么垃圾收集器就是对堆中“死去”的对象进行回收,那么如何判断对象“存活”还是“死亡”是一个值得研究的事情。也是如题所提问的:哪些对象需要回收?

2.1 引用计数算法

        引用计数算法判断对象是否存活是这样的:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就+1;当引用失效时,计数器值就-1。当某对象的计数器为0,就意味着该对象已经“死亡”。

        客观来说,引用计数算法虽然占用了一定额外的内存空间来奇数,但其原理简单,效率也不错,所以在大多数情况是一个不错的算法。但是,其有一个致命的缺陷:循环引用问题。如以下代码所示:

public class Test{
    public Object instance = null;

    public static void testReferenceCounting(){
        Test A = new Test();    //A对象
        Test B = new Test();    //B对象
        A.instance = B;    //A对象的属性instance指向B
        B.instance = A;    //B对象的属性instance指向A
        
        A = null;    //将A置为空
        B = null;    //将B置为空
    }
}

        如上述代码所示,此时如果使用引用计数算法,A和B彼此的引用计数都为1(互相引用),故不会被回收,但是其对象实际上已经是null了,应该被回收。这就是循环引用的问题。

 2.2 可达性分析算法

        Java、C#等语言中,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活。这个算法的基本思路为:

        通过一些列成为"GC Roots"的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径成为“引用链”,如果某个对象到GC Roots之间没有任何引用链项链(用图论的话来说就是GC Roots到该对象不可达时),则证明此对象不可能再被使用。

        在Java中,GC Roots对象通常包括以下几种情况:

  • 虚拟机栈中引用的对象,eg:各个线程中被调用方法栈中的局部变量
  • 方法区中类静态属性引用的对象,eg:Java类的引用类型静态变量
  • 方法区中常量引用的对象,eg:字符串常量池里的引用
  • 在本地方法栈中JNI引用的对象
  • Java虚拟机内部的引用,eg:基本数据类型的Class对象
  • 被同步锁(Synchronized关键字)持有的对象

2.3谈谈引用

        在Java中,共有四种引用:强引用、软引用、弱引用、虚引用

2.3.1强引用

        强引用指的是最传统的“引用”定义,类似于Object obj = new Object();

        只有所有GC Roots对象都不通过【强引用】引用该对象,该对象才能被垃圾回收。

2.3.2软引用

        软引用指的是描述一些还有用,但非必须的对象。JDK1.2使用SoftReference类实现。

        被软引用关联的对象,在系统内存不足时,会进行第一次GC,在该GC后如果内存仍然不足,才会回收软引用所指向的对象。(即逃过第一次GC)

2.3.3弱引用

        弱引用和软引用类似,也是描述一些非必需的对象,但是其程度更弱一些。JDK1.2后使用 WeakReference类实现。

        弱引用关联的对象只能生存到下一次GC,即无论内存是否充足,在垃圾回收时都会回收弱引用对象。

2.3.4虚引用

        虚引用是最弱的一种引用关系。

        一个对象是否有虚引用的存在,不会怼契生存时间构成影响。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时受到一个系统通知。其应用场景在上一节中的直接内存中有提到。如JVM是无法直接释放直接内存的。

        ByteBuffer的实现类内部,使用了Cleaner(虚引用)来检测ByteBuffer对象,一旦ByteBuffer对象被垃圾回收,那么就会由ReferenceHandler线程通过Cleaner的clean方法调用freeMemory来释放直接内存(ByteBuffer是可以被GC的,通过Cleaner虚引用来检测ByteBuffer是否被回收,可以通过clean方法去间接调用freeMemory来释放直接内存)

三、垃圾回收算法(How)

        在JVM的垃圾收集器中,大多数都遵循了“分代收集”理论,其建立在三个分代假说上:

  • 弱分代假说:绝大多数对象都是朝生夕灭的
  • 强分代假说:熬过越多次垃圾收集过程的对象就越难消亡。
  • 跨代应用假说:跨代引用对于同代引用来说仅占少数

 这两个分代假说奠定了一种设计原则:收集器应该将Java堆划分为不同的区域:新生代、老年代。显而易见,如果一个区域大多数对象都是朝生夕灭的,那么就把它们集中在一起,每次回收时只关注如何保留少量的存活不去关注大量将要被回收的对象,就能以较低代价回收大量空间。而对于难以消亡的对象,就把它们集中放在一起,以较低频率去回收这个区域,就能同时兼顾垃圾回收的时间和内存的空间有效利用。

        如上,因此有了"Minor GC"、“Major GC”、“Full GC”。其中Minor GC只针对于新生代,Major GC只针对于老年代(目前只有CMS收集器回单独收集老年代的垃圾),Full GC是收集整个Java堆和方法区的垃圾。

        对于垃圾回收的算法,主要分有:“标记-复制算法”、“标记-清除算法”、“标记-整理算法”

3.1 标记-清除算法

        标记-清楚算法如名字一样,分为标记清除两个阶段:首先标记出所有需要回收的对象(也可以标记存活对象);最后统一回收掉所有被标记的对象(或回收所有没被标记的对象)。

优点:速度快

缺点:造成内存碎片,会导致程序再分配大对象时,无法提供足够的连续内存(如数组)

3.2 标记-整理算法

        标记-整理算法与标记-清除算法的大致过程一样,但后续清除过程不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动(整理),然后直接清理掉边界意外的内存。如图所示:

 优点:没有内存碎片,提升系统吞吐量

 缺点:时间慢(在移动对象时,会造成STP(Stop the World))

 故关注系统吞吐量的垃圾收集器Parallel Scavenge是基于标记-整理算法的

    关注系统延时的垃圾收集器CMS则是基于标记-清除算法的。

3.3 标记-复制算法

        标记-复制算法简称为复制算法,其为了解决标记-清除算法面对大量可回收对象时执行效率低的问题。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另一块上,然后再把原来使用过的内存一次全部清理掉。其适用于对象存活少的情况,因为复制的开销就会比较少。

 优点:不会有内存碎片

 缺点:需要占用双倍的内存空间

JVM大多都优先采用复制算法去回收新生代(朝生夕灭,存活少)

3.4分代垃圾回收

        前面说过,根据三种假说,提出了将内存区域进行分代的理论,现详细介绍分代垃圾回收

  • 对象首先分配在伊甸园区域
  • 新生代空间不足时,触发 Minor gc,伊甸园和 幸存区from 存活的对象使用 复制算法复制到 幸存区to 中,存活的对象年龄加 1并且交换 from to(保持to是空)
  • Minor gc 会引发 stop the world,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行
  • 当对象寿命超过阈值时,会晋升至老年代,最大寿命是15(4bit)
  • 当老年代空间不足,会先尝试触发 Minor gc,如果之后空间仍不足,那么触发 full gc,STW的时间更长

3.5跨代引用造成的问题

        在之前有提到过三个假说,其中第三个假说:

跨代应用假说:跨代引用对于同代引用来说仅占少数。

        比如,如果某个新生代对象存在跨代引用,即有老年代对象引用指向该对象,但其实随着老年代对象难以消亡,新生代对象也会得以存活,直至新生代对象在年龄增长后也晋升到了老年代中,这时候跨代引用问题也消除了。

        但是在我们在Minor GC时,扫描某个存在跨代引用的新生代对象时,会因为跨代引用去扫描整个老年代。而JVM的做法是在新生代建立一个全局的数据结构——记忆集(Remembered Set),而通常的记忆集使用的时卡表(Card Table),这个结构将老年代划分为若干块,标识老年代的哪一块内存存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会加入到GC Roots的扫描中。

Card Table示意图如下:

 卡表最简单的形式可以是一个字节数组,每个数组元素对应一个内存区域中一块特定大小的内存块,这个内存块成为卡页,也就是之前说的将老年代分成若干块。每个卡页内存为512字节。

一个卡页的内存通常包含不止一个对象,只要卡页内有一个或多个对象的字段存在跨代指针就会将卡表数组的元素就标识为1,成为脏卡(Dirty card),没有则表示为0。在GC时,之筛选出卡表中变脏的元素对应的卡页,不会将全部卡页加入到GC Roots中一并扫描。

四、垃圾收集器

        第三章介绍了很多垃圾回收算法,而本章所讲的垃圾收集器就是这些算法的具体实践者。本章主要介绍几种经典的垃圾收集器,如Serial、ParNew、Parallel Scavenge、CMS、G1收集器

4.1 Serial收集器

        Serial收集器时最基础的收集器,其是一个单线程工作的收集器。其单线程体现在在进行垃圾收集时,不仅仅只使用一条线程进行垃圾回收,且必须暂停其他所有的工作线程,直到它收集结束

 优点:内存消耗少,适合单核处理器

 缺点:单线程,经常需要阻塞等待垃圾收集

4.2 ParNew收集器

        ParNew收集器实质上是Serial收集器的多线程并行版本(并行,不是并发),它可以使用多条线程进行垃圾收集,其他和Serial收集器完全一致。

 它是新生代首选的收集器,可以与CMS收集器配合工作,这也是其成为新生代首选收集器的原因。CMS的出现巩固了ParNew的地位。

4.3 Parallel Scavenge收集器

        Parallel Scavenge收集器也是一款新生代收集器,它是基于标记-复制算法实现的,同时也能够并行执行垃圾收集。其与ParNew非常相似,但是它的关注点和ParNew不同。Parallel Scavenge关注的是达到一个可控制的吞吐量

吞吐量=(运行用户代码时间)/(运行用户代码时间+运行垃圾收集时间)

高吞吐量可以最高效率地利用处理器资源,尽快完成程序的运算任务。

Parallel Scavenge的老年代版本是Parallel Old,其支持多线程并发收集,基于标记-整理算法

4.4 CMS收集器

        CMS(Concurrent Mark Sweep)收集器与Parallel Scavenge不同,它是以获取最短回收停顿时间为目标的收集器(以响应时间优先),其从名字也能看出它支持多线程并发运行,并且是基于标记-清除算法的

        CMS的运作过程比较复杂,整个过程分为四个步骤:

  1. 初始标记(需要STW):CMS的初始标记仅仅只是标记一下GC Roots能直接关联到的对象,整个过程中虽然是需要stw的,但是因为标记对象很少,所以速度很快,可以近似没有停顿。
  2. 并发标记:CMS的并发标记是从初始标记标记过的GC Roots直接关联的对象开始,遍历整个对象图的过程,这个过程耗时较长,但是不需要停顿用户的线程,可以进行并发运行,所以不会进行停顿。
  3. 重新标记(需要STW):重新标记是为了修正并发标记期间,因为用户线程可以和垃圾收集线程并发运作而导致的标记产生变动的那一部分对象的标记记录。这个阶段的stw通常比初始标记要长,但也远比并发标记阶段的时间短(而并发标记不暂停其他线程,所以不会停顿)
  4. 并发清除 :并发清除会清理删除掉标记阶段标记为死亡的对象,这个过程也是与用户线程进行并发的。所以也会很快。

        可以看出,CMS整体上最耗时的并发标记和并发清除阶段,垃圾收集线程都可以和用户线程一起工作,所以从总体上,CMS收集器的内存回收过程始于用户线程一起并发的,所以是一款:并发收集、低停顿的收集器。

        但同时,其也有一些不足

  • CMS收集器无法处理“浮动垃圾”。有可能出现并发失败,导致一次完全的STW的Full GC

        在CMS的并发标记和并发清除阶段,虽然其可以和用户线程进行并发运行带来效率上的好处,但也会造成一些隐患:并发时,用户线程有可能会造成新的对象回收,此时新的回收对象无法进行标记,因此CMS无法在当次收集中处理掉它们,这部分垃圾就称“浮动垃圾”

        解决方案是CMS不能像其他收集器一样,在老年代快满时才进行垃圾回收,必须预留一部分空间提供给并发收集时可能产生的浮动垃圾。但同时也会有风险:当预留的这部分空间无法满足程序创建新对象(大对象)的需要,此时就会出现“并发失败”,从而导致虚拟机将会冻结用户线程的执行,启用Serial Old来重新进行老年代的收集,停顿时间就非常长了。

  • CMS是一款基于“标记-清理”的收集器,所以其也会有标记-清理算法所带来的弊端——内存碎片,当经历了过多的标记-清理后,会产生大量的内存碎片,导致虽然内存足够,但是无法创建大对象的情况,此时也会不得不提前触发一次Full GC

4.5 Garbage First(G1)收集器

         相比于CMS这种追求低延时的垃圾收集器,G1收集器是一款可以指定预期的延时时间的同时保证吞吐量的一款垃圾收集器。

        同时G1收集器摒弃了之前对内存进行分代分区的思想,虽然在执行过程中还是区分新生代、老年代,但是只是逻辑上的新生代、老年代。在物理上G1将内存分成了一个个Region,每个Region都有可能成为新生代(Eden、Survivor)和老年代。而相同代之间不一定是连续的内存空间,这样更容易让收集器对不同的Region采用不同的策略处理,有计划地对整个Java堆全区域垃圾收集。G1回去追踪各个Region里面垃圾堆积的“价值”,这也是Garbage First名字的由来。

Region还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过一个Region的一半即可认为是大对象。

G1在整体上是标记-整理算法,区域之间是复制算法

4.5.1 G1垃圾回收阶段

 (1)Young Collection(新生代垃圾回收)

内存在初始时,会有一些类加载等情况,使得出现Eden区域:

 当Eden区域的内存逐渐每占满时,就会触发Young Collection,这时会创建一个幸存区Survivor,然后通过复制算法,将Eden区域的幸存对象通过复制算法,复制进Survivor区域

 当幸存去Survivor的内存逐渐也要占满时,会再次触发一次Young Collection,这时候会将原来幸存区中的对象分到两个部分:对于年龄比较大的对象,会将其复制到老年区,对于年龄还不够的对象,会将其复制到新的一个Survivor区域,与此同时其他Eden区域也会通过复制算法将幸存对象复制到这个新的Survivor区域:

在这个Young Collection过程中,会进行STW,并且会对GC Root进行一个初始标记  

(2)Young Collection + CM(concurrent mark并发标记)

        上述说到,Young Collection过程中,会触发对GC Root的一个初始标记,会有一次STW。

到了第二个阶段会加入一个CM并发标记的操作:当老年代占用对空间比例打到阈值时,会进行并发标记(不会STW):

 (3)Mixed Collection

        在这个阶段会对E、S、O进行全面的垃圾回收,对于E和S来说,还是上述的回收机制,当要对老年代进行回收时,会有选择的去选择回收价值最高的老年代进行回收,并且回收也是使用的复制算法复制到一个新的老年代中。

 在这个过程中会有最终标记筛选回收两个过程。

上述讲的是G1回收的各个阶段,下图表示的是整个过程 

  • 初始标记:仅仅只是标记一下GC Roots能直接关联到的对象。这个阶段需要 停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
  • 并发标记:从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆 里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以 后,还要重新处理SATB(原始快照)记录下的在并发时有引用变动的对象。
  • 最终标记:对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留 下来的最后那少量的SATB记录。
  • 筛选回收:负责更新Region的统计数据,对各个Region的回 收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region 构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧 Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。
CMS与G1优缺点对比
G1
优势

1.可以指定最大停顿时间,分Region内存布局更能计划回收区域内存

2.G1整体是标记-整理的,局部上是复制算法的,所以不会造成内存碎片

劣势

1.就内存占用来说,G1和CMS都是用卡表来处理跨代指针,但是G1的卡表是双向卡表(本质是哈希表,其中key是别的Region(指向自己),value是一个集合,里面存储的是卡表的索引号)。并且无论是新生代还是老年代,都要有一份卡表。

2.就执行负载来说,CMS需要用写后屏障来更新维护卡表。而G1因为其复杂的卡表结构,其维护成本更高。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值