JVM实践:内存分配与GC

1,046 阅读9分钟

前言

  最近看了那本著名的《深入理解Java虚拟机》前几章。俗话说,纸上得来终觉浅,绝知此事要躬行,正好书上也提供了几个小例子,作者把内存分配那块的几个小例子自己跑了下,感受了下jvm垃圾回收和内存分配。

事前准备

  我看的是第二版,书里的几个内存分配的小实验还是有新生代老年代之分的。不过目前的jdk版本貌似都默认是G1收集器了,G1收集器没有明确的分代,内存分为一个个region,和所有其它收集器相比都是另一套东西了,没法做实验,也没法体会书中所说的Eden区,survivor区,空间分配担保等功能,所以首先配置jvm参数,从而才能完成实验。

image.png

  我们可以在IDEA的Run tab里点Edit Configurations,然后可以对项目的VM options进行设置。
介绍几个配置参数:-Xlog:gc*表示垃圾回收时,打印相关信息;-XX:+UseSerialGC表示使用Serial+Serial Old收集器组合进行垃圾回收;-Xms20m表示设置堆的初始大小为20m, -Xmx20m设置堆的最大大小为20m, -Xmn10m设置堆的新生代大小为10m。在这个VM options里,用空格把这些参数分开,就可以了。接下来针对书里3.6节给的几个内存分配和回收策略,记录下实验过程:

对象优先在Eden区分配

public class Lab {
    private static final int _1MB = 1024 * 1024;
    public static void main(String[] args) {
        byte[] allocation1, allocation2, allocation3, allocation4;
        allocation1 = new byte[2 * _1MB];
        allocation2 = new byte[2 * _1MB];
        allocation3 = new byte[2 * _1MB];
        allocation4 = new byte[4 * _1MB];

    }
}

  代码和书上的一模一样。输出如下:

image.png

  看黄线这两行,当allocation4进行分配时,发现Eden区内存不够了,所以进行一次Minor GC,GC之后,新生代由8168k变为了927k,其中,Eden区清0了,From区(也就是一个survivor区)由0变为927k,但是这6m的内存并不是垃圾,无法清除,也放入不了survivor区(只有1m),所以放入了老年代里。

  最后的结果里,老年代占了百分之60,也就是那6m内容,全进了老年代,eden区占了一半左右,也就是allocation4这4m,survivor区占了百分之90。

  有个疑问是,为啥一开始Eden区就占了8168m?不是只分了6m吗?这个问题我去搜了下,大概是因为程序在运行前需要初始化一些必要的对象,如Class loader、Object、静态变量等,导致的初始内存占用。然后第一次Minor GC之后,清除了一些垃圾,还剩927k是清不了的,由于复制算法,进了survivor区,Eden剩下那6m由于空间不够进不了survivor区,只好进了老年代里,然后allocation4这4m又进了Eden区。这就是Eden区优先分配了。

大对象直接进入老年代

  如上例,可以看到4m的allocation4分配时,导致了新生代的一次Minor GC,为了避免这种情况,可以让大一点的对象直接进入老年代。加上参数-XX:PretenureSizeThreshold=3145728 进行设置之后,大于3m的对象会直接进入老年代,这样就少了那次Minor GC。加上参数后,运行上一例的同样的代码,

image.png

  可以看出,4m的对象直接进了老年代,新生代没有GC。同时也可以看出,虽然只分了6m对象,新生代却占满了,这和我们上文提到的那个疑问吻合。

长期存活的对象将进入老年代

  jvm给每个对象定义了一个Age计数器,若对象在Eden出生并经过第一次Minor GC后仍然存活并被survivor区接纳,将移入survivor区里,同时年龄为1,之后每在survivor区里熬过一轮Minor GC,年龄都会增加1岁,默认对象15岁时,进入老年代。我们可以通过参数-XX:MaxTenuringThreshold来设置此临界年龄。
由于我的机器初始化时就有1m左右的不知道干什么(上文提过,可能是一些初始化对象)的内存,所以改了下内存,把堆大小改为了80m,新生代老年代各40m,Eden区32m。

public class Lab {
    private static final int _1MB = 1024 * 1024;
    public static void main(String[] args) {
        byte[] allocation0, allocation1, allocation2, allocation3, allocation4;
        allocation1 = new byte[1 * _1MB];
        allocation2 = new byte[16 * _1MB];
        allocation3 = new byte[16 * _1MB];
        allocation4 = new byte[16 * _1MB];
    }
}

默认情况下,我们运行之

image.png

  接着我们把-XX:MaxTenuringThreshold参数设置为1

image.png

  可以看到,第二次GC时,survivor区里的对象直接进了老年代了。   这个规则的意义,我觉得就是有的对象一直在survivor区,但是众所周知,survivor区分为from区和to区,看似一直在survivor区,其实也一直在来回复制,所以设置一个年龄界限,总是来回横跳的直接去老年代吧,也别折腾了。毕竟这种复制,也是要消耗资源的。

动态对象年龄判定

  为了更好的适应不同程序的内存情况,jvm并不是要求年龄必须到达了MaxTenuringThreshold才进入老年代,如果Survivor区相同年龄所有对象大小总和大于survivor区的一半,年龄大于或等于该年龄的对象就直接进入老年区。可以用-XX:TargetSurvivorRatio来设置占survivor区百分之多少时,进入老年代。   我又把内存改为之前的堆总大小20m了,😀,由于不同机器初始化在Eden区占的内存大小不一,所以这部分实验没有按照书上的,自己设计了一个小实验如下:

public class Lab {
    private static final int _1MB = 1024 * 1024;
    public static void main(String[] args) {
        byte[] allocation0, allocation1, allocation2, allocation3, allocation4;
        allocation1 = new byte[4 * _1MB];
        allocation2 = new byte[4 * _1MB];
        allocation3 = new byte[4 * _1MB];
    }
}

image.png

  可以看到,一共进行了两次GC,allocation2分配时,就进行了GC,这是因为由于有对象头,内存补白以及上面提到的初始内存引用等必须占空间的东西,所以Eden区在allocation1分配之后,总占用大小是大于4m的,所以Eden剩余空间不够,进行第一次Minor GC,这时候,4m的allocation1直接进入老年代,survivor区有925k的对象,年龄设置为1,上文提到过,应该是一些对象头等信息。
同理,进行allocation3的分配时,Eden区还是不足4m,所以仍然进行Minor GC,但是,这时候,我们发现,Survivor区由925k变为1k了,而老年代的内存却大于8m,正好比8m大了924k,也就是survivor区924k的东西都去了老年代。这就是动态对象年龄判定,这924k的对象大于了survivor区的一半,所以直接进了老年代了。。。至于为什么还剩了1k,我只能认为可能这玩意不是对象吧,是一些其它奇奇怪怪的东西。。。

空间分配担保

  发生Minor GC前,jvm会检查老年代最大可用的连续空间大小,只要此连续空间大于新生代对象总大小或者历次晋升的平均大小,则进行Minor GC,否则,进行Full GC(其实有个HandlePromotionFailure参数,不过书上说,已经不用了),代码如下:

public class Lab {
    private static final int _1MB = 1024 * 1024;
    public static void main(String[] args) {
        byte[] allocation0, allocation1, allocation2, allocation3, allocation4;
        allocation1 = new byte[4 * _1MB];
        allocation2 = new byte[2 * _1MB];
        allocation3 = new byte[2 * _1MB];
        allocation4 = new byte[4 * _1MB];
        allocation4 = null;
        allocation4 = new byte[2 * _1MB];
    }
}

image.png

  allocation3分配时,进行了一次Minor GC,进行最后一次allocation4的分配时,老年代还剩4m空间,而新生代此时有6m内容,分配担保失败了,并且平均晋升到老年代的大小为6m(就晋升了一次),所以,进行了Full GC,在图中两个黄线中间可以看到Full GC的过程。最后由于那个4m的空间没有再被引用了,在Full GC时被回收掉了,所以,保证了空间可以正常的分配。
之所以进行空间分配担保,应该还是担心老年代重载不了那么多新生代的对象吧。所以预先控制一下,预测下老年代是否能hold住。不过只是种预测,即使剩余连续空间大于平均晋升时间,也代表不了这次的晋升对象大小就能装得下。

踩坑记录

  最开始配置jvm堆相关的参数时,修改的是下面这个配置文件,结果连IDEA都启动不了。。。提示空间不足,无法打开IDEA。。。所以改参数在Run里面改下配置就行。

image.png

几个配置参数

-Xlog:gc*:打印垃圾回收信息
-XX:TargetSurvivorRatio=50:默认情况下,如果Survivor区相同年龄所有对象大小总和大于survivor区的一半,年龄大于或等于该年龄的对象就直接进入老年区。可以用-XX:TargetSurvivorRatio来设置占survivor区百分之多少时,进入老年代
-Xms20m -Xmx20m -Xmn10m:设置堆大小
-XX:MaxTenuringThreshold:设置对象在survivor区里的存活年龄
-XX:PretenureSizeThreshold=3145728 :设置直接进入老年代的对象的大小

总结

  本文使用Serial+Serial Old收集器组合,对jvm的内存分配和垃圾回收就行了实验。垃圾收集器很多,我也尝试了其它几个。CMS收集器,jdk14已经不支持了,没法实验;ParNew这个也没法设置;Parallel收集器,其实和Serial大体上一样,都是新生代+老年代,新生代分为Eden和Survivor。当然,肯定还有细微差别,这里就不整了,之后有空会研究下G1的运行流程。