
JDK 提供了 ExecutorService 接口以及默认的实现类, 并通过 Executors 工具类暴露了一些常见的用法. 如果使用不当, 将会很容易造成线上问题. 这里介绍一个曾经遇到的这样的问题.
一开始看到了某个应用里面的一台机器开始 GC overhead 告警, 通过查看 C overhead 指标数据, 发现该应用里其它几台机器也快要报警了. 感觉像是一个慢慢的内存泄漏, 完全把 heap 吃完可能需要比较长的时间.
找到那台告警的机器, 首先下载分享 verbose GC log, 发现已经开始频繁 Full GC, 老年代内存基本耗尽. 拉长时间看, 还是可以看到一开始老年代还是有空闲内存的, 然后渐渐被吃光.
既然是 heap 问题, 那就做了一个 heap dump, 同时做了一个 thread dump. 对 heap dump 使用 MAT 进行分析, 发现大量 io.netty.buffer.PoolThreadCache, 占用了一半多的 heap 空间, 而这些对象实例都是 ThreadLocal 的对象.
ThreadLocal 对象是每个 thread 对应一个实例, 那么必然有这么多 live thread. 查看刚才做的 thread dump, 发现共有 6352 个线程, 其中有 5465 个线程的名字有个共同的模式: pool-xxxx-thread-1. 这是线程池里面线程默认的名字, 并且没有发现 thread-2 这样的名字. 这些线程没有任何任务可做, 都在等待任务的到来, 看上去永远也不会等到.
从上面的数量和名字大概可以猜测, 有人用了线程池, 并且创建了非常多的线程池. git clone 该应用的源代码, 搜了一下 ExecutorService 关键字, 果然验证了这个猜想:
有个新手在一个方法里面创建了 ExexutorService, 方法结束就丢弃, 并且没有调用 shutdown() 或 shutdownNow() 方法, 这是新手很容易出现的问题.
一般说来, 线程池都是作为全局存在, 在需要的时候创建, 根据需要设置 core pool size 和 max pool size, 当线程池不在需要的时候, 一定要 shutdown, 否则这些线程会一直存活. 首先, 这个例子里面的在一个方法里面创建线程池, 本来就是不对的, 线程池是为了线程复用, 显然在这里并没有达到这个效果, 方法结束就不能再复用了. 其次, 忘记关闭导致泄露.
虽然这里是由太多的 io.netty.buffer.PoolThreadCache 所暴露的, 即使不由这个 PoolThreadCache 暴露, 早晚会由太多的线程导致 heap 被用光.
原文链接: http://www.tianxiaohui.com/index.php/interestingbug.html