JVM 垃圾收集算法 标记-清楚、标记-复制、标记-整理
摘要
Java程序在运行过程中会产生大量的对象,但是内存大小是有限的,如果光用而不释放,那内存迟早被耗尽。如C、C++程序,需要程序员手动释放内存,Java则不需要,是由垃圾回收器去自动回收。
垃圾回收器回收内存至少需要做两件事情:标记垃圾、回收垃圾。于是诞生了很多算法及垃圾回收器。
垃圾判断算法
即判断JVM中的所有对象,哪些对象是存活的,哪些对象可回收的算法。
引用计数算法
在对象中添加一个属性用于标记对象被引用的次数,每多一个其他对象引用,计数+1,当引用失效时,计数-1,如果计数=0,表示没有其他对象引用,就可以被回收。
这个算法无法解决循环依赖的问题。
可达性分析算法
通过一系列被称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系链向下搜索,如果某个对象无法被搜索到,则说明该对象无引用执行,可回收。相反,则对象处于存活状态,不可回收。
JVM中的实现是找到存活对象,未打标记的就是无用对象,GC时会回收。
哪些对象可以作为GC Root呢:
- 所有Java线程当前活跃的栈帧里指向GC堆里的对象的引用;换句话说,当前所有正在被调用的方法的引用类型的参数/局部变量/临时值。
- VM的一些静态数据结构里指向GC堆里的对象的引用,例如说HotSpot VM里的Universe里有很多这样的引用。
- JNI handles,包括global handles和local handles
- (看情况)所有当前被加载的Java类
- (看情况)Java类的引用类型静态变量
- (看情况)Java类的运行时常量池里的引用类型常量(String或Class类型)
- (看情况)String常量池(StringTable)里的引用
垃圾回收算法
1、标记-清除算法
概念:
顾名思义,标记-清除算法分为两个阶段,标记(mark)和清除(sweep)。
标记:遍历所有的GC Roots,然后将所有的GC Roots可达的对象标记为存活的对象。
清除:清除的过程将遍历所有堆中的对象,将没有标记的对象全部清除。
图解:
对上图中的黄色部分进行垃圾回收,回收后的截图如下所示:
从图中可知,进行标记清理后,可用内存增加,但是清除垃圾后的内存地址不连接,出现垃圾碎片。
缺点:
1、执行效率不稳定,如果Java堆中包含大量对象,而且大部分是需要被回收的,这时必须记性大量标记及清除动作,导致标记和清除两个过程执行效率都随对象数量增长而降低。
2、内存空间碎片化的问题,标记、清除后会产生大量的不连续内存碎片,空间碎片太可能会导致当以后需要分配大对象时无法找到足够的连续内存二不得不提前触发另一次垃圾收集动作。
2、标记-复制算法
概念:
复制算法将内存分为两个区间,这两个区间是动态的,在任意一个时间点,所有分配的对象内存只能在其中一个区间(活动区间),另外一个区间就是空闲区间。
当有效内存空间耗尽时,JVM将暂停程序运行,开启复制算法GC线程。GC线程会将活动区间内的存活对象,全部复制到空闲区间,且严格按照内存地址一次排列,与此同时,GC线程将更新存活对象的内存引用地址指向新的内存地址。这个时候空闲内存已经变成了活动区间,垃圾对象全部在原来的活动区间,清理掉垃圾对象,原活动区间就变成了空闲区间。
这种方式内存的代价太高,每次基本上都要浪费一半的内存。于是将该算法进行了改进,内存区域不再是按照1:1去划分,而是将内存划分为8:1:1三部分,较大那份内存是Eden区,其余是两块较小的内存区叫Survior区。每次都会优先使用Eden区,若Eden区满,就将对象复制到第二块内存区上,然后清除Eden区,如果此时存活的对象太多,以至于Survivor不够时,会将这些对象通过分配担保机制复制到老年代中。(java堆又分为新生代和老年代)。
图解:
优点:
1、很好地解决了“标记-清除”算法,内存布局混乱的缺点。
缺点:
1、浪费一半的内存。
2、假设对象存活率为100%,那么“标记-复制”算法的GC过程就是重复的把对象复制一遍,而且将所有的引用地址重置一遍。可以预见的复制所消耗的时间随着对象存活率达到一定程度将会变成灾难。所以“标记-复制”算法使用的场景是可以忍受只是用50%内存,对象存活率非常低
3、标记-整理算法
概念:
标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
图解:
优点:
1、弥补了“标记-清除”算法,内存区域分散的缺点
2、弥补了“标记-复制”算法内存减半的代价
缺点:
1、效率不高,对于“标记-清除”而言多了整理工作。
4、分代收集算法
当前商业虚拟机的垃圾收集都采用分代收集。此算法没啥新鲜的,就是将上述三种算法整合了一下。具体如下:
根据各个年代的特点采取最适当的收集算法
1、在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法。只需要付出少量存活对象的复制成本就可以完成收集。
2、老年代中因为对象存活率高、没有额外空间对他进行分配担保,就必须用标记-清除或者标记-整理。
测试案例
以下测试采用的是Serial加Serial Old收集器组合。
查看当前jdk默认额收集器使用以下语句。
java -XX:+PrintCommandLineFlags -version
执行结果:
1、对象优先在Eden分配
测试代码:
/** * @Description 对象优先在Eden分配 * VM参数:-verbose: gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC */ public class AllocationMemory { private static final int _1MB=1024 * 1024; public static void main(String[] args) { byte[] allocation1,allocation2,allocation3; allocation1=new byte[3 * _1MB]; allocation2=new byte[2 * _1MB]; allocation3=new byte[5 * _1MB]; } }
测试结果:
原因分析:
* eden 8M
* from 1M
* to 1M
* 老年代 10M
allocation1=new byte[3 * _1MB];
执行此行代码时,新生代Eden为空,满足分配3M的需求
执行完成后,allocation1指向的对象在新生代Eden区;
allocation2=new byte[2 * _1MB];
执行此行代码时,新生代Eden包含3M的对象,满足分配2M的需求
执行完成后,allocation2指向的对象在新生代Eden区;
allocation3=new byte[5 * _1MB];
对allocation3分配内存时,发现Eden已经占用5MB,剩余的空间不满足分配allocation3需要的5MB内存,因此发生了Minor GC。
从测试结果的图中Minor GC发生时内存变化可以看出,新生代内存从7679K变化成608K,新生代的内存使用量基本清空了。
而整个堆的大小从7679K变化成5728K,堆区的使用量仍然包含5MB的数据,可以判断出之前的5MB数据从新生代复制到了老年代了。
又因为新生代的servivor区的from区和to区分别各站1MB的内存,根本不足以放下2MB和3MB的对象。所以对象优先在Eden区分配。
执行垃圾回收后,新生代Eden区清空,满足分配5M的需求,allocation3指向的对象在新生代Eden区。
因此程序执行完成后,堆区的情况是:
eden space 8192K, 63% --包含allocation3 指向的5MB对象
the space 10240K, 50% --包含 allocation1、allocation2指向的对象。
2、大对象直接进入老年代
测试代码:
/** * @Description 大对象直接进入老年代 * VM参数:-verbose: gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC * -XX:PretenureSizeThreshold=3145728 */ public class PretenureSizeThreshold { private static final int _1MB=1024 * 1024; public static void main(String[] args) { byte[] allocation; allocation= new byte[4 * _1MB]; } }
测试结果:
原因分析:
虚拟机提供了-XX:PretenureSizeThreshold参数,制定大于该设置值的对象直接进入老年代。
从测试结果中可以看出老年代占用了4MB空间,新生代Eden区占用不足4MB,因此可以判断生成的4MB对象是在老年代。这是因为-XX:PretenureSizeThreshold被设置为3MB,因此超过3MB的对象都会直接在老年代进行分配。
3、长期存活的对象将进入老年代
测试代码
public class TenuringThreshold { public static final int _1Mb =1024*1024; public static void main(String[] args) { byte[] alloctation1=new byte[_1Mb/4]; byte[] alloctation2=new byte[8 * _1Mb]; byte[] alloctation3=new byte[8 * _1Mb]; alloctation3=null; alloctation3=new byte[8 * _1Mb]; } }
测试结果:
1、VM参数:-verbose: gc -Xms40M -Xmx40M -Xmn20M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:MaxTenuringThreshold=1 -XX:+PrintTenuringDistribution
2、VM参数:-verbose: gc -Xms40M -Xmx40M -Xmn20M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:MaxTenuringThreshold=15 -XX:+PrintTenuringDistribution
原因分析:
1、VM参数:-verbose: gc -Xms40M -Xmx40M -Xmn20M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:MaxTenuringThreshold=1 -XX:+PrintTenuringDistribution
eden 16M
from 2M
to 2M
老年代 20M
程序执行完后, allocation1 指向的对象 在 老年代 , allocation2 在老年代, allocation3 先指向的对象被垃圾回收了, allocation3后指向的对象在eden区。
byte[] alloctation1=new byte[_1Mb/4];
* 执行完后,allocation1 指向的对象在新生代的Eden区;
byte[] alloctation2=new byte[8 * _1Mb];
* 执行完后,allocation2 指向的对象在新生代的Eden区;
byte[] alloctation3=new byte[8 * _1Mb];
* 因为eden区不足需要分配的8M空间,所以触发了young GC,
* GC发生后,allocation1 指向的对象从eden区复制到servivor区,allocation2所指向的对象, 因为servivor区空间不够分配,触发空间担保,进入老年代。
* GC完成后 alloctation3 指向的对象在新生代的Eden区;
alloctation3=null;
alloctation3=new byte[8 * _1Mb];
* 因为Eden区不足需要分配的8M空间,所有触发了young GC
* GC发生后,eden区中的8M对象因为GC Root未可达,所有判断为死对象,被垃圾回收器回收, allocation1 指向的对象因未达到1次的年龄限制,所以复制到了老年代。
* allocation2所指向的对象仍然指向老年代。
* GC完成后,eden区满足8M对象分配的空间要求,alloctation3 指向的对象在新生代的Eden区;
2、VM参数:-verbose: gc -Xms40M -Xmx40M -Xmn20M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:MaxTenuringThreshold=15 -XX:+PrintTenuringDistribution
*程序执行完后, allocation1 指向的对象 在 servivor区 , allocation2 在老年代, allocation3 先指向的对象被垃圾回收了, allocation3后指向的对象在eden区。
byte[] alloctation1=new byte[_1Mb/4];
* 执行完后,allocation1 指向的对象在新生代的Eden区;
byte[] alloctation2=new byte[8 * _1Mb];
* 执行完后,allocation2 指向的对象在新生代的Eden区;
byte[] alloctation3=new byte[8 * _1Mb];
* 因为eden区不足需要分配的8M空间,所以触发了young GC,
* GC发生后,allocation1 指向的对象从eden区复制到servivor区,allocation2所指向的对象, 因为servivor区空间不够分配,触发空间担保,进入老年代。
* GC完成后 alloctation3 指向的对象在新生代的Eden区;
alloctation3=null;
alloctation3=new byte[8 * _1Mb];
* 因为Eden区不足需要分配的8M空间,所有触发了young GC
* GC发生后,eden区中的8M对象因为GC Root未可达,所有判断为死对象,被垃圾回收器回收, allocation1 指向的对象因未达到15次的年龄限制,仍然在servivor区。
* allocation2所指向的对象仍然指向老年代。
* GC完成时,eden区满足8M对象分配的空间要求,alloctation3 指向的对象在新生代的Eden区;
4、动态年龄判断
测试代码:
/** * VM参数:-verbose: gc -Xms40M -Xmx40M -Xmn20M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC * * * -XX:MaxTenuringThreshold=15 -XX:+PrintTenuringDistribution * @Version 1.0 */ public class SpaceGuarantee { public static final int _1MB=1024 * 1024; public static void main(String[] args) { byte[] alloctation1=new byte[_1MB/4]; //屏蔽#1此行代码后,就不会触发动态年龄判断了。 //byte[] alloctation2=new byte[_1MB/4]; //#1 byte[] alloctation3=new byte[8 * _1MB]; byte[] alloctation4=new byte[8 * _1MB]; alloctation4=null; alloctation4=new byte[8 * _1MB]; } }
测试结果:
1、未屏蔽 #1 处代码
2、屏蔽 #1 处代码
原因分析:
通过两个结果的对比,发下在没有屏蔽#1处代码的情况下,Servivor区占用为0%,而老年代比预期占用的高,也就说明了survivor区的数据在经历第二次GC的年龄计算就全部转入到老年代了,并没有等到15岁的临界年龄。
而屏蔽了#1处代码的情况下,Servivor区占用为38%,而老年代与预期值相似。说明servivor区的数据经历第二次GC的年龄计算没有复制到老年代。
这是因为没有屏蔽#1处代码的情况下,allocation1、allocation2两个对象加起来达到了512K,并且他们是同年龄的,满足同年对象达到Servivor区空间一半的规则。
byte[] alloctation1=new byte[_1MB/4];
* 新生代Eden区满足256K对象的空间需求, allocation1 指向的对象被分配到新生代的eden区
byte[] alloctation2=new byte[_1MB/4];
* 新生代Eden区满足256K对象的空间需求, allocation2 指向的对象被分配到新生代的eden区
byte[] alloctation3=new byte[8 * _1MB];
* 新生代Eden区满足8MB对象的空间需求, allocation3 指向的对象被分配到新生代的eden区
byte[] alloctation4=new byte[8 * _1MB];
* 需要分配8MB的内存到新生代的eden区,Eden区空间不足,触发young GC,
* GC发生后,allocation1 指向的对象被复制到新生代的servivor区,allocation2指向的对象被复制到新生代的servivor区,
* allocation3指向的对象因为servivor区不足以放下8MB的对象,所以触发空间担保,被复制到老年代中。
* GC完成后,allocation4指向的对象被分配到新生代的Eden区。
alloctation4=null;
alloctation4=new byte[8 * _1MB];
* 需要分配8MB的内存到新生代的eden区,Eden区空间不足,发生young GC,
* GC发生后,allocation1 指向的对象本应该继续保留在servivor区中,但是因为servivor区中相同年龄所有对象的大小的总和大于servivor空间的一半,因此触发了动态年龄判断,所以被复制到老年代。
* allocation2 指向的对象本应该继续保留在servivor区中,但是因为servivor区中相同年龄所有对象的大小的总和大于servivor空间的一半,因此触发了动态年龄判断,所以被复制到老年代。
* eden区中的8M对象因为GC Root未可达,所有判断为死对象,被垃圾回收器回收。
* GC完成后,eden区满足8M对象分配的空间要求,alloctation4 指向的对象在新生代的Eden区;
5、空间分配担保
测试代码:
/** * VM参数:-verbose: gc -Xms40M -Xmx40M -Xmn20M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC * eden 16M * from 2M * to 2M * 老年代 20M * * @Version 1.0 */ public class HandlePromotion { public static final int _1MB = 1024*1024; public static void main(String[] args) { byte[] allocation1=new byte[4*_1MB]; byte[] allocation2=new byte[4*_1MB]; byte[] allocation3=new byte[4*_1MB]; allocation1=null; byte[] allocation4=new byte[4*_1MB]; byte[] allocation5=new byte[4*_1MB]; byte[] allocation6=new byte[4*_1MB]; allocation4=null;//#1 allocation5=null;//#2 allocation6=null;//#3 byte[] allocation7=new byte[4*_1MB]; } }
测试结果:
1、未注释#1,#2,#3 三行代码的情况
2、注释#1,#2,#3 三行代码的情况
原因分析:
对比 未注释#1,#2,#3 三行代码的情况 与注释#1,#2,#3 三行代码的情况 发现
未注释#1,#2,#3 三行代码的情况下 发生了两次Minor GC。
而注释#1,#2,#3 三行代码的情况下 发生了两次Minor GC,并且第二Minor GC执行时因空间担保失败而引发了一次Full GC。
执行byte[] allocation4=new byte[4*_1MB]代码,需要向Eden区申请4MB内存空间,因为Eden空间不足分配,触发第一次GC,
在发生minor GC之前,虚拟机检查老年代20M内存,之前没有晋升到过老年代的对象,因此满足需求,所有发生Minor GC。
在此次GC中,Eden区会清除掉原来allocation1指向的4MB内存区,allocation2、allocation3指向的内存因Servivor无法满足分配
因此被复制到新生代。此次从新生代复制到老年代的内存为8MB。
未注释#1,#2,#3 三行代码的情况下,执行byte[] allocation7=new byte[4*_1MB]代码,需要向Eden区申请4MB内存,但Eden区内存无法满足,因此触发了第二次GC,
在发生minor GC之前,虚拟机先检查老年代最大可用空间为12M,大于晋升的平均值8M,因此满足空间担保需求,所有发生Minor GC。
在此次GC中,Eden区中 allocation4 、allocation5、allocation6所指向的对象,因为成为了非存活对象,所有被清楚了。
执行完GC后,allocation7 指向的对象被分配到Eden区。
注释#1,#2,#3 三行代码的情况下,执行byte[] allocation7=new byte[4*_1MB]代码,需要向Eden区申请4MB内存,但Eden区内存无法满足,因此触发了第二次GC,
在发生minor GC之前,虚拟机先检查老年代剩余最大可用空间大于晋升的平均值8M,因此满足空间担保需求,所有发生Minor GC。
在此次GC中,Eden区中 allocation4 、allocation5、allocation6所指向的对象都存活对象,所以需要复制到Servivor区,但Servivor区不足以放下4MB大小的对象,需要晋升到老年代。
但老年代剩余可用空间不够放下此次晋升的对象。因此引发了Full GC。