Java经典面试题:一个线程两次调用start()方法会出现什么情况?
大家好,我是 Oracle首席工程師楊曉峰。 今天想和大家深入聊聊線程,相信大家對于線程這個概念都不陌生,它是Java并發(fā)的基礎(chǔ)元素,理解、操縱、診斷線程是Java工程師的必修課,但是你真的掌握線程了嗎?
今天我要問你的問題是,一個線程兩次調(diào)用start()方法會出現(xiàn)什么情況?談?wù)劸€程的生命周期和狀態(tài)轉(zhuǎn)移。
?
典型回答
Java的線程是不允許啟動兩次的,第二次調(diào)用必然會拋出IllegalThreadStateException,這是一種運行時異常,多次調(diào)用start被認(rèn)為是編程錯誤。
關(guān)于線程生命周期的不同狀態(tài),在Java 5以后,線程狀態(tài)被明確定義在其公共內(nèi)部枚舉類型java.lang.Thread.State中,分別是:
- 新建(NEW),表示線程被創(chuàng)建出來還沒真正啟動的狀態(tài),可以認(rèn)為它是個Java內(nèi)部狀態(tài)。
- 就緒(RUNNABLE),表示該線程已經(jīng)在JVM中執(zhí)行,當(dāng)然由于執(zhí)行需要計算資源,它可能是正在運行,也可能還在等待系統(tǒng)分配給它CPU片段,在就緒隊列里面排隊。
- 在其他一些分析中,會額外區(qū)分一種狀態(tài)RUNNING,但是從Java API的角度,并不能表示出來。
- 阻塞(BLOCKED),這個狀態(tài)和我們前面兩講介紹的同步非常相關(guān),阻塞表示線程在等待Monitor lock。比如,線程試圖通過synchronized去獲取某個鎖,但是其他線程已經(jīng)獨占了,那么當(dāng)前線程就會處于阻塞狀態(tài)。
- 等待(WAITING),表示正在等待其他線程采取某些操作。一個常見的場景是類似生產(chǎn)者消費者模式,發(fā)現(xiàn)任務(wù)條件尚未滿足,就讓當(dāng)前消費者線程等待(wait),另外的生產(chǎn)者線程去準(zhǔn)備任務(wù)數(shù)據(jù),然后通過類似notify等動作,通知消費線程可以繼續(xù)工作了。Thread.join()也會令線程進(jìn)入等待狀態(tài)。
- 計時等待(TIMED_WAIT),其進(jìn)入條件和等待狀態(tài)類似,但是調(diào)用的是存在超時條件的方法,比如wait或join等方法的指定超時版本,如下面示例:
public final native void wait(long timeout) throws InterruptedException;
- 終止(TERMINATED),不管是意外退出還是正常執(zhí)行結(jié)束,線程已經(jīng)完成使命,終止運行,也有人把這個狀態(tài)叫作死亡。
在第二次調(diào)用start()方法的時候,線程可能處于終止或者其他(非NEW)狀態(tài),但是不論如何,都是不可以再次啟動的。
?
考點分析
今天的問題可以算是個常見的面試熱身題目,前面的給出的典型回答,算是對基本狀態(tài)和簡單流轉(zhuǎn)的一個介紹,如果覺得還不夠直觀,我在下面分析會對比一個狀態(tài)圖進(jìn)行介紹。總的來說,理解線程對于我們?nèi)粘i_發(fā)或者診斷分析,都是不可或缺的基礎(chǔ)。
面試官可能會以此為契機(jī),從各種不同角度考察你對線程的掌握:
- 相對理論一些的面試官可以會問你線程到底是什么以及Java底層實現(xiàn)方式。
- 線程狀態(tài)的切換,以及和鎖等并發(fā)工具類的互動。
- 線程編程時容易踩的坑與建議等。
可以看出,僅僅是一個線程,就有非常多的內(nèi)容需要掌握。我們選擇重點內(nèi)容,開始進(jìn)入詳細(xì)分析。
?
知識擴(kuò)展
首先,我們來整體看一下線程是什么?
從操作系統(tǒng)的角度,可以簡單認(rèn)為,線程是系統(tǒng)調(diào)度的最小單元,一個進(jìn)程可以包含多個線程,作為任務(wù)的真正運作者,有自己的棧(Stack)、寄存器(Register)、本地存儲(Thread Local)等,但是會和進(jìn)程內(nèi)其他線程共享文件描述符、虛擬地址空間等。
在具體實現(xiàn)中,線程還分為內(nèi)核線程、用戶線程,Java的線程實現(xiàn)其實是與虛擬機(jī)相關(guān)的。對于我們最熟悉的Sun/Oracle JDK,其線程也經(jīng)歷了一個演進(jìn)過程,基本上在Java 1.2之后,JDK已經(jīng)拋棄了所謂的Green Thread,也就是用戶調(diào)度的線程,現(xiàn)在的模型是一對一映射到操作系統(tǒng)內(nèi)核線程。
如果我們來看Thread的源碼,你會發(fā)現(xiàn)其基本操作邏輯大都是以JNI形式調(diào)用的本地代碼。
private native void start0(); private native void setPriority0(int newPriority); private native void interrupt0();這種實現(xiàn)有利有弊,總體上來說,Java語言得益于精細(xì)粒度的線程和相關(guān)的并發(fā)操作,其構(gòu)建高擴(kuò)展性的大型應(yīng)用的能力已經(jīng)毋庸置疑。但是,其復(fù)雜性也提高了并發(fā)編程的門檻,近幾年的Go語言等提供了協(xié)程(coroutine),大大提高了構(gòu)建并發(fā)應(yīng)用的效率。于此同時,Java也在Loom項目中,孕育新的類似輕量級用戶線程(Fiber)等機(jī)制,也許在不久的將來就可以在新版JDK中使用到它。
下面,我來分析下線程的基本操作。如何創(chuàng)建線程想必你已經(jīng)非常熟悉了,請看下面的例子:
Runnable task = () -> {System.out.println("Hello World!");}; Thread myThread = new Thread(task); myThread.start(); myThread.join();我們可以直接擴(kuò)展Thread類,然后實例化。但在本例中,我選取了另外一種方式,就是實現(xiàn)一個Runnable,將代碼邏放在Runnable中,然后構(gòu)建Thread并啟動(start),等待結(jié)束(join)。
Runnable的好處是,不會受Java不支持類多繼承的限制,重用代碼實現(xiàn),當(dāng)我們需要重復(fù)執(zhí)行相應(yīng)邏輯時優(yōu)點明顯。而且,也能更好的與現(xiàn)代Java并發(fā)庫中的Executor之類框架結(jié)合使用,比如將上面start和join的邏輯完全寫成下面的結(jié)構(gòu):
Future future = Executors.newFixedThreadPool(1) .submit(task) .get();這樣我們就不用操心線程的創(chuàng)建和管理,也能利用Future等機(jī)制更好地處理執(zhí)行結(jié)果。線程生命周期通常和業(yè)務(wù)之間沒有本質(zhì)聯(lián)系,混淆實現(xiàn)需求和業(yè)務(wù)需求,就會降低開發(fā)的效率。
從線程生命周期的狀態(tài)開始展開,那么在Java編程中,有哪些因素可能影響線程的狀態(tài)呢?主要有:
- 線程自身的方法,除了start,還有多個join方法,等待線程結(jié)束;yield是告訴調(diào)度器,主動讓出CPU;另外,就是一些已經(jīng)被標(biāo)記為過時的resume、stop、suspend之類,據(jù)我所知,在JDK最新版本中,destory/stop方法將被直接移除。
- 基類Object提供了一些基礎(chǔ)的wait/notify/notifyAll方法。如果我們持有某個對象的Monitor鎖,調(diào)用wait會讓當(dāng)前線程處于等待狀態(tài),直到其他線程notify或者notifyAll。所以,本質(zhì)上是提供了Monitor的獲取和釋放的能力,是基本的線程間通信方式。
- 并發(fā)類庫中的工具,比如CountDownLatch.await()會讓當(dāng)前線程進(jìn)入等待狀態(tài),直到latch被基數(shù)為0,這可以看作是線程間通信的Signal。
?
我這里畫了一個狀態(tài)和方法之間的對應(yīng)圖:
Thread和Object的方法,聽起來簡單,但是實際應(yīng)用中被證明非常晦澀、易錯,這也是為什么Java后來又引入了并發(fā)包。總的來說,有了并發(fā)包,大多數(shù)情況下,我們已經(jīng)不再需要去調(diào)用wait/notify之類的方法了。
前面談了不少理論,下面談?wù)劸€程API使用,我會側(cè)重于平時工作學(xué)習(xí)中,容易被忽略的一些方面。
先來看看守護(hù)線程(Daemon Thread),有的時候應(yīng)用中需要一個長期駐留的服務(wù)程序,但是不希望其影響應(yīng)用退出,就可以將其設(shè)置為守護(hù)線程,如果JVM發(fā)現(xiàn)只有守護(hù)線程存在時,將結(jié)束進(jìn)程,具體可以參考下面代碼段。注意,必須在線程啟動之前設(shè)置。
Thread daemonThread = new Thread(); daemonThread.setDaemon(true); daemonThread.start();再來看看Spurious wakeup。尤其是在多核CPU的系統(tǒng)中,線程等待存在一種可能,就是在沒有任何線程廣播或者發(fā)出信號的情況下,線程就被喚醒,如果處理不當(dāng)就可能出現(xiàn)詭異的并發(fā)問題,所以我們在等待條件過程中,建議采用下面模式來書寫。
// 推薦 while ( isCondition()) {waitForAConfition(...); }// 不推薦,可能引入bug if ( isCondition()) {waitForAConfition(...); }Thread.onSpinWait(),這是Java 9中引入的特性。我在專欄第16講給你留的思考題中,提到“自旋鎖”(spin-wait, busy-waiting),也可以認(rèn)為其不算是一種鎖,而是一種針對短期等待的性能優(yōu)化技術(shù)。“onSpinWait()”沒有任何行為上的保證,而是對JVM的一個暗示,JVM可能會利用CPU的pause指令進(jìn)一步提高性能,性能特別敏感的應(yīng)用可以關(guān)注。
再有就是慎用ThreadLocal,這是Java提供的一種保存線程私有信息的機(jī)制,因為其在整個線程聲明周期內(nèi)有效,所以可以方便地在一個線程關(guān)聯(lián)的不同業(yè)務(wù)模塊之間傳遞信息,比如事務(wù)ID、Cookie等上下文相關(guān)信息。
它的實現(xiàn)結(jié)構(gòu),可以參考源碼,數(shù)據(jù)存儲于線程相關(guān)的ThreadLocalMap,其內(nèi)部條目是弱引用,如下面片段。
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;}}// … }當(dāng)Key為null時,該條目就變成“廢棄條目”,相關(guān)“value”的回收,往往依賴于幾個關(guān)鍵點,即set、remove、rehash。
下面是set的示例,我進(jìn)行了精簡和注釋:
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;// ?掃描并清理發(fā)現(xiàn)的廢棄條目,并檢查容量是否超限if (!cleanSomeSlots(i, sz) && sz >= threshold)rehash();// 清理廢棄條目,如果仍然超限,則擴(kuò)容(加倍) }具體的清理邏輯是實現(xiàn)在cleanSomeSlots和expungeStaleEntry之中,如果你有興趣可以自行閱讀。
?
結(jié)合專欄第4講的介紹引用類型,我們會發(fā)現(xiàn)一個特別的地方,通常弱引用都會和引用隊列配合清理機(jī)制使用,但是ThreadLocal是個例外,它并沒有這么做。
這意味著,廢棄項目的回收依賴于顯式地觸發(fā),否則就要等待線程結(jié)束,進(jìn)而回收相應(yīng)ThreadLocalMap!這就是很多OOM的來源,所以通常都會建議,應(yīng)用一定要自己負(fù)責(zé)remove,并且不要和線程池配合,因為worker線程往往是不會退出的。
?
今天,我介紹了線程基礎(chǔ),分析了生命周期中的狀態(tài)和各種方法之間的對應(yīng)關(guān)系,這也有助于我們更好地理解synchronized和鎖的影響,并介紹了一些需要注意的操作,希望對你有所幫助。
總結(jié)
以上是生活随笔為你收集整理的Java经典面试题:一个线程两次调用start()方法会出现什么情况?的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: SpringCloud的版本
- 下一篇: Java面试中常问的计算机网络方面问题