作者:楊曉峰來源:極客時間《Java核心技術36式》
大家好,我是 Oracle首席工程師楊曉峰。 今天想和大家深入聊聊線程,相信大家對於線程這個概念都不陌生,它是Java並發的基礎元素,理解、操縱、診斷線程是Java工程師的必修課,但是你真的掌握線程了嗎?
以下內容來自從我的專欄--《Java核心技術36講》,每一道Java經典面試題,從「典型回答」、「考點分析」、「知識擴展」三方面剖析這道題的來龍去脈及知識要點。對Java面試題感興趣的朋友,可以拉到文末,掃碼或者點擊「閱讀原文」訂閱我的專欄。
今天我要問你的問題是,一個線程兩次調用start()方法會出現什麼情況?談談線程的生命周期和狀態轉移。
典型回答Java的線程是不允許啟動兩次的,第二次調用必然會拋出IllegalThreadStateException,這是一種運行時異常,多次調用start被認為是編程錯誤。
關於線程生命周期的不同狀態,在Java 5以後,線程狀態被明確定義在其公共內部枚舉類型java.lang.Thread.State中,分別是:
新建(NEW),表示線程被創建出來還沒真正啟動的狀態,可以認為它是個Java內部狀態。
就緒(RUNNABLE),表示該線程已經在JVM中執行,當然由於執行需要計算資源,它可能是正在運行,也可能還在等待系統分配給它CPU片段,在就緒隊列裡面排隊。 在其他一些分析中,會額外區分一種狀態RUNNING,但是從Java API的角度,並不能表示出來。
阻塞(BLOCKED),這個狀態和我們前面兩講介紹的同步非常相關,阻塞表示線程在等待Monitor lock。比如,線程試圖通過synchronized去獲取某個鎖,但是其他線程已經獨佔了,那麼當前線程就會處於阻塞狀態。
等待(WAITING),表示正在等待其他線程採取某些操作。一個常見的場景是類似生產者消費者模式,發現任務條件尚未滿足,就讓當前消費者線程等待(wait),另外的生產者線程去準備任務數據,然後通過類似notify等動作,通知消費線程可以繼續工作了。Thread.join()也會令線程進入等待狀態。
計時等待(TIMED_WAIT),其進入條件和等待狀態類似,但是調用的是存在超時條件的方法,比如wait或join等方法的指定超時版本,如下面示例:
public final native void wait(long timeout) throws InterruptedException;
在第二次調用start()方法的時候,線程可能處於終止或者其他(非NEW)狀態,但是不論如何,都是不可以再次啟動的。
考點分析今天的問題可以算是個常見的面試熱身題目,前面的給出的典型回答,算是對基本狀態和簡單流轉的一個介紹,如果覺得還不夠直觀,我在下面分析會對比一個狀態圖進行介紹。總的來說,理解線程對於我們日常開發或者診斷分析,都是不可或缺的基礎。
面試官可能會以此為契機,從各種不同角度考察你對線程的掌握:
可以看出,僅僅是一個線程,就有非常多的內容需要掌握。我們選擇重點內容,開始進入詳細分析。
知識擴展首先,我們來整體看一下線程是什麼?
從作業系統的角度,可以簡單認為,線程是系統調度的最小單元,一個進程可以包含多個線程,作為任務的真正運作者,有自己的棧(Stack)、寄存器(Register)、本地存儲(Thread Local)等,但是會和進程內其他線程共享文件描述符、虛擬地址空間等。
在具體實現中,線程還分為內核線程、用戶線程,Java的線程實現其實是與虛擬機相關的。對於我們最熟悉的Sun/Oracle JDK,其線程也經歷了一個演進過程,基本上在Java 1.2之後,JDK已經拋棄了所謂的Green Thread,也就是用戶調度的線程,現在的模型是一對一映射到作業系統內核線程。
如果我們來看Thread的源碼,你會發現其基本操作邏輯大都是以JNI形式調用的本地代碼。
private native void start0();
private native void setPriority0(int newPriority);
private native void interrupt0();
這種實現有利有弊,總體上來說,Java語言得益於精細粒度的線程和相關的並發操作,其構建高擴展性的大型應用的能力已經毋庸置疑。但是,其複雜性也提高了並發編程的門檻,近幾年的Go語言等提供了協程(coroutine),大大提高了構建並發應用的效率。於此同時,Java也在Loom項目中,孕育新的類似輕量級用戶線程(Fiber)等機制,也許在不久的將來就可以在新版JDK中使用到它。
下面,我來分析下線程的基本操作。如何創建線程想必你已經非常熟悉了,請看下面的例子:
Runnable task = () -> {System.out.println("Hello World!");};
Thread myThread = new Thread(task);
myThread.start();
myThread.join();
我們可以直接擴展Thread類,然後實例化。但在本例中,我選取了另外一種方式,就是實現一個Runnable,將代碼邏放在Runnable中,然後構建Thread並啟動(start),等待結束(join)。
Runnable的好處是,不會受Java不支持類多繼承的限制,重用代碼實現,當我們需要重複執行相應邏輯時優點明顯。而且,也能更好的與現代Java並發庫中的Executor之類框架結合使用,比如將上面start和join的邏輯完全寫成下面的結構:
Future future = Executors.newFixedThreadPool(1)
.submit(task)
.get();
這樣我們就不用操心線程的創建和管理,也能利用Future等機制更好地處理執行結果。線程生命周期通常和業務之間沒有本質聯繫,混淆實現需求和業務需求,就會降低開發的效率。
從線程生命周期的狀態開始展開,那麼在Java編程中,有哪些因素可能影響線程的狀態呢?主要有:
線程自身的方法,除了start,還有多個join方法,等待線程結束;yield是告訴調度器,主動讓出CPU;另外,就是一些已經被標記為過時的resume、stop、suspend之類,據我所知,在JDK最新版本中,destory/stop方法將被直接移除。
基類Object提供了一些基礎的wait/notify/notifyAll方法。如果我們持有某個對象的Monitor鎖,調用wait會讓當前線程處於等待狀態,直到其他線程notify或者notifyAll。所以,本質上是提供了Monitor的獲取和釋放的能力,是基本的線程間通信方式。
並發類庫中的工具,比如CountDownLatch.await()會讓當前線程進入等待狀態,直到latch被基數為0,這可以看作是線程間通信的Signal。
我這裡畫了一個狀態和方法之間的對應圖:
Thread和Object的方法,聽起來簡單,但是實際應用中被證明非常晦澀、易錯,這也是為什麼Java後來又引入了並發包。總的來說,有了並發包,大多數情況下,我們已經不再需要去調用wait/notify之類的方法了。
前面談了不少理論,下面談談線程API使用,我會側重於平時工作學習中,容易被忽略的一些方面。
先來看看守護線程(Daemon Thread),有的時候應用中需要一個長期駐留的服務程序,但是不希望其影響應用退出,就可以將其設置為守護線程,如果JVM發現只有守護線程存在時,將結束進程,具體可以參考下面代碼段。注意,必須在線程啟動之前設置。
Thread daemonThread = new Thread();
daemonThread.setDaemon(true);
daemonThread.start();
再來看看Spurious wakeup。尤其是在多核CPU的系統中,線程等待存在一種可能,就是在沒有任何線程廣播或者發出信號的情況下,線程就被喚醒,如果處理不當就可能出現詭異的並發問題,所以我們在等待條件過程中,建議採用下面模式來書寫。
// 推薦
while ( isCondition()) {
waitForAConfition(...);
}
// 不推薦,可能引入bug
if ( isCondition()) {
waitForAConfition(...);
}
Thread.onSpinWait(),這是Java 9中引入的特性。我在專欄第16講給你留的思考題中,提到「自旋鎖」(spin-wait, busy-waiting),也可以認為其不算是一種鎖,而是一種針對短期等待的性能優化技術。「onSpinWait()」沒有任何行為上的保證,而是對JVM的一個暗示,JVM可能會利用CPU的pause指令進一步提高性能,性能特別敏感的應用可以關注。
再有就是慎用ThreadLocal,這是Java提供的一種保存線程私有信息的機制,因為其在整個線程聲明周期內有效,所以可以方便地在一個線程關聯的不同業務模塊之間傳遞信息,比如事務ID、Cookie等上下文相關信息。
它的實現結構,可以參考源碼,數據存儲於線程相關的ThreadLocalMap,其內部條目是弱引用,如下面片段。
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
// …
}
當Key為null時,該條目就變成「廢棄條目」,相關「value」的回收,往往依賴於幾個關鍵點,即set、remove、rehash。
下面是set的示例,我進行了精簡和注釋:
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i];; …) {
//…
if (k == null) {
// 替換廢棄條目
replaceStaleEntry(key, value, i);
return;
}
}
tab[i] = new Entry(key, value);
int sz = ++size;
// 掃描並清理發現的廢棄條目,並檢查容量是否超限
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();// 清理廢棄條目,如果仍然超限,則擴容(加倍)
}
具體的清理邏輯是實現在cleanSomeSlots和expungeStaleEntry之中,如果你有興趣可以自行閱讀。
結合專欄第4講的介紹引用類型,我們會發現一個特別的地方,通常弱引用都會和引用隊列配合清理機制使用,但是ThreadLocal是個例外,它並沒有這麼做。
這意味著,廢棄項目的回收依賴於顯式地觸發,否則就要等待線程結束,進而回收相應ThreadLocalMap!這就是很多OOM的來源,所以通常都會建議,應用一定要自己負責remove,並且不要和線程池配合,因為worker線程往往是不會退出的。
今天,我介紹了線程基礎,分析了生命周期中的狀態和各種方法之間的對應關係,這也有助於我們更好地理解synchronized和鎖的影響,並介紹了一些需要注意的操作,希望對你有所幫助。
更多的Java面試題深度分析,歡迎訂閱我的《Java核心技術 36講》,有五大模塊:「Java基礎」、「Java進階」、「Java應用開發擴展」、「Java安全基礎」、「Java性能基礎」,重點圍繞「道」與「術」,講解Java面試的核心知識點。
如果暫時不準備面試,照樣可以通過這個專欄,進行查漏補缺,提升自身的Java技能。
點擊「閱讀原文」,瀏覽或訂閱此專欄