为啥 java.lang.ThreadGroup 把内存干爆了
最近分析了一个 Java 内存泄漏的例子, 干爆内存的不是业务对象, 竟然是 1162 万个 java.lang.ThreadGroup 对象. 到底是谁这么丧心病狂的创建这么多 ThreadGroup 又不释放呢? 为啥干爆 heap 的不是 ThreadGroup 里面的 Thread 呢?
这个 heap dump 并不大, 只有1.5G, MAT 工具给出的分析结果直指 ThreadGroup, 但是它只说一个 ThreadGroup 占用了这么多. 如下图:
为啥它说只有一个 ThreadGroup, 而我在前面提到是 1162 万个呢? 原因就是 ThreadGroup 是分层级结构的, java.lang.ThreadGroup 有个字段是 groups, 里面就是它的子 group. 而上面 MAT 分析给出的结果就是父 group, 它的 子 Group 包含了特别多的 ThreadGroup. 如下图:
从上图可以看到这个父 ThreadGroup 的名字是 main, 是 JVM 自己管理的一个 ThreadGroup. 它的 groups 字段里面包含了一个长度为 1677万 长度的数组. 截图中并且给出了每个子 ThreadGroup的名字.
同时, 我们从 Object Histogram 里面也能看到巨多的 ThreadGroup 的实例, 如下图:
从上图可以看到, 通常在 Object Histogram 中一般会拔得头筹的 byte[], char[], String 对象等这次黯然失色, 竟然输给了 ThreadGroup. 另外有细心的读者会发现, 这里的 ThreadGroup 总共才 1162 万, 为啥上面 main ThreadGroup 的子 groups 里面已经显示 1677 万了? 其实这里是数组的故事, Java 里面的数组, 你不能长度每增加一个就新申请一个数组吧, 所以一般数组的长度会在一定程度上大于或等于其中实例的个数, 当快超过的时候, 在申请一个新的, 是不是很类似 ArrayList 里面的 array 的增长?
哪里创建这么多ThreadGroup?
既然已经看到了这么多不该出现的 ThreadGroup, 那么就要追问是哪里的代码创建这么多 ThreadGroup? 看上去最容易找到的方式是: 根据 ThreadGroup 的 name 来找. 因为在之前的图中, 我们已经很明确看到子 ThreadGroup 的名字是固定的, 并且是一个奇怪的名字, 这种一般是和某种业务过程结合在一起的. 于是我们拿这个名字去这个应用程序的 git repo 去搜, 一无所获, 然后扩大范围去该 team 的所有repo 去搜, 去公司整个可见的所有repo 去搜, 都没找到. 然后根据这个名字里面的关键字拆开去搜, 依然找不到. 所以, 很有可能它是某个不可见的某个分支, 或者没被索引, 或者在某个 jar 包里面, 无论如何, 我们从静态代码去分析, 就是找不到它的影子.
那么, 我们只能通过 btrace 去看了: 既然创建这么多Thread Group, 那么我们就堵在创建 ThreadGroup 的入口处: ThreadGroup 的 init 方法上. 在使用 btrace 去观测之前, 我们先验证一下, 它是否在一直增长:
如上图所示, 它在大约1分钟18秒的时间内, 增加了 4400 个 ThreadGroup, 看起来, 很是狂妄.
我们编写了如下的 btrace 脚本, 让它打印当时的 stacktrace.
import org.openjdk.btrace.core.annotations.*;
import static org.openjdk.btrace.core.BTraceUtils.*;
import org.openjdk.btrace.core.BTraceUtils.Strings;
@BTrace
public class ClosedByInterruptExceptionTracer {
@OnMethod( clazz="/java\\.lang\\.ThreadGroup/", method="<init>" )
public static void createException() {
println(Strings.strcat("current thread: ", name(currentThread())));
println(jstackStr());
}
}
打印的栈很清楚的告诉我们哪个地方创建了新的 ThreadGroup:
java.lang.ThreadGroup.<init>(ThreadGroup.java:117)
java.lang.ThreadGroup.<init>(ThreadGroup.java:96)
com.txh.core.executor.DaemonThreadFactory.<init>(DaemonThreadFactory.java:35)
com.txh.core.executor.TaskExecutor.<init>(TaskExecutor.java:109)
com.txh.core.executor.TaskExecutor.newExecutor(TaskExecutor.java:90)
//省略了其它更多业务及框架代码的栈帧
至此, 我们找到了产生这么多新 ThreadGroup 的地方.
为啥创建这么多ThreadGroup? 好玩?
找到了代码, 就能回答我们的很多问题了, 为啥我们没有通过搜索 ThreadGroup 的 name 找到这段代码? 因为它这个repo 我们根本不可见, 当然搜不到. 为啥创建这么多 ThreadGroup 呢? com.txh.core.executor.TaskExecutor 代码封装了JDK 提供的 ThreadPoolExecutor 和 ExecuteService, 然后对里面的 ThreadFactory 做了定制化, 当创建新的 TaskExecutor 的时候, 创建新的 ThreadGroup 以用来给以后要创建的 Thread 做分组. 而真正的线程创建是由真正要使用线程干活的时候, 再实时创建.
所以, 我们看到的业务代码片段大概是这个样子的:
TaskExecutor executor = new TaskExecutor(name);
//do some tasks
executor.shutdown();
然而, 在每个请求到来的时候, 上面的片段都要执行很多次, 就创建了非常多的 TaskExecutor, 每个 TaskExecutor 都创建了一个新的 ThreadGroup. 之后 TaskExecutor shutdown() 的时候, 并没有去调用 ThreadGroup 的 destroy() 方法. 而这个 destroy() 真正做了一些善后工作, 比如把它从父 ThreadGroup 里面去掉. 否则, 即便引用这个 ThreadGroup的 TaskExecutor 被销毁了, 它的父 ThreadGroup 还在引用着它, 本例中的父 ThreadGroup 还是 main, 更不可能等 main ThreadGroup 自动销毁了.
为啥没这么多线程?
看上去每次创建一个 TaskExecutor, 都会创建一个 ThreadGroup, 同时创建1个或更多Thread, 但是这个 JVM 的Thread 却正常呢?
那是因为每次它都调用了 TaskExecutor 的shutdown(), 这个 shutdown() 其实是继承自 JDK 的 ThreadPoolExecutor. 当 shutdown() 被调用的时候, 它会设置线程池的状态为 SHUTDOWN 状态, 而当 worker 线程去拿任务队列里的任务的时候, 发现线程池已经是 SHUTDOWN 状态, 就会自动走向自杀的结局, 因为线程池都要 shutdown 了, 留里面的线程又有何用.
所以归根结底是因为扩展了 ThreadPoolExecutor 和 实现了 ExecutorService, 并且自己增加了新的 ThreadGroup, 但是在 shutdown() 的时候, 没有对 ThreadGroup 做清理.
然而真的就这么多了吗?
我们给开发人员的建议是: 把这个线程池改成全局 Singleton, 复用珍贵的线程池. 这个方法可以避开每次要创建 ThreadGroup 和 Thread, 提高了应用性能, 也解决了这个问题. 但是我们检查之前的代码的时候, 发现一个ThreadGroup 之外有趣的逻辑漏洞.
那段代码片段大概是这样的.
TaskExecutor executor = new TaskExecutor(name);
for(MyTask task : myTaskList) {
executor.submit(task);
}
//do some tasks
executor.shutdown();
你发现问题了吗? 这么快速的把任务扔进去, 然后立马 shutdow() 这个线程池, 你确定任务已经干完了? 任务已经开始干了? 做任务的线程已经创建出来了? 很有可能任务还没开始干呢? 线程池已经 shutdown() 了. 我只能说交给这个线程池去做的任务确实可能是太不重要了, 以至于都没人关心它是不是被干完, 干好.