注:本文主要参考《深入理解Java虚拟机 第2版》
1、概述
由于JVM内存自治,需要对于内存中的数据进行管理,其中“垃圾”的数据需要进行清除(回收),留出空闲内存供新的数据使用。
其中线程私有的内存区域,如程序计数器、虚拟机栈、本地方法栈这3个区域,随着线程的灭,生命周期已知,线程生命周期结束,内存自动回收,无需再进行回收。
而Java对和方法区则不同,这部分是多个线程共用的,内存的分片和回收都是动态的,垃圾收集器所关注的就是这个区域。
2、对象是否是“垃圾”
垃圾收集算法,是指需要识别内存中的“垃圾”识别,并进行内存回收的一种算法,主要有两部分,垃圾的识别方式,以及收集的方式。
2.1、垃圾的识别方式
垃圾的识别方式主要有如下几种:
-
引用计数算法:给每个对象添加一个引用计数器,有个地方引用它则计数器值加1,党引用失效,计数器值减1。当计数器值为0时则表示没有被引用,可以进行回收,优缺点如下:
优点:简单、高效;
缺点:无法解决循环引用(object1引用object2,object2又引用了object1,双方的引用计数器值都为1,但是其实这两个对象已经不可用了)问题; -
可达性分析算法:从一系列的“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,党一个对象没有任何引用链相连时,表明此对象不可用,可以回收,如object1和object2相互引用,但是从“GC Roots”到object1和object2没有引用链,那么可以进行回收,从而解决了循环引用的问题,其中的“GC Roots”包括如下几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象;
- 方法区中的静态属性引用的对象;
- 方法区中常量引用的对象;
- 本地方法栈中JNI(即一般说的Native方法)引用的对象;
2.2、引用类型对垃圾回收的影响
在JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference),强度依次逐渐减弱:
- 强引用:类似Object obj = new Object()这类引用,只要强引用存在,垃圾收集器永远不会回收掉呗引用的对象;
- 软引用:用于描述一些还有用但并非必须的对象,对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常;
- 弱引用:也是用于描述非必须对象,但是强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集器发生之前,当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用瓜拉纳了的对象;
- 虚引用:也称为幽灵引用或者幻影应用,,它是最弱的一种引用关系,一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象的示例,为一个对象生孩子虚引用关联的唯一目的就是这个对象被收集器回收是收到一个系统通知;
2.3、方法区的回收
方法区,HotSpot虚拟机中的永久代也有垃圾回收,只是回收率较低,此内存区域主要回收两部分内容:废弃常量和无用的类:
- 废弃常量:判断比较简单,没有任何一个对象引用此常量,就是“垃圾”;
- 无用的类:同时满足以下三个条件才算是无用的类:
- 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例;
- 加载该类的ClassLoader已经被回收;
- 该类对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法;
3、垃圾收集算法
3.1、标记-清除算法
它最基础的收集算法(其他算法基于此算法思路进行改进),分为两个阶段“标记” 和“清除”两个阶段,标记使用前面的垃圾识别方式进行,但是不足之处有如下两点:
- 效率低,标记和清除过程的效率都不高;
- 空间问题,标记清除之后会产生大量不连续的内存碎片,导致后续分配较大对象时,无法分配,会引发另一次垃圾收集动作;
3.2、复制算法
复制算法,是将可用的内存容量划分为大小相等的两块,每次只使用其中一块,当这块用完后,就将还存活的对象复制到另外一块内存中,然后把已使用的这一块内存全部清理掉。
现在商业虚拟机都采用这种收集算法来回收新生代,以为新生代存活率低,所以不需要按照1:1来分配内存而是将内存分为一块较大的Eden和两块较小的Survivor空开,每次使用Eden和其中的一块Survivor空间。当回收时,将Eden和Survivo中还存活的对象一次性的复制到另一块Survivor空间上,最后清理掉Eden和刚才使用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小是8:1,不过没有办法保证每次回收都只有不多余10%的对象存活,党Survivor空间不足是,需要依赖其他内存(这里是指老年代)进行分配担保。
这个算法的优点是:
- 分配效率高,内存无碎片,分配新对象是,按顺序分配即可;
缺点是:
- 内存大小变小,变为原来的一版,会引发频繁的垃圾收集;
- 存活率较高,需要复制的对象较多,效率贬低;
3.3、标记整理算法
对于存活率较高时就需要进行较多的复制操作,效率会变低,针对于老年代的特点,标记整理算法比较合适,就是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
3.4、分代收集算法
分代收集算法是根据对象存活周期的不同将内存划分为几块,一般是把Java堆分为新生代和老年代,这样可以根据各年代的特点使用最适当的收集算法:
- 新生代存活率较低,采用复制算法;
- 老年代存活率较高,没有额外空间对它进行担保,就必须使用“标记-清理”和“标记-整理算法”来进行回收。
4、HopSpot垃圾收集器
基于上述的方法论,下面有几种垃圾收集器的实现,由于内存数据是实时变化的,所以再垃圾回收时,需要暂停所有线程(“Stop The World”),这样一来就会给用户带来不良体验。收集器不断的优化,改进就是为了减少Stop The World的时间,提高垃圾收集的效率以及减少对用户的影响。
相关概念:
- 安全点(Safepoint):为了高效获知对象引用位置,HotSpot采用了OopMap的偏移量来计算,但是不能为每条指令都生成OopMap,这样数据太大,效率会变低,而且引用关系也实时变化,因此需要在某个“特定的位置”上记录这些信息,让线程在此安全点才能暂停,达到Stop The World,进行回收的动作;
- 安全区域(Safe Region):安全点的扩展,在某端执行时间内,关系不变化,也可以进行回收动作;
4.1、Serial收集器
Serial收集器是最基本的、发展历史最悠久的收集器,是一个单线程的收集器,收集器实现原理:
- 新生代,采用复制算法,需要暂停所有用户线程;
- 采用“标记-整理”算法,需要暂停所有用户线程;
优点:
- 简单而高效(与其他收集器的单线程相比)当前Client模式下,此收集器是比较好的选择;
缺点:
- 多线程环境下,暂停时间太久,影响用户体验;
4.2、ParNew收集器
ParNew收集器是Serial收集器的多线程版本,是Server模式下的虚拟机中首选的新生代收集器,只有它能够与CMS收集器(老年代的)配合工作。在多线程环境下,对于CPU资源的有效利用还是有很好的效果的。默认开启的收集线程数量与CPU的数量相同。也可以通过参数进行控制。
4.3、Paralle Scavenge收集器
是一个新生代收集器,使用“复制算法”的收集器,也是并行的多线程收集器。它与其他收集器的区别是,它的目标是达到一个可控制的吞吐量。
吞吐量 = 运行用户代码时间 / (运行用户代码时间+垃圾收集时间)
4.4、Serial Old收集器
Serial Old是Serial收集器老年代版本,也是单线程收集器,使用“标记-整理”算法,这个收集器的主要意义是在于给Client模式下的虚拟机使用的。主要有两种用途:
- JDK 1.5以及之前的版本中与Paralle Scavenge收集器搭配使用;
- 作为CMS收集器的后备语言,发生在Concurrent Mode Failure时使用;
4.5、Parallel Old收集器
是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。在注重吞吐量以及CPU资源敏感的场景,可以先考虑使用Parallel Scavenge加Parallel Old收集器。
4.6、CMS收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它是基于“标记-清除”算法实现的,它的运作过程相对于前面的几种收集器更为复杂一点,分为4步:
- 初始标记(CMS init mark),需要“Stop The World”,这一步仅仅只是标记GC Rootes能直接关联到的对象速度比较快;
- 并发标记(CMS concurrent mark),是仅限GC Roots Tracing的过程;
- 重新标记(CMS remark),需要“Stop The World”,为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记时间稍长写,但是远比并发标记时间短;
- 并发清除(CMS concurrent sweep);
缺点如下:
- 对CPU资源敏感,虽然不会导致用户线程停顿,但是因为会占用一部分CPU资源而导致引用程序变慢,总吞吐量会降低;
- CMS收集器无法处理浮动垃圾(Floating Garbage),由于CMS收集器在工作时,用户线程也进行工作,所以CMS收集器运行期间预留的内存无法满足程序需要,就会出现Concurrent Moe Failure失败而导致另一次Full GC的产生,这时会虚拟机将启动后备方案,临时启用Serial Old收集器来重新进行老年代的垃圾收集;
- CMS是基于“标记-清除”算法实现的,会产生碎片,不过它提供了一个-XX:+UseCMSCompactAtFullCollection开关,用于CMS收集器顶不住要进行FullGC是开启内存碎片的合并整理过程;
4.7、G1 收集器
G1(Garbage-First),是当今收集器技术发展的最前沿成果之一,与其他GC收集器相比最大的区别在于,它的内存收集范围不在是整个新生代或者老年代,而是将整个内存分为多个大小相等的独立区域(Region),具有如下特点:
- 并行与并发:可以使用多个CPU来缩短Stop-The-World停顿的时间;
- 分代收集:它不需要其他收集器的配合就可以管理整个堆;
- 空间整合:与CMS的“标记-清理”算法不同,G1整体是基于“标记-整理”算法实现的收集器,从局部(两个Region之间)上看是基于“复制”算法实现的,不论如何,不会产生内存碎片;
- 可预测的停顿:建立了可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集器上的时间不得超过N秒;
由于分成了多个Region,因此为了处理跨Region之间的引用问题,需要对每个Region都维护一个Remembered Set来处理跨Region之间的信息,这样可以达到不用全量扫描对也能全部枚举出GC Roots;
除了Remembered Set的操作,G1收集器分如下几步工作:
- 初始标记(Initial Marking),需要Stop-The-World,仅仅标记一下GC Roots;
- 并发标记(Concurrent Marking),从GC Roots到堆中对象的可达性分析;
- 最终标记(Final Marking),需要Stop-The-World,修正并发标记中产生的变化部分;
- 筛选回收(Live Data Counting And Evacuation),根据用户的停顿时间来制定回收计划;
5、小结
冰山之下,另有洞天,Java能够做到一次编写,到处运行,而JVM就是冰山之下的支撑,其中的设计思想是非常值得我们去了解学习的,这样能够让我们在工作中做到知其然知其所以然。