为什么要了解垃圾回收机制?
当我们需要排查各种内存溢出、内存泄漏问题时,当垃圾回收机制成为系统达到更高并发量的瓶颈时,我们需要对这些“自动化”的技术实施必要的监控和调节。简而言之,就是为了解决实际开发中可能出现的内存问题和并发问题。
如何判断实例对象已死,能够回收?
引用计数算法:在实例对象中添加一个计数器,当被引用一次时进行+1,引用失效则-1
优点:原理简单,判定效率高
缺点:占用额外内存,存在循环访问导致内存泄漏的问题
public class ReferenceCountingGC {
public Object instance = null;
private static final int _1MB = 1024 * 1024;
/**
* 这个成员属性的唯一意义就是占点内存,以便能在GC日志中看清楚是否有回收过
*/
private byte[] bigSize = new byte[2 * _1MB];
public static void testGC() {
//objA指向实例1,实例1 ++=1
ReferenceCountingGC objA = new ReferenceCountingGC();
//objB指向实例2,实例2 ++=1
ReferenceCountingGC objB = new ReferenceCountingGC();
//objA再次引用了objB,也就是实例2,实例2 +1=2
objA.instance = objB;
//objB再次引用了objA,也就是实例1,实例1 +1=2
objB.instance = objA;
//objA取消引用,实例1 -1=1
objA = null;
//objB取消引用,实例2 -1=1
objB = null;
//此时,两个实例对象已经不会再被用到,但是由于计数不为0,因此不会被GC回收,造成垃圾泄漏
System.gc();
}
}
可达性分析算法:算法的基本思路就是通过
一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
那么问题来了,谁可以作为GC ROOTS对象?
·在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
·在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
·在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
·在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
·Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
·所有被同步锁(synchronized关键字)持有的对象。
·反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
引用的分类:强引用,软引用,弱引用,虚引用
强引用:Object obj=new Object();只要强引用还在,GC就不会把对回收
软引用:有用但不必须,当系统要发生内存溢出前会把这些对象回收
弱引用:非必须对象,当下一次进行GC时不管内存够不够都会回收
虚引用:一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知
finalize()方法是对象逃脱GC的唯一一次机会,如果对象在该方法中重新与引用链上的任一对象建立了关联,那么JVM将会从待回收列表中移除该对象,否则该对象就没有机会逃脱被GC的命运了。 注意: 对象的finalize()方法只会被JVM执行一次,如果进行二次GC再次触发了该对象回收就不会再执行了,直接会回收掉。
在低调度优先级执行,是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。
这样做的原因是,如果某个对象的finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃
public class FinalizeEscapeGC { public static FinalizeEscapeGC SAVE_HOOK=null; public void isAlive(){ System.out.println("don't abandon me,i'm still alive"); } @Override protected void finalize() throws Throwable{ super.finalize(); System.out.println("the last time for me to survive"); FinalizeEscapeGC.SAVE_HOOK=this; } public static void main(String[] args) throws InterruptedException { SAVE_HOOK=new FinalizeEscapeGC(); SAVE_HOOK=null; System.gc(); Thread.sleep(500); if(SAVE_HOOK!=null){ SAVE_HOOK.isAlive(); }else{ System.out.println("oh my god, i'm dead"); } SAVE_HOOK=null; System.gc(); Thread.sleep(500); if(SAVE_HOOK!=null){ SAVE_HOOK.isAlive(); }else{ System.out.println("oh my god, i'm dead"); } } }
结果如下:
the last time for me to survive
don't abandon me,i'm still alive
oh my god, i'm dead
代码中有两段完全一样的代码片段,执行结果却是一次逃脱成功,一次失败了。这是因为任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()方法不会被再次执行,因此第二段代码的自救行动失败了
方法去的回收机制:
方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。回收废弃常量与回收Java堆中的对象非常类似。举个常量池中字面量回收的例子,假如一个字符串“java”曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是“java”,换句话说,已经没有任何字符串对象引用常量池中的“java”常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个“java”常量就将会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似
那么,如何判断一个类型属于不再被使用的类呢?
·该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
·加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如
OSGi、JSP的重加载等,否则通常是很难达成的。
·该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方
法
【没有实例对象,没有加载器,不能反射】
注意:满足上述三个条件的类被允许回收而不是必须回收
补充:关于是否要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class以及-XX:+TraceClass-Loading、-XX:+TraceClassUnLoading查看类加载和卸载信息,其中-verbose:class和-XX:+TraceClassLoading可以在Product版的虚拟机中使用,-XX:+TraceClassUnLoading参数需要FastDebug版[1]的虚拟机支持。在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力
垃圾收集算法:
设计原则:根据对象熬过垃圾回收过程的次数存储到Java堆划分出来的不同的区域上。如果一个区域上的对象多为朝生夕灭,那么就将重点放在如何保留少量对象的存活上,而不是标记那些大量要被回收的对象,这样可以以较低的代价换回大量的内存。如果是难以消亡的对象就集中放一起,以较低的频率来回收,这样同时兼顾了垃圾收集的时间开销和内存空间的有效利用。
为什么要在堆中划分不同的区域?
能够每次只回收其中某一个或者某部分的区域,这样能够根据不同区域对象的特点设计不同的垃圾收集算法
分代收集不简单之处在于:
对象之间不是孤立的,对象之间存在同代引用也存在跨代引用(老年代引用新生代)
假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样[3]。遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。
解决方法:
将老年代进一步细分,一部分是存在跨代引用的,另一部分不存在跨代引用,当我们再次进行局限于新生代区域的收集,我们只需要将存在跨代引用的堆内对象加入GCRoots即可
标记清除算法:标记需要回收的对象,然后统一收回被标记的对象;或者标记存活的对象,然后统一收回未被标记的对象。标记的过程就是判定是否为垃圾的过程。
缺点:它的主要缺点有两个:第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作
标记复制算法:将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉
优点:如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效
缺点:内存缩小为原来一半,空间浪费大;当存活对象多的时候就需要大量的复制,效率降低
优化的半区复制分代策略: 具体做法是把新生代分为一块较大的Eden空间和两块较小的
Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将EdenSurvivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1。当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保
内存的分配担保好比我们去银行借款,如果我们信誉很好,在98%的情况下都能按时偿还,于是银行可能会默认我们下一次也能按时按量地偿还贷款,只需要有一个担保人能保证如果我不能还款时,可以从他的账户扣钱,那银行就认为没有什么风险了。内存的分配担保也一样,如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入老年代,这对虚拟机来说就是安全的
标记整理算法:在标记完成后,让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存(老年代)
移动对象的弊端: 如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行(被称为STOP THE WORLD)但如果跟标记-清除算法那样完全不考虑移动和整理存活对象的话,弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。譬如通过“分区空闲分配链表”来解决内存分配问题。内存的访问是用户程序最频繁的操作,甚至都没有之一,假如在这个环节上增加了额外的负担,势必会直接影响应用程序的吞吐量。简而言之,移动则内存回收时会更复杂,不移动则内存分配时会更复杂
和稀泥的算法:让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间
Hot Spot算法细节实现:
准确式垃圾收集:所以当用户线程停顿下来之后,其实并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得到哪些地方存放着对象引用的。在HotSpot的解决方案里,是使用一组称为OopMap的数据结构来达到这个目的。一旦类加载动作完成的时候,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置【安全点】记录下栈里和寄存器里哪些位置是引用
OopMap缺点:可能导致引用关系变化,或者说导致OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的额外存储空间,这样垃圾收集伴随而来的空间成本就会变得无法忍受的高昂
安全点:用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。因此,安全点的选定既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷
如何在垃圾收集发生时让所有线程(这里其实不包括执行JNI调用的线程)都跑到最近的安全点,然后停顿下来?
抢先式中断:不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上
主动式中断:思想是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在Java堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象
安全点的缺点:安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集
过程的安全点。但是,程序“不执行”的时候呢?所谓的程序不执行就是没有分配处理器时间,典型的场景便是用户线程处于Sleep状态或者Blocked状态,这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间
安全区域:是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。为了避免构成GCRoots扫描过多,提出了OopMap,为了避免OopMap占用过多内存提出了安全点,为了避免安全点的“不执行”缺点,提出了安全区域
当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止
记忆集与卡表:
目标:为解决对象跨代引用所带来的问题
解释:记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构
原理:卡表最简单的形式可以只是一个字节数组,字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)。一般来说,卡页大小都是以2的N次幂的字节数(一对多的关系)
一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代
指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描
记忆集的维护:写屏障
写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值后的则叫作写后屏障(Post-Write Barrier)
特点:每次只要对引用进行更新,就会产生额外的开销,不过这个开销与Minor GC时扫描整个老年代的代价相比还是低得多的
什么是伪共享?
伪共享是处理并发底层细节时一种经常需要考虑的问题,现代中央处理器的缓存系统中是以缓存行(Cache Line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低
优化:设置访问位,当前没有线程访问才允许进入并修改访问位
问题:当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色:
·赋值器插入了一条或多条从黑色对象到白色对象的新引用;
·赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。
解决方法:
增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。
原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。