1. 程式人生 > >執行緒池踩坑 - 作為例項成員或方法區域性變數的誤區

執行緒池踩坑 - 作為例項成員或方法區域性變數的誤區

轉自 https://blog.csdn.net/firefile/article/details/80747569

本文目錄:

  1. 概述
  2. 驗證
  3. 剖析
  4. 小結
  5. 概述

執行緒池可以把執行緒複用起來,減少執行緒建立銷燬的時間和資源消耗,提高了程式任務執行的吞吐率。

就像執行緒屬於全域性使用的資源一樣,執行緒池一般也是全域性性,對整個應用程序的執行緒複用做有效的管理。設計者一般都會把執行緒池作為類的靜態成員或者單例成員,存活於整個程序的生命週期。

但是還是例外地看到了類似這樣的程式碼。

比如放到了方法體中作為區域性變數:

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);
    ...
}

這些執行緒池的使用看起來挺正常的,隱藏著一個很嚴重的問題:

當物件例項不再使用或者方法執行完畢後,什麼時候會釋放執行緒 ,關閉執行緒池?

不同的執行緒池表現不一樣。主要看是否設定了核心執行緒數。

  1. 如果沒有設定核心執行緒數,比如 newCachedThreadPool ,線上程池的執行緒空閒時間到達 60s 後,執行緒會關閉,所有執行緒關閉後執行緒池也相應關閉回收。
  2. 如果設定了核心執行緒數,比如 newSingleThreadExecutor 和 newFixedThreadPool ,如果沒有主動去關閉,或者設定核心執行緒的超時時間,核心執行緒會一直存在不會被關閉,這個執行緒池就不會被釋放回收。

2. 驗證

我們設計一個 Demo 來驗證一下:

  1. 選用 JDK 提供的單執行緒執行緒池,這個執行緒池的核心執行緒數為 1。
  2. 該執行緒池作為一個物件的成員變數。
  3. 這個類的例項物件在方法體中執行,外界對它沒有引用
  4. 最後呼叫一下 System.gc 主動回收
  5. 等待一段時間後,打印出程序的堆資訊,檢視相關的類。

按照上面的思路,建立了 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 的工具來獲取當前存活的物件資訊:

  1. jps 列印獲取程序號 4540
  2. 呼叫 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,如果沒有主動釋放或者設定執行緒超時,如果放在成員變數中,會發生物件例項洩漏。

同樣,放在方法體中做區域性變數也會有這樣的問題。

3. 剖析

為什麼會有這樣的現象?

執行緒池無法被回收,是因為執行緒池的引用被它的內部類 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 需要這幾種情況:

  1. 執行緒池的狀態 >= STOP
  2. 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,該佇列提供了兩種方法來獲取任務:

  1. poll,可以設定超時時間,當超時後會得到一個空任務。
  2. take,阻塞住,直到有任務出現。

從上面的 getTask 方法中我們可以看到:

  1. 當前執行緒數大於核心執行緒,會呼叫 poll,超時後返回空任務。
  2. 當前執行緒數小於等於核心執行緒,並且呼叫了 allowCoreThreadTimeOut 方法允許核心執行緒超時關閉的情況下,也是呼叫 poll,超時後返回空任務。
  3. 其他情況,呼叫 take 阻塞等待。

我們上面使用單個核心執行緒的執行緒池,在沒有任務的情況下,核心執行緒正處於 getTask ,呼叫阻塞佇列 BlockingQueue 的 take 方法阻塞等待獲取到任務,從而導致執行緒池包括裡面的核心執行緒遲遲不被關閉並且回收。

4. 小結

像上面那樣去設定執行緒池,可以理解為執行緒池的區域性應用。

不推薦用這樣的方式,因為區域性執行緒池能做到的事情,全域性執行緒池也可以做到。而且全域性單例的執行緒池還可以不用考慮關閉執行緒池的問題,畢竟生命週期和程序一致。

如果業務場景非要這樣用的話,並且執行緒池有核心執行緒的情況下,要注意做兩件事情防止物件洩漏:

  1. 對核心執行緒設定超時時間。
  2. 主動呼叫 shutdown 或 shutdownNow 來關閉執行緒池。