说道垃圾回收,那么首要问题就是jvm如何判断一个对象已经死亡呢
说白了,就是为每个对象设立一个引用计数器,每当有一个引用指向它,计数器加一,引用失效或是转移则减一,很容易想到,当计数器为0时认为该对象死亡。
但就是这样一个原理简单、判定效率高的算法却没有在主流的java虚拟机中得到使用,原因是他存在一个致命的问题——循环引用
即当两个"死亡"的对象互相引用着对方时,出现了类似"死锁"的情况,死亡的对象当然不会对他的指针进行修改,所以这两个对象会一直占用内存,造成内存泄漏。
![img]
可达性分析算法是当前广为接受的算法,该算法采用了一系列称为"GC Roots"的根对象作为起始节点集,从该节点集开始向下搜索,形成一个"存活链",所有在存活链中的对象即为存活对象,否则判定为死亡
在Java中,可作为根对象的对象有以下几种:
对象在第一次标记死亡后,并不会立即被回收,而是有一个自救的机会,即为java中的"析构函数"finalize(),可以在重写该方法中重新为该对象加上引用,但一般虚拟机等待的时间有限,即在二次标记时该对象还未完成自救,则立即回收该对象,所以这个自救是有概率的。
传统的对象引用只单单指地址引用关系,对于绝大多数对象引用足矣,但对于某种"可有可无"对象的引用关系描述则显得乏力,所以在jdk1.2之后有了四大引用类型,分别是:
最简单的回收算法,过程分为"标记"和"清除",顾名思义,先标记死亡的对象,在统一回收
缺点:1.标记清除的效率会随着对象的增多而骤减
2.产生大量内存碎片,如下图
将内存空间分为两个子块,每次只使用其中一个子块,再一次垃圾回收中,将新分配的对象和上一次GC存活的对象统一移向另一个子块,然后对死亡对象进行回收。
缺点:1.存活对象的复制需要大量开销
2.每次只使用一半空间,造成大量空间浪费![img]
一种基于标记清除算法的改进算法,每次标记后,让存活的对象统一向内存的一端移动,然后再进行回收
一种集成了以上算法的收集理论,基于强分代假说(绝大多数对象都是朝生夕灭的)和弱分代假说(熬过越多次标记的对象就越难消亡)之上
分代收集顾名思义将内存区域划分为多个不同的区域:
绝大多数对象的分配和回收都发生在新生代,所以新生代采用的是标记复制算法,在此基础上,新生代又分为Eden区和两个Survivor区(from区和to区),每次内存分配都发生在eden区,少部分情况也分配在from区.在发生一次内存回收时,将from区存放的对象(上次gc存活下来的对象)和eden区存活下来的对象移向to区。
经历15次移动的对象将会被移动到老年代,老年代基本都是些存活时间比较久的对象,所以在老年代采用的是标记整理算法。
为什么说基本呢,这里存在一个叫做分配担保的问题,如果即将要分配的对象大于eden和from区的剩余空间的话,这些对象便通过分配担保直接分配到老年代。
JDK1.8之后改为元空间,元空间物理上不在JVM堆内存中,而在计算机内存中,方法区便在其中
随着虚拟机技术的不断发展,查找存活链的过程已经可以和用户线程并发了,但根节点枚举必须保证在一个能保障一致性的快照中进行,即暂时性的暂停用户进程。
但根节点的扫描需要扫描所有的对象引用,并计算他们的类型,这就需要大量的时间,导致用户进程的"Stop The World"。但事实果真如此吗,其实不然,虚拟机自然有方法知道引用中的对象类型。在hotspot虚拟机中,是一组被称之为OopMap的数据结构,一旦类加载完成后,hotspot就会把对象的引用中的数据类型计算出来,并在某些特定的位置记录下哪些位置存放着引用。这样根节点枚举时只需要去扫描这些引用的位置就可以。
如果每个需要修改引用关系的指令都要为其维护OopMap,无疑是一个巨大的开销,所以前面说过,虚拟机只是在某些特定的位置记录下引用信息,而这些特定的位置被称之为安全点。同时也规定,只有当所有正在运行的用户线程进行到安全点时,才能进行垃圾回收,这也需要安全点的选取一般是方法调用、循环等这些序列复用的长时间指令。
同时也迎来了一个问题,如何保证进行垃圾回收时,所有线程都运行到了安全点呢,有两种方案:
抢占式中断
由系统控制,在需要垃圾回收时先中断所有进程,如果发现有进程没有到达安全点,则让它继续运行一会,直到安全点。
主动式中断
当系统需要垃圾回收时,将一个标志位置为真。每个用户线程都需要不断地去轮询这个标志位,如果标志位为真,则运行到最近的安全点挂起。
前面讲到,通过线程与虚拟机之间的交互保证到达安全点,那么如果该线程正处于阻塞状态或者睡眠状态呢,在安全点的基础上,虚拟机又针对这种情况设置了一个安全区域(在安全区域内的指令不会修改引用关系)
分代收集理论带来了一个很现实的问题,就是跨代引用(例如一个老年代的对象指向了一个新生代对象),为了考虑这种情况,每次新生代的标记过程都要扫描一边老年代,又是一个大开销,为了解决这个问题,在新生代设立了一种数据结构——记忆集(记录由非收集区指向收集区的引用)。
卡表就是记忆集的一种实现方式,他将老年代分为一个一个的内存块,并标注了哪些内存块存在跨代引用,这样在标记时就只需要扫描这些内存块就可以。
卡表有效地解决了跨代引用的问题,那么卡表应该如何维护的,如何保证在引用关系更新后能立即更新卡表。虚拟机引入了一个机器码层面的控制手段——aop切面,将类型赋值的指令视作切入点,将维护卡表的操作作为aop增量服务即可有效维护卡表。
为了方便理解,这里引入一个三色标记算法来模拟查找存活链的过程
则很容易想象到该过程是一个以灰色对象为波峰的蔓延,如下图:
但是如果在并发的标记过程中,用户线程修改了某些引用关系,则会出现两种情况:
第二种情况对程序难以造成影响,所以一般都选择处理第一种情况,又称为"对象消失",以三色标记算法举例,对象消失当且仅当以下两个情况同时满足时发生:
用户线程插入了一条或多条从黑色对象到白色对象的引用
解决办法:增量更新
黑色对象如果有插入到白色对象的引用,则将其记录下来,等待并发扫描结束后,再重新扫描这些黑色对象
用户线程删除了所有由灰色对象到该白色对象的直接饮用或间接引用
解决办法:原始快照
当灰色对象要删除指向白色对象的引用时,就将该引用关系记录下来,等并发扫描结束后再将这些灰色对象重新扫描一次
新生代收集器
老年代收集器
以获取最短回收停顿时间为目标,基于标记清除
步骤:
初始标记:stw(stop the world)标记Gc Roots和其关联对象
并发标记:查找存活链,可与用户线程并发
重新标记:stw,增量更新实现
并发清除:回收死亡对象
与用户线程的并发导致用户线程变慢,为此提供了一个i-CMS变种,使并发变为并行(到jdk9被废弃)
为了提供并发的内存空间,CMS在老年代溢出之前就会进行一次FullGC,一般会有一个阈值,如果预留的空间无法满足并发需要,则会产生一次stw的垃圾回收
标记清除会产生大量内存碎片,可用虚拟机参数调节
整堆收集器