注册

如何诊断Java 应用线程泄漏

大家经常听到内存泄漏, 那么线程泄漏是指什么呢?

线程泄漏是指 JVM 里面的线程越来越多, 而这些新创建的线程在初期被使用之后, 再也不被使用了, 然而也没有被销毁. 通常是由于错误的代码导致的这类问题.

一般通过监控 Java 应用的线程数量的相关指标, 都能发现这种问题. 如果没有很好的对这些指标的监控措施, 或者没有设置报警信息, 可能要到等到线程耗尽操作系统内存导致OOM才能暴露出来.

最常见的例子

在生产环境中, 见过很多次类似下面例子:

public void handleRequest(List<String> requestPayload) {
if (requestPayload.size() > 0) {
ExecutorService executor = Executors.newFixedThreadPool(2);

for (String str : requestPayload) {
final String s = str;
executor.submit(new Runnable() {
@Override
public void run() {
// print 模拟做很多事情
System.out.println(s);
}
});
}
}
// do some other things
}

这段代码在处理一个业务请求, 业务请求中包含很多小的任务, 于是想到使用线程池去处理每个小任务, 于是创建了一个 ExecutorService, 接着去处理小任务去了.

错误及改正

看到这段代码, 大家会觉的不可能啊, 怎么会有人这么使用线程池呢? 线程池不是这么用的啊? 一脸问号. 可是现实情况是: 总有新手写出这样的代码.

有的新手被指出这个问题之后, 就去查文档, 发现 ExecutorService 有 shutdown() 和 shutdownNow() 方法啊, 于是就在 for 循环后边加了 executor.shutdown(). 当然, 这会解决线程泄漏的问题. 但却不是线程池正确的用法, 因为这样虽然避免了线程泄漏, 却还是每次都要创建线程池, 创建新线程, 并没有提升性能.

正确的使用方法是做一个全局的线程池, 而不是一个局部变量的线程池, 然后在应用退出前通过 hook 的方式 shutdown 线程池.

然而, 我们是在知道这段代码位置的前提下, 很快就修好了. 如果你有一个复杂的 Java 应用, 它的线程不断的增加, 我们怎么才能找到导致线程泄漏的代码块呢?

情景再现

通常情况下, 我们会有每个应用的线程数量的指标, 如果某个应用的线程数量启动后, 不管分配的 CPU 个数, 一直保持上升趋势, 那么就危险了. 这个时候, 我们就会去查看线程的 Thread dump, 去查看到底哪些线程在持续的增加, 为什么这些线程会不断创建, 创建新线程的代码在哪?

找到出问题的代码

在 Thread dump 里面, 都有线程创建的顺序, 还有线程的名字. 如果新创建的线程都有一个自己定义的名字, 那么就很容易的找到创建的地方了, 我们可以根据这些名字去查找出问题的代码.

根据线程名去搜代码

比如下面创建的线程的方式, 就给了每个线程统一的名字:

Thread t = new Thread(new Runnable() {
@Override
public void run() {
}
}, "ProcessingTaskThread");
t.setDaemon(true);
t.start();

如果这些线程启动之前不设置名字, 系统都会分配一个统一的名字, 比如thread-npool-m-thread-n, 这个时候通过名字就很难去找到出错的代码.

根据线程处理的业务逻辑去查代码

大多数时候, 这些线程在 Thread dump 里都表现为没有任何事情可做, 但有些时候, 你可以能发现这些新创建的线程还在处理某些业务逻辑, 这时候, 根据这些业务逻辑的代码向上查找创建线程的代码, 也不失为一种策略.

比如下面的线程栈里可以看出这个线程池在处理我们的业务逻辑代码 AsyncPropertyChangeSupport.run, 然后根据这个关键信息, 我们就可以查找出到底那个地方创建了这个线程:

"pool-2-thread-4" #159 prio=5 os_prio=0 cpu=7.99ms elapsed=354359.32s tid=0x00007f559c6c9000 nid=0x6eb in Object.wait()  [0x00007f55a010a000]
java.lang.Thread.State: WAITING (on object monitor)
at java.lang.Object.wait(java.base@11.0.18/Native Method)
- waiting on <0x00000007c5320a88> (a java.lang.ProcessImpl)
at java.lang.Object.wait(java.base@11.0.18/Object.java:328)
... 省略 ...
at com.tianxiaohui.JvmConfigBean.propertyChange(JvmConfigBean.java:180)
at com.tianxiaohui.AsyncPropertyChangeSupport.run(AsyncPropertyChangeSupport.java:346)
at java.util.concurrent.Executors$RunnableAdapter.call(java.base@11.0.18/Executors.java:515)
at java.util.concurrent.FutureTask.run(java.base@11.0.18/FutureTask.java:264)
at java.util.concurrent.ThreadPoolExecutor.runWorker(java.base@11.0.18/ThreadPoolExecutor.java:1128)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(java.base@11.0.18/ThreadPoolExecutor.java:628)
at java.lang.Thread.run(java.base@11.0.18/Thread.java:829)

使用 btrace 查找创建线程的代码

在上面2种比较容易的方法已经失效的时候, 还有一种一定能查找到问题代码的方式, 就是使用 btrace 注入拦截代码: 拦截创建新线程的地方, 然后打印当时的线程栈.

我们稍微改下官方的拦截启动新线程的例子, 加入打印当前栈信息:

import org.openjdk.btrace.core.annotations.BTrace;
import org.openjdk.btrace.core.annotations.OnMethod;
import org.openjdk.btrace.core.annotations.Self;

import static org.openjdk.btrace.core.BTraceUtils.*;

@BTrace
public class ThreadStart {
@OnMethod(
clazz = "java.lang.Thread",
method = "start"
)
public static void onnewThread(@Self Thread t) {
D.probe("jthreadstart", Threads.name(t));
println("starting " + Threads.name(t));
println(jstackStr());
}
}

然后执行 btrace 注入, 一旦有新线程被创建, 我们就能找到创建新线程的代码, 当然, 我们可能拦截到不是我们想要的线程创建栈, 所以要区分, 哪些才是我们希望找到的, 有时候, 上面的代码中可以加一个判断, 比如线程名字是不是符合我们要找的模式.

$ ./bin/btrace 1036 ThreadStart.java
Attaching BTrace to PID: 1036
starting HandshakeCompletedNotify-Thread
java.base/java.lang.Thread.start(Thread.java)
java.base/sun.security.ssl.TransportContext.finishHandshake(TransportContext.java:632)
java.base/sun.security.ssl.Finished$T12FinishedConsumer.onConsumeFinished(Finished.java:558)
java.base/sun.security.ssl.Finished$T12FinishedConsumer.consume(Finished.java:525)
java.base/sun.security.ssl.SSLHandshake.consume(SSLHandshake.java:392)

上面的代码, 就抓住了一个新创建的线程的地方, 只不过这个可能不是我们想要的.

除了线程会泄漏之外, 线程组(ThreadGroup) 也有可能泄漏, 导致内存被用光, 感兴趣的可以查看生产环境出现的一个真实的问题: 为啥 java.lang.ThreadGroup 把内存干爆了

总结

针对线程泄漏的问题, 诊断的过程还算简单, 基本过程如下:

  1. 先确定是哪些线程在持续不断的增加;
  2. 然后再找出创建这些线程的错误代码;
    1. 根据线程名字去搜错误代码位置;
    2. 根据线程处理的业务逻辑代码去查找错误代码位置;
    3. 使用 btrace 拦截创建新线程的代码位置

作者:int0x03
链接:https://juejin.cn/post/7219695336422621221
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

0 个评论

要回复文章请先登录注册