小豹子帶你看原始碼:Java 執行緒池(二)例項化

LeopPro發表於2018-01-31

承上啟下:上一篇文章小豹子講了我為什麼想要研究執行緒池的程式碼,以及我計劃要怎樣閱讀程式碼。這篇文章我主要閱讀了執行緒池例項化相關的程式碼,並提出了自己的疑問。

3 千里之行,始於例項化

3.1 先建立一個執行緒池玩玩

我們首先看構造器的宣告,ThreadPoolExecutor 有四個過載構造器,其中三個分別指定了不同的預設引數值,我們直接看引數最全的構造器:

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler)
複製程式碼

引數有點多,我們有點懵,但並不是無從下手。我們去看程式碼上方的 JavaDoc:

  • corePoolSize:要保留在池中的執行緒數。即便執行緒空閒,不小於該引數的執行緒也將被保留。除非設定了 allowCoreThreadTimeOut
  • maximumPoolSize:池中允許的最大執行緒數
  • keepAliveTime:當池中執行緒數大於核心池數量(corePoolSize)時,大於核心池數量部分的執行緒空閒持續 keepAliveTime 時間後,將被終止
  • unit:keepAliveTime 引數的時間單位
  • workQueue:在任務被執行之前用於儲存任務的佇列。這個佇列只包含由 execute 方法提交的 Runnable 任務
  • threadFactory:executor 建立新執行緒時使用的執行緒工廠
  • handler:用於處理由於超過執行緒上限或佇列上限而產生的拒絕服務異常

那麼我們根據文件來建立一個執行緒池:

@Test
public void newInstanceTest() {
    ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(5, 10, 60, TimeUnit.SECONDS, 
        new LinkedBlockingQueue<Runnable>(), 
        new ThreadFactory() {
            @Override
            public Thread newThread(Runnable r) {
                return new Thread();
            }
        }, new RejectedExecutionHandler() {
            @Override
            public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
                System.out.println("拒絕服務");
        }
    });
}
複製程式碼

這裡我們建立了一個核心池數量為 5,最大執行緒數為 10,執行緒保持時間為 60 秒的執行緒池。

3.2 初始化時,執行緒池做了什麼?

我們跟蹤到程式碼中,看例項化的過程中,構造器為我們做了什麼:

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler) {
    if (corePoolSize < 0 ||
        maximumPoolSize <= 0 ||
        maximumPoolSize < corePoolSize ||
        keepAliveTime < 0)
        throw new IllegalArgumentException();
    if (workQueue == null || threadFactory == null || handler == null)
        throw new NullPointerException();
    this.acc = System.getSecurityManager() == null ?
            null :
            AccessController.getContext();
    this.corePoolSize = corePoolSize;
    this.maximumPoolSize = maximumPoolSize;
    this.workQueue = workQueue;
    this.keepAliveTime = unit.toNanos(keepAliveTime);
    this.threadFactory = threadFactory;
    this.handler = handler;
}
複製程式碼

這裡很容易理解,前面進行了輸入引數的檢查,this.acc 是訪問控制器上下文,這裡我們不深入研究它。唯一值得一提的就是 unit.toNanos(keepAliveTime),這是將引數中的 keepAliveTime 轉換成納秒,似乎也不難理解,但我有一個疑問:為什麼要抽象時間單位?抽象時間段不好麼?比如我設計一個 Period 類表示一段時間,裡面有幾個靜態方法用於例項化,比如 Period.fromSeconds(long n) 表示 n 秒的一段時間,然後可以使用 Period#toNanos() 這類的方法將該段時間傳化為納秒。這樣可以是引數更簡潔,表意更明確。不知兩種設計方案的優缺,還望各位指點。

我們繼續看 ThreadPoolExecutor 的初始化:

private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));

private static final int COUNT_BITS = Integer.SIZE - 3;
private static final int CAPACITY   = (1 << COUNT_BITS) - 1;

private static final int RUNNING    = -1 << COUNT_BITS;
private static final int SHUTDOWN   =  0 << COUNT_BITS;
private static final int STOP       =  1 << COUNT_BITS;
private static final int TIDYING    =  2 << COUNT_BITS;
private static final int TERMINATED =  3 << COUNT_BITS;
複製程式碼

又是一堆天書,但似乎 RUNNINGSHUTDOWN 等是表示某種狀態的常量,至於它們的賦值為什麼這麼特殊,其他變(常)量都是幹嘛的?老套路,看文件。

文件告訴我們:ctl 是表示執行緒池狀態的原子整形,它包含兩部分:工作執行緒數、執行狀態。為了將兩個變數用一個原子整形表示,我們限制工作執行緒數最多隻能有 (2^29)-1(大概 5 億)個,而空餘的高三位用來儲存執行狀態。

執行狀態可能有這些值:

  • RUNNING:允許提交新任務,處理佇列中的任務
  • SHUTDOWN:不允許提交新任務,但處理佇列中的任務
  • STOP:不允許提交新任務,不處理佇列中的任務,打斷執行中的任務
  • TIDYING:所有任務已經終止,工作執行緒數為零,執行緒過渡到 TIDYING時將呼叫 terminated()回撥方法
  • TERMINATED:terminated() 方法完成後

這些值之間的順序很重要,執行狀態的值隨時間單調遞增,但在一個生命週期內不需要經歷過所有的狀態。

狀態的轉換:

  • RUNNING -> SHUTDOWN:呼叫 shutdown() 觸發,或者隱含在 finalize()
  • (RUNNING / SHUTDOWN) -> STOP:呼叫 shutdownNow() 觸發
  • SHUTDOWN -> TIDYING:當佇列和池均為空時觸發
  • STOP -> TIDYING:當池為空時觸發
  • TIDYING -> TERMINATED:terminated() 執行結束之後

看過文件之後,我們再回頭看這幾個常量的賦值:首先 COUNT_BITSInteger 的長度減 3,其他幾個狀態量分別是 -1、0、1,2,3 向高位移動 COUNT_BITS 位的結果,這也就對應著文件所寫,用一個整形的高三位來儲存執行緒池的狀態。CAPACITY 的值是 1 向高位移動 COUNT_BITS 位再減一,字面意思是容量,這不難理解,COUNT_BITS 就是代表執行緒池所能容納的最大執行緒數,而值得一提的是,這個值在二進位制層面上具有另一個意義:CAPACITY 的二進位制值高三位為 0,其他位為 1。具體用途,我們後面細說。

現在只剩 ctl 我們不清楚了,首先從文件中我們可以獲知 ctl 是包含了執行狀態與執行緒數量的一個整形原子變數,那麼 ctlOf(RUNNING, 0) 是什麼意思呢?我們來看 ThreadPoolExecutor 中的靜態方法:

private static int runStateOf(int c)     { return c & ~CAPACITY; }
private static int workerCountOf(int c)  { return c & CAPACITY; }
private static int ctlOf(int rs, int wc) { return rs | wc; }

private static boolean runStateLessThan(int c, int s) {
    return c < s;
}

private static boolean runStateAtLeast(int c, int s) {
    return c >= s;
}

private static boolean isRunning(int c) {
    return c < SHUTDOWN;
}
複製程式碼

這裡小豹子帶大家回憶一下位運算:
& 是按位與運算子,輸入均為 1 輸出為 1,其他為 0;
| 是按位或運算子,輸入均為 0 輸出為 0,其他為 1;
~ 是按位非運算子,輸入為 0 輸出為 1,輸入為 1 輸出為 0;

我們看 ctlOf(int rs, int wc),其中 rs 指執行狀態(runState),wc 值執行緒數(workerCount)。rs 值的特點是高三位表示執行狀態,而其他低位均為 0,wc 值的特點是高三位為 0(因為不大於 CAPACITY 嘛),低位表示執行緒數。那麼對兩個值進行按位或運算,正好就將兩個值的有效位合併到一個整形變數中。我們再回頭看 ctl 變數的初始化 new AtomicInteger(ctlOf(RUNNING, 0))。這回應該就清楚了,ctlOf(RUNNING, 0) 表示執行狀態是 RUNNING,執行緒數為 0 的執行緒池狀態。

那麼 runStateOfworkerCountOf 就不必多說,是從 ctl 中剝離出執行狀態值和執行緒數,在這裡 CAPACITY 的作用就體現出來,它表示一種標誌位,因為它二進位制值的特性(前文提到)使得另一個值與它進行位與(或非與)運算時可以得到值的低位(或高位)。接下來我著重解釋一下 isRunning(int c),首先我們要已知兩個事實:

  1. 執行狀態值之間的順序很重要,執行狀態的值隨時間單調遞增,RUNNING 是最小的,SHUTDOWN 次之。
  2. 執行狀態儲存在 ctl 變數的高三位。

那麼判斷當前執行緒池的狀態是否為 RUNNING,有沒有必要將 ctl 中的狀態值提取出來,再與 RUNNING 常量進行對比呢?沒有必要,因為狀態值佔高位,只要狀態值小於 SHUTDOWNctl 就必然小於 SHUTDOWN,而小於 SHUTDOWN 的狀態只有 RUNNING,因此只要 ctl 值小於 SHUTDOWN,它就一定是 RUNNING 狀態。其他函式(runStateLessThanrunStateAtLeast)同理,直接對比就好。

3.3 疑問

看到 ThreadPoolExecutor 中用一個原子變數儲存兩種狀態的設計思想,我心中產生一個疑問:為什麼要這樣做?為了節省記憶體麼?肯定不是,執行緒池的主要應用場景應該是伺服器,而用時間換空間(還只換了這麼點空間)是非常不值得的。那麼我唯一能想到的解釋是,有利於提高併發效能。

我記得我在看《高效能 MySQL》的時候,作者告訴我這樣一種思想:熱點分離。

書中描繪了這樣一個應用場景,一個類似微博的應用,後臺要統計總髮貼數。那麼每一次獲取資料都要 count(*) 這肯定不現實。現實一點的做法是,在資料庫中維護一個表示總髮貼數的記錄,每一次使用者發帖,這個值就加 1。這種方案併發效能也不是很好。因為這個欄位至少要加行鎖,每次使用者發帖,總髮貼數加 1 時都會引起鎖競爭。這相當於把使用者發帖行為序列化了。

書中的解決方案是設計一張表,其中有 n 條記錄(比如說 100 條),每一次使用者發帖,在這 100 條記錄中選一條記錄(可以是隨機選擇,也可以根據時間取模)自加 1。然後每隔一段時間將表中的所有記錄加和賦值到第一條記錄中,刪除其他記錄。這樣一來,原先是 N 個執行緒爭搶一把鎖,現在是 N 個執行緒爭搶一百把鎖。併發效能當然得到了增加。這就是所謂的熱點分離。

ThreadPoolExecutorctl 的設計似乎反其道而行之。把兩個需要併發訪問的值“捏”到了一起。除非執行狀態和執行緒數往往同時變化,否則這樣做,我理解不了它是怎樣提高併發效能的。我決定暫時擱置這個問題,在後續對原始碼的學習過程中,我相信我能得到答案。

系列文章

小豹子還是一個大三的學生,小豹子希望你能“批判性的”閱讀本文,對本文內容中不正確、不妥當之處進行嚴厲的批評,小豹子感激不盡。

相關文章