JVM(三)图解垃圾回收

当问到垃圾回收的时候该怎么去回答?为什么那么多面试官喜欢从如何判断对象应该被回收开始问?
JVM(三)图解垃圾回收_第1张图片
Java和C++之间永远隔着一道由自动内存管理和自动垃圾回收筑起来的高墙,里面的人想出去,外面的人想进来。——《深入理解Java虚拟机》

Java语言是一门自动进行垃圾回收的语言

垃圾回收(GC) 指的是:对象在使用完毕之后,在应用系统之后的运行过程中,不会再使用到这个对象,那么这个对象就应该被回收掉,让出它所占用的内存空间给将要被创建的对象。

在一个应用系统中,创建对象的动作时时刻刻都在发生,每一次查询,每一次请求,每一次后台接口的调用都会创建出很多很多的对象去支撑这次动作的触发。在有限的内存空间中,使用完毕之后不清理这些对象,后果是显而易见的。

而回顾C++语言,每一个类的创建都伴随着构造函数去分配内存空间,对象的消亡都有析构函数去回收对象占用的空间。好处很明显,不用担心内存泄露,内存溢出等问题,坏处也很明显,若不是对整个业务的熟悉,在不该销毁的时候销毁了对象,那么也会导致应用中导出都是空指针异常,而手动处理这些对象的回收工作也是一个很庞大的工程。而自动的内存管理是不是一件好事呢?看完本文相信会有答案的。
其实自动垃圾回收的概念的诞生时间远远早于Java语言的诞生。而发展到今天,JVM能将垃圾回收的停顿时间控制在10毫秒之内,近乎已经达到了极致。而这10毫秒有多么的来之不易,在设计一个自动垃圾回收平台的道路上,究竟充满了多少的坎坷!

首先,什么样的对象需要被回收!

答案是很简单的,不使用了就需要被回收,而怎么才能找到这些要被回收的对象呢?
两种办法,一种是引用计数器。另外一种是可达性分析。
引用计数器:就是如果还有某一个对象存在着当前对象的引用,那么当前对象就不会回收,在当前对象中额外的维护一个计数器,当有其他对象使用到自己的时候就计数器加一,使用完毕之后计数器减一,垃圾回收的时候只要检查所有对象,找到所有计数器数据是0的对象回收掉即可。因为无法解决循环引用的问题,也就是两个对象在今后的应用中都不会再使用到,但是他们两个对象彼此存在引用,采样这种算法就会导致有些本应该被回收的对象不会被回收。存在缺陷,目前已经不再采用。如下图左边的DE两个对象。
可达性分析:选择一些会长时间存在内存中的对象,然后遍历这个对象的引用链路,然后回收堆内存中那些不在这个引用链路上的对象即可。如下图右边,一个DE对象没有在GC Root的引用链路上,所以被标记为不可达,这样就很好的解决了引用计数器的循环引用的问题。
JVM(三)图解垃圾回收_第2张图片

那什么样的对象可以作为GC Root呢?

一般来说,在将来的一段时间中,会长时间存在于整个系统应用中的对象作为GCRoot是比较合适的。因为JVM会给每一个线程开辟一份内存空间,所以每一个线程里面,当前正在运行的方法里面用到的一些方法作为GC Root也是可以的。Java并发编程中的锁对象也是可以作为GC Root的。具体的就不再一一列举了,大概有以下:
1、栈帧中的本地变量表中的引用对象(参数,局部变量,临时变量)
2、方法区中类静态属性引用的对象(Java类的引用类型静态变量)
3、在方法区中常量引用
4、本地方法栈中的JNI(native方法)引用的对象
5、Java虚拟机的内部引用(常驻异常对象,系统类加载器)
6、被synchronized关键字持有的对象
7、反映虚拟机情况的JMXBean,本地代码缓存等
这些对象可以作为可达性分析的根节点集合,叫做GC Roots

能作为GC Root的对象那么多,怎么才能有效的收集这些对象呢?

确实,因为满足在未来一段时间之内长时间存在于应用中的对象太多太多了,不可能遍历这些区域的全部对象信息,这样是根本不可能做到的。并且在根节点枚举(也就是统计GC Roots)这个过程中是必须要暂停全部的用户线程的,如果遍历怎么可能做到只停顿10毫秒,JVM采用在类加载阶段就预先处理这些对象信息的办法。
当类加载的动作完成之后,虚拟机会把对象引用的信息放到OopMap的一个数据结构中,这样收集器在扫描的时候就知道这些引用了,不需要一个不漏的从方法区等GC Roots开始查找。

OopMap 记录了方法执行时候用到的本地变量到堆上对象的引用关系,也就是只要触发了垃圾回收,那么只要去找正在运行的线程的这个OopMap集合就能快速找到能作为根节点的GC Root了。
JVM(三)图解垃圾回收_第3张图片

只要GC触发,就去线程上找GC Roots对象,这样靠谱吗?

是啊,确实会有一些情况导致GC Roots不是我们期望的那种会长时间存活的对象,当时比起扫描整个方法区,扫描整个堆空间里面的全部对象来说,这已经是很靠谱的办法了,有些对象只能等待下一次垃圾回收的时候再去处理啦,虚拟机规定,默认情况下,只有一个崭新的对象熬过了15次垃圾回收,你才能真正的被虚拟机仍可为“长时间存活的对象”而转移到老年代中,成为委以重任的对象,但是不排除还有被回收的可能性!
但是这还不足以让我们相信JVM有可靠的垃圾收集机制!JVM会让线程跑进入安全点再去进行垃圾回收!
安全点:线程中会长时间执行的字节码片段,能作为安全点的地方有循环,方法调用等。
也就是当JVM在进行暂停用户线程进行跟节点枚举的时候,会通知用户线程跑进这些代码块里面,然后才进行跟节点的枚举。这样的好处就是,统计的GC Root对象是值得信赖的!这样在可达性分析的时候,就会扫出更加准确的对象去回收。
但是这样也有额外的开销,那就是每一个线程需要去不断的监听是否收到了垃圾回收的动作。
如下图,因为有了安全点和安全区域,GC线程在进行跟节点枚举的时候,就靠谱了许多。
JVM(三)图解垃圾回收_第4张图片
到这里,解决了找那些对象作为跟节点进行可达性分析的问题。接下来就是解决如何找到堆内存中根据这些根节点分析之后不可达的对象的问题。但是,随着就是许多问题就接踵而来。

首先需要分析一下对象生死的性质

在实际的应用中,其实绝大部分的对象是朝生夕死的,并且熬过了越多次数的垃圾回收,就代表这个对象其实是越难死亡的。这是垫定虚拟机分代垃圾回收机制的两条重要假说。
这两个假说决定了很多垃圾收集器的设计理念和统一原则:应该将堆划分成不同的区域,将对象依据年龄分配到不同的区域中进行存储。 这样,同一个区域中的对象年龄就大概是相同的,这样做的好处是:
1、如果同一个区域的对象基本都是朝生夕死,集中放在一起,就只需要关注保留少量的存活,不去标记那些大量会被收集的对象。
2、如果同一个区域的对象基本都是难以消亡的对象,集中在一起,只需要使用较低的评率来回收这个区域的对象即可。同时兼顾了内存开销和时间开销。
也就意味着,把堆空间按照存储对象死亡的难易进行空间划分,这样在垃圾回收的时候,就能避免每次都回收整个堆空间。
JVM(三)图解垃圾回收_第5张图片
但是这些概念还不足够解决复杂的垃圾收集的问题,因为会存在着跨代引用的问题。新生代对象被老年代引用,老年代也会被新生代引用。难道要去扫描整个老年代或者新生代?
所以有了第三个跨代引用假说:跨带引用相对于同代引用来说仅仅占极少数。隐含的意思就是,互相引用的两个对象,应该是倾向于同时生存或者同时消亡。
比如:某个新生代的对象引用了老年代对象,新生代的对象会在收集的时候得以存活,随着年龄的增长也来到老年代中。利用这条假说,在新生代上建立一个全局的记忆集结构,这个结构把老年代划分成若干个小块,标识出哪一块内存会存在跨带引用。
当发生了新生代的垃圾回收的时候,只需要把那些被标记了有跨带引用的老年代中对象作为GC Root即可。 这种方法需要在对象改变了引用之后,维护记录数据的正确性,会增加一些额外的开销,但是比起全部扫描整个老年代来说,是很划算的。


有了这些理论基础,下面这些专业概念应该是轻松理解:
部分收集:不是对整个堆内存进行垃圾收集。
新生代收集:Minor GC / Young GC
老年代收集:Major GC / Old GC
混合收集: Mix GC 收集整个新生代和部分老年代,目前只有G1收集器有这种行为
整堆收集:Full GC

记忆集和卡表

为了解决跨代引用,在新生代中建立了记忆集的数据结构,用于避免把整个老年代加进GC Roots扫描范围。
记忆集:一种用于记录从非收集区域指向收集区域的指针集合。这种记录的成本在空间占用和维护上都是很占据成本的。 收集器只需要判断这个区域是否有指向收集区域的指针就可以了,细节东西不需要关心。一种每一个记录都精确到一块内存区域,该区域内有对象含有跨带指针叫做卡表。
卡表:(本区域的一块内存中,有对象持有着指向收集区域的引用)卡表是一个字节数组,每一个元素都对应着标识内存区域一块特定大小的内存(卡页)。一个卡页的内存中通常包含不止一个对象,只要卡页内有对象的字段存在着跨代指针,就把这个卡表的数组元素的标识值为1,称为这个元素变脏。没有标识的为0。

总结起来就是:有其他分代区域中的对象引用了本区域的对象时,其他分代对象对应在卡表的指针元素就会变脏。
在发生了垃圾收集,只要筛选出卡表中变脏的元素,就能知道哪些卡页内存块中包含跨带指针,把他们加入GC Roots中一并扫描即可。
JVM(三)图解垃圾回收_第6张图片

写屏障

写屏障是维护卡表的重要工具,因为当对象引用发生改变的时候,必须让虚拟机能把对应分代对应卡表的对应数据变脏,这样在垃圾回收的时候才能极大的提升效率。但是为什么又有了写屏障呢?

如何在对象赋值的时候去更新维护卡表。其他分代的对象引用了本区域的对象的时候,卡表上对应的元素就应该变脏。 假如是字节码,虚拟机完全可以处理。但是即时编译器编译之后的产物已经是纯粹的机器指令,虚拟机不可能介入其中。
写屏障相当于对引用类型复制这个动作进行的一个环绕通知。一旦收集器在写屏障中增加了对卡表的操作,只要更新了引用,就会产生额外的开销。不过这个跟扫描整个分代的代价比起来还是可以接受的。
JVM(三)图解垃圾回收_第7张图片
处理完毕跨分代的引用,接下来就开始进行可达性分析了!

如果在可达性分析的过程中,用户线程改变了对象的引用怎么办?

目前JVM提供的垃圾收集器,有些是在标记对象是否可达(标记)阶段暂停全部的用户线程的,但这也就导致暂停的时间比较长。也有一些垃圾收集器采取的是并发标记的办法。也就是不停止用户线程,也不停止GC线程,多个用户线程和多个GC线程同时运行。这就会产生很严重的后果!
虚拟机采取的是三色标记办法:
白色:没有被垃圾收集器访问。刚开始的时候所有对象都是白色的。
黑色:已经被垃圾收集器访问,并且这个对象的所有引用都已经扫描过。它是安全存活的。
灰色:对象被垃圾收集器访问,但是这个对象存在的引用没有扫描完毕。
可以看成是灰色是波峰的波纹。
JVM(三)图解垃圾回收_第8张图片

1、最直接的影响就是,当标记清除的速度赶不上对象分配的速度,这不就是偷鸡不成蚀把米吗,JVM就采取了多种处理办法,一种是当并发标记清除的时候,无法为对象分配内存空间,就只能暂停全部的用户线程去进行垃圾的清理。另外一种办法是提供一个阈值参数,比如说当新生代使用了68%之后就触发垃圾回收。

2、更加危险的影响就是,由于用户线程的存在改变了标记的状态。
如果原来可达的对象不可达,那完全可以接收,这种对象叫做浮动垃圾。下一次垃圾回收就没有那么好的运气了。分析出现的原因只有两个:
赋值器插入了一条或者多条从黑色对象到白色对象的引用。
赋值器删除了全部从灰色对象到该白色对象的直接或者间接引用。
只要破坏这两个条件中的一个就可以解决并发扫描时候对象消失问题。

增量更新:破坏的是第一个条件。当给黑色对象插入白色对象引用的时候,就记录下这个插入的引用记录下来,等扫描结束,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。
原始快照:破坏第二个条件。当灰色对象要删除指向白色对象的引用关系的时候,就将这个要删除的节点的引用记录下来,在扫描结束之后,将这些记录过引用关系的灰节点为根,重新扫描。
知识整理到这里,已经能获取到那些对象需要进行垃圾回收了,接下来就是怎么去回收这些对象的问题了。也就是常见的垃圾回收算法。

标记-清除算法

先标记需要收集的对象,回收所有被标记的对象。或者也可以标记存活对象,回收所有没有被标记的对象。
这种算法有两个主要缺点:
1、执行效率不稳定。有时堆中的大量对象需要被回收,对象的数量决定了执行的效率。
2、空间碎片化。会产生大量不连续的内存碎片。若后面有大对象被创建,就不得不再一次进行垃圾回收,间接导致GC频繁发生。
JVM(三)图解垃圾回收_第9张图片

标记-复制算法

准确的说是半区复制,把实际容量划分为相等的两份,把存活下来的对象赋值到另外一个半区上,然后把已经使用过的内存空间清理掉。
如果内存中的大部分对象在GC Root之后大部分是存活的,就会产生大量的复制开销。
如果内存中只有少部分是存活的,就能很好的解决内存碎片问题。
优缺点是很明显的,实现简单,运行高效。缺点就是实际可用空间只有一半。
这种算法被运用在具有“朝生夕死”的新生代上,IBM公司的研究显示,98%的对象都熬不过第一轮的垃圾收集。所以根本不需要1:1的比例。
JVM(三)图解垃圾回收_第10张图片
1989年,出现了一种更加优化的半区优化策略——Appel式回收。HotSpot虚拟机的Serial、ParNew收集器都是采用这种思想。
具体做法是:把新生代划分成一块比较大的区域Eden区(伊甸园区)和两块比较小的Survivor区(幸存者0区,幸存者1区),当幸存者区域不足以存放一次Miner GC之后幸存对象的时候,就需要依赖其他内存区域进行分配担保(逃生门安全设计)。
对象只分配在伊甸园区和其中的一个幸存者区,发生垃圾收集的时候,把存活的对象一次性复制到另一块幸存者区域,然后清理到伊甸园区和已经用过的那块幸存者区域的内存空间。
HotSpot虚拟机的伊甸园区和两块幸存者区域的比例是 8:1:1

标记-整理算法

标记过程都一样,后续的步骤不是直接对可回收对象进行清理,而是把所有存活对象往内存空间的一端移动,然后清理掉边界以外的内存空间。
但是,移动对象并且更新引用将会是一项极为负重的工作。而且最关键的是移动的过程必须暂停全部的用户线程,stop the world。

权衡一下:如果使用标记清除,那么产生的内存碎块只能依赖于复杂的内存分配来解决,但是一个系统创建对象是及其频繁的过程,这样势必导致系统的吞吐量下降。
如果使用标记整理,那么就会存在对象的移动,在垃圾收集的时候,系统会发生停顿。
HotSpot虚拟机中,专注于吞吐量的Parallel Scavenge收集器采用的是标记整理算法。
专注于低延迟的CMS收集器则是基于标记清除算法。

JVM(三)图解垃圾回收_第11张图片
下一篇计划讲解HotSpot虚拟机中的各种垃圾收集器。
本文全是自己看书理解之后绘制的图,如果理解有偏差,还望纠正。

你可能感兴趣的:(JVM)