0%

Java筑基-:(04)JVM垃圾回收器及性能调优

一、JVM 中常见的垃圾回收器

一般来说的话,年轻代占整个堆空间的 1/3 ,而老年代占整个堆空间的的 2/3 。

但是对于 Parallel Scavenge/Parallel Old 这对组合(这种组合目前的吞吐量最大,前者回收年轻代,后者回收老年代,从名字也能看出来)垃圾收集器而言,之前说的一些内容可能不适用了:

  • 堆的大小是会动态变化的,不是一成不变的,比如最开始是 200 ,后来空间不够,GC 过后被扩容到 300M ;如果发现空闲堆太多,可能就缩小到 100M 了;

  • 年轻代不一定占堆空间的 1/3 了,年轻代和老年代的占比也可能变化

  • 年轻代里面也可能不是 8:1:1 的结构了

一般来说,平时建议堆的最大大小和最小大小设置同一个值,不要让空间时不时变化。

二、响应优先的垃圾回收器

后来,大家的需求变成响应优先CMS (Concurrent Mark Sweep) 标记清除 垃圾回收器就是这个跨时代的垃圾回收器。 它是单独针对老年代的垃圾收集器。导致卡顿的一般是老年代的 GC 操作。它是怎么做到响应时间最短呢?步骤如下:

  1. 初始标记——GC Roots 的数量是决定性因素

  2. 并发标记

  3. 重新标记

  4. 并发清除

为什么能响应时间最短,那是因为把耗时的标记和清除操作都能并发了!

为什么能做到并发清理?因为标记-清除可以做到并发,因为我并不关心垃圾,我只关心与 GC Roots 有连接的部分。所以垃圾被覆盖啊之类的都是可以的,所以我能够不暂停而去做清理操作

三、CMS 的预清理

预清理主要往 2 个方面努力:

  • 在并发标记的阶段,如果 Eden 区有 A 对象引用到 老年代中与GC Roots没有连接的 B 对象(会被老年代里面视为垃圾),就要把 B 标记为GC Roots 可达的点。这个操作本来是放在重新标记阶段,现在放在并发标记阶段。这样,减少了重新标记 STW 的时间。

  • 并发标记阶段,如果老年代内部引用发生变化(之前不可达的变为可达的了),建一个类似于卡表结构,后续的重新标记阶段就无需考虑这个区域了

处理 From 和 to 区(那 2 个survivor 区域的名字)的对象 , 到老年代可达,导致老年代的并发标记中的引用变化。并发可中断预处理的过程就是 :

1
2
3
4
while(xxx){
1、处理 from 和 to 区的对象到老年代可达,导致的老年代并发标记中的引用变化
2、老年代内部的引用变化,记录在一张表中
}

这样,在重新标记就可能只需要扫描部分区域即可。

退出循环的部分条件:

  • 时间控制,达到一个时间之后自动停止

  • Eden 区的内存使用达到设定的比例,如果比例太高的话,你这个并发预处理没什么作用

都是为了让一些在重新标记做的事情,可以放到并发标记阶段,减少停留时间

预处理是做一次,扫到就扫到了,没扫到就算了;而 并发可中断预处理 会在 while 中一遍一遍地轮,多次扫描。

CMS 中的问题:

  • CPU 敏感:由于有多线程

  • 浮动垃圾:由于是多线程,所以会有一些清理不到,比如你边扫地别人边丢垃圾

  • 内存碎片:CMS 本质还是标记-清理算法

四、JVM 的调优技巧

如果需要确定总堆的大小,可以用堆空间的活跃数据来做,比如,JVM 运行了一个星期之后,一般就能得到其活跃区间的大小了,一般总堆以及 年轻代、老年代的设置规则如下:

空间 倍数
总堆大小 3~4倍活跃数据大小
年轻代 1~1.5倍活跃数据大小
老年代 2~3倍活跃数据大小
永久代/元空间 1.2~1.5倍Full GC 后的永久代空间占用

4.1 拓展-增大年轻代空间能不能提高 GC 效率?

答案: 可以。因为如下原因:

  • GC 时间间隔会增大。扩容之后,一般扫描的时间间隔会增加,比如以前是 500ms ,后续会变为 1000ms。

  • 放在年轻代回收比放在老年代回收好,因为新生代的复制算法效率比较高。未增大之前,可能过两轮对象就进入老年代了(大对象直接进入、或者说某一个年龄超过一半了,等等都是会进入老年代的,不一定得年龄到了);但是增大之后,由于新生代对象的朝生夕死的特性,还没挨到下一次GC,对象就已经不可达了,进入不了老年代

  • 扫描判断对象是否存活的耗时比复制存活对象的耗时少。年轻代 gc 消耗的时间是: 扫描对象(耗时 T1) 、复制存活的对象到幸存区(T2),那么总时间是 T1 + T2 。那么想想,当我们扩容成 2 倍的时候,是什么情况?扫描时间应该是变成了 2 * T2 了,但是存活对象的复制过程呢?由于GC间隔时间拉长,很多对象被回收了,复制存活对象的耗时一般会比 2 * T2 要小。

4.2 JVM 如何如何避免 Minor GC 时扫描全堆的?

前面我们说不同的代有不同的垃圾回收器,他们的算法是一样的,如下图所示:

那么,当跨代引用的时候,是如何做到无需扫描全堆的呢?举个例子,假如你要回收年轻代,我们老年代里面有个对象 A,引用了年轻代中的一个对象 B,那么老年代的 A 会被作为 GC Roots ,那么你要确定这个根,你要在老年代里面去找根?

所以如果有跨代,那么我们是否要进行全堆的扫描(不然没法知道GC Roots 啊,虽然你说上述情况可以作为 GC Roots)?答案是不用的,因为 JVM 可能会维护有卡表结构,它标记了所有的对象。未跨代的时候,是某个标记,假设说是 0 ;如果有跨代引用,在卡表里面就标记成另一个了,假设是 0 。

4.3 常量池(方法区)

常量池一般有 3 种,分别如下:

  • Class 常量池:.class 文件中带的,编译时的字面量、引用。

  • 运行时常量池:运行时如果用到的某个类A ,如果 ClassLoader 还未加载这个类 A ,那么就会是一个符号引用,这是个常量,到运行时符号引用转为直接引用。

  • 字符串常量池:规范里面是没有的。与 String 的设计思想有关系,String 对象不可变(类被final,value数组也是final)

new String(“abc”) ,已经有字面量 “abc” 了,在编译的 时候就会被放入常量池里面,并且在堆里面会创建一个 String 对象,值指向常量池的那个 “abc” 。

“ab” + “cd” +”ef” 这种,编译器在编译的时候就会变成 “abcdef” 直接给优化了

如果发现如下这种循环次数比较多的字符串相加,编译器也会优化:

1
2
3
4
5
String str = "ab";

for(int i = 0;i<100;i++) {
str += arr[i];
}

变为使用 StringBuilder 来实现。

谢谢你的鼓励