垃圾收集(GC)起源于Lisp,远比Java的历史更久,它主要思考了三件事情:
本章就根据这三个点进行分析。
在java堆中存放着无数的对象,有些对象任然在使用,有些对象已经死去(不被任何途径所使用),我们需要回收的便是这些死去的变量。
引用计数
有一种判断对象是否存活的办法便是引用计数法,它在对象内存中开辟出一块区域,当此对象有其他对象引用之时,该区域的引用计数器便加1,当失去一个引用时,便减1,当引用计数为0时,代表着该对象处于无用的状态了。
然而,引用计数却不是jvm中用于判断对象是否存活的方法,原因就是使用引用计数还需要许多其他的工作要做,因为单纯的引用计数会有很多问题,最常见的便是循环引用,既 A->B->A,这导致了a和b的引用计数永远不会为0,对象永远也无法回收。
可达性分析
java采用可达性分析的方法判断对象是否存活,具体方法是:通过一系列"GC roots"作为起始节点集,从这些节点根据引用关系向下搜索,形成一条引用链,如果一个对象不在引用链中,则可以认为该对象已死亡。
可达性分析的关键就是GC Roots,具体GC Roots有:
大部分的虚拟机的垃圾收集器,都遵循了分代收集的理论进行设计。分代收集建立在两个分代假说之上:
弱分代假说:绝大部分对象都是朝生夕死。
熬过越多次垃圾收集过程的对象就越难消亡。
这两个假说奠定了多款垃圾收集器的一致设计原则:将Java堆划分成不同的区域,然后根据对象的年龄(熬过垃圾回收的次数)分到不同的区域中存储。如果一个区域中的对象大部分都是朝生夕死,那么只要将其中少量需要存活的对象标记即可实现清理,如果一个区域都是不容易回收的对象,那么就可以降低回收频率。根据不同的区域回收,出现了Minor GC、MajorGC、Full GC。
根据上文,现代商业虚拟机里,一般会把内存划分为年轻代和老年代。然而实际情况中,对象的相互引会存在跨区引用,单独对一个区域进行GC时,任然会出现不少问题,不得不额外遍历整个区域,于是添加第三条经验法则:
跨代引用假说:跨代引用相对于同代引用来说仅占少数。
依据这条假说,只需在新生代上建立一个全局的数组结构(记忆集)这个结构把老年代划分为若干小块,标示出那一块会发生跨代引用,当发生Minor GC时,只有包含了跨代引用的小块内存才会被加入到GC ROOTS进行扫描。
根据不同内存区域中对象的特性,虚拟机采用不同的清理算法:1、标记-清除法 2、复制算法 2、标记整理法。
标记-清除算法
标记所有需要回收的对象,然后统一回收掉被标记的对象。
缺点:1:效率低,大量对象需要标记。2:内存碎片化严重
标记-复制算法
将内存分为两个区域,每次只用其中一块,当内存用完需要回收时候,将存活的对象统一复制到第二块区域,第一块区域可以直接清空。
优点:回收内存只需要移动指针就行,清除效率高,同时解决了碎片化的问题。
缺点:内存只有一半可以用,浪费严重。
改进:根据使用经验,大部分对象是朝生夕死,于是将内存分为三块区域,一块Eden两块Survivor,比例是8:1:1。每次只是用Eden和一块Survivor,当需要回收时候,将Eden和Survivor中的对象复制到另一块Survivor,然后清空剩下的区域。这样只有10%的内存被浪费了,兼顾了效率和空间。
标记-整理算法
标记复制算法如果想要百分百完全,必须要用50%的内存做备份,如果按照8:1:1分配,在遇到极端情况下,会出现大部分内存不需要回收从而导致内存不够的问题,对于老年代,这个问题比较常见,于是采用标记整理算法:在标记的基础上,将所有存活的对象向内存空间的一端移动,然后直接清理掉区域外的所有内存。
优点:没有碎片化的问题
缺点:移动整理内存时,必须停止一切用户活动,必须要谨慎考虑使用。
想要减少移动时间,需要使用链式结构,但是会带来更多复杂度,可以看出,无论是否移动对象都存在弊端,移动则回收内存时会更复杂,不移动,则碎片化严重,分配内存时候复杂。具体要根据虚拟机关注的重点来选择。
可达性分析的垃圾标记有两个环节 GC roots枚举和GC roots 引用链,这两个环节决定了gc时机。GC有一个必须的要求,那就是GC时,整个虚拟机中对象的引用关系是不能变的,或者说回收结果必须是回收发生时就已经决定好的。 这就导致了GC时候不可避免的出现“Stop The World”,会严重导致运行效率,gc的回收时机就变得至关重要。
GC Roots 节点枚举时机
根节点枚举时必须有保障当前对象之间引用关系一致性的快照,一致性指的是枚举期间对象之间的引用关系不回变化,这导致枚举时不可避免停止整个用户线程,哪怕停止的时间非常短。
为了快速完成根节点枚举,引入了OopMap的数据结构,一旦类完成加载,就把对象内什么偏移上是什么数据给计算出来,这样就可以在枚举时,直接获取到需要的数据。
安全点
OopMap虽然有用,但是对象关系是时刻变化的,如果为每一条指令都都创建OopMap,则会大大增加内存,于是在特点的位置创建OopMap,这些位置就是安全点。着意味着,只要在安全点的对象关系是正确的,也只有在这个时刻可以执行GC。
出于以上,安全点会选择**“程序长时间执行”**的位置,因为长时间执行的地方如果没有安全点,就会导致程序长时间无法进入安全点,导致gc等待的时间过长从而导致JVM线程停止过长,但是指令流执行的速度其实都很快,普通指令不太可能出现长时间执行,只有那些指令序列复用的地方会出现长时间执行,比如方法的调用、非counted的循环跳转、异常跳转等,在这些指令处都会产生安全点。(这里就会存在一个优化的方向,在counted循环中,如果循环足够大,就会出现迟迟不能进入安全点导致线程被停止过久)
线程中断
安全区域
当用户线程不执行时,就无法走到安全点然后进入挂起,于是引入安全区域,安全区域指的是在某一段代码片段中,引用关系不会发生变化,可以理解为拉伸开的安全点,当用户线程进入到安全区域以后,会有一个标记,发生GC时候,出于安全区域的线程也视作出于安全点。
GC Roots 引用链
虚拟机中,引用链时通过一个独立线程并行记录的,但是随着用户线程的运行,对象的的引用关系在不停的变化,有两种情况
本来该消亡的对象任然存在,这种情况可以接受,最多在下几轮去回收这些内存。
本来应该存在的对象被认定为回收,这种情况会导致严重的错误。重点是处理这种情况。
想象一下,将已经扫描过所有依赖的对象认定为黑色,将扫描部分但不是全部依赖的对象认定为灰色,将没有扫描过的对象认为是白色,那么GC roots引用链扫描的过程其实就是按照黑色节点->灰色节点->白色节点中不断添加黑色节点以及减少白色节点的过程。黑色节点最终会存活,白色节点最后会回收。当收集过程中,原本一个被灰色节点依赖的黑色节点中的依赖发生变化断开,也即是灰色->黑色断开,此时这个黑色节点变成了白色节点,然后又有新的黑色节点依赖了这个白色节点,那么理想情况这个节点应该变成黑色,但是由于并行的缘故,这个变化发生在扫描之后,这个节点任然被认定为白色被回收,发生了严重的事故。根据一些理论得出来两个条件,只有当都满足时候会出现对象消失的问题:
赋值器插入了一条或多条从黑色对象到白色对象的新引用。(既此白色对象应该变成黑色对象)
赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。(也就是说一个白色节点本身被灰色和黑色同时引用,但是由于黑色节点只会被扫描一次,所以如果删除灰色节点到白色节点的引用,系统会默认引用链已经断开而没有发现任然有黑色节点连接着白色节点)
于是只要破坏两个条件之一就可以消除这种现象,分别产生了两个方案:增量更新、原始快照。
分代收集的跨区引用问题
分代回收内存有一个跨区引用问题,既测量回收A区的对象时,在B区有对象引用了该对象,如何处理。
复制代码
记忆集与卡表
通过引入记忆集的数据结构解决分区引用问题,记忆集记录了从非收集区域(B)指向收集区域(A)的指针集合。不考虑成本问题最简单的实现方式是数组就可以。
数组成本太过高昂,实际上只需要知道某一块区域是否有指向收集区域的引用即可,所以有三种精度:字长精度、对象精度、卡精度。最常用的是卡卡精度,卡表的作用是映射关系,类似map。HotSpot采用字节数组来实现卡表:
CARD_TABLE [this address >> 9]=1;
每块区域大小2的9次幂既512字节,在这块区域内如果有引用其他区域对象,CARD_TABLE[curAddress]=1
设置为1。
本章主要介绍了一下java中GC的一些原理和细节,但是GC的真正的实现者——垃圾收集器这里没有提到,垃圾收集器有许多中,它们针对了不同的分代内存区域,相互搭配,按照不同的规则回收内存。但是篇幅有限这里没有展开细说,我准备作为番外篇单独介绍一下各个垃圾收集器。
感谢收看~。