[这是我参与「第四届青训营 」笔记创作活动的第13天]
如何判断对象是否存活?
引用计数算法
判断对象是否存活的算法是这样的:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值加1;当引用失效时,计数器减1;任何时刻计数器都为0的对象就是不可能再被使用的。
虽然引用计数算法的实现简单,判断效率也很高,但Java中并没有选用引用计数算法来管理内存,其中最重要的一个原因是它很难解决对象之间相互循环引用的问题。
例子:
在main()方法中,对象objA和objB都有字段instance,赋值令objA.instance=objB及objB.instance=objA,除此之外这两个对象再无任何引用,实际上这两个对象都已经不能再被访问,但是它们因为相互引用着对象方,异常它们的引用计数都不为0,于是引用计数算法无法通知GC收集器回收它们。
/**
* 执行后,objA和objB会不会被GC呢?
*/
public class ReferenceCountingGC {
public Object instance = null;
private static final int _1MB = 1024*1024;
private byte[] bigSize = new byte[2 * _1MB]; //占用内容
public static void main(String[] args) {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
System.gc(); //模拟gc
}
}
打印:
[GC (System.gc()) [PSYoungGen: 6987K->480K(55808K)] 6987K->488K(182784K), 0.0014880 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (System.gc()) [PSYoungGen: 480K->0K(55808K)] [ParOldGen: 8K->388K(126976K)] 488K->388K(182784K), [Metaspace: 3264K->3264K(1056768K)], 0.0051151 secs] [Times: user=0.02 sys=0.00, real=0.00 secs]
Heap
PSYoungGen total 55808K, used 481K [0x0000000782000000, 0x0000000785e00000, 0x00000007c0000000) eden space 48128K, 1% used [0x0000000782000000,0x0000000782078690,0x0000000784f00000) from space 7680K, 0% used [0x0000000784f00000,0x0000000784f00000,0x0000000785680000) to space 7680K, 0% used [0x0000000785680000,0x0000000785680000,0x0000000785e00000) ParOldGen total 126976K, used 388K [0x0000000706000000, 0x000000070dc00000, 0x0000000782000000) object space 126976K, 0% used [0x0000000706000000,0x0000000706061108,0x000000070dc00000) Metaspace used 3271K, capacity 4496K, committed 4864K, reserved 1056768K class space used 360K, capacity 388K, committed 512K, reserved 1048576K
在运行结果中可以看到GC日志中包含“6987K->480K”,新生代内存发生了变化,意味着虚拟机并没有因为这两个对象相互引用就不回收它们,这也证明了虚拟机并不是通过引用计数算法来判断对象是否存活的。
这里扩展一下日志中的名词:
Java堆可分为:
- PSYoungGen:年轻代(内部又分为Eden space(新生代)、from space和to space)
- ParOldGen:老年代
方法区:Metaspace(元空间)
注意:这里开启日志要在IDEA的Run下的Edit Configuration指定的类名下VM Options添加JVM日志参数,例如:
- -verbose:gc (开启打印垃圾回收日志)
- -Xloggc:idea_gc.log (设置垃圾回收日志打印的文件,文件名称可以自定义)
- -XX:+PrintGCTimeStamps (打印垃圾回收时间信息时的时间格式)
- -XX:+PrintGCDetails (打印垃圾回收详情)
这里应该注意一下:我打印的日志对象并没有进入老年代,但对象刚创建时是分配在新生代的,要进入老年代默认年龄要到了15才可以,但网上部分结果objA和objB却是进入了老年代。这是因为Java堆区会动态增长,刚开始时堆区较小,对象进入老年代还有一规则:当Survior空间中同一代的对象大小之和超过Survior空间的一半时,对象将直接进入老年代。
可达性分析算法(Java或C#)
这个算法的基本思路就是通过一系列名为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的,又称根搜索法。
例如下面的Object5、Object6、Object7虽然有相互判断,但它们到GC Roots是不可达的,所以它们将会定为是可回收对象。
在Java中,可作为GC Roots对象的包括下面几种:
- 虚拟机栈(栈帧中的本地变量表)中的引用的对象
- 方法区中的类静态属性引用的对象
- 方法区中的常量引用的对象
- 本地方法栈中JNI引用的对象
finalize()方法最终判定对象是否存活
即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历再次标记过程。标记的前提是对象在进行可达性分析后发现没有与GC Roots相连接的引用链。
-
第一次标记并进行一次筛选
筛选的条件是此对象是否有必要执行finalize()方法。
当对象没有覆盖finalize方法,或者finzlize方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”,对象被回收。
-
第二次标记
如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个名为 F-Queue 的队列之中,并在稍后由一条虚拟机自动建立的、低优先级的Finalizer线程去执行。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束。这样做的原因是,如果一个对象finalize()方法中执行缓慢,或者发生死循环(更极端的情况),将很可能会导致F-Queue队列中的其他对象永久处于等待状态,甚至导致整个内存回收系统崩溃。 finalize()方法是对象脱逃死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模标记,如果对象要在finalize()中成功拯救自己—只要重新与引用链上的任何的一个对象建立关联即可,譬如把自己赋值给某个类变量或对象的成员变量,那在第二次标记时它将移除出“即将回收”的集合。如果对象这时候还没逃脱,那基本上它就真的被回收了。
流程图如下:
/**
* 此代码演示了两点
* 1、对象可以在被GC时自我拯救
* 2、这种自救的机会只有一次,因为一个对象的finalize()方法最多只能被系统自动调用一次。
*/
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK = null;
public void isAlive() {
System.out.println("活着");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed");
FinalizeEscapeGC.SAVE_HOOK = this;
}
public static void main(String[] args) throws InterruptedException {
SAVE_HOOK = new FinalizeEscapeGC();
//对象第一次成功拯救自己
SAVE_HOOK = null;
System.gc();
Thread.sleep(500); //finalize方法优先级很低,所有暂停0.5秒以等待它
if(SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("死了");
}
//对象第二次拯救自己失败
SAVE_HOOK = null;
System.gc();
Thread.sleep(500);
if(SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("死了");
}
}
}
打印:
finalize method executed
活着
死了
从结果可以看出,SAVE_HOOK对象的finalize()方法确实被GC收集器触发过,并且在被收集前成功逃脱一次。
但要注意:任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()方法不会被再次执行,因此第二段代码的自救行动失败了。 并且建议大家尽量避免使用它,运行代价过高。
引用(Reference)
无论是通过引用计数算法判断对象的引用数量,还是通过根搜索算法判断对象的引用链是否可达,判定对象是否存活都与“引用”有关。在JDK 1.2之前,Java中的引用的定义很传统:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。在JDK 1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)四种,这四种引用强度依次逐渐减弱。
- 强引用:就是指在程序代码之中普遍存在的,类似
Object obj = new Object()这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。 - 软引用:用来描述一些还有用,但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中并进行第二次回收。如果这次回收还是没有足够的内存,才会抛出内存溢出异常。在JDK 1.2之后,提供了SoftReference类来实现软引用。
- 弱引用:也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2之后,提供了WeakReference类来实现弱引用。
- 虚引用:也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是希望能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2之后,提供了PhantomReference类来实现虚引用。
回收方法区
Java虚拟机规范中确实说过可以不要求虚拟机在方法区实现垃圾收集,而且在方法区中进行垃圾收集的“性价比”一般比较低。
方法区中的垃圾回收主要是:废弃常量及无用类。判断常量是否废弃与判断堆中对象十分相似。例如,若常量池中存在字符串“abc”,而系统中并没有任何String对象的值为“abc”的,也就是没有任何对象引用它,那么它就可以被回收了。无用类的判定稍微复杂点,需要满足:
- 该类的所有对象实例已经被回收,也就是Java堆中不存在该类的任何实例;
- 加载该类的ClassLoader已经被回收;
- 该类的类对象Class没有在任何地方被引用,无法使用反射来访问该类的方法。
当方法区中的类满足以上条件时,就可以对无用类进行回收了,这里说的仅仅是“可以”,而并不是和对象一样,不使用了就必然会回收。是否对类进行回收,HotSpot虚拟机提供了各种配置,这里不多讲。
在大量使用反射、动态代理、CGLIB等ByteCode框架、动态生成JSP以及OSGI这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保存永久代不会溢出。
##垃圾收集算法
Java 语言的一大特点就是可以进行自动垃圾回收处理,而无需开发人员过于关注系统资源,例如内存资源的释放情况。自动垃圾收集虽然大大减轻了开发人员的工作量,但是也增加了软件系统的负担。
由于垃圾收集算法的实现涉及大量的程序细节,而且各个平台的虚拟机操作内存的方法各不相同,因此下面只讨论几种算法的思想。
标记-清除算法(Mark-Sweep)
可以说它是最基础的收集算法,下面其他收集算法都是基于这种思路并对其不足进行改进得到的。
-
标记-清除算法将垃圾回收分为两个阶段:
-
标记阶段:首先标记出所有需要回收的对象。
如何标记,在上面的“怕判断对象是否存活”里讲了
-
清除阶段:标记完成后,统一回收被标记的对象
-
-
存在的缺点:
- 效率问题:标记清除过程效率不高
- 空间问题:标记清除之后会产生大量的不连续的内存碎片(空间碎片太多可能会导致以后在程序运行过程中需要分配较大的对象时,无法找到足够的连续的内存空间而不得不提前触发另一次垃圾收集动作)
复制算法(Copying)
为了解决mark-sweep算法效率问题,主要用在HotSpot中的新生代收集中。
-
算法实现
- 将现有的内存空间分为两块,每次只使用其中一块
- 当其中一块时候完的时候,就将还存活的对象复制到另外一块上去
- 再把已使用过的内存空间一次清理掉
-
优点
- 由于是每次都对整个半区进行内存回收,内存分配时不必考虑内存碎片问题
- 只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效
-
缺点
- 内存减少为原来的一半,太浪费了
- 对象存活率较高的时候就要执行较多的复制操作,效率变低
- 如果不使用50%的对分策略,老年代需要考虑的空间担保策略
-
演化
并不需要根据1:1划分内存空间,而是将内存划分为一块较大的Eden Space和两块较小的Survior Space
JavaHeap内存回收模型(当前商业虚拟机大多使用此算法回收新生代)
标记-整理算法(Mark-Compact)
由于复制算法的缺点,及老年代的特点(存活率高,没有额外内存对其进行空间担保),老年代一般不使用复制算法
-
算法思想
- 标记阶段:首先标记出所有需要回收的对象。与“标记-清除”一样
- 让存活的对象向内存的一段移动。而不跟“标记-清除”直接对可回收对象进行清理
- 再清理掉边界以外的内存
由于老年代存活率高,没有额外内存对老年代进行空间担保,那么老年代只能采用标记-清理算法或者标记整理算法.
分代收集算法 (Generational Collecting)
当前的商业虚拟机的垃圾收集都采用,把Java堆分为新生代和老年代。根据各个年代的特点采用最适当的收集算法。
- 在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,选用:复制算法
- 在老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清除”或者“标记-整理”算法来进行回收。
垃圾收集器
垃圾收集器相对来说就是内存回收的具体实现,不同虚拟机下会提供不同的垃圾收集器。并且可以提供参数供用户根据自己的应用特点和要求组合各个年代所使用的收集器。下面主要讨论的是基于JDK 1.7 UPDATE 14 之后的HotSpot虚拟机。
JVM 1.7 HotSpot虚拟机的垃圾收集器
上图展示了JVM 1.7中7种作用于不同分代的收集器。
- 新生代收集器:Serial、ParNew、Parallel Scavenge;
- 老年代收集器:Serial Old、Parallel Old、CMS;
- G1 收集器
如果收集器之间存在连线,就说明它们可以搭配使用。
- Serial/Serial Old、Serial/CMS、ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1;
补充:
并发垃圾收集和并行垃圾收集的区别:
- 并行(Parallel) : 指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态;如ParNew、Parallel Scavenge、Parallel Old;
- 并发(Concurrent) : 指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行);用户程序在继续运行,而垃圾收集程序线程运行于另一个CPU上;如CMS、G1(也有并行);
Minor GC和Full GC的区别:
- Minor GC : 又称新生代GC,指发生在新生代的垃圾收集动作;因为Java对象大多是朝生夕灭,所以Minor GC非常频繁,一般回收速度也比较快
- Full GC : 又称Major GC或老年代GC,指发生在老年代的GC;出现Full GC经常会伴随至少一次的Minor GC(不是绝对,Parallel Sacvenge收集器就可以选择设置Major GC策略);Full GC速度一般比Minor GC慢10倍以上;
就现在而言,垃圾收集器并没有最好的区分,也没有万能的,只有最合适的。下面将一一介绍上面图中的各种垃圾收集器。
Serial收集器
Serial收集器是一个新生代收集器,单线程收集器,使用复制算法,单线程指不仅仅只会使用一个CPU或者一条收集线程去完成垃圾收集,更重要的是它在进行垃圾收集时,必须暂停其他所有线程的工作 。
如上图所示,在安全点时需要暂停所有用户线程。
Serial收集器是虚拟机运行在Client模式下的默认新生代收集器。其优点:简单而高效(与其他收集器的单线程比),对于限定单个CPU的环境来说,Serial收集器减少了线程交互的开销。 所以运行在Client模式下的虚拟机来说是一个不错的选择。
总结:
- 特性:单线程收集器,使用复制收集算法收集新生代。在单线程环境下,垃圾收集时必须暂停所有工作线程(一般称为“Stop The World” , 简称STW)直到收集完成。
- 使用场景:当前是虚拟机运行在Client模式下的默认新生代收集器
- 优点:简单高效(与其他收集器的单线程相比)
- 缺点:JVM在后台自动发起和自动完成的,把用户正常的工作线程全部停掉,即GC停顿,会带给用户不良体验
ParNew收集器
ParNew收集器其实就是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集时之外,其余行为包括Serial收集器的收集算法、STW、对象分配规则、回收策略等都一样,实现上也共用了许多代码。
注意:现在它是许多Server模式下的虚拟机中首选的新生代收集器其中一个与性能无关的原因是,除了Serial收集器外目前只有ParNew收集器能与CMS收集器配合工作。
ParNew 收集器也是使用 -XX:+UseConcMarkSeepGC选项后的默认新生代收集器,也可以使用-XX:UseParNewGC选项来强制指定;
默认开启的垃圾收集器线程数就是CPU数量,可通过-XX:parallelGCThreads参数来限制收集器线程数。
Serial收集器 VS ParNew收集器: 单CPU,ParNew 不会比Serial收集效果更好,但是随着CPU的数量增加。ParNew则在GC时对系统资源有效利用更好。
总结:
- 特性:多线程收集器,使用复制算法收集,其余STW、对象分配规则和回收策略都和Serial收集器一样,称为Serial的多线程版本。能与CMS收集器配合工作。
- 使用场景:在Server模式下,ParNew收集器是一个非常重要的收集器。除了单线程的Serial外,目前配合CMS只有它最好
- 优点:随着CPU数量增加,在GC时对资源利用更好
- 缺点:和Serial有着相同的缺点,GC停顿会对用户产生不良体验
Parallel Scanvenge收集器
Parallel Scanvenge收集器也是一个新生代收集器,使用复制算法,又是并行(Parallel)多线程收集器。
但它主要的特点是它的关注点和其他收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scanvenge收集器则是达到一个可控制的吞吐量。
吞吐量 = 程序运行时间/(程序运行时间+垃圾收集时间),例如虚拟机总运行100分钟,其中垃圾收集花费1分钟,则吞吐量为99%.
短停顿时间适合和用户交互的程序,良好的响应速度能提高用户体验。高吞吐量适合高效利用CPU资源,主要用于后台运算而不需要太多交互的任务。
Parallel Scavenge收集器提供两个参数用于精确控制吞吐量,一个GC自适应参数:
| 参数 | 描述 |
|---|---|
| -XX:MaxGCPauseMillis | 控制最大垃圾收集停顿时间,是个大于0的毫秒数 |
| -XX:GCTimeRatio | 设置垃圾收集时间占总时间的比率,吞吐量的倒数,默认值为99 |
| -XX:UseAdaptiveSizePolicy | 当打开后,不需要手工指定新生代大小、Eden与Survivor区的比例、晋升老年代对象大小等细节参数,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种调节方法称为GC自适应调节策略 |
- MaxGCPauseMillis 允许的值是一个大于 0 的毫秒数,收集器将尽可能保证回收花费的时间不超过这个设定值,但是并不是将这个值设置的稍小一点,就能使系统的垃圾回收速度变的更快,原因是GC停顿时间缩短是以牺牲吞吐量和新生代空间来换取的。 MaxGCPauseMillis 参数设置小,系统把新生代调小,虽然收集一次时间变短,但是也会导致垃圾收集发生更加频繁。
- 选项-XX:GCTimeRatio=19,设置了垃圾收集时间占总时间的5%–1/(1+19)****
- UseAdaptiveSizePolicy 可以做为Paralle Scavenge 与 ParNew的重要区别
总结:
- 特性:新生代收集器,采用复制算法,多线程收集,主要对吞吐量进行控制,也称为吞吐量收集器
- 使用场景:高吞吐量为目标,即减少垃圾收集时间,让用户代码获取更长的运行时间;当用户程序运行具有多个CPU上,对暂停时间没有特别高的要求时,即程序主要在后台进行运算而无需与用户进行过多交互的任务
- 优点:可以专注于吞吐量为目标的程序,拥有GC自适应的调节策略
- 缺点:对于对收集效率高的程序没什么出色的地方,相当于ParNew收集器
Serial Old 收集器
Serial Old是 Serial收集器的老年代版本,采用标记-整理算法收集老年代垃圾,也主要给Client模式下使用。
在Server模式下的两大用途:
- 在JDK 1.5 以及之前的版本中与Parallel Scanvenge收集器搭配使用
- 作为CMS收集器的后备预案,在并发收集发生
Concurrent Mode Failure时使用
总结:
- 特性:单线程,老年代收集器,使用标记-整理算法
- 使用场景:主要也是给Client模式下的虚拟机使用
Parallel Old 收集器
Parallel Old 是Parallel Scanvenge 收集器的老年代版本,使用多线程和标记-整理算法,不过这个收集器在JDK 1.6后才提供。
由于之前有一个Parallel Scanvenge新生代收集器,但却无老年代收集器与之完美结合,只能采用Serial Old老年代收集器,但是由于Serial Old收集器在Server模式下性能低下(无法充分利用CPU资源),其吞吐量反而不一定有ParNew+CMS组合好。
所以后面才出现了Parallel Old收集器,它的出现使得“吞吐量优先”收集器终于有了一套名副其实的应用组合。
在注重吞吐量以及CPU资源敏感的场合中,都可以优先考虑Parallel Scanvenge + Parallel Old收集器。
总结:
- 特性:多线程,老年代收集器,使用标记-整理算法
- 使用场景:注重吞吐量以及CPU资源敏感的场合,使用Parallel Scanvenge + Parallel Old组合
CMS收集器
CMS(Concurrent Mark Sweep) 收集器是一种以获取最短回收停顿时间为目标的收集器。目前大部分Java应用Web端或B/S系统的服务端都应用这个收集器。
从图上可以看出主要经历四个过程基本过程:
-
初始标记(CMS initial mark)
- 需要停顿其他用户线程(STW),仅仅只是标记出
GC Roots能直接关联到的对象(即需要存活的直接对象),速度很快
- 需要停顿其他用户线程(STW),仅仅只是标记出
-
并发标记(CMS concurrent mark)
- 与用户线程并发进行,该阶段是进行
GC Roots根搜索算法阶段,会判定对象是否需要存活
- 与用户线程并发进行,该阶段是进行
-
重新标记(CMS remark)
- 需要停顿其他用户线程(STW),为了修正并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间会被初始标记阶段稍长,但比并发标记阶段短。
-
并发清除(CMS concurrent sweep)
- 与用户线程并发进行,该阶段是进行垃圾清除的过程,会清除无
GC Roots引用的对象
- 与用户线程并发进行,该阶段是进行垃圾清除的过程,会清除无
所以,总体来说CMS收集器的内存回收过程是与用户一起并发执行的,这款收集器是在JDK 1.5推出的真正意义上的并发收集器。
但CMS也存在着明显的缺点:
-
对CPU资源非常敏感:
并发收集虽然不会暂停用户线程,但因为占用一部分CPU资源,还是会导致应用程序组变慢,总吞吐量降低。
CMS的默认收集线程数量是 = (CPU数量+3)/4
当CPU数量多于4时,收集线程占用的CPU资源不少于25%,随CPU数量增加而下降;不足4个时,影响可能很大。针对这种情况,曾出现了“增量式并发收集器”类似使用抢占式来模拟多任务机制的思想,让收集线程和用户现场交替运行,减少收集运行时间,但这样垃圾收集时间更长,并且效果不明显,现已
deprecated。 -
无法处理浮动垃圾:
并发清理时用户线程还在运行,这个阶段产生的垃圾无法再这次被清理,只能等待下次
Full gc被处理。这些需清理的对象被称为“浮动垃圾”,如果等老年代填满后再去gc操作,这样可能会导致内存不足无法分配的问题。因此CMS收集器不能像其他收集器等到老年代几乎填满再去收集,需要预留空间提供并发收集时使用。
通过
-XX:CMSInitiatingOccupancyFraction去设置触发百分比,以便降低内存回收次数从而获取更好的性能- 在JDK 1.5时,CMS收集器会在老年代使用了68%的空间后就会被激活,这样会使得GC次数变多。
- 在JDK 1.6时,CMS收集器的启动阀值已经提升到92%,要是CMS运行期间预留的内存无法满足程序其他线程需要,就会出现
Concurrent Mode Failure失败,从而临时启动Serial Old收集器重新老年代收集,导致收集时间更长。
-
标记-清除算法的缺点:
使用标记-清除算法会导致大量碎片产生,从而大对象无法分配,又提前触发
Full gc操作。解决问题:CMS收集器提供
-XX:+UseCMSCompactAtFullCollection参数(默认开启),用于在CMS收集器顶不住要进行Full gc时开启内存碎片的合并整理过程。但内存整理时间无法并发,又导致停顿时间变长。为了不是等到顶不住时再触发内存整理,可以通过-XX:+CMSFullGCsBeforeCompaction设置执行多少次不压缩的Full GC后,来一次压缩整理;默认为0,也就是说每次都执行Full GC,不会进行压缩整理;
总结:
- 特性:老年代收集器、基于标记-清理(Mark-Sweep)算法,注重最短回收停顿时间,并发收集、低停顿
- 使用场景:注重服务器响应时间,系统停顿时间短,以此给带来友好体验。目前大部分Java应用Web端或B/S系统的服务端都应用这个收集器
G1收集器
G1是JDK 1.7 提供的一款面向服务端应用的垃圾收集器。主要目标是替换掉JDK 1.5发布的CMS收集器。
相比其他收集器,特点有以下几点:
-
并行与并发:
- 能充分利用多CPU、多核环境下的硬件优势;
- 使用多个CPU(CPU或CPU核心)来缩短STW停顿的时间;
- 同时可以并发让垃圾收集与用户程序同时运行
-
分代收集
- 分代概念保留,同时能独立管理整个GC堆(新生代和老年代),而不需要与其他收集器搭配;
- 虽然是独立的,但它能够采用不同方式去处理新建对象和已经存活了一段时间、熬过多次GC的老年代对象以获取更多收集效果。
-
空间整合
- 从整体上看是基于“标记-整理”算法实现;
- 从局部(两个Region之间)上来看是基于“复制”算法实现的;
- 这两种算法都意味着G1运行期间不会产生内存空间碎片,收集后能使内存更规整,遇到分配大对象时不会因为没有连续空间而进行下一次GC,甚至一次
Full gc。
-
可预测的停顿
- 降低停顿是G1和CMS共同的关注点
- 但G1除了追求低停顿,还能建立可预测的停顿模型,即可以明确地指定在一个长度为M的时间片内,消耗在垃圾收集的时间不超过N毫秒。
堆内存分布与其他收集器的区别
相对其他收集器,G1的Java堆内存布局是将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,都是一部分Region(不需要连续)的集合。
为什么G1收集器可以实现可预测的停顿
- 可以有计划地避免在整个Java堆中进行全区域的垃圾收集;
- G1可以跟踪各个Region里面的垃圾堆积的价值大小(回收所获得空间大小及回收所需时间的经验值),在后台维护一个优先列表;
- 每次根据允许的收集时间,优先回收价值最大的Region(名称Garbage-First的由来)。
- 这就保证了G1收集器在有限时间内可以获取尽可能高的收集效率。
一个对象被多个Region区域引用的问题
-
问题由来:
首先
Region是不可能孤立的。一个Region中的对象可能被其他任意Region中对象引用。导致判断对象存活时,是否需要扫描整个Java堆才能保证准确?在其他的分代收集器,也存在这样的问题(而G1更突出):回收新生代也不得不同时扫描老年代?这样的话会降低Minor GC的效率 -
如何解决:
无论G1还是其他分代收集器,JVM都是使用Remembered Set来避免全局扫描:
- 每个Region中都有一个与之对应的
Remembered Set; - 每次
Reference类型数据写操作时,会产生一个Writer Barrier暂停中断写操作; - 然后检查
Reference引用的对象是否处于不同的Region之中(在分代中就是检查是否老年代中的对象引用了新生代的对象); - 如果是,则通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set之中;
- 当进行垃圾收集时,在GC根节点的枚举范围中加入Remembered Set即可保证不对全堆扫描也不会遗漏
- 每个Region中都有一个与之对应的
现在可以看一张具体是如何保证的分析图
现在再来分析一下G1收集器的大致过程(不计算维护Remembered Set的操作):
-
初始标记(Intial Marking)
- 仅标记一下
GC Roots能直接关联到的对象; - 且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象;
- 需要STW,但速度很快;
- 仅标记一下
-
并发标记(Concurrent Marking)
- 进行GC Roots Tracing的过程;
- 刚才产生的集合中标记出存活对象;
- 耗时较长,与用户程序并发执行;
- 并不能保证可以标记出所有存活对象(还有最终标记)
-
最终标记(Final Marking)
- 为了修正并发标记期间因用户程序继续运行而导致标记变动的那一部分对象的标记记录
- 上一阶段(并发标记阶段)对象的变化记录在线程的
Remembered Set Log中; - 这个阶段把
Remembered Set Log合并到Remembered Set中; - 需要STW,且停顿时间比初始标记稍长,但远比并发标记短;虽然停顿长,但是能并行执行最终标记过程;
- 采用多线程并行执行来提高效率;
-
筛选回收(Live Data Counting and Evacuation)
- 首先排序各个
Region的回收价值和成本; - 根据用户期望的GC停顿时间来制定回收计划;
- 首先排序各个
###扩展:GC日志
对于GC日志的理解,这里我通过上面打印的GC log来分析一下:
[GC (System.gc()) [PSYoungGen: 6987K->480K(55808K)] 6987K->488K(182784K), 0.0014880 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (System.gc()) [PSYoungGen: 480K->0K(55808K)] [ParOldGen: 8K->388K(126976K)] 488K->388K(182784K), [Metaspace: 3264K->3264K(1056768K)], 0.0051151 secs] [Times: user=0.02 sys=0.00, real=0.00 secs]
Heap
PSYoungGen total 55808K, used 481K [0x0000000782000000, 0x0000000785e00000, 0x00000007c0000000) eden space 48128K, 1% used [0x0000000782000000,0x0000000782078690,0x0000000784f00000) from space 7680K, 0% used [0x0000000784f00000,0x0000000784f00000,0x0000000785680000) to space 7680K, 0% used [0x0000000785680000,0x0000000785680000,0x0000000785e00000) ParOldGen total 126976K, used 388K [0x0000000706000000, 0x000000070dc00000, 0x0000000782000000) object space 126976K, 0% used [0x0000000706000000,0x0000000706061108,0x000000070dc00000) Metaspace used 3271K, capacity 4496K, committed 4864K, reserved 1056768K class space used 360K, capacity 388K, committed 512K, reserved 1048576K
- 首先,
[GC和[Full GC说明了这次垃圾收集的停顿类型,而不是用来区分新生代GC和来年代GC的,如果有Full,则是发生了一次STW的,而后面的(System.gc())则是调用System.gc方法触发的一次收集。 - 而
[PSYoungGen、[ParOldGen、[Metaspace则表示GC发生的区域,这里的区域名是与使用的GC收集器密切相关的,这里打印出的可以知道使用的收集器为Parallel Scanvenge+Parallel Old收集器组合,然后Metaspace是jdk1.8后才出现的,用来替代之前的[Perm持久代名称。其他收集器对应的分别是:Serial 收集器:[DefNew,ParNew收集器:[ParNew等。 - 后面方括号中的
6987K->480K(55808K)含义是“GC前该内存区域已使用容量->GC后该内存区域已使用容量(该内存区域总容量)”。6987K->488K(182784K)则是GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆总容量)。 0.0014880 secs表示该内存区域GC所占用的时间,[Times: user=0.00 sys=0.00, real=0.00 secs]则表示为user、sys和real与Linux的time命令的输出时间含义一致,分别代表用户态消耗的CPU时间、内核态消耗的CPU时间和操作从开始到结束所经过的墙钟时间。