原始碼|從序列執行緒封閉到物件池、執行緒池

monkeysayhi發表於2019-01-10

今天講一個牛逼而實用的概念,序列執行緒封閉物件池是序列執行緒封閉的典型應用場景;執行緒池糅合了物件池技術,但核心實現不依賴於物件池,很容易產生誤會。本文從序列執行緒封閉和物件池入手,最後通過原始碼分析執行緒池的核心原理,釐清物件池與執行緒池之間的誤會。

JDK版本:oracle java 1.8.0_102

執行緒封閉與序列執行緒封閉

執行緒封閉

執行緒封閉是一種常見的執行緒安全設計策略:僅在固定的一個執行緒內訪問物件,不對其他執行緒共享

使用執行緒封閉技術,物件O始終只對一個執行緒T1可見,“單執行緒”中自然不存線上程安全的問題。

ThreadLocal是常用的執行緒安全工具,見原始碼|ThreadLocal的實現原理。執行緒封閉在Servlet及高層的web框架Spring等中應用不少。

序列執行緒封閉

執行緒封閉雖然好用,卻限制了物件的共享。序列執行緒封閉改進了這一點:物件O只能由單個執行緒T1擁有,但可以通過安全的釋出物件O來轉移O的所有權;在轉移所有權後,也只有另一個執行緒T2能獲得這個O的所有權,並且釋出O的T1不會再訪問O

所謂“所有權”,指修改物件的權利。

相對於執行緒封閉,序列執行緒封閉使得任意時刻,最多僅有一個執行緒擁有物件的所有權。當然,這不是絕對的,只要執行緒T1事實不會再修改物件O,那麼就相當於僅有T2擁有物件的所有權。序列線層封閉讓物件變得可以共享(雖然只能序列的擁有所有權),靈活性得到大大提高;相對的,要共享物件就涉及安全釋出的問題,依靠BlockingQueue等同步工具很容易實現這一點。

物件池是序列執行緒封閉的經典應用場景,如資料庫連線池等。

物件池

物件池利用了序列封閉:將物件O“借給”一個請求執行緒T1,T1使用完再交還給物件池,並保證“未擅自發布該物件”且“以後不再使用”;物件池收回O後,等T2來借的時候再把它借給T2,完成物件所有權的傳遞

猴子擼了一個簡化版的執行緒池,使用者只需要覆寫newObject()方法:

public abstract class AbstractObjectPool<T> {
  protected final int min;
  protected final int max;
  protected final List<T> usings = new LinkedList<>();
  protected final List<T> buffer = new LinkedList<>();
  private volatile boolean inited = false;

  public AbstractObjectPool(int min, int max) {
    this.min = min;
    this.max = max;
    if (this.min < 0 || this.min > this.max) {
      throw new IllegalArgumentException(String.format(
          "need 0 <= min <= max <= Integer.MAX_VALUE, given min: %s, max: %s", this.min, this.max));
    }
  }

  public void init() {
    for (int i = 0; i < min; i++) {
      buffer.add(newObject());
    }
    inited = true;
  }

  protected void checkInited() {
    if (!inited) {
      throw new IllegalStateException("not inited");
    }
  }

  abstract protected T newObject();

  public synchronized T getObject() {
    checkInited();

    if (usings.size() == max) {
      return null;
    }
    if (buffer.size() == 0) {
      T newObj = newObject();
      usings.add(newObj);
      return newObj;
    }
    T oldObj = buffer.remove(0);
    usings.add(oldObj);
    return oldObj;
  }

  public synchronized void freeObject(T obj) {
    checkInited();
    if (!usings.contains(obj)) {
      throw new IllegalArgumentException(String.format("obj not in using queue: %s", obj));
    }

    usings.remove(usings.indexOf(obj));
    buffer.add(obj);
  }
}
複製程式碼

AbstractObjectPool具有以下特性:

  • 支援設定最小、最大容量
  • 物件一旦申請就不再釋放,避免了GC

雖然很簡單,但大可以用於一些時間敏感、資源充裕的場景。如果時間進一步敏感,可將getObject()、freeObject()改寫為併發程度更高的版本,但記得保證安全釋出安全回收;如果資源不那麼充裕,可以適當增加物件回收策略。

可以看到,一個物件池的基本行為包括:

  • 建立物件newObject()
  • 借取物件getObject()
  • 歸還物件freeObject()

典型的物件池有各種連線池、常量池等,應用非常多,模型也大同小異,不做解析。令人迷惑的是執行緒池,很容易讓人誤以為執行緒池的核心原理也是物件池,下面來追一遍原始碼。

執行緒池

首先擺出結論:執行緒池糅合了物件池模型,但核心原理是生產者-消費者模型

繼承結構如下:

image.png

使用者可以將Runnable(或Callables)例項提交給執行緒池,執行緒池會非同步執行該任務,返回響應的結果(完成/返回值)。

猴子最喜歡的是submit(Callable<T> task)方法。我們從該方法入手,逐步深入函式棧,探究執行緒池的實現原理。

submit()

submit()方法在ExecutorService介面中定義,AbstractExecutorService實現,ThreadPoolExecutor直接繼承。

public abstract class AbstractExecutorService implements ExecutorService {
...
    public <T> Future<T> submit(Callable<T> task) {
        if (task == null) throw new NullPointerException();
        RunnableFuture<T> ftask = newTaskFor(task);
        execute(ftask);
        return ftask;
    }
...
}
複製程式碼

AbstractExecutorService#newTaskFor()建立一個RunnableFuture型別的FutureTask。

核心是execute()方法。

execute()

execute()方法在Executor介面中定義,ThreadPoolExecutor實現。

public class ThreadPoolExecutor extends AbstractExecutorService {
...
    public void execute(Runnable command) {
        if (command == null)
            throw new NullPointerException();
        int c = ctl.get();
        if (workerCountOf(c) < corePoolSize) {
            if (addWorker(command, true))
                return;
            c = ctl.get();
        }
        if (isRunning(c) && workQueue.offer(command)) {
            int recheck = ctl.get();
            if (! isRunning(recheck) && remove(command))
                reject(command);
            else if (workerCountOf(recheck) == 0)
                addWorker(null, false);
        }
        else if (!addWorker(command, false))
            reject(command);
    }
...
}
複製程式碼

我們暫且忽略執行緒池的池化策略。關注一個最簡單的場景,看能不能先回答一個問題:執行緒池中的任務如何執行?

核心是addWorker()方法。以8行的引數為例,此時,執行緒池中的執行緒數未達到最小執行緒池大小corePoolSize,通常可以直接在9行返回。

addWorker()

簡化如下:

public class ThreadPoolExecutor extends AbstractExecutorService {
...
    private boolean addWorker(Runnable firstTask, boolean core) {
        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 {
                    int rs = runStateOf(ctl.get());
                    if (rs < SHUTDOWN) {
                        workers.add(w);
                        workerAdded = true;
                    }
                } finally {
                    mainLock.unlock();
                }
                if (workerAdded) {
                    t.start();
                    workerStarted = true;
                }
            }
        } finally {
            if (! workerStarted)
                addWorkerFailed(w);
        }
        return workerStarted;
    }
...
}
複製程式碼

我去掉了很多用於管理執行緒池、維護執行緒安全的程式碼。假設執行緒池未關閉,worker(即w,下同)新增成功,則必然能夠將worker新增至workers中。workers是一個HashSet:

private final HashSet<Worker> workers = new HashSet<Worker>();
複製程式碼

哪裡是物件池?

如果說與物件池有關,那麼workers即相當於示例程式碼中的using,應用了物件池模型;只不過這裡的using是一直增長的,直到達到最大執行緒池大小maximumPoolSize。

但是很明顯,執行緒池並沒有將執行緒釋出出去,workers也僅僅完成using“儲存執行緒”的功能。那麼,執行緒池中的任務如何執行呢?跟執行緒池有沒有關係?

哪裡又不是?

注意9、17、24行:

  • 9行將我們提交到執行緒池的firstTask封裝入一個worker。
  • 17行將worker加入workers,維護起來
  • 24行則啟動了worker中的執行緒t

核心在與這三行,但執行緒池並沒有直接在addWorker()中啟動任務firstTask,代之以啟動一個worker。最終任務必然被啟動,那麼我們繼續看Worker如何啟動這個任務。

Worker

Worker實現了Runnable介面:

private final class Worker
    extends AbstractQueuedSynchronizer
    implements Runnable
{
...
    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);
    }
...
}
複製程式碼

為什麼要將構造Worker時的引數命名為firstTask?因為當且僅當需要建立新的Worker以執行任務task時,才會呼叫建構函式。因此,任務task對於新Worker而言,是第一個任務firstTask。

Worker的實現非常簡單:將自己作為Runable例項,構造時在內部建立並持有一個執行緒thread。Thread和Runable的使用大家很熟悉了,核心是Worker的run方法,它直接呼叫了runWorker()方法。

runWorker()

敲黑板!!!

重頭戲來了。簡化如下:

public class ThreadPoolExecutor extends AbstractExecutorService {
...
    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();
                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);
        }
    }
...
}
複製程式碼

我們在前面將要執行的任務賦值給firstTask,5-6行首先取出任務task,並將firstTask置為null。因為接下來要執行task,firstTask欄位就沒有用了。

重點是10-31行的while迴圈。下面分情況討論。

case1:第一次進入迴圈,task不為null

case1對應前面作出的諸多假設。

第一次進入迴圈時,task==firstTask,不為null,使10行布林短路直接進入迴圈;從而16行執行的是firstTask的run()方法;異常處理不表;最後,finally程式碼塊中,task會被置為null,導致下一輪迴圈會進入case2。

case2:非第一次進入迴圈,task為null

case2是更普遍的情況,也就是執行緒池的核心。

case1中,task被置為了null,使10行布林表示式執行第二部分(task = getTask()) != null(getTask()稍後再講,它返回一個使用者已提交的任務)。假設task得到了一個已提交的任務,從而16行執行的是新獲得的任務task的run()方法。後同case1,最後task仍然會被置為null,以後迴圈都將進入case2。

getTask()

任務從哪來呢?簡化如下:

public class ThreadPoolExecutor extends AbstractExecutorService {
...
    private Runnable getTask() {
        boolean timedOut = false;

        for (;;) {
            try {
                Runnable r = timed ?
                    workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
                    workQueue.take();
                if (r != null)
                    return r;
                timedOut = true;
            } catch (InterruptedException retry) {
                timedOut = false;
            }
        }
    }
...
}
複製程式碼

我們先看最簡單的,19-28行。

首先,workQueue是一個執行緒安全的BlockingQueue,大部分時候使用的實現類是LinkedBlockingQueue,見原始碼|併發一枝花之BlockingQueue

private final BlockingQueue<Runnable> workQueue;
複製程式碼

假設timed為false,則呼叫阻塞的take()方法,返回的r一定不是null,從而12行退出,將任務交給了某個worker執行緒。

一個小細節有點意思:前面每個worker執行緒runWorker()方法時,在迴圈中加鎖粒度在worker級別,直接使用的lock同步;但因為每一個woker都會呼叫getTask(),考慮到效能因素,原始碼中getTask()中使用樂觀的CAS+SPIN實現無鎖同步。關於樂觀鎖和CAS,可以參考我的另一篇文章原始碼|併發一枝花之ConcurrentLinkedQueue【偽】

workQueue中的元素從哪來呢?這就要回顧execute()方法了。

execute()

public class ThreadPoolExecutor extends AbstractExecutorService {
...
    public void execute(Runnable command) {
        if (command == null)
            throw new NullPointerException();
        int c = ctl.get();
        if (workerCountOf(c) < corePoolSize) {
            if (addWorker(command, true))
                return;
            c = ctl.get();
        }
        if (isRunning(c) && workQueue.offer(command)) {
            int recheck = ctl.get();
            if (! isRunning(recheck) && remove(command))
                reject(command);
            else if (workerCountOf(recheck) == 0)
                addWorker(null, false);
        }
        else if (!addWorker(command, false))
            reject(command);
    }
...
}
複製程式碼

前面以8行的引數為例,此時,執行緒池中的執行緒數未達到最小執行緒池大小corePoolSize,通常可以直接在9行返回。進入8行的條件是“當前worker數小於最小執行緒池大小corePoolSize”

如果不滿足,會繼續執行到12行。isRunning(c)判斷執行緒池是否未關閉,我們關注未關閉的情況;則會繼續執行布林表示式的第二部分workQueue.offer(command),嘗試將任務command放入佇列workQueue。

workQueue.offer()的行為取決於執行緒池持有的BlockingQueue例項。Executors.newFixedThreadPool()、Executors.newSingleThreadExecutor()建立的執行緒池使用LinkedBlockingQueue,而Executors.newCachedThreadPool()建立的執行緒池則使用SynchronousQueue。以LinkedBlockingQueue為例,建立時不配置容量,即建立為無界佇列,則LinkedBlockingQueue#offer()永遠返回true,從而進入12-18行。

更細節的內容不必關心了,當workQueue.offer()返回true時,已經將任務command放入了佇列workQueue。當未來的某個時刻,某worker執行完某一個任務之後,會從workQueue中再取出一個任務繼續執行,直到執行緒池關閉,直到海枯石爛。

CachedThreadPool是一種無界執行緒池,使用SynchronousQueue能進一步提升效能,簡化程式碼結構。留給讀者分析。

case2小結

可以看到,實際上,執行緒池的核心原理與物件池模型無關,而是生產者-消費者模型

image.png
  • 生產者(呼叫submit()或execute()方法)將任務task放入佇列
  • 消費者(worker執行緒)迴圈從佇列中取出任務處理任務(執行task.run())。

鉤子方法

回到runWorker()方法,在執行任務的過程中,執行緒池保留了一些鉤子方法,如beforeExecute()、afterExecute()。使用者可以在實現自己的執行緒池時,可以通過覆寫鉤子方法為執行緒池新增功能。

猴子不認為鉤子方法是一種好的設計。因為鉤子方法大多依賴於原始碼實現,那麼除非瞭解原始碼或API宣告絕對的嚴謹正確,否則很難正確使用鉤子方法。等發生錯誤時再去了解實現,可能就太晚了。說到底,還是不要使用類似extends這種表達“擴充套件”語義的語法來實現繼承,詳見Java中如何恰當的表達“繼承”與“擴充套件”的語義?

當然,鉤子方法也是極其方便的。權衡看待。

總結

相對於執行緒封閉,序列執行緒封閉離使用者的距離更近一些,簡單靈活,實用性強,很容易掌握。而執行緒封閉更多淪為單純的設計策略,單純使用執行緒封閉的場景不多。

執行緒池與序列執行緒封閉、物件池的關係不大,但經常被混為一談;沒看過原始碼的很難想到其實現方案,面試時也能立分高下。

執行緒池的實現很有意思。在追原始碼之前,猴子一直以為執行緒池就是把執行緒存起來,用的時候取出來執行任務;看了原始碼才知道實現如此之妙,簡潔優雅效率高。原始碼才是最好的老師。


本文連結:原始碼|從序列執行緒封閉到物件池、執行緒池
作者:猴子007
出處:monkeysayhi.github.io
本文基於 知識共享署名-相同方式共享 4.0 國際許可協議釋出,歡迎轉載,演繹或用於商業目的,但是必須保留本文的署名及連結。

相關文章