天天看點

如何診斷Java 應用線程洩漏

如何診斷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-n, pool-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([email protected]/Native Method)
	- waiting on <0x00000007c5320a88> (a java.lang.ProcessImpl)
	at java.lang.Object.wait([email protected]/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([email protected]/Executors.java:515)
	at java.util.concurrent.FutureTask.run([email protected]/FutureTask.java:264)
	at java.util.concurrent.ThreadPoolExecutor.runWorker([email protected]/ThreadPoolExecutor.java:1128)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run([email protected]/ThreadPoolExecutor.java:628)
	at java.lang.Thread.run([email protected]/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. 然後再找出建立這些線程的錯誤代碼;根據線程名字去搜錯誤代碼位置;根據線程處理的業務邏輯代碼去查找錯誤代碼位置;使用 btrace 攔截建立新線程的代碼位置;

繼續閱讀