掌握Java GC:深入理解并高效配置GC参数

在 Java 中, GC 的对象是堆空间和永久区

引用计数算法

20241229154732_Xidtz6g4.webp

  • Java 不再使用
  • Python,COM,ActionScript3 使用
  • 性能差
  • 不能解决循环引用问题

标记 - 清除算法

标记阶段

在标记阶段,首先通过根节点,标记所有从根节点开始的可达对象

清除阶段

清除所有未被标记的对象

标记 - 压缩算法

20241229154732_8Sh9Pelm.webp

标记 - 压缩算法适合用于存活对象较多的场合,如老年代.
它在标记 - 清除算法的基础上做了一些优化.

标记阶段

从根节点开始,对所有可达对象做一次标记

压缩阶段

将所有存活对象压缩到内存一端, 然后清除边界外的所有空间

复制算法

20241229154732_GNfE6QNt.webp

  • 与标记 - 清除算法相比, 复制算法是一种相对高效的回收方式
  • 不适合存活对象较多的场合, 如老年代
  • 将原来的内存分为相同大小的两块, 每次只是用其中一块, 在垃圾回收时, 将正在是用的内存中的对象复制到未使用的内存块中, 之后清除正在是用的内存中的所有对象,
    交换两个内存的角色, 完成垃圾回收

问题:

  • 空间浪费, 只是用了一半

是用标记清理和复制算法配置回收垃圾

20241229154732_xecT49yQ.webp

  1. 在最上面那块大的区域产生新对象。
  2. 大对象不太适合在复制空间,因为复制空间的容量是有限的,所以需要一个大的空间做担保,所以让老年代做担保。这样产生的大对象直接进入老年代。
  3. 每一次 GC,对象的年龄就会 +1,一个对象在几次 GC 后仍然没有被回收,则这个对象就是一个老年对象。老年对象是一个长期被引用的对象,老年对象将被放入老年代。
  4. 步骤 1 中产生的小对象,将进入到复制空间。原先复制空间中的新对象也将被复制到另一块复制空间
  5. 清空垃圾对象

20241229154732_2Cv7w0uQ.webp

一个堆分为 new generation(新生代) , tenured generation(老年代) 和 compacting perm gen。
而 new generation 分为 eden space,from space(有些地方称为 s0 和 s1,表示幸存代) , to space。
eden space 就是上面那种图中,对象产生的地方。
from space 和 to space 是两块大小一样的区域,是上图中的复制空间。
new generation 的可用总空间就是 eden space+ 一块复制空间(另一块不算),但是根据 new generation 的地址访问可以算出是 eden space +
两块复制空间区域,所以复制算法浪费了一部分空间。

分代思想

依据对象的存活周期进行分类,短命对象归为新生代,长命对象归为老年代。
根据不同代的特点,选取合适的收集算法

  • 少量对象存活,适合复制算法
  • 大量对象存活,适合标记清理或者标记压缩
  • 进入老年代的对象有两种情况:
    1. 新生代空间不够,老年代做担保存放一些大对象
    2. 某些对象多次 GC 后仍然存在,进入老年代。

老年代的大多数对象都是第 2 种情况,所以老年代的对象的生命周期比较长,GC 的发生也比较少,会有大量对象存活,所以不用复制算法,而改为标记清理或者标记压缩。
所有的算法,需要能够识别一个垃圾对象,因此需要给出一个可触及性的定义

可触及性

从根节点可以触及到这个对象
可复活的
一旦所有引用被释放,就是可复活状态
因为在 finalize() 中可能复活该对象
不可触及的
在 finalize() 后,可能会进入不可触及状态
不可触及的对象不可能复活
可以回收
下面举个例子来说明可复活这个状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class CanReliveObj{
public static CanReliveObj obj;
@Override
protected void finalize() throws Throwable{
super.finalize();
System.out.println("CanReliveObj finalize called");
obj = this;
}
@Override
public String toString(){
return "I am CanReliveObj";
}
public static void main(String[] args) throws InterruptedException{
obj = new CanReliveObj();
obj = null; // 可复活
System.gc();
Thread.sleep(1000);
if (obj == null){
System.out.println("obj 是 null");
}
else{
System.out.println("obj 可用");
}
System.out.println("第二次gc");
obj = null; // 不可复活
System.gc();
Thread.sleep(1000);
if (obj == null){
System.out.println("obj 是 null");
}
else{
System.out.println("obj 可用");
}
}
}

输出:

1
2
3
4
CanReliveObj finalize called
obj 可用
第二次 gc
obj 是 null

一般我们认为,对象赋值 null 后,对象就可以被 GC 了,在上述实例中,在 finalize 中,又将 obj=this,使对象复活。因为 finalize 只能调用一次,所以第二次
GC 时,obj 被回收。
因此对于 finalize 会有这样的建议:

  • 经验:避免使用 finalize(),操作不慎可能导致错误。
  • finalize 优先级低,何时被调用(在 GC 时被调用,何时发生 GC 不确定) 不确定
  • 可以使用 try-catch-finally 来替代它

另外在之前,我们一直在提到从根出发,那么根是指哪些对象呢?

  • 栈中引用的对象
  • 方法区中静态成员或者常量引用的对象(全局对象)
  • JNI 方法栈中引用对象

Stop-The-World

Stop-The-World 是 Java 中一种全局暂停的现象。
全局停顿,所有 Java 代码停止,native 代码可以执行,但不能和 JVM 交互

多半由于 GC 引起,当然 Dump 线程、死锁检查、堆 Dump 都有可能引起 Stop-The-World

GC 时为什么会有全局停顿?
类比在聚会时打扫房间,聚会时很乱,又有新的垃圾产生,房间永远打扫不干净,只有让大家停止活动了,才能将房间打扫干净。

危害

  • 长时间服务停止,没有响应
  • 遇到 HA 系统,可能引起主备切换,严重危害生产环境。

新生代的 GC(Minor GC),停顿时间比较短
老年代的 GC(Full GC),停顿时间可能比较长

串行收集器

20241229154732_z9Ogdnsj.webp

串行收集器是最古老,最稳定以及效率高的收集器
可能会产生较长的停顿,只使用一个线程去回收
-XX:+UseSerialGC

  • 新生代、老年代使用串行回收
  • 新生代复制算法
  • 老年代标记 - 压缩

20241229154732_fKWU1kDv.webp

并行收集器

ParNew

20241229154732_msvwo0y6.webp

  • -XX:+UseParNewGC(new 代表新生代,所以适用于新生代)
    • 新生代并行
    • 老年代串行
  • Serial 收集器新生代的并行版本
  • 复制算法
  • 多线程,需要多核支持
  • -XX:ParallelGCThreads 限制线程数量

Parallel

20241229154732_HBBpLaFF.webp

  • 类似 ParNew

  • 新生代复制算法

  • 老年代 标记 - 压缩

  • 更加关注吞吐量

  • -XX:+UseParallelGC

  • 使用 Parallel 收集器 + 老年代串行

  • -XX:+UseParallelOldGC

  • 使用 Parallel 收集器 + 并行老年代

  • -XX:MaxGCPauseMills

    • 最大停顿时间,单位毫秒
    • GC 尽力保证回收时间不超过设定值
  • -XX:GCTimeRatio

    • 0-100 的取值范围
    • 垃圾收集时间占总时间的比
    • 默认 99,即最大允许 1% 时间做 GC
  • 这两个参数是矛盾的。因为停顿时间和吞吐量不可能同时调优

CMS 收集器

20241229154732_XvTdgL4p.webp

  • Concurrent Mark Sweep 并发标记清除

  • 标记 - 清除算法

  • 与标记 - 压缩相比

  • 并发阶段会降低吞吐量

  • 老年代收集器(新生代使用 ParNew)

  • -XX:+UseConcMarkSweepGC

  • 初始标记

    • 根可以直接关联到的对象
    • 速度快
  • 并发标记(和用户线程一起)

    • 主要标记过程,标记全部对象
  • 重新标记

    • 由于并发标记时,用户线程依然运行,因此在正式清理前,再做修正
  • 并发清除(和用户线程一起)

    • 基于标记结果,直接清理对象

特点

  • 尽可能降低停顿

  • 会影响系统整体吞吐量和性能

    • 比如,在用户线程运行过程中,分一半 CPU 去做 GC,系统性能在 GC 阶段,反应速度就下降一半
  • 清理不彻底

    • 因为在清理阶段,用户线程还在运行,会产生新的垃圾,无法清理
  • 因为和用户线程一起运行,不能在空间快满时再清理

    • -XX:CMSInitiatingOccupancyFraction 设置触发 GC 的阈值
    • 如果不幸内存预留空间不够,就会引起 concurrent mode failure
  • -XX:+ UseCMSCompactAtFullCollection Full GC 后,进行一次整理

    • 整理过程是独占的,会引起停顿时间变长
  • -XX:+CMSFullGCsBeforeCompaction

    • 设置进行几次 Full GC 后,进行一次碎片整理
  • -XX:ParallelCMSThreads

    • 设定 CMS 的线程数量

CMS 的提出是想改善 GC 的停顿时间,在 GC 过程中的确做到了减少 GC 时间,但是同样导致产生大量内存碎片,又需要消耗大量时间去整理碎片,从本质上并没有改善时间。

GC 参数整理

-XX:+UseSerialGC:在新生代和老年代使用串行收集器
-XX:SurvivorRatio:设置 eden 区大小和 survivior 区大小的比例
-XX:NewRatio: 新生代和老年代的比
-XX:+UseParNewGC:在新生代使用并行收集器
-XX:+UseParallelGC :新生代使用并行回收收集器
-XX:+UseParallelOldGC:老年代使用并行回收收集器
-XX:ParallelGCThreads:设置用于垃圾回收的线程数
-XX:+UseConcMarkSweepGC:新生代使用并行收集器,老年代使用 CMS+ 串行收集器
-XX:ParallelCMSThreads:设定 CMS 的线程数量
-XX:CMSInitiatingOccupancyFraction:设置 CMS 收集器在老年代空间被使用多少后触发
-XX:+UseCMSCompactAtFullCollection:设置 CMS 收集器在完成垃圾收集后是否要进行一次内存碎片的整理
-XX:CMSFullGCsBeforeCompaction:设定进行多少次 CMS 垃圾回收后,进行一次内存压缩
-XX:+CMSClassUnloadingEnabled:允许对类元数据进行回收
-XX:CMSInitiatingPermOccupancyFraction:当永久区占用率达到这一百分比时,启动 CMS 回收
-XX:UseCMSInitiatingOccupancyOnly:表示只在到达阀值的时候,才进行 CMS 回收