Java 執行緒池的理論與實踐

TheAlchemist發表於2017-05-03

前段時間公司裡有個專案需要進行重構,目標是提高吞吐量和可用性,在這個過程中對原有的執行緒模型和處理邏輯進行了修改,發現有很多基礎的多執行緒的知識已經模糊不清,如底層執行緒的執行情況、現有的執行緒池的策略和邏輯、池中執行緒的健康狀況的監控等,這次重新回顧了一下,其中涉及大量java.util.concurrent包中的類。本文將會包含以下內容:

  1. Java中的Thread與作業系統中的執行緒的關係
  2. 執行緒切換的各種開銷
  3. ThreadGroup存在的意義
  4. 使用執行緒池減少執行緒開銷
  5. Executor的概念
  6. ThreadPoolExecutor中的一些具體實現
  7. 如何監控執行緒的健康
  8. 參考ThreadPoolExecutor來設計適合自己的執行緒模型

一、問題描述

這個專案所在系統的軟體架構(從開發到運維)基本上採用的是微服務架構,微服務很好地解決了我們系統的複雜性問題,但是隨之也帶來了一些問題,比如在此架構中大部分的服務都擁有自己單獨的資料庫,而有些(很重要的)業務需要做跨庫查詢。相信這種「跨庫查詢」的問題很多實踐微服務的公司都碰到過,通常這類問題有以下幾種解決方案(當然,還有更多其他的方案,這裡就不一一敘述了):

  1. 嚴格通過服務提供的API查詢。這樣做的好處是將服務完全當做黑盒,可以最大限度得減少服務間的依賴與耦合關係,其次還能根據實際需求服務之間使用不同的資料庫型別;缺點是則代價太大。
  2. 將關心的資訊冗餘到自己的庫中,並提供API讓其他服務來主動修改。優點是資訊更新十分實時,缺點是增加了服務間的依賴。
  3. 指令與查詢分離(CQRS)。將可能被其他服務關心的資料放入資料倉儲(或者做成類似於物化檢視、搜尋引擎等),資料倉儲只提供讀的功能。優點是對主庫不會有壓力,服務只要關心實現自己的業務就好,缺點是資料的實時性會受到了挑戰。

指令與查詢分離

結合實際情況,我們使用的是第3種方案。然而隨著越來越多的業務依賴讀庫,甚至依賴其中一些狀態的變化,所以讀庫的資料同步如果出現高延時,則會直接影響業務的進行。出了幾次這種事情後,於是下決心要改善這種情況。首先想到的就是使用執行緒池來進行訊息的消費(寫入讀庫),JDK自從1.5開始提供了實用而強大的執行緒池工具——Executor框架。

二、Executor框架

Executor框架在Java1.5中引入,大部分的類都在包java.util.concurrent中,由大神Doug Lea寫成,其中常用到的有以下幾個類和介面:

  1. java.util.concurrent.Executor一個只包含一個方法的介面,它的抽象含義是:用來執行一個Runnable任務的執行器。
  2. java.util.concurrent.ExecutorService對Executor的一個擴充套件,增加了很多對於任務和執行器的生命週期進行管理的介面,也是通常進行多執行緒開發最常使用的介面。
  3. java.util.concurrent.ThreadFactory一個生成新執行緒的介面。使用者可以通過實現這個介面管理對執行緒池中生成執行緒的邏輯
  4. java.util.concurrent.Executors提供了很多不同的生成執行器的實用方法,比如基於執行緒池的執行器的實現。

三、為什麼要用執行緒池

Java從最開始就是基於執行緒的,執行緒在Java裡被封裝成一個類java.lang.Thread。在面試中很多面試官都會問一個很基礎的關於執行緒問題:

Java中有幾種方法新建一個執行緒?

所有人都知道,標準答案是兩種:繼承Thread或者實現Runnable,在JDK原始碼中Thread類的註釋中也是這麼寫的。

然而在我看來這兩種方法根本就是一種,所有想要開啟執行緒的操作,都必須生成了一個Thread類(或其子類)的例項,執行其中的native方法start0()

Java中的執行緒

Java中將執行緒抽象為一個普通的類,這樣帶來了很多好處,譬如可以很簡單的使用物件導向的方法實現多執行緒的程式設計,然而這種程式寫多了容易會忘記,這個物件在底層是實實在在地對應了一個OS中的執行緒。

作業系統中的執行緒和程式

上圖中的程式(Process)可以看做一個JVM,可以看出,所有的程式有自己的私有記憶體,這塊記憶體會在主存中有一段對映,而所有的執行緒共享JVM中的記憶體。在現代的作業系統中,執行緒的排程通常都是整合在作業系統中的,作業系統能通過分析更多的資訊來決定如何更高效地進行執行緒的排程,這也是為什麼Java中會一直強調,執行緒的執行順序是不會得到保證的,因為JVM自己管不了這個,所以只能認為它是完全無序的。

另外,類java.lang.Thread中的很多屬性也會直接對映為作業系統中執行緒的一些屬性。Java的Thread中提供的一些方法如sleep和yield其實依賴於作業系統中執行緒的排程演算法。

關於執行緒的排程演算法可以去讀作業系統相關的書籍,這裡就不做太多敘述了。

執行緒的開銷

通常來說,作業系統中執行緒之間的上下文切換大約要消耗1到10微秒

從上圖中可以看出執行緒中包含了一些上下文資訊:

  • CPU棧指標(Stack)、
  • 一組暫存器的值(Registers),
  • 指令計數器的值(PC)等,

它們都儲存在此執行緒所在的程式所對映的主存中,而對於Java來說,這個程式就是JVM所在的那個程式,JVM的執行時記憶體可以簡單的分為如下幾部分:

  1. 若干個棧(Stack)。每個執行緒有自己的棧,JVM中的棧是不能儲存物件的,只能儲存基礎變數和物件引用。
  2. 堆(Heap)。一個JVM只有一個堆,所有的物件都在堆上分配。
  3. 方法區(Method Area)。一個JVM只有一個方法區,包含了所有載入的類的位元組碼和靜態變數。

其中#1中的棧可以認為是這個執行緒的上下文,建立執行緒要申請相應的棧空間,而棧空間的大小是一定的,所以當棧空間不夠用時,會導致執行緒申請不成功。在Thread的原始碼中可以看到,啟動執行緒的最後一步是執行一個本地方法private native void start0(),程式碼1是OpenJDK中start0最終呼叫的方法:

//程式碼1
JVM_ENTRY(void, JVM_StartThread(JNIEnv* env, jobject jthread))
  JVMWrapper("JVM_StartThread");
  JavaThread *native_thread = NULL;
  bool throw_illegal_thread_state = false;

  // We must release the Threads_lock before we can post a jvmti event
  // in Thread::start.
  {
    MutexLocker mu(Threads_lock);

    //省略一些程式碼

      jlong size =
             java_lang_Thread::stackSize(JNIHandles::resolve_non_null(jthread));
      size_t sz = size > 0 ? (size_t) size : 0;
      native_thread = new JavaThread(&thread_entry, sz);
  }

  if (native_thread->osthread() == NULL) {
    THROW_MSG(vmSymbols::java_lang_OutOfMemoryError(),
              "unable to create new native thread");
  }

  Thread::start(native_thread);

JVM_END

從程式碼1中可以看到,執行緒的建立首先需要棧空間,所以過多的執行緒建立可能會導致OOM。

同時,執行緒的切換會有以下開銷:

  1. CPU中執行上下文的切換,導致CPU中的「指令流水線(Instruction Pipeline)」的中斷和CPU快取的失效。
  2. 如果執行緒太多,執行緒切換的時間會比執行緒執行的時間要長,嚴重浪費了CPU資源。
  3. 對於共享資源的競爭(鎖)會導致執行緒切換開銷急劇增加。

根據以上的描述,所以通常建議儘可能建立較少的執行緒,減少鎖的使用(尤其是synchronized),儘量使用JDK提供的同步工具。而為了減少執行緒上下文切換帶來的開銷,通常使用執行緒池是一個有效的方法。

Java中的執行緒池

Executor框架中最常用的大概就是java.util.concurrent.ThreadPoolExecutor了,對於它的描述,簡單的說就是「它維護了一個執行緒池,對於提交到此Executor中的任務,它不是建立新的執行緒而是使用池內的執行緒進行執行」。對於「數量巨大但執行時間很小」的任務,可以顯著地減少對於任務執行的開銷。java.util.concurrent.ThreadPoolExecutor中包含了很多屬性,通過這些屬性開發者可以定製不同的執行緒池行為,大致如下:

1. 執行緒池的大小:corePoolSizemaximumPoolSize

ThreadPoolExecutor中執行緒池的大小由這兩個屬性決定,前者指當執行緒池正常執行起來後的最小(核心)執行緒數,當一個任務到來時,若當前池中執行緒數小於corePoolSize,則會生成新的執行緒;後者指當等待佇列滿了之後可生成的最大的執行緒數。在例1中返回的物件中這兩個值相等,均等於使用者傳入的值。

2. 使用者可以通過呼叫java.util.concurrent.ThreadPoolExecutor上的例項方法來啟動核心執行緒(core pool)

3. 可定製化的執行緒生成方式:threadFactory

預設執行緒由方法Executors.defaultThreadFactory()返回的ThreadFactory進行建立,預設建立的執行緒都不是daemon,開發者可以傳入自定義的ThreadFactory進行對執行緒的定製化。

5. 非核心執行緒的空閒等待時間:keepAliveTime

6. 任務等待佇列:workQueue

這個佇列是java.util.concurrent.BlockingQueue<E>的一個例項。當池中當前沒有空閒的執行緒來執行任務,就會將此任務放入等待佇列,根據其具體實現類的不同,又可分為3種不同的佇列策略:

  1. 容量為0。如:java.util.concurrent.SynchronousQueue等待佇列容量為0,所有需要阻塞的任務必須等待池內的某個執行緒有空閒,才能繼續執行,否則阻塞。呼叫Executors.newCachedThreadPool的兩個函式生成的執行緒池是這個策略。
  2. 不限容量。如:不指定容量的java.util.concurrent.LinkedBlockingQueue等待佇列的長度無窮大,根據上文中的敘述,在這種策略下,不會有多於corePoolSize的執行緒被建立,所以maximumPoolSize也就沒有任何意義了。呼叫Executors.newFixedThreadPool生成的執行緒池是這個策略。
  3. 限制容量。如:指定容量的任何java.util.concurrent.BlockingQueue<E>在某些場景下(本文中將描述這種場景),需要指定等待佇列的容量,以防止過多的資源消耗,比如如果使用不限容量的等待佇列,當有大量的任務到來而池內又無空閒執行緒執行任務時,會有大量的任務堆積,這些任務都是某個類的物件,是要消耗記憶體的,就可能導致OOM。如何去平衡等待佇列和執行緒池的大小要根據實際場景去斷定,如果配置不當,可能會導致資源耗盡、執行緒上下文切換消耗、或者執行緒排程消耗。這些都會直接影響系統的吞吐。

7. 任務拒絕處理器:defaultHandler

如果任務被拒絕執行,則會呼叫這個物件上的RejectedExecutionHandler.rejectedExecution()方法,JDK定義了4種處理策略,使用者可以自定義自己的任務處理策略。

8. 允許核心執行緒過期:allowCoreThreadTimeOut

上面說的所有情況都是基於這個變數為false(預設值)來說的,如果你的執行緒池已經不使用了(不被引用),但是其中還有活著的執行緒時,這個執行緒池是不會被回收的,這種情況就造成了記憶體洩漏——一塊永遠不會被訪問到的記憶體卻無法被GC回收。
使用者可以通過在拋棄執行緒池引用的時候顯式地呼叫shutdown()來釋放它,或者將allowCoreThreadTimeOut設定為true,則在過期時間後,核心執行緒會被釋放,則其會被GC回收。

四、如果執行緒死掉了怎麼辦

幾乎所有Executors中生成執行緒池的方法的註釋上,都有代表相同意思的一句話,表示如果執行緒池中的某個執行緒死掉了,執行緒池會生成一個新的執行緒代替它。下面是方法java.util.concurrent.Executors.newFixedThreadPool(int)上的註釋。

If any thread terminates due to a failure during execution prior to shutdown, a new one will take its place if needed to execute subsequent tasks.

執行緒死亡的原因

我們都知道守護執行緒(daemon)會在所有的非守護執行緒都死掉之後也死掉,除此之外導致一個非守護執行緒死掉有以下幾種可能:

  1. 自然死亡,Runnable.run()方法執行完後返回。
  2. 執行過程中有未捕獲異常,被拋到了Runnable.run()之外,導致執行緒死亡。
  3. 其宿主死亡,程式關閉或者機器當機。在Java中通常是System.exit()方法被呼叫
  4. 其他硬體問題。

執行緒池要保證其高可用性,就必須保證執行緒的可用。如一個固定容量的執行緒池,其中一個執行緒死掉了,它必須要能監控到執行緒的死亡並生成一個新的執行緒來代替它。ThreadPoolExecutor中與執行緒相關的有這樣幾個概念:

  1. java.util.concurrent.ThreadFactory,在Executors中有兩種ThreadFactory,但其提供的執行緒池只使用了一種java.util.concurrent.Executors.DefaultThreadFactory,它是簡單的使用ThreadGroup來實現。
  2. java.lang.ThreadGroup,從Java1開始就存在的類,用來建立一個執行緒的樹形結構,可以用它來組織執行緒間的關係,但其並沒有對其包含的子執行緒的監控。
  3. java.util.concurrent.ThreadPoolExecutor.Worker,ThreadPoolExecutor對執行緒的封裝,其中還包含了一些統計功能。

ThreadPoolExecutor中如何保障執行緒的可用

在ThreadPoolExecutor中使用了一個很巧妙的方法實現了對執行緒池中執行緒健康狀況的監控,程式碼2是從ThreadPoolExecutor類原始碼中擷取的一段程式碼,它們在一起說明了其對執行緒的監控。

可以看到,在ThreadPoolExecutor中的執行緒被封裝成一個物件Worker,而將其中的run()代理到ThreadPoolExecutor中的runWorker(),在runWorker()方法中是一個獲取任務並執行的死迴圈。如果任務的執行出了什麼問題(如丟擲未捕獲異常),processWorkerExit()方法會被執行,同時傳入的completedAbruptly引數為true,會重新新增一個初始任務為null的Worker,並隨之啟動一個新的執行緒。

//程式碼2
//ThreadPoolExecutor的動態內部類
private final class Worker extends AbstractQueuedSynchronizer implements Runnable {

    /** 物件中封裝的執行緒 */
    final Thread thread;
    /** 第一個要執行的任務,可能為null. */
    Runnable firstTask;
    /** 任務計數器 */
    volatile long completedTasks;

    //省略其他程式碼

    Worker(Runnable firstTask) {
        setState(-1); // inhibit interrupts until runWorker
        this.firstTask = firstTask;
        this.thread = getThreadFactory().newThread(this);
    }
    /** Delegates main run loop to outer runWorker  */
    public void run() {
        runWorker(this);
    }
}

final void runWorker(Worker w) {
    Thread wt = Thread.currentThread();
    boolean completedAbruptly = true;
    try {
        while (task != null || (task = getTask()) != null) {
            w.lock();
            try {
                beforeExecute(wt, task);
                try {
                    task.run();
                } finally {
                    afterExecute(task, thrown);
                }
            } finally {
                task = null;
                w.completedTasks++;
                w.unlock();
            }
        }
        completedAbruptly = false;
    } finally {
        processWorkerExit(w, completedAbruptly);
    }
}

private void processWorkerExit(Worker w, boolean completedAbruptly) {
    if (runStateLessThan(c, STOP)) {
        if (!completedAbruptly) {
            int min = allowCoreThreadTimeOut ? 0 : corePoolSize;
            if (min == 0 && ! workQueue.isEmpty())
                min = 1;
            if (workerCountOf(c) >= min)
                return; // replacement not needed
        }
        addWorker(null, false);
    }
}

private boolean addWorker(Runnable firstTask, boolean core) {
    retry:
    for (;;) {
        int c = ctl.get();
        int rs = runStateOf(c);

        // Check if queue empty only if necessary.
        if (rs >= SHUTDOWN &&
            ! (rs == SHUTDOWN &&
               firstTask == null &&
               ! workQueue.isEmpty()))
            return false;

        for (;;) {
            int wc = workerCountOf(c);
            if (wc >= CAPACITY ||
                wc >= (core ? corePoolSize : maximumPoolSize))
                return false;
            if (compareAndIncrementWorkerCount(c))
                break retry;
            c = ctl.get();  // Re-read ctl
            if (runStateOf(c) != rs)
                continue retry;
            // else CAS failed due to workerCount change; retry inner loop
        }
    }

    boolean workerStarted = false;
    boolean workerAdded = false;
    Worker w = null;
    try {
        w = new Worker(firstTask);
        final Thread t = w.thread;
        if (t != null) {
            final ReentrantLock mainLock = this.mainLock;
            mainLock.lock();
            try {
                // Recheck while holding lock.
                // Back out on ThreadFactory failure or if
                // shut down before lock acquired.
                int rs = runStateOf(ctl.get());

                if (rs < SHUTDOWN ||
                    (rs == SHUTDOWN && firstTask == null)) {
                    if (t.isAlive()) // precheck that t is startable
                        throw new IllegalThreadStateException();
                    workers.add(w);
                    int s = workers.size();
                    if (s > largestPoolSize)
                        largestPoolSize = s;
                    workerAdded = true;
                }
            } finally {
                mainLock.unlock();
            }
            if (workerAdded) {
                t.start();
                workerStarted = true;
            }
        }
    } finally {
        if (! workerStarted)
            addWorkerFailed(w);
    }
    return workerStarted;
}

五、回到我的問題

由於各種各樣的原因,我們並沒有使用資料庫自帶的主從機制來做資料的複製,而是將主庫的所有DML語句作為訊息傳送到讀庫(DTS),同時自己實現了資料的重放。第一版的資料同步服務十分簡單,對於主庫的DML訊息處理和消費(寫入讀庫)都是在一個執行緒內完成的.這麼實現的優點是簡單,但缺點是直接導致了表與表之間的資料同步會受到影響,如果有一個表A忽然來了很多的訊息(往往是批量修改資料造成的),則會佔住訊息處理通道,影響其他業務資料的及時同步,同時單執行緒寫庫吞吐太小。

上文說到,首先想到的是使用執行緒池來做訊息的消費,但是不能直接套用上邊說的Executor框架,由於以下幾個原因:

  1. ThreadPoolExecutor中預設所有的任務之間是不互相影響的,然而對於資料庫的DML來說,訊息的順序不能被打亂,至少單表的訊息順序必須有序,不然會影響最終的資料一致。
  2. ThreadPoolExecutor中所有的執行緒共享一個等待佇列,然而為了防止表與表之間的影響,每個執行緒應該有自己的任務等待佇列。
  3. 寫庫操作的吞吐直接受到提交事務數的影響,所以此多執行緒框架要可以支援任務的合併。

重複造輪子是沒有意義的,但是在我們這種場景下JDK中現有的Executor框架不符合要求,只能自己造輪子。

我的實現

首先把執行緒抽象成「DML語句的執行器(Executor)」。其中包含了一個Thread的例項,維護了自己的等待佇列(限定容量的阻塞佇列),和對應的訊息執行邏輯。

除此之外還包含了一些簡單的統計、執行緒健康監控、合併事務等處理。

Executor的物件實現了Thread.UncaughtExceptionHandler介面,並繫結到其工作執行緒上。同時ExecutorGroup也會再生成一個守護執行緒專門來守護池內所有執行緒,作為額外的保險措施。

把執行緒池的概念抽象成執行器組(ExecutorGroup),其中維護了執行器的陣列,並維護了目標表到特定執行器的對映關係,並對外提供執行訊息的介面,其主要程式碼如下:

//程式碼3
public class ExecutorGroup {

    Executor[] group = new Executor[NUM];
    Thread boss = null;
    Map<String, Integer> registeredTables = new HashMap<>(32);
//    AtomicInteger cursor = new AtomicInteger();
    volatile int cursor = 0;

    public ExecutorGroup(String name) {
        //init group
        for(int i = 0; i < NUM; i++) {
            logger.debug("啟動執行緒{},{}", name, i);
            group[i] = new Executor(this, String.format("sync-executor-%s-%d", name, i), i / NUM_OF_FIRST_CLASS);

        }
        startDaemonBoss(String.format("sync-executor-%s-boss", name));
    }

    //額外的保險
    private void startDaemonBoss(String name) {
        if (boss != null) {
            boss.interrupt();
        }
        boss = new Thread(() -> {
            while(true) {
                //休息一分鐘。。。

                if (this.group != null) {
                    for (int i = 0; i < group.length; i++) {
                        Executor executor = group[i];
                        if (executor != null) {
                            executor.checkThread();
                        }
                    }
                }
            }

        });
        boss.setName(name);
        boss.setDaemon(true);
        boss.start();
    }
    public void execute(Message message){
        logger.debug("執行訊息");

        //省略訊息合法性驗證

        if (!registeredTables.containsKey(taskKey)) {
            //已註冊
//          registeredTables.put(taskKey, cursor.getAndIncrement());
            registeredTables.put(taskKey, cursor++ % NUM);
        }
        int index = registeredTables.get(taskKey);
        logger.debug("執行訊息{},註冊索引{}", taskKey, index);
        try {
            group[index].schedule(message);
        } catch (InterruptedException e) {
            logger.error("準備訊息出錯", e);
        }

    }

}

完成後整體的執行緒模型如下圖所示:

新的執行緒模型

Java1.7新加入的TransferQueue

Java1.7中提供了新的佇列型別TransferQueue,但只提供了一個它的實現java.util.concurrent.LinkedTransferQueue<E>,它有更好的效能表現,可它是一個無容量限制的佇列,而在我們的這個場景下必須要限制佇列的容量,所以要自己實現一個有容量限制的佇列。

相關文章