细说jvm(五)、垃圾回收器入门

1,020 阅读9分钟

之前的文章

1、细说jvm(一)、jvm运行时的数据区域

2、细说jvm(二)、java对象创建过程

3、细说jvm(三)、对象创建的内存分配

4、细说jvm(四)、垃圾回收算法

接下来会用几篇的功夫来讲讲垃圾回收器,这块是个比较重要的地方,我也会在垃圾回收器这部分内容讲关于GC的优化,在涉及到CMS以及G1的时候篇幅会比较大,因为这是现在最常用的垃圾回收器,我得多讲点才能对你有所帮助。

我的文章里总共会讲到Serial,Serial Old,Parallel Scavenge,Parallel Old,ParNew,CMS,G1,以及ZGC这些垃圾回收器,用的多的我会细讲,用的少的我就只会说说工作过程。所以本篇先只说Serial,Serial Old,Parallel Scavenge,Parallel Old,ParNew这五款回收器,CMS和G1和ZGC我会单独用文章来说。

Serial 和 Serial old

从Serial这个单词我们可以理解出,这个垃圾回收器在运行的时候,它必须要暂停其他的工作线程直到它收集结束,另外这个玩意是单线程的,是针对新生代的回收器,老年代则是使用Serial old,老年代和新生代采取的算法也不相同,新生代使用的是复制算法,而老年代使用的是标记整理算法。这个收集器在单cpu的工作条件下表现会比较优秀,因为避免了线程上下文切换带来的开销。

ParNew

这个垃圾回收器可以说是Serial 的多线程版本,也是一款并行收集器。

并行和并发的区别是:并发是指的是和用户线程一起运行,即并发过程不会暂停用户线程,但是并行是需要暂停用户线程的,也就
是说,ParNew在GC的时候是需要STW的。

除过多线程之外,这个收集器其他行为和Serial可以说是完全一样的(我自己就一直是这么理解的)。我们可以使用-XX:ParallelGCThreads来调整进行垃圾回收的线程数量,值得注意的是,垃圾回收的线程的数量绝对不是越多越好,越多的线程只会增大线程上下文切换的开销。另外,ParNew是针对新生代的收集器,使用ParNew的时候,老年代需要另外选择其他的收集器,可以是Serial或者是CMS,一般会选择CMS。

Parallel 和 Parallel Old

Parallel的全称是Parallel Scavenge,这玩意是一个针对新生代的回收器,与之对应的Parallel Old则是针对老年代的回收器。在jdk1.8中,这两个回收器是一套的,什么意思呢?就是当我们用了-XX:+UseParallelGC之后就会自动的在老年代给我们搭配上Parallel Old。事实上即使在jdk1.8之前,能和Parallel Scavenge搭配的老年代回收器也只有Serial Old和Parallel Old。Parallel是不能和CMS搭配使用的,这个原因是因为Parallel和CMS使用了不同的jvm分代框架。

为什么不能和CMS搭配使用?这里看R大在这个链接里的回答 https://hllvm-group.iteye.com/group/topic/27629#post-199089

Parallel的这两个回收器是属于并行收集器,它们设计的目标是为了尽可能的提高吞吐量。所谓吞吐量,指的是运行用户代码和总的运行时间的比值,总的运行时间包含垃圾回收时间和运行用户代码的时间。算法的使用上,新生代的Parallel Scavenge使用的是复制算法,老年代的Parallel Old则使用的是标记整理算法。Parallel提供了两个参数-XX:MaxGCPauseMillis和-XX:GCTimeRatio以便于我们可以更精准的控制吞吐量,我们一个一个的来看一下它们的用法。

-XX:MaxGCPauseMillis

这个参数的值是一个毫秒的值,意思是所允许每次的最大停顿时间,如果设置了这个值的话,jvm将尽力满足我们的这个要求。但是千万别以为这个值是越小越好的,因为太小的停顿往往意味着牺牲了吞吐量,具体原因我们在本文的最后一部分说。

-XX:GCTimeRatio

这个参数的值一个0到100的整数,意思是所允许的垃圾回收时间占总的程序运行时间的比例,默认是99,意思就是最大允许百分之一的垃圾回收时间。

除过这两个参数之外,我们还可以设置自适应的策略,-XX:+UseAdaptiveSizePolicy,这个参数设置之后,我们就不需要再设置新生代大小(-Xmn),以及eden和survivor的比例(-XX:SurvivorRatio)以及晋升到老年代对象大小(-XX:PretenureSizeThreshold)等细节的参数了。

关于Parallel收集器,我们再多说说它的内存分配策略和悲观策略,因为这点也是它很不一样的地方。

内存分配策略

常规的收集器在当年轻代放不下的时候,往往出触发一次young gc,但是到了Parallel这里,有一些特殊,具体分为两点,一是当整个新生代放不下某个对象的时候,这个对象会直接进入老年代,另一方面是当整个新生代都可以放下但只是eden的空间不够时,则尝试young一次。我们使用代码来证明刚才说的这两个点是对的,来看看代码

jvm参数,证明第一点用
//-Xms100m
//-Xmx100m
//-Xmn50m
//-XX:SurvivorRatio=8
//-verbose:gc              // 和printGCDetails这个参数的作用是一样的
//-XX:+PrintGCDetails
//-XX:+PrintGCDateStamps
//-XX:+PrintHeapAtGC       //每次gc前后打印堆内存空间使用的情况
//-XX:+UseParallelOldGC
public static void main(String[] args) {
      byte[][] use = new byte[7][];
      use[0] = allocM(10);
      use[1] = allocM(10);
      use[2] = allocM(10);
      use[3] = allocM(20);
}

private static byte[] allocM(int n) {
      return new byte[1024 * 1024 * n];
}

输出结果如下: 我一点一点解释下,在main方法的最后一行代码这里,分配了20M空间给新的对象,此时其实新生代eden的空间已经不够了,但是并没有发生GC,而是直接将对象分配在了老年代。输出结果的图这个只是程序运行结束时候的内存使用情况,我们可以清楚的看出来根本就没有发生GC,另外我们可以看到,老年代被使用了20480K,这正好是我们最后分配的对象的大小,可能会有同学奇怪为什么新生代被使用的大小大于30M,这个是因为在堆内存中还有着一些常量,它们占据了额外的内存,因此新生代大小是大于30M的,注意哈,这些常量也是受GC约束的。到这里第一点就证明了。我们再来看看第二点,这时候,我们保持参数不变,把上面的代码改成下面这个样子:

public static void main(String[] args) throws InterruptedException {
    allocM(10);
    allocM(10);
    allocM(10);
    allocM(10);
}

private static byte[] allocM(int n) {
    return new byte[1024 * 1024 * n];
}

可以看到输出如下: 在main方法的最后一行代码中,我们分配了10M的空间给新的对象,此时eden已经被使用了30M,剩下eden的空间明显不足以分配新对象,但是此时Survivor的两个区域(共10M,注意eden和两个Survivor区域加起来的和是新生代)还没有被使用,满足第二个点的条件,于是触发了一次young gc,也有人叫minor gc。

minor gc == young gc ,都是针对新生代的垃圾回收
悲观策略

这个策略具体指的是:在执行Young GC之后,如果预计下次晋升老年代的平均大小,比当前老年代的剩余空间要大的话,则会触发一次Full GC。

其他收集器没有这个策略,其他收集器仅仅是在执行young gc之前,如果估计之前晋升老年代的平均大小,比当前老年代的剩余空间要大的话,则会放弃young gc,转而触发full gc,Parallel不仅仅有这个,还多了上面的悲观策略

我们依然使用代码证明下,保持上面的参数,仅仅把代码改成如下样子:

public static void main(String[] args) throws InterruptedException {
    byte[][] use = new byte[7][];
    use[0] = allocM(10);
    use[1] = allocM(10);
    use[2] = allocM(10);
    System.out.println("要发生GC了。。。。。。。");
    use[3] = allocM(10);
}

private static byte[] allocM(int n) {
    return new byte[1024 * 1024 * n];
}

输出结果如下 大家不要被这么长的日志吓到,这都是纸老虎而已(后边我还会教大家用可视化工具分析GC,所以别怕),其实就是因为我们在每次gc的前后打印了堆内存空间的使用情况而已,所以显得日志很长。我们可以看到,最后一次分配对象之前发生了两次GC,其中第一次是young gc,第二次是full gc,young gc这个还顺便证明了上面说过的第二点,但是在young gc之后,发现之前晋升到老年代的大小已经大于当前老年代剩下的空间了,所以触发了一次full gc,注意蓝色方框内的字Ergonomics而不是Allocation Failure,这个意味这这次full gc并不是由于分配失败引起的,而是由于jvm自身的机制引起的。

画外音:用Parallel 这两个收集器的时候full gc可能比你想象中的会多一些

jvm不可能三角之间的矛盾

jvm的内存大容量,以及低停顿和吞吐量之间这三者是个不可能三角,这个的意思就是很难同时把三点全部都做的非常好,内存的大容量,意味着垃圾回收器就必须回收更多的空间,这就必须造成更大的停顿时间,而为了缩小停顿时间,就必须并发执行,并发执行,增加了线程上下文切换带来的开销,于是吞吐量就会被降低。我们一般来说更关注的是延迟,因为很难去忍受应用有个一两秒的停顿(这个在2G以上堆内存的时候发生full gc尤其容易有这样时间的停顿)。你可以想象一下,我们的服务是一个很长链路中的一部分,然后我们的应用由于full gc停了一秒多,如果是高并发的情况下,上游很快就会堆积很多请求。另外就是这和用户体验是息息相关的,用户是很难以忍受我们的应用频繁卡顿的,你想如果双十一抢东西的时候页面卡顿那用户的心情简直可以美如画,然后把你喷的体无完肤最后还会附上一句看看人家系统都不卡的致命嘲讽。