17、一個線程兩次調用start()方法會出現什麼情況?(高併發編程----3)

目錄

一個線程兩次調用 start() 方法會出現什麼情況?談談線程的生命週期和狀態轉移。

典型回答

考點分析

知識擴展

首先,我們來整體看一下線程是什麼?

線程的基本操作

談談線程 API 使用

一課一練


今天我們來深入聊聊線程,相信大家對於線程這個概念都不陌生,它是 Java 併發的基礎元素,理解、操縱、診斷線程是 Java 工程師的必修課,但是你真的掌握線程了嗎?

 

一個線程兩次調用 start() 方法會出現什麼情況?談談線程的生命週期和狀態轉移。

典型回答

Java 的線程是不允許啓動兩次的,第二次調用必然會拋出IllegalThreadStateException,這是一種運行時異常,多次調用 start 被認爲是編程錯誤。

關於線程生命週期的不同狀態,在 Java 5 以後,線程狀態被明確定義在其公共內部枚舉類型 java.lang.Thread.State 中,分別是:

1、新建狀態(New):新創建了一個線程對象。

2、就緒狀態(Runnable):線程對象創建後,其他線程調用了該對象的start()方法。該狀態的線程位於可運行線程池中,變得可運行,等待獲取CPU的使用權。

3、運行狀態(Running):就緒狀態的線程獲取了CPU,執行程序代碼。

4、阻塞狀態(Blocked):阻塞狀態是線程因爲某種原因放棄CPU使用權,暫時停止運行。直到線程進入就緒狀態,纔有機會轉到運行狀態。阻塞的情況分三種:

(一)、等待阻塞:運行的線程執行wait()方法,JVM會把該線程放入等待池中。(wait會釋放持有的鎖)

(二)、同步阻塞:運行的線程在獲取對象的同步鎖時,若該同步鎖被別的線程佔用,則JVM會把該線程放入鎖池中。

(三)、其他阻塞:運行的線程執行sleep()或join()方法,或者發出了I/O請求時,JVM會把該線程置爲阻塞狀態。當sleep()狀態超時、join()等待線程終止或者超時、或者I/O處理完畢時,線程重新轉入就緒狀態。(注意,sleep是不會釋放持有的鎖)

5、死亡狀態(Dead):線程執行完了或者因異常退出了run()方法,該線程結束生命週期。

 

在第二次調用 start() 方法的時候,線程可能處於終止或者其他(非 NEW)狀態,但是不論如何,都是不可以再次啓動的。


考點分析

今天的問題可以算是個常見的面試熱身題目,前面的給出的典型回答,算是對基本狀態和簡單流轉的一個介紹,如果覺得還不夠直觀,我在下面分析會對比一個狀態圖進行介紹。總的來說,理解線程對於我們日常開發或者診斷分析,都是不可或缺的基礎。

面試官可能會以此爲契機,從各種不同角度考察你對線程的掌握:

  •     相對理論一些的面試官可以會問你線程到底是什麼以及 Java 底層實現方式。
  •     線程狀態的切換,以及和鎖等併發工具類的互動。
  •     線程編程時容易踩的坑與建議等。

可以看出,僅僅是一個線程,就有非常多的內容需要掌握。我們選擇重點內容,開始進入詳細分析。


知識擴展

首先,我們來整體看一下線程是什麼?

從操作系統的角度,可以簡單認爲,線程是系統調度的最小單元,一個進程可以包含多個線程,作爲任務的真正運作者,有自己的棧(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

 

談談線程 API 使用

前面談了不少理論,下面談談線程 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 等上下文相關信息。

它的實現結構,可以參考源碼,ThreadLocal的數據存儲於線程相關的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 和鎖的影響,並介紹了一些需要注意的操作,希望對你有所幫助。


一課一練

關於今天我們討論的題目你做到心中有數了嗎?今天我準備了一個有意思的問題,寫一個最簡單的打印 HelloWorld 的程序,說說看,運行這個應用,Java 至少會創建幾個線程呢?然後思考一下,如何明確驗證你的結論,真實情況很可能令你大跌眼鏡哦。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章