深入理解Java虚拟机-第三章 垃圾收集器与内存分配策略(上)

第三章 垃圾收集器与内存分配策略(上)

3.1 概述

垃圾处理器实际上仅关注三个点:哪些内存需要回收、什么时候回收以及如何回收。三点会在后面一一讲解
前面第二章大致讲述了 JVM 运行时区域的各个部分,我们会发现程序计数器、虚拟机栈、本地方法栈这三个区域基本上是随着线程生而生,死而死。虚拟机栈中的栈帧在类结构确定下来的时候基本就可以知道分配多大的大小。所以这几个区域的内存分配和回收基本是确定的。我们基本不用考虑这几个区域的垃圾回收问题,因为随着线程结束或方法结束时,内存自然而然就释放掉了。于是我们把目光主要聚集在 Java 堆和方法区这两个区域。

3.2 对象已死吗

3.2.1 引用计数算法

引用计数算法其实非常简单,就是给对象添加一个引用计数器,只要对象被引用一次,他的计数器就加1,而当每个引用失效后,计数器减1。任何情况下只要计数器为0,这个对象就没有人使用了,即可回收。这种算法固然简单有效,但还是产生了问题。请看如下代码:

/**
 * 互相引用后,计数器都不为零
 */
public class ReferencceCountingGC{
	public Object instance = null;
	
	private static final int _1MB = 1024*1024;
	
	/**
	 * 这个成员属性的唯一意义就是占些内存,以便能在GC日志中看清楚是否被回收过
	 */
	private byte[] bigSize = new byte[2 * _1MB];
	
	public static void main(String[] args){
		// 这时,一个新的对象(我们称对象1)被创建出来并且被指向给rcgc1。
		// 所以对象1的计数器为1
		ReferencceCountingGC rcgc1 = new ReferencceCountingGC();
		// 这时,又一个新的对象(我们称对象2)被创建出来并且被指向给rcgc2。
		// 所以对象2的计数器也为1
		ReferencceCountingGC rcgc2 = new ReferencceCountingGC();
		
		// 对象2又被指向给对象1的 instance 字段。
		// 所以对象2的计数器加1,当前计数器为2
		rcgc1.instance = rcgc2;
		// 同理,对象1的计数器加1,当前计数器为2
		rcgc2.instance = rcgc1;
		
		// 此时将 rcgc1 和 rcgc2 都释放掉
		// 对象1 和 对象2 的计数器各减1 ,当前为一
		rcgc1 = null;
		rcgc2 = null;
		// 理论上这两个对象的计数器永远不可能为0了,所以永远不会被回收
		System.gc()
	}
}

经过上述代码论证,如果采用计数器算法,这种互相引用将永远无法回收。但是当你加入参数后真正跑这个方法的时候你会发现,其实这两个对象是被回收了的。为什么呢,其实很好解释,因为 JVM 判断对象是否存活并不是采用的引用计数算法 😛 。

2.3.2 可达性分析算法

在主流的商业语言中(例如C++、Java),判断对象是否存活都是靠可达性算法来实现的。所谓可达性算法,如图所示,就是通过一系列被称为“GC Roots”的对象作为起始点,然后向下搜索引用,搜索所走过的路被称为引用链。当一个对象不在任何一条引用链上的时候,这个对象被视为不可达对象,即无用对象。就是说这个对象没有任何一个人引用。代表他可以被回收了。这样就解释了2.3.1里互相引用为什么可以被回收掉,虽然对象1和对象2的成员变量中还有彼此的引用,但是 GC Roots 所查找的引用链中找不到对这两个对象的引用(因为 rcgc1 和 rcgc2 两个根引用都被置空),就像图中的不可达对象,虽然还有引用但是他们到 GC Roots是不可达的,所以他们被判定为可回收对象。
可达性分析算法判定对象是否可回收
在 Java 语言中,可作为 GC Roots 的对象有以下几种

  • 虚拟机栈(栈帧中的局部变量表)中引用的对象
  • 方法区里类的静态变量引用的对象
  • 方法区里常量引用的对象
  • 本地方法栈中JNI(即一般说法的 Native 方法)引用的对象
3.2.3 再谈引用

JDK 1.2 以前,Java 对引用的定义很传统:

如果 reference 类型的数据中存储的数值代表的是另一块内存的起始地址,就成这块内存代表着一个引用。

这种定义很纯粹但很狭隘,我们无法描述一些鸡肋的对象。就是说内存足够我希望你保留,但是当内存不够了你就给滚蛋。于是1.2版本后,JDK 针对引用在此细分为四种。这里我觉得书中的描述不太通俗易懂,于是引入网上一大神在某公众号回复的评论来再次解释:

在 Java 语言中,除了基本数据类型外,其他的都是指向各类对象的对象引用;Java中根据其生命周期的长短,将引用分为4类。

1 强引用
特点:我们平常典型编码 Object obj = new Object() 中的 obj 就是强引用。通过关键字 new 创建的对象所关联的引用就是强引用。 当 JVM 内存空间不足,JVM 宁愿抛出 OutOfMemoryError 运行时错误(OOM),使程序异常终止,也不会靠随意回收具有强引用的“存活”对象来解决内存不足的问题。对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为 null,就是可以被垃圾收集的了,具体回收时机还是要看垃圾收集策略。

2 软引用
特点:软引用通过SoftReference类实现。 软引用的生命周期比强引用短一些。只有当 JVM 认为内存不足时,才会去试图回收软引用指向的对象:即JVM 会确保在抛出 OutOfMemoryError 之前,清理软引用指向的对象。软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收器回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。后续,我们可以调用 ReferenceQueue 的 poll() 方法来检查是否有它所关心的对象被回收。如果队列为空,将返回一个 null ,否则该方法返回队列中前面的一个 Reference 对象。
应用场景:软引用通常用来实现内存敏感的缓存。如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。

3 弱引用
特点:弱引用通过 WeakReference 类实现。 弱引用的生命周期比软引用短。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。由于垃圾回收器是一个优先级很低的线程,因此不一定会很快回收弱引用的对象。弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。
应用场景:弱应用同样可用于内存敏感的缓存。

4 虚引用
特点:虚引用也叫幻象引用,通过 PhantomReference 类来实现。无法通过虚引用访问对象的任何属性或函数。幻象引用仅仅是提供了一种确保对象被 finalize 以后,做某些事情的机制。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。虚引用必须和引用队列 (ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。
ReferenceQueue queue = new ReferenceQueue();
PhantomReference pr = new PhantomReference(object, queue);
程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取一些程序行动。
应用场景:可用来跟踪对象被垃圾回收器回收的活动,当一个虚引用关联的对象被垃圾收集器回收之前会收到一条系统通知。

3.2.4 生存还是死亡(对象的自我救赎)

其实即使可达性分析算法分析出的不可达对象也不一定是“非死不可”的。为何这么说,其实系统在执行垃圾回收时,会进行两遍标记。先是执行可达性分析算法,发现不可达对象时,进行一次标记。然后系统会去筛选一遍,将需要执行 finalize() 方法的实例筛选出来。除了系统已经执行过 finalize() 方法和没有重写过 finalize() 方法的实例,剩下的就是需要执行的。
如果这个对象需要执行 finalize() 方法,则会被放入一个叫 F-Queue 的队列当中,并且在稍后由一个虚拟机创建的低优先级的 Finalizer 线程去挨个执行 finalize() 方法。这里的执行仅仅承诺执行,但是不承诺等待返回。因为这里如果出现死循环、死锁等事故的话,会导致队列剩下的无法执行。
如果对象想要“自我救赎”的话,只需要在 finalize() 方法中重新与引用链上的任何一个对象建立关联即可,譬如把自己(this)赋值给某个类变量或对象的成员变量,那么第二次标记时它将被移除出“即将回收”的集合。举例如下:

public class FinalizeEscapeGC {
	public static FinalizeEscapeGC fegc = null;

	@Override
	protected void finalize() throws Throwable {
		super.finalize();
		System.out.println("finalize method executed");	
		FinalizeEscapeGC.fegc = this;
	}

	public static void main(String[] args) throws Throwable {
        // 此时生成对象1
        fegc = new FinalizeEscapeGC();
        System.out.println("fegc's address is " + fegc.toString());
        // 对象1从引用链上脱离
        fegc = null;
        // 告诉系统进行垃圾回收
        System.gc();
        // 因为 finalize 方法的优先级很低,所以睡500毫秒等待
        Thread.sleep(500);
        if (null != fegc) {
            System.out.println("I'm alive");
            // 此处打印地址,
            System.out.println("after gc, fegc's address is " + fegc.toString());
        } else {
            System.out.println("I'm dead");
        }

        // 对象1从引用链上脱离
        fegc = null;
        // 告诉系统进行垃圾回收
        System.gc();
        // 因为 finalize 方法的优先级很低,所以睡500毫秒等待
        Thread.sleep(500);
        if (null != fegc) {
            System.out.println("I'm alive");
            // 此处打印地址,
            System.out.println("after gc, fegc's address is " + fegc.toString());
        } else {
            System.out.println("I'm dead");
        }

    }

}

执行结果如下:

fegc's address is com.simon.jvm.FinalizeEscapeGC@66d3c617
finalize method executed
I'm alive
after gc, fegc's address is com.simon.jvm.FinalizeEscapeGC@66d3c617
I'm dead

通过打印我们可以看出,第一次打印的对象和 GC 后的对象是同一个。也就是说,这个对象并没有被回收掉。说明了我们的逻辑是正确的,但是同样的代码第二次为什么又被回收掉了?还记得我们的前提条件吗?
除了系统已经执行过 finalize() 方法和没有重写过 finalize() 方法的实例,剩下的就是需要执行的
明白了,第二次再进行二次标记的时候,它就属于系统执行过 finalize() 方法这一批了,所以不会再执行一遍。于是就被回收掉了。也就是说,任何一个对象的 finalize() 方法都只会被系统自动调用一次,如果对象面临下一次回收,他的 finalize() 方法是不会被再次执行的。

3.2.5 回收方法区

JVM针对方法区的回收,要比针对堆的回收有更严格的检查机制和筛选条件。方法区的垃圾收集主要回收的是废弃常量及无用类。回收废弃常量比较好理解,他跟回收 Java 堆的实例对象非常相似。例如,有一个字符串 “ABC” 进入了常量池,但是当前系统没有任何一个 String 对象为 “ABC” 的,也就是说这个字符串没有任何引用,就需要被回收。常量池中的其他符号引用量也与字面量类似。
但是判断一个类是否是无用类就比较麻烦,需要满足以下三个条件

  • 该类的所有实例都已被回收,也就是 Java 堆中不存在任何这个类的实例。
  • 加载该类的所有ClassLoader 都已经被回收
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,也就是说无法在任何地方通过反射访问该类的方法
    虚拟机每次回收无用类前都需要预检查上面三个条件,只有三个条件都通过了才允许回收,最终是否回收则由相应参数控制。

3.3 垃圾收集算法

3.3.1 标记 - 清除算法

标记 - 清除(Mark-Sweep)算法是一个最基础的收集算法,后续的算法多多少少是基于这个算法的思路来进行的。
就像他的名字,这种算法的过程就是先标记,再清除。先通过前面讲的可达性算法,标记处不可达对象,然后再一次性回收。清除效果如下:
标记 - 清除算法
缺点有如下两点:

  • 效率问题:标记和清除两个动作理论上的效率其实并不高,所以算法性能比较低
  • 空间问题:标记和清除后,就会留有大量的碎片空间。这样分配对象的时候就不得不采用空闲列表方式,但是会有个明显的问题,就是大对象如果没有足够的碎片空间去分配的话,就需要再次执行垃圾回收直到有为止。这样明显会提升垃圾回收的次数,性能问题也就不言而喻。
3.3.2 复制算法

复制算法讲的就是将可用内存化为容量大小相等的两部分,每次只用其中的一部分,当这一块的内存用完了,就将存活的对象复制到另一块上,然后本块内存整个释放(如图)。这样使得每次垃圾回收是整块回收,释放的空间是规整的连续的,可以用指针碰撞法直接分配内存空间。不仅提高了垃圾回收的效率,也提高了分配空间的速度。
复制算法
目前的商业虚拟机都采用这种收集算法来回收新生代,但是这样一次只能用一半的空间,未免太浪费,也并不是很合适。经过IBM公司的专门研究表明,新生代中的对象98%都是“朝生夕死”的,所以并不需要按照1:1划分。HotSpot从一开始就设计出 Eden 区、From Survivor 区和 To Survivor 区。并以8:1:1的方式划分。每次使用时就使用 Eden 区加其中一个 Survivor 区。这样整个的可用区就是90%,只有10%的内存会被“浪费”。当然我们没办法保证每次回收都只有不多于10%的对象,如果有大对象就需要依赖其他内存,也就是老年代。我们管这个叫分配担保。
原书中对于分配担保描述的形象生动,记录在此:

内存的分配担保就好比我们去银行借款,如果我们信誉很好,在98%的情况下都能按时偿还,于是银行可能会默认我们下一次也能按时按量地偿还贷款,只需要有一个担保人能保证如果我不能还款时,可以从他的账户扣钱,那银行就认为没有风险了。内存的分配担保也一样,如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。

3.3.3 标记 - 整理算法

因为复制算法在对象存活率较高的情况下就要进行很多的复制操作(从 一个 S 区到另一个 S 区),这引起了效率问题。并且复制算法需要分配担保,年轻代可以让老年代担保,但老年代就不可能再担保给别人不然就反反复复无穷尽也了。这时就推出了标记 - 整理算法(Mark - Compact),与标记 - 清理算法相似的是,他们都会去标记不可达对象,但是不同的是标记 - 整理算法标记后不进行清除操作,而是将所有存活的对象都向一段移动,最后直接清理掉端边界意外的内存(如图)。
标记 - 整理算法

3.3.4 分代收集算法

这种算法说白了就是一种分类收集的思想。就是根据对象存货周期的不同将内存划分为几块。像生存周期比较短的,就划分成年轻代使用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代这种对象存活率较高、没有额外空间对它进行分配担保的,就需要用标记 - 整理或标记 - 清除算法了。

3.4 HotSpot 的算法实现

3.4.1 枚举根节点

所谓枚举根节点,实际上就是前文提到的可达性分析中的 GC Roots。我们要一个不拉的将所有的 GC Roots 都找出来,但是找这个的过程中一定会有引用的变更。所以为保准确性,我们引入 STW(Stop The World) 概念,就是所有线程都要停止下来等待我检测完(类似妈妈打扫卫生时要你不要动一样)。这样一定会造成用户的卡顿,所以我们一定要减少 STW 的时间和次数。但是有的时候仅仅方法区就几百兆,这怎么缩短时间呢。此处引用原文一段话来解释如何不用遍历所有的地方就能知道哪里有引用

目前主流的Java虚拟机都采用的是准确式GC,当执行系统停顿下来后,我们不需要一个不漏地检查完所有执行上下文和全局的引用位置。在HotSpot的实现中,是使用一组称为OOPMap的数据结构来达到这个目的的,首先在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样,GC在扫描的时候,就可以根据OOPMap上记录的信息准确定位到哪个区域中有对象的引用,这样大大减少了通过逐个遍历来找出对象引用的时间消耗。

其实这里即使引用原文的话,我的印象也是比较模糊的(这里理解的请直接看3.4.2。。)。我迷惑的点是在这个 OopMap 到底是存了个什么东西。或者说到底一个实例对象是对应着一个 OOPMap 还是一组 OopMap 。在网上查了许多资料,我又翻来覆去读这一段话,豁然开朗。不过可能有些不太准确欢迎指正。
我们知道,GC 枚举根节点的时候,主要针对的目标是实例,看哪些地方正在使用这些实例(也就是说找这些实例的引用)。那么这些实例的引用存在哪呢——方法区和虚拟机栈栈帧中的局部变量表里,也就是说我找到了这些引用 相应的实例我就找到了,所以引入 OopMap 这个东西。
有两个地方会记录或叫更新 OopMap ,原文说首先在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,后半句应该就是并放入 OopMap,也就是说 OopMap 是在类加载完成的时候就会记录一份,但是记录了什么呢,原文说的对象我理解实际上是应该是类对象而不是类实例,也就是 java.lang.Class 对象。在加载类之后,方法区内就已经存了类信息,这时计算这个类的成员变量或静态常量是什么类型的并且存起来了。
第二个地方呢,原文是 在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这个特定的位置其实说的有些模糊,猛地看上去像是在描述在某个地方存储着东西。他这里想描述的这个“特定的位置”其实就是后文的安全点,代表的其实是代码的行数。比如执行到return 这行代码时,记录下这条指令的时候,栈上和寄存器里哪些位置是引用。
还是引入 RednaxelaFX 大神的话看上去比较清晰(这些其实是我得出结论后再查到的,与我的猜想大差不差。成就感爆棚哈哈哈):

在HotSpot中,对象的类型信息里有记录自己的OopMap,记录了在该类型的对象内什么偏移量上是什么类型的数据。所以从对象开始向外的扫描可以是准确的;这些数据是在类加载过程中计算得到的。

每个被JIT编译过后的方法也会在一些特定的位置记录下OopMap,记录了执行到该方法的某条指令的时候,栈上和寄存器里哪些位置是引用。这样GC在扫描栈的时候就会查询这些OopMap就知道哪里是引用了。这些特定的位置主要在:
1、循环的末尾
2、方法临返回前 / 调用方法的call指令后
3、可能抛异常的位置
这种位置被称为“安全点”(safepoint)。
这样通过遍历这几个 OopMap 即可得到现在究竟有多少个 GC Roots。
原文请戳这里

3.4.2 安全点(SafePoint)

在 OopMap 的帮助下,HotSpot 可以准确快速的扫描到GC Roots。但是问题也很明显,导致 OopMap 变更的指令也太多了,也不能执行一步就生成一个 OopMap 吧,这样占用的空间就太大了。于是就诞生了安全点这个概念,也就是上一小节提到的特定的位置。就是说,程序不是能在任何地方都可以停下来 GC 的,只有到达安全点才可以。那么安全点的选定就变得尤为重要,少了,GC等的时间太长,多了又拖慢效率。本着“是否具有程序长时间执行的特征”为标准进行选定(句话是原文,咱也不知道不知道作者自己能不能看明白这话啥意思,反正我是看不懂),反正选定了几个位置如下:

  • 循环的末尾
  • 方法临返回前 / 调用方法的call指令后
  • 可能抛异常的位置

书中只提了JIT在编译时会在记录OopMap,那么在解释器中执行的方法和Native方法呢,引入R神的回复作以记录:

在解释器中执行的方法则可以通过解释器里的功能自动生成出OopMap出来给GC用。

对Java线程中的JNI方法,它们既不是由JVM里的解释器执行的,也不是由JVM的JIT编译器生成的,所以会缺少OopMap信息。那么GC碰到这样的栈帧该如何维持准确性呢?
HotSpot的解决方法是:所有经过JNI调用边界(调用JNI方法传入的参数、从JNI方法传回的返回值)的引用都必须用“句柄”(handle)包装起来。JNI需要调用Java API的时候也必须自己用句柄包装指针。在这种实现中,JNI方法里写的“jobject”实际上不是直接指向对象的指针,而是先指向一个句柄,通过句柄才能间接访问到对象。这样在扫描到JNI方法的时候就不需要扫描它的栈帧了——只要扫描句柄表就可以得到所有从JNI方法能访问到的GC堆里的对象。
但这也就意味着调用JNI方法会有句柄的包装/拆包装的开销,是导致JNI方法的调用比较慢的原因之一。

对于安全点,另一个需要考虑的问题就是如何在需要 GC 的时候,让所有的线程都跑到最近的安全电上再停下来。这有抢先式中断和主动式终端两种方式供选择:

  • 抢先式中断:现将所有线程中断,然后恢复没到安全点的线程让他继续跑到安全点
  • 主动式终端:更新中断标志,所有线程轮询这个标志。一旦发现标志更换,就自己中断挂起。查询标志的地方跟安全点是重合的(还有创建对象分配内存的时候也要去查询)。这样就保证了中断的时候一定是在安全点的。
3.4.3 安全区域

安全点看上去完美解决了如何进入GC的问题,但是如果这时线程被阻塞住了或者在sleep怎么办。理论上他是不会执行的,也就没办法进入所谓的安全点。那怎么办,GC 不能一直等着他吧。这是就引入了安全区(Safe Region)的概念。
安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始 GC 都是安全的。我们也可以吧 Safe Region 看作是被扩展了的 Safepoint。
当线程执行到安全区的时候,先标识下自己已经进入了安全区。这样在 JVM 要发起 GC 的时候,就不用管这些进了安全区的线程了。当这些线程走出安全区的时候,要先检查系统有没有在 GC ,如果有的话,要等 GC 完毕之后再走出去。

3.5 垃圾收集器

如果说垃圾回收算法是内存回收的方法论,那么垃圾收集器就是具体实现。仅 HotSpot 虚拟机就提供了 7 种垃圾收集器,具体分类如下图所示。
垃圾收集器
上图展示了 7 种作用于不同分代的收集器,如果两个收集器之间有连线的话,说明他们可以搭配使用。世界上不存在哪个收集器最好哪个最差(就如同语言一样,没有最好。PHP除外),只是针对不同场景使用不同的收集器会有更好的效果。

3.5.1 Serial / Serial Old 收集器

Serial 收集器:这是一个最基本、发展历史最悠久的收集器,曾经(在JDK 1.3.1之前)是虚拟机新生代收集的唯一选择。这个收集器是一个单线程的收集器,这里的“单线程”的意义并不仅仅说明它只会使用一个 CPU 或一条收集线程去完成GC工作,他还代表在垃圾收集时会“Stop The World”,即在用户不可见的情况下把用户正常工作的线程全部停掉。虽然好像老而旧,但是它仍然简单而高效(毕竟专门来做GC,也不需要考虑并发等问题),在用户的桌面场景中分配给虚拟机的内存一般来说比较小,停顿时间完全可以控制在几十到一百毫秒之内。这个停顿是可以接受的,所以 Serial 收集器对于运行在 Client 模式下的虚拟机来说是一个很好的选择。

Serial Old 收集器:这是 Serial 收集器的老年代版本,他同样是一个单线程的收集器。使用的是上文提到过得 标记 - 整理算法。它的主要意义跟 Serial 一样,都是用于 Client 模式下的虚拟机。但是 Old版本还有其余两种用途:

  1. 在 JDK 1.5 以及之前版本,跟 Parallel Scavenge 收集器搭配使用(虽然 PS 中有自己的 PS markSweep 收集器来进行老年代收集,但实现上跟 Serial Old 非常相似,此处放在一起)。
  2. 用于作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。

工作模式如下图所示:
Serial / Serial Old 收集器运行示意图
使用-XX:+UseSerialGC可以使用Serial+Serial Old模式运行进行内存回收(这也是虚拟机在Client模式下运行的默认值)

3.5.2 ParNew 收集器

ParNew 收集器说白了就是 Serial 收集器的“多线程”版,他的实现跟 Serial 收集器除了启动多条线程收集垃圾意外并没有很多创新的地方,但是他却是普遍应用于 Server 端。为啥呢,因为 JDK 1.5 版本开发出的 CMS 收集器(HotSpot 虚拟机真正意义上第一款并发的垃圾收集器,他能做到让垃圾收集线程和用户线程(基本上)同步)只能配合 ParNew 或 Serial ( CMS 为老年代收集器,需要搭配一个年轻代的收集器使用)。但是在现在这种多核 CPU 环境下,ParNew 的效率要明显高于 Serial (单核他的效率要低于 Serial,甚至双核的效率可能都比不过 Serial )。
工作流程如图:
ParNew / Serial Old 收集器运行示意图

注意:从ParNew收集器开始,后面还会接触到几款并发和并行的收集器。并发和并行,这两个名词都是并发编程中的概念,解释如下:

  • 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
  • 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个CPU上
3.5.3 Parallel Scavenge / Parallel Old 收集器

Parallel Scavenge 收集器(下面简称PS)是一个新生代的垃圾收集器。同样的,这也是一个并行的收集器,这跟 ParNew 有啥区别呢。区别还挺大的,他们之间的区别最主要是在于目标不同。 CMS 等收集器,都是为减少 STW 而设计的,PS 不同,他是为了提高系统吞吐量而生。所谓吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即 吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)。
减少 STW 的时间是为了提高用户体验,而提高吞吐量则是为了高效率运用 CPU 。比如像 ElasticSearch / Hadoop 这种高运算量平台,其实更在意的是尽快完成程序的运算任务。
Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。
这里要注意,MaxGCPauseMillis 这个参数并不一定是越短越好,因为GC停顿时间缩短是以牺牲吞吐量和新生代空间换来的。原来10秒一次,一次100毫秒。现在5秒一次,一次70毫秒。看上去好像是持续时间短了,但是原来10秒钟只停100毫秒,现在10秒要停140毫秒。
Parallel Old 收集器(下面简称 PO)是 PS 收集器的老年代版本。采用了多线程和标记 - 整理算法。
工作过程如图:
Parallel Scavenge / Parallel Old 收集器工作流程

这章有点长,学了一天也才刚学完几个简单的收集器。剩下两个大收集器(CMS 、G1)和剩余的零碎知识点放到下半部分。

本文仅是在自我学习 《深入理解Java虚拟机》这本书后进行的自我总结,有错欢迎友善指正

欢迎友善交流,不喜勿喷~
Hope can help~

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值