作為例項成員或方法區域性變數的誤區
本文目錄:
- 概述
- 驗證
- 剖析
- 小結
- 概述
執行緒池可以把執行緒複用起來,減少執行緒建立銷燬的時間和資源消耗,提高了程式任務執行的吞吐率。
就像執行緒屬於全域性使用的資源一樣,執行緒池一般也是全域性性,對整個應用程序的執行緒複用做有效的管理。設計者一般都會把執行緒池作為類的靜態成員或者單例成員,存活於整個程序的生命週期。
但是還是例外地看到了類似這樣的程式碼。
比如放到了方法體中作為區域性變數:
private static void sampleFunc() { ExecutorService executor = Executors.newFixedThreadPool(10); for (int i = 0; i < 100; i++) { executor.execute(new Runnable() { @Override public void run() { ... } }); } }
又或者放入了生命週期短的物件中,作為它的成員變數:
public class SampleClass {
...
private final ExecutorService mExecutor = Executors.newFixedThreadPool(2);
...
}
這些執行緒池的使用看起來挺正常的,隱藏著一個很嚴重的問題:
當物件例項不再使用或者方法執行完畢後,什麼時候會釋放執行緒 ,關閉執行緒池?
不同的執行緒池表現不一樣。主要看是否設定了核心執行緒數。
如果沒有設定核心執行緒數,比如 newCachedThreadPool ,線上程池的執行緒空閒時間到達 60s 後,執行緒會關閉,所有執行緒關閉後執行緒池也相應關閉回收。 如果設定了核心執行緒數,比如 newSingleThreadExecutor 和 newFixedThreadPool ,如果沒有主動去關閉,或者設定核心執行緒的超時時間,核心執行緒會一直存在不會被關閉,這個執行緒池就不會被釋放回收。
- 驗證
我們設計一個 Demo 來驗證一下:
選用 JDK 提供的單執行緒執行緒池,這個執行緒池的核心執行緒數為 1。 該執行緒池作為一個物件的成員變數。 這個類的例項物件在方法體中執行,外界對它沒有引用 最後呼叫一下 System.gc 主動回收 等待一段時間後,打印出程序的堆資訊,檢視相關的類。
按照上面的思路,建立了 SimpleClass:
public class SimpleClass { private final int mIndex; private Executor mExecutors = Executors.newSingleThreadExecutor(); public SimpleClass(int index) { mIndex = index; } public void runTask() { mExecutors.execute(new Runnable() { @Override public void run() { System.out.println("[" + mIndex + "] execute"); } }); } }
然後在 main 方法裡執行
public class TestThreadLife {
public static void main(String[] args) {
test();
System.gc();
}
private static void test() {
for (int i = 0; i < 10; i++) {
new SimpleClass(i).runTask();
}
}
}
過一段時間後,使用 JDK 的工具來獲取當前存活的物件資訊:
jps 列印獲取程序號 4540 呼叫 jmap -histo 4540 讀取堆中的物件資訊。 可以在控制檯看到這樣的記錄:
E:\code\workspace-demo\TestJava>jmap -histo 4540
num #instances #bytes class name
----------------------------------------------
...
10: 20 7520 java.lang.Thread
...
52: 10 480 java.util.concurrent.LinkedBlockingQueue
53: 10 480 java.util.concurrent.ThreadPoolExecutor$Worker
54: 30 480 java.util.concurrent.locks.ReentrantLock
...
218: 1 16 com.intellij.rt.execution.application.AppMain$1
219: 1 16 concurrent.threadpool.TestThreadLife.SimpleClass
...
Total 9740 33368472
經過一段時間,再加上主動呼叫 GC,10 個 SimpleClass 例項已經基本被回收了,但是 10 個 ThreadPoolExecutor 的例項依然還在。
這就是我們上面提到的,帶有核心執行緒的 ThreadPoolExecutor,如果沒有主動釋放或者設定執行緒超時,如果放在成員變數中,會發生物件例項洩漏。
同樣,放在方法體中做區域性變數也會有這樣的問題。
- 剖析
為什麼會有這樣的現象?
執行緒池無法被回收,是因為執行緒池的引用被它的內部類 Worker 持有了。而 Worker 和執行緒一一對應,是對 Thread 的增強,所以本質上就是因為執行緒沒有被釋放。
那麼任務佇列已經空了,並且外界也沒有任務過來,執行緒為什麼還沒有被釋放?
看 ThreadPoolExecutor 的 runWorker方法:
final void runWorker(Worker w) {
Thread wt = Thread.currentThread();
Runnable task = w.firstTask;
w.firstTask = null;
w.unlock(); // allow interrupts
boolean completedAbruptly = true;
try {
while (task != null || (task = getTask()) != null) {
w.lock();
// If pool is stopping, ensure thread is interrupted;
// if not, ensure thread is not interrupted. This
// requires a recheck in second case to deal with
// shutdownNow race while clearing interrupt
if ((runStateAtLeast(ctl.get(), STOP) ||
(Thread.interrupted() &&
runStateAtLeast(ctl.get(), STOP))) &&
!wt.isInterrupted())
wt.interrupt();
try {
beforeExecute(wt, task);
Throwable thrown = null;
try {
task.run();
} catch (RuntimeException x) {
thrown = x; throw x;
} catch (Error x) {
thrown = x; throw x;
} catch (Throwable x) {
thrown = x; throw new Error(x);
} finally {
afterExecute(task, thrown);
}
} finally {
task = null;
w.completedTasks++;
w.unlock();
}
}
completedAbruptly = false;
} finally {
processWorkerExit(w, completedAbruptly);
}
}
我們看到要執行執行緒退出 processWorkerExit 需要這幾種情況:
執行緒池的狀態 >= STOP getTask 獲取到空任務
第一個條件,執行緒池的狀態要達到 STOP,需要呼叫 shutdown 或者 shutdownNow 方法,我們不滿足。
第二個條件,getTask 獲取到空任務,繼續看 getTask 的程式碼:
private Runnable getTask() {
boolean timedOut = false; // Did the last poll() time out?
for (;;) {
int c = ctl.get();
int rs = runStateOf(c);
// Check if queue empty only if necessary.
if (rs >= SHUTDOWN && (rs >= STOP || workQueue.isEmpty())) {
decrementWorkerCount();
return null;
}
int wc = workerCountOf(c);
// Are workers subject to culling?
boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;
if ((wc > maximumPoolSize || (timed && timedOut))
&& (wc > 1 || workQueue.isEmpty())) {
if (compareAndDecrementWorkerCount(c))
return null;
continue;
}
try {
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
workQueue.take();
if (r != null)
return r;
timedOut = true;
} catch (InterruptedException retry) {
timedOut = false;
}
}
}
任務佇列使用的是阻塞佇列 BlockingQueue,該佇列提供了兩種方法來獲取任務:
poll,可以設定超時時間,當超時後會得到一個空任務。 take,阻塞住,直到有任務出現。
從上面的 getTask 方法中我們可以看到:
當前執行緒數大於核心執行緒,會呼叫 poll,超時後返回空任務。 當前執行緒數小於等於核心執行緒,並且呼叫了 allowCoreThreadTimeOut 方法允許核心執行緒超時關閉的情況下,也是呼叫 poll,超時後返回空任務。 其他情況,呼叫 take 阻塞等待。
我們上面使用單個核心執行緒的執行緒池,在沒有任務的情況下,核心執行緒正處於 getTask ,呼叫阻塞佇列 BlockingQueue 的 take 方法阻塞等待獲取到任務,從而導致執行緒池包括裡面的核心執行緒遲遲不被關閉並且回收。
- 小結
像上面那樣去設定執行緒池,可以理解為執行緒池的區域性應用。
不推薦用這樣的方式,因為區域性執行緒池能做到的事情,全域性執行緒池也可以做到。而且全域性單例的執行緒池還可以不用考慮關閉執行緒池的問題,畢竟生命週期和程序一致。
如果業務場景非要這樣用的話,並且執行緒池有核心執行緒的情況下,要注意做兩件事情防止物件洩漏:
對核心執行緒設定超時時間。 主動呼叫 shutdown 或 shutdownNow 來關閉執行緒池。