Java經典面試題:一個線程兩次調用start()方法會出現什麼情況?

2021-02-19 SpringForAll社區

作者:楊曉峰來源:極客時間《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技能。



點擊「閱讀原文」,瀏覽或訂閱此專欄

相關焦點

  • 面試前必看Java線程面試題
    java.lang.Thread 類的實例就是一個線程但是它需要調用java.lang.Runnable接口來執行,由於線程類本身就是調用的Runnable接口所以你可以繼承java.lang.Thread 類或者直接調用Runnable接口來重寫run()方法實現線程。更多詳細信息請點擊這裡。5. 什麼是線程安全?Vector是一個線程安全類嗎?
  • Java 線程面試題 Top 50
    在典型的Java面試中, 面試官會從線程的基本概念問起, 如:為什麼你需要使用線程, 如何創建線程,用什麼方式創建線程比較好(比如:繼承thread類還是調用Runnable接口),然後逐漸問到並發問題像在Java並發編程的過程中遇到了什麼挑戰,Java內存模型,JDK1.5引入了哪些更高階的並發工具,並發編程常用的設計模式,經典多線程問題如生產者消費者,哲學家就餐,讀寫器或者簡單的有界緩衝區問題
  • Java面試題-多線程篇十三
    兩種方式:java.lang.Thread 類的實例就是一個線程但是它需要調用java.lang.Runnable接口來執行,由於線程類本身就是調用的Runnable接口所以你可以繼承java.lang.Thread 類或者直接調用Runnable接口來重寫run()方法實現線程。
  • Java經典面試題答案解析(1-80題)
    finally 是異常處理語句結構的一部分,一般以ty-catch-finally出現,finally代碼塊表示總是被執行.finalize 是Object類的一個方法,該方法一般由垃圾回收器來調用,當我們調用System.gc() 方法的時候,由垃圾回收器調用finalize()方法,回收垃圾,JVM並不保證此方法總被調用.3.
  • 一些經典Java面試題&答案解析 || 附《Effective Java》中文版
    我們給大家準備了一些面試題,所有題目都是經過精心挑選的,很基礎又考驗求職者的基本功,應該說被面試到的機率很大。希望能對你有所幫助。1、下列代碼輸出內容是什麼?start()用來啟動一個線程,當調用start方法後,系統才會開啟一個新的線程,進而調用run()方法來執行任務,而單獨的調用run()就跟調用普通方法是一樣的,已經失去線程的特性了。因此在啟動一個線程的時候一定要使用start()而不是run()。2、下列代碼執行結果是?
  • 經典java面試題23道
    ,而wait方法必須要notily方法來喚醒 (2)sleep方法屬於Thread類,而wait屬於Object類(3)sleep方法不會釋放鎖的資源,而wait方法會釋放鎖的資源 面試官:每個引用對象都有wait方法嗎?
  • JNI-Thread中start方法調用與run方法回調分析
    前言在java編程中,線程Thread是我們經常使用的類。那麼創建一個Thread的本質究竟是什麼,本文就此問題作一個探索。1.JNI機制的基本使用當我們new出一個Thread的時候,僅僅是創建了一個java層面的線程對象,而只有當Thread的start方法被調用的時候,一個線程才真正開始執行了。
  • 【堪稱經典】JAVA多線程和並發基礎面試問答
    當我們調用線程的start()方法時,狀態被改變為Runnable。線程調度器會為Runnable線程池中的線程分配CPU時間並且講它們的狀態改變為Running。其他的線程狀態還有Waiting,Blocked和Dead。讀這篇文章可以了解更多關於線程生命周期的知識。6. 可以直接調用Thread類的run()方法麼?
  • 阿里面試官問我Java線程和作業系統線程什麼關係
    這個問題是安琪拉之前面試被問到的一個問題,正好順著上一篇文章介紹完線程調用時的用戶態和內核態的切換,後續把Java 並發的都一起講了。面試官:聽前一個面試官說你Java並發這塊掌握的不錯,我們深入的交流一下;我:  看了看面試官頭部稀疏的結締組織,已然覺得這場面試不簡單,不過好在事前把安琪拉的博客看了個遍,有所準備,我回答說:咳咳,掌握的還算可以。
  • Java中Thread.start和Thread.run是什麼?有什麼區別
    線程類的start()方法可以用來啟動線程;該方法會在內部調用Runnable接口的run()方法,以在單獨的線程中執行run()方法中指定的代碼。2.Java中的run()方法是什麼?線程類的run()方法是Runnable接口的一個抽象方法,由java虛擬機直接調用的,不會創建的新線程。
  • 你見過老外的 Java 面試題嗎 (上)?
    鑑於題目比較多,會分成上下 2 篇 來整理,主要是面對 Java 的基礎,看看老外的面試題和我們有什麼區別。當然問題是老外問的,答案是我編的。>沒有足夠的內存去創建對應的本地方法棧,那麼 Java虛擬機將會拋出一個 OutofMemoryError 異常Java 虛擬機棧用於管理 Java 方法的調用,而本地方法棧用於管理本地方法的調用它的具體做法是 Mative Method Stack  中登記 native 方法,在 Execution Engine 執行時加載本地方法庫當某個線程調用一個本地方法時
  • Java程式設計師面試中的多線程問題
    很多核心Java面試題來源於多線程(Multi-Threading)和集合框架(Collections Framework),理解核心線程概念時,嫻熟的實際經驗是必需的。這篇文章收集了 Java 線程方面一些典型的問題,這些問題經常被高級工程師所問到。0.Java 中多線程同步是什麼?
  • 【Java面試題】常見Java面試知識點總結-1
    我是:小職(z_zhizuobiao)找我:✅ 解鎖高薪工作 ✅ 免費獲取乾貨教程這裡整理了部分較為重點的Java面試題內容,而且對於答案有困惑,補充了解釋內容,便於理解。1. 什麼是Java虛擬機?為什麼Java被稱作是「平臺無關的程式語言」?
  • 10個經典的 Java main 方法面試題
    以下是筆者認為比較經典的關於Java main方法的面試題,與其說是Java面試題,其實也是Java的一些最基礎知識問題,分享給大家,如有錯誤,請指出。1.不用main方法如何定義一個類?main()方法一定是靜態的。如果main()允許是非靜態的,那麼在調用main方法時,JVM就得實例化它的類。在實例化時,還得調用類的構造函數。如果這個類的構造函數有參數,那麼屆時就會出現歧義。例如,在下面的程序中,在實例化類「A」的時候,JVM傳遞什麼參數?
  • 綜合性18道面試官必問經典Java面試題!
    Thread類的start()和run()方法的區別start()方法會創建新的線程並啟動該線程,所以該方法會調用其他native方法,而run()方法就是:正常的Java方法調用,即在原來的線程中執行java代碼。
  • 初學Java多線程:向線程傳遞數據的三種方法
    初學Java多線程:向線程傳遞數據的三種方法 本文講述在學習Java多線程中需要學習的向線程傳遞數據的三種方法。由於線程的運行和結束是不可預料的,因此,在傳遞和返回數據時就無法象函數一樣通過函數參數和return語句來返回數據。
  • Java面試總結之Java基礎
    無論是工作多年的高級開發人員還是剛入職場的新人,在換工作面試的過程中,Java基礎是必不可少的面試題之一。能不能順利通過面試,拿到自己理想的offer,在準備面試的過程中,Java基礎也是很關鍵的。對於工作多年的開發人員來說,Java基礎往往是會被大家所忽略的,但在面試的過程中,確是必不可少的問題。在這篇文章裡就來為大家總結一下經常會被問到的Java基礎題。
  • Java面試題解析(事務+緩存+資料庫+多線程+JVM)
    並發過程中會出現的問題:丟失更新:是不可重複讀的特殊情況。如果兩個事物都讀取同一行,然後兩個都進行寫操作,並提交,第一個事物所做的改變就會丟失。髒讀:一個事務讀取到另一個事務未提交的更新數據。start()方法會使得該線程開始執行,java虛擬機會去調用該線程的run()方法。
  • 騰訊面試官:如何停止一個正在運行的線程?我一臉蒙蔽...
    停止一個線程意味著在任務處理完任務之前停掉正在做的操作,也就是放棄當前的操作。停止一個線程可以用Thread.stop()方法,但最好不要用它。雖然它確實可以停止一個正在運行的線程,但是這個方法是不安全的,而且是已被廢棄的方法。
  • 「011期」JavaSE面試題(十一):多線程(1)
    本文轉載自【微信公眾號:java進階架構師,ID:java_jiagoushi】經微信公眾號授權轉載,如需轉載與原文作者聯繫開篇介紹大家好,我是Java面試題庫的提褲姐,今天這篇是JavaSE系列的第十一篇