以前的一次full gc排查经历。
问题排查#
之前偶然看 Grafana,发现应用在当天出现了两次比较明显的 GC 耗时尖峰,差不多白天一次,晚上一次。但是再看老年代,占用一直不高,曲线也没什么变化。

看起来不像老年代快满了,那这两次 GC 是哪里来的?
没有办法,进 Docker 容器看下 GC 日志:
1
2
3
4
5
| [Full GC (System.gc())
[CMS: 667220K->573688K(2097152K)]
1847021K->573688K(3984640K),
[Metaspace: 379950K->379950K(1402880K)],
1.9782745 secs]
|
这下看到原因了:Full GC (System.gc())。
没想到群众里面有坏人,有人在偷偷调用 System.gc()!
在项目里搜索了一圈,没有找到相关调用,只好用 Arthas。因为触发时间不固定,所以把 Arthas 的输出保存到文件里,第二天再来查看:
1
2
| stack java.lang.System gc -n 10 >> /tmp/arthas-gc.log &
quit
|
抓到了这样的调用栈:
1
2
3
4
| thread_name=GC Daemon; id=20; is_daemon=true; priority=2
@java.lang.System.gc()
at sun.misc.GC$Daemon.run(GC.java:109)
|
原来调用 System.gc() 的是 GC Daemon。
顺着调用栈看一下 JDK 8 的代码:

sun.misc.GC 里定义了一个 Daemon 内部类。它会读取 latencyTarget,再通过 maxObjectInspectionAge() 判断是不是太久没有进行相应的垃圾检查;达到阈值,就调用 System.gc(),否则继续等待。
但是很明显,这个不是我们最终想要的,还需要找到是在哪里调用了了这个GC.requestLatency。这里正常来说就是本机启一下服务,打个断点,看看堆栈是谁在创建,但是我不想看了,就咨询了一下chatgpt大人,它先让我看看当前的阈值:
1
| ognl '@sun.misc.GC@currentLatencyTarget()'
|
得到结果:
36,000,000 毫秒,也就是 10 小时。
然后 ChatGPT 大人就把怀疑对象指向了 Apache CXF:

结果还真是这个,Apache CXF 是一个服务框架,我们项目里的 WebService 用到了它。顺着这个线索,找到了 org.apache.cxf.common.logging.JDKBugHacks,看看它的类注释:

这个类主要是给一些 JDK 问题打补丁,部分实现借鉴了 Tomcat。罪魁祸首如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| // Several components end up calling:
// sun.misc.GC.requestLatency(long)
//
// Those libraries / components known to trigger memory leaks due to
// eventual calls to requestLatency(long) are:
// - javax.management.remote.rmi.RMIConnectorServer.start()
try {
if (!skipHack("org.apache.cxf.JDKBugHacks.gcRequestLatency")) {
Class<?> clazz = Class.forName("sun.misc.GC");
Method method = clazz.getDeclaredMethod("currentLatencyTarget");
Long l = (Long) method.invoke(null);
if (l != null && l.longValue() == 0) {
//something already set it, move on
method = clazz.getDeclaredMethod("requestLatency", Long.TYPE);
method.invoke(null, Long.valueOf(36000000));
}
}
} catch (Throwable e) {
//ignore
}
|
这个补丁的作用#
这份代码主要处理的问题,是RMI创建的 GC Daemon 长期持有应用的类加载器,导致应用卸载后,相关的类和资源无法正常回收。
RMI 支持 JVM 之间的远程方法调用,里面用到了 DGC,也就是分布式垃圾回收。客户端拿到的是远程对象的引用,服务端不能直接检查客户端的堆,不知道这个引用是不是已经没人用了。
在 OpenJDK 8 的实现里,客户端通过虚引用和引用队列跟踪本地 LiveRef。当对应引用被 GC 处理后,客户端会通过 clean 调用通知服务端:不需要再为这个客户端保留远程对象了。
但是有可能出现客户端很长时间都不发生相关 GC的情况。因此,RMI DGC 会调用 GC.requestLatency(...),给本地 GC 提一个延迟请求,尽量避免这种情况。
问题主要出在jdk本身,第一次注册请求时,如果 GC Daemon 还不存在,就需要创建这个线程。 看一下这里 JDK 8 实现:
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
| private static class Daemon extends Thread {
private Daemon(ThreadGroup tg) {
super(tg, "GC Daemon");
}
public static void create() {
PrivilegedAction<Void> pa = new PrivilegedAction<Void>() {
public Void run() {
ThreadGroup tg =
Thread.currentThread().getThreadGroup();
// 一直找到 root ThreadGroup
for (ThreadGroup tgn = tg;
tgn != null;
tg = tgn, tgn = tg.getParent());
Daemon d = new Daemon(tg);
d.setDaemon(true);
d.setPriority(Thread.MIN_PRIORITY + 1);
d.start();
GC.daemon = d;
return null;
}
};
AccessController.doPrivileged(pa);
}
}
|
看起来只是普通地创建一个线程。但在 new Daemon(tg) 调用父类构造方法时,Thread 内部还会做一件事:继承创建者线程的上下文类加载器,也就是 TCCL(Thread Context ClassLoader)。相关逻辑如下:
1
2
3
4
5
6
7
8
| Thread parent = currentThread();
SecurityManager security = System.getSecurityManager();
if (security == null || isCCLOverridden(parent.getClass())) {
this.contextClassLoader = parent.getContextClassLoader();
} else {
this.contextClassLoader = parent.contextClassLoader;
}
|
也就是说,在构造线程对象时,TCCL 的引用就已经传过去了,不用等到 start()。 前面虽然把线程放到了根 ThreadGroup,但线程组和上下文类加载器是两回事,这样做不会自动把 TCCL 换成系统类加载器。
假设第一次触发创建的是一个 Web 应用线程,而它的 TCCL 正好是这个应用的类加载器,那么新建的 GC Daemon 也就拿到了同一个引用。
但如果应用在同一个 JVM 里卸载、重新部署,旧的应用类加载器本来应该可以回收,这个仍然存活的线程却还在抓着它。只看 TCCL 这条强引用链,就已经足以让它保持可达:
1
2
3
| 存活的 GC Daemon 线程(GC Root)
└── contextClassLoader
└── 旧 Web 应用的 ClassLoader
|
类加载器无法回收,它所加载的类也就无法随之卸载,相关静态字段引用的对象还可能继续留在堆里。这样多重新部署几次,就可能把好几代应用的类和相关对象一起留下来。Tomcat 的 gcDaemonProtection 就是针对这一类问题做防护。Tomcat 类加载器泄漏防护
这时候 Full GC 也没用,因为引用链还在,因此 CXF 的补丁实际不是为了fullgc,而是趁这个线程还没有创建,先换好上下文类加载器,提前把它创建出来。
把 JDKBugHacks.doHacks() 的外层逻辑和前面那段反射调用合在一起,流程相当于下面这样:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| Thread current = Thread.currentThread();
ClassLoader original = current.getContextClassLoader();
try {
// 先切换创建者线程的 TCCL
current.setContextClassLoader(ClassLoader.getSystemClassLoader());
if (sun.misc.GC.currentLatencyTarget() == 0) {
// 再注册请求,触发 GC Daemon 的创建
sun.misc.GC.requestLatency(36000000L);
}
} finally {
// 恢复创建者线程原来的 TCCL
current.setContextClassLoader(original);
}
|
解决方案#
因为我们没有使用RMI,可以通过 JVM 启动参数跳过这一项补丁:
1
| -Dorg.apache.cxf.JDKBugHacks.gcRequestLatency=false
|
评论
评论审核后展示。邮箱不会公开,当前暂不发送回复邮件。