1. 程式人生 > >《Java特種兵》5.1 基礎介紹

《Java特種兵》5.1 基礎介紹

本文是《Java特種兵》的樣章,感謝博文視點和作者授權本站釋出

5.1 基礎介紹

†† 5.1.1 執行緒基礎

本節內容介紹給那些還沒接觸過Java執行緒的朋友,希望能有個感性認識。

Java執行緒,英文名是Thread,所有的Java程式的執行都是在程序中分配執行緒來處理的。如果是一個main方法,則由一個主執行緒來處理,如果不建立自定義執行緒,那麼這個程式就是單執行緒的。如果是Web應用程式,那麼就由Web容器分配執行緒來處理(在4.4.1節中介紹了在Tomcat原始碼中是如何分配執行緒的)。

也許在使用main方法寫程式碼時我們感覺不到多執行緒的存在,在Web程式中也感覺不到多執行緒和自己編寫程式有什麼關係,但是當遇到一些由於Java併發導致的古怪的問題時,當需要自己用多執行緒來編寫程式或者控制多個執行緒訪問共享資源時,就會用到相應的知識。

掌握知識的目標是駕馭知識,要駕馭知識的前提是瞭解知識,認識它的內在!

在Java程式碼中,單獨建立執行緒,都要使用類java.lang.Thread,通常可以通過繼承並擴充套件Thread原本的run()方法,也可以建立一個Thread,將一個Runnable任務實體作為引數傳入,這是通過執行緒來執行任務的過程,但並不能說實現了Runnable介面就是一個執行緒。

Runnable介面,顧名思義,就是“可以被執行”的意思。在Java語言中還有一些類似的介面,例如Closeable,它只能代表“可以被關閉”,Closeable介面的描述中提供了一個close()方法要求子類實現。類似的,Runnable介面提供了一個要求在實現類中去實現的run()方法,換句話說,Runnable介面只是說明了外部程式都可以通過其例項化物件呼叫到run()方法,因此我們通常把它叫作“任務”(切忌將任務和時間掛在一起,基於時間的任務只是一類特殊的任務而已)。

從另一個角度來看,一個執行緒的啟動是需要通過Thread.start()方法來完成的,這個方法會呼叫本地方法(JNI)來實現一個真正意義上的執行緒,或者說只有start()成功呼叫後由OS分配執行緒資源,才能叫作執行緒,而在JVM中分配的Thread物件只是與之對應的外殼。

Runnable既然不是執行緒,那麼有何用途?

前面提到,可以把Runnable看成一個“任務”,如果它僅僅與Thread配合使用,即在建立執行緒的時候將Runnable的例項化物件作為一個引數傳入,那麼它將被設定到Thread所在的物件中一個名為“target”的屬性上,Thread預設的run()方法是呼叫這個target的run()方法來完成的,這樣Runnable的概念就與執行緒隔離了——它本身是任務;執行緒可以執行任務;否則Thread需要通過子類去實現run()方法來描述任務的內容。

在後文中會提到執行緒池中的每個Thread可以嘗試獲取多個Runnable任務,每次獲取過來後呼叫其run()方法,這樣就更加明顯地說明Thread和Runnable不是一個概念。

區分了這個概念後,下面用一段簡單程式碼來模擬一個執行緒的建立和啟動。請看程式碼清單5-1,在這段程式碼中new Thread() {…}在Java堆中建立了一個簡單的Java物件,當通過這個物件呼叫其start()方法後就啟動了一個執行緒。不過大家需要注意的是,在這段程式碼中胖哥將兩條程式碼合併為一條來完成,不過在內在的執行上依然會是兩條程式碼來完成。

程式碼清單5-1 一個簡單的Thread的執行

public static void main(String []args) {
		new Thread() {
			public void run() {
				System.out.println("我是被建立的執行緒,我執行了...");
			}
		}.start();
		System.out.println("main process end...");
	}

}.start();

System.out.println("main process end...");

}

為了簡單起見,這段程式使用了一個匿名子類,重寫了Thread的run()方法,與單獨寫一個繼承於Thread的類在功能上是一致的。

這段程式,只是讓初學者瞭解到執行緒的存在。

如果是順序執行的程式,則應當先輸出“我是被建立的執行緒,我執行了…”,然後再輸出“main process end…”(因為程式碼順序是這樣的),但是大家通過測試結果會發現不一定,而且一般是先輸出“main process end…”,這是因為run()方法被另一個執行緒呼叫了,main()方法啟動執行緒後就直接向下執行,不過啟動執行緒還需要做一些核心呼叫的處理,最後才會由C區域的方法回撥Java中的run()方法,此時main執行緒可能已經輸出了內容。

為了進一步驗證,大家在main()方法和run()方法內部分別輸出當前執行緒ID或NAME,即可發現執行的執行緒是完全不同的,如:Thread.currentThread().getName()。

此程式碼驗證了兩個結果:

◎ 通過Thread的start()方法啟動了另一個執行緒來處理任務。

◎ 執行緒的run()方法呼叫並不是執行緒在呼叫start()方法時被同步呼叫的,而是需要一個很短暫的延遲。

執行緒到底是什麼東西?它與程序有何區別呢?

通常將執行緒理解為輕量級程序,它和程序有個非常大的區別是,多個執行緒是共享一個程序資源的,對於OS的許多資源的分配和管理(例如記憶體)通常是程序級別的,執行緒只是OS排程的最小單位,執行緒相對程序更加輕量一些,它的上下文資訊會更少,它的建立與銷燬會更加簡單,執行緒因為某種原因掛起後不會導致整個程序被掛起,一個程序中又可以分配許多的執行緒,所以執行緒是許多應用系統中大家所喜歡的東西。

但是並非多執行緒就沒有問題,它有個很大的問題就是,由於某個執行緒佔用過多的資源會導致整個程序“宕”機,由於資源共享,所以執行緒之間會相互影響,但是多程序通常不會有這個問題(它們共享伺服器資源,相互影響的級別在伺服器資源上,而不是在程序內部)。

選擇多執行緒還是多程序要根據實際情況來定,類似於Nginx這類負載均衡軟體就採用多程序模型,因為它的非同步I/O對於高併發來講,已經足以解決程序或執行緒資源不足的情況,而且比多執行緒模型處理得更好,因為它是I/O密集型的。但是應用程式如果是計算密集型的,或者涉及大量的業務邏輯處理,則並不適合這樣做,換句話說,最終還得根據實際場景來定。

前文中提到,new Thread()操作並非完成了執行緒的建立,只有當呼叫start()方法時才會真正在系統中存在一個執行緒。在OS處理執行緒上也有多種方式,至於執行緒是哪種方式,對於我們來講並不是那麼重要,我們只需要知道存在一個單獨的執行緒可以被排程即可。

我們回想一下第3章提到的一些內容:當大量分配執行緒後,可能會報錯“unable to create new native thread”,說明執行緒使用的是堆外的記憶體空間,也再次說明Thread本身所對應的例項僅僅是JVM內的一個普通Java物件,是一個執行緒操作的外殼,而不是真正的執行緒。

補充知識:通過Thread的例項物件呼叫start()方法到底是怎麼啟動執行緒的?下面對其實現方式做一些簡單的補充。

◎ 基於Kernel Thread(KLT)的對映來實現:KLT是核心執行緒,核心執行緒由OS直接完成排程切換,它相對應用程式的執行緒來講只是一個介面,外部程式會使用一種輕量級程序(Light Weight Process,LWP)來與KLT進行一對一的介面呼叫。也就是說,程序內部會嘗試利用OS的核心執行緒去參與實際的排程,而自己使用API呼叫作為中間橋樑與自己的程式進行互動。

◎ 基於使用者執行緒(User Thread,UT)的實現:這種方式是考慮是否可以沒有中間這一層對映,自己的執行緒直接由CPU來排程,或許理論上效率會更高。不過這樣實現時,使用者程序所需要關注的抽象層次會更低一些,跳過OS更加接近CPU,即自己要去做許多OS做的事情,自然的OS的排程演算法、建立、銷燬、上下文切換、掛起等都要自己來搞定(因為CPU只做計算)。這樣做顯然很麻煩,許多人曾經嘗試過,後來放棄了。

◎ 混合實現方式:它的設計理念是希望保留Kernel執行緒原有架構,又想使用使用者執行緒,輕量級程序依然與Kernel執行緒一一對應保持不變,唯一變化的就是輕量級程序不再與程序直接掛鉤,而是與使用者執行緒掛鉤,使用者執行緒並不一定必須與輕量級程序一一對應,而是多對多,就像在使用一個輕量級程序列表一樣,這樣增加了一層來解除輕量級程序與原程序之間的耦合,可能會使得排程更為靈活。

在以前的JDK版本中,嘗試使用UT的方式來實現,但後來放棄了,採用了與Kernel執行緒對應的方式,至於一些細節,與具體的平臺有很大的關係,JVM會適當考慮具體平臺的因素去實現,在JVM規範中也沒規定過必須如何去實現,所以對於程式設計師來講,只需要知道在new Thread(),呼叫start()方法後,理論上就有一個可以被OS排程的執行緒了。

†† 5.1.2 多執行緒

在上一節的程式碼中,自己建立了一個執行緒,main()方法本身也有一個執行緒,雖然有主次之分,但是已經是多執行緒了。

寫多執行緒程式無非就是加執行緒數量,讓多個執行緒可以並行地去做一些事情。大家可以根據程式碼清單5-1增加執行緒來模擬,本書就不再給出程式碼了。

大家在程式碼清單5-1的基礎上,多建立幾個Thread就得到多執行緒的結果了,例如可以讓多個執行緒輸出某些結果,通過輸出會發現它們會交替輸出,而不是一個執行緒輸出結束後,下一個執行緒緊跟著再輸出結果。

†† 5.1.3 執行緒狀態

談執行緒,就必然要談狀態,為何?

對執行緒的每個操作,都可能會使執行緒處於不同的工作機制下,在不同的工作機制下某些動作可能會對它產生不同的影響,而不同的工作機制就是用狀態來標誌的,所以我們一定要了解它的狀態;否則在編寫多執行緒程式時,就會出現奇怪的問題。在本小節中,胖哥會逐個描述執行緒中的狀態,說明導致此執行緒狀態可能的原因,以及在某種狀態下可以做的事情。

我們不僅要關注執行緒本身的狀態,而且要養成一種關注狀態變化的習慣,甚至於在自己做多執行緒設計時嘗試用一些狀態控制某些東西。因為在多執行緒的知識體系中,關於狀態的資訊,遠遠不止執行緒本身的狀態這樣一些資訊(當然它是最基礎的),在後文中介紹的許多Java的併發模型中,都會存在各種各樣的狀態轉換,如果沒有養成習慣去抓住這個重點,我們將很難看懂程式碼。

要獲取狀態可以通過執行緒(Thread)的getState()來獲取狀態的值。例如,獲取當前執行緒的狀態就可以使用Thread.currentThread().getState()來獲取。該方法返回的型別是一個列舉型別,是Thread內部的一個列舉,全稱為“java.lang.Thread.State”,這個列舉中定義的型別列表就是Java語言這個級別對應的執行緒狀態列表,包含了NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED這些值。現在對照原始碼中的註釋,以及胖哥自己的理解來說明它們的意思。

(1)NEW狀態
2F6KKOFKN`VST1FYW)RZYKV
意思是這個執行緒沒有被start()啟動,或者說還根本不是一個真正意義上的執行緒,從本質上講這只是建立了一個Java外殼,還沒有真正的執行緒來執行。

再一次提醒大家注意:呼叫了start()並不代表狀態就立即改變,中間還有一些步驟,如果在這個啟動的過程中有另一個執行緒來獲取它的狀態,其實是不確定的,要看那些中間步驟是否已經完成了。

(2)RUNNABLE狀態
7`M((26P$4H0W]XP7UCS5FN
當處於NEW狀態的執行緒發生start()結束後執行緒將變成RUNNABLE狀態。程式正在執行中的執行緒就肯定處於RUNNABLE狀態,上面提到用Thread.currentThread().getState()來獲取當前執行緒的狀態,只會得到“RUNNABLE”,而不會得到其他的值,因為要得到結果就必然處於執行中。所以,獲取狀態都是獲取其他執行緒的狀態,而不是自己的狀態。

RUNNABLE狀態也可以理解為存活著正在嘗試徵用CPU的執行緒(有可能這個瞬間並沒有佔用CPU,但是它可能正在傳送指令等待系統排程)。由於在真正的系統中,並不是開啟一個執行緒後,CPU就只為這一個執行緒服務,它必須使用許多排程演算法來達到某種平衡,不過這個時候執行緒依然處於RUNNABLE狀態。

舉個例子:當某個執行中的執行緒發生了yield()操作時,其實看到的執行緒狀態也是RUNNABLE,只是它有一個細節的內部變化,就是做一個簡單的讓步。既然談到讓步,我們就來簡單說說什麼叫作讓步。

胖哥認為這是一種“高素質”的做法,它自己可能在做大量CPU計算,認為自己會在相對較長的時間內佔用資源,如果排程演算法存在問題,就會一直佔用CPU,所以在適當的時候做下讓步,讓別人也來使用下CPU資源。

在生活中就好比一群人排隊到取款機上取款,有些人可能喜歡查了取、取了查、查了再取、取了再查,也許中間還有許多思考的過程(或許在計算),也許還有許多從包裡拿出和放入的動作,或許再打個電話,再整理下衣服。這在這些人心目中是正常的,因為他們認為現在是屬於自己的私人空間,但卻忽略了後面還有很多人在等待的因素,可能有人在等待太久以後就放棄了(就像放棄CPU排程一樣)。而高素質的人會覺得自己佔用的時間太長了,會“不好意思”,主動意識到耽誤了別人太多的時間,自己會出來讓別人先處理,等別人處理好以後自己再進去。

對應到程式碼中,比如某些任務可能會在一個比較集中的時間在後臺啟動,有可能反覆執行,有可能執行時間相對較長。在資源有限的情況下,這樣的系統有可能和其他的系統部署在一臺伺服器上,甚至於一個程序上,自然會相互搶佔資源,在某些必要的情況下,可以使用該方式做出一點讓步,讓雙方的資源得到平衡。

RUNNABLE狀態可以由其他的許多狀態通過某些操作後進入該狀態,處於RUNNABLE狀態的執行緒本身也可以執行許多操作轉換為其他的狀態,比如執行synchronized、sleep()、wait()等操作。在接下來的狀態介紹中,還會提到許多和RUNNABLE相關的狀態轉換關係。

不過,就Java本身層面的RUNNABLE狀態來講,並不代表它一定處於執行中的狀態,例如在BIO中,執行緒正阻塞在網路等待時,看到的狀態依然是RUNNABLE狀態,而在底層執行緒已經被阻塞,這也是Java內在一些狀態不協調的問題所在。所以我們不僅僅要看狀態本身,還得了解更多的計算機與Java之間的關係才能在面對問題時更加接近本質。

(3)BLOCKED狀態
LS_}R9MF(`HPCF[}U$P(P7J
BLOCKED稱為阻塞狀態,或者說執行緒已經被掛起,它“睡著”了,原因通常是它在等待一個“鎖”,當某個synchronized正好有執行緒在使用時,一個執行緒嘗試進入這個臨界區,就會被阻塞,直到另一個執行緒走完臨界區或發生了相應鎖物件的wait()操作後,它才有機會去爭奪進入臨界區的權利。

細節補充:synchronized會有各種粒度的問題,這裡的臨界區是指多個執行緒嘗試進入同一塊資源區域,這個區域在Java程式碼中的體現方式通常是基於某個物件鎖程式碼片段。關於它的一些細節,將在後文中詳細介紹。

爭取到鎖的權利後才會從BLOCKEN狀態恢復到RUNNABLE狀態,如果在徵用鎖的過程中沒有搶到,那麼它就又要回到休息室去等待了。

在實際的工作中,BLOCKEN狀態也並非顯式地存在於synchronized上,可能會是一種巢狀隱藏的方式,例如使用了某種三方控制元件、集合類。

一旦執行緒處於阻塞狀態,執行緒就像真的什麼也不做一樣,在Java層面始終無法喚醒它。許多人說現在用interrupt()方法來喚醒它,小夥伴們可以進行小測試,一點用處都沒有,因為interrupt()只是在裡面做一個標記而已,不會真正喚醒處於阻塞狀態的執行緒。

所以,在程式中出現synchronized時通常會考慮它的粒度問題,更要考慮它是否可能會被死鎖的問題。

(4)WAITING狀態
SUSZCN(KH@7_O0D6XIKF9ZO
這種狀態通常是指一個執行緒擁有物件鎖後進入到相應的程式碼區域後,呼叫相應的“鎖物件”的wait()方法操作後產生的一種結果。變相的實現還有LockSupport.park()、LockSupport.parkNanos()、LockSupport.parkUntil()、Thread.join()等,它們也是在等待另一個物件事件的發生,也就是描述了等待的意思。

上面提到的BLOCKEN狀態也是等待的意思,它們有什麼關係與區別呢?

其實BLOCKEN是虛擬機器認為程式還不能進入某個區域,因為同時進去就會有問題,這是一塊臨界區。發生wait()操作的先決條件是要進入臨界區,也就是執行緒已經拿到了“門票”,自己可能進去做了一些事情,但此時通過判定某些業務上的引數(由具體業務決定),發現還有一些其他配合的資源沒有準備充分,那麼自己就等等再做其他的事情。

理解起來是不是很麻煩?其實有一個非常典型的案例就是通過wait()和notify()來完成生產者消費者模型,當生產者生產過快,發現倉庫滿了,即消費者還沒有把東西拿走(空位資源還沒準備好)時,生產者就等待有空位再做事情,消費者拿走東西時會發出“有空位了”的訊息,那麼生產者就又開始工作了。反過來也是一樣,當消費者消費過快發現沒有存貨時,消費者也會等存貨到來,生產者生產出內容後發出“有存貨了”的訊息,消費者就又來搶東西了。

這種通過制衡方式的協調工作機制,在工作中用得很多,它稍加變化就能產生巨大的價值,現代的Java語言很牛,已經將這些複雜的細節包裝成了物件,對外提供了很好用的API,這些API為我們提供的僅僅是簡單的任務內容輸入,具體的排程細節由Java來完成。

在這種狀態下,如果發生了對該執行緒的interrupt()是有用的,處於該狀態的執行緒內部會丟擲一個InterruptedException異常,這個異常應當在run()方法裡面捕獲,使得run()方法正常地執行完成。當然在run()方法內部捕獲異常後,還可以讓執行緒繼續執行,這完全是根據具體的應用場景來決定的。

在這種狀態下,如果某執行緒對該鎖物件做了notify()動作,那麼將從等待池中喚醒一個執行緒重新恢復到RUNNABLE狀態。除notify()方法外,還有一個notifyAll()方法,前者是喚醒一個處於WAITING狀態的執行緒,而後者是喚醒所有的執行緒。

Object.wait()是否需要死等呢?不是,除中斷外,它還有兩個重構方法:

◎ Object.wait(int timeout),傳入的timeout引數是超時的毫秒值,超過這個值後會自動喚醒,繼續做下面的操作(不會丟擲InterruptedException異常,但是並不意味著我們不去捕獲,因為不排除其他執行緒會對它做interrupt()動作)。

◎ Object.wait(int timeout , int nanos),這是一個更精確的超時設定,理論上可以精確到納秒,這個納秒值可接受的範圍是0~999999(因為1000000ns等於1ms)。

同樣的,LockSupport.park()、LockSupport.parkNanos()、LockSupport.parkUntil()、Thread. join()這些方法都會有類似的重構方法來設定超時,達到類似的目的,不過此時的狀態不再是WAITING,而是TIMED_WAITING。

通常寫程式碼的人肯定不想讓程式死掉,但是又希望通過這些等待、通知的方式來實現某些平衡,這樣就不得不去嘗試採用“超時+重試+失敗告知”等方式來達到目的。

(5)TIMED_WAITING狀態
LS_}R9MF(`HPCF[}U$P(P7J
相信使用過執行緒的小夥伴們都應該使用過Thread.sleep(),前文中已經提到了通過其他的方式也可以進入這種TIME_WATING狀態。或許可以這種理解:當呼叫Thread.sleep()方法時,相當於使用某個時間資源作為鎖物件,進而達到等待的目的,當時間達到時觸發執行緒回到工作狀態。

(6)TERMINATED狀態

執行緒結束了就處於這種狀態,換句話說,run()方法走完了,執行緒就處於這種狀態。其實這只是Java語言級別的一種狀態,在作業系統內部可能已經登出了相應的執行緒,或者將它複用給其他需要使用執行緒的請求,而在Java語言級別只是通過Java程式碼看到的執行緒狀態而已。

下面再來探討一些問題。

為什麼wait()和notify()必須要使用synchronized?

如果不用就會報錯IllegalMonitorStateException,常見的寫法如下:

synchronized(object) {
   object.wait();//object.notify();
}
synchronized(this) {
   this.wait();
}
synchronized fun() {
   this.wait();//this.notify();
}

首先要明確,wait()和notify()的實現基礎是基於物件存在的。那為什麼要基於物件存在呢?

解釋:既然要等,就要考慮等什麼,這裡等待的就是一個物件發出的訊號,所以要基於物件而存在。

不用物件也可以實現,比如suspend()/resume()就不需要,但是它們是反面教材,表面上簡單,但是處處都是問題,在5.1.4節中會介紹。

理解基於物件的這個道理後,目前認為它呼叫的方式只能是Object.wait()方法,這樣才能和物件掛鉤。但這些東西還與問題“wait()/notify()為什麼必須要使用synchronized”沒有半點關係,或者說與物件扯上關係,為什麼非要用鎖呢?

我們還得繼續探討:既然是基於物件的,因此它不得不用一個數據結構來存放這些等待的執行緒,而且這個資料結構應當是與該物件繫結的(通過檢視C++程式碼,發現該資料結構為一個雙向連結串列),此時在這個物件上可能同時有多個執行緒呼叫wait()/notify()方法。

在向這個物件所對應的雙向連結串列中寫入、刪除資料時,依然存在併發的問題,理論上也需要一個鎖來控制。在JVM核心原始碼中並沒有發現任何自己用鎖來控制寫入的動作,只是通過檢查當前執行緒是否為物件的OWNER來判定是否要丟擲相應的異常。由此可見它希望該動作由Java程式這個抽象層次來控制,它為什麼不想去自己控制鎖呢?

因為有些時候更低抽象層次的鎖未必是好事,因為這樣的請求對於外部可能是反覆迴圈地去徵用,或者這些程式碼還可能在其他地方複用,也許將它粗粒度化會更好一些,而且這樣的程式碼寫在Java程式中本身也會更加清晰,更加容易看到相互之間的關係。

在這個問題上,胖哥的解釋就到此結束了,其中包含了許多個人的理解,有興趣的朋友,可以去查閱資料細化這個問題的根源。

interrupt()操作線上程處於BLOCKEN狀態時沒用,在其他狀態下都有效嗎?

interrupt()操作對執行緒處於RUNNING狀態時也沒用,或者說只對處於WAITING和TIME_WAITING狀態的執行緒有用,讓它們產生實質性的異常丟擲。

在通常情況下,如果執行緒處於執行中狀態,也不會讓它中斷,如果中斷是成立的,則可能會導致正常的業務執行出現問題。另外,如果不想用強制手段,就得為每條程式碼的執行設立檢查,但是這個動作很麻煩,JVM不願意做這件事情,它做interrupt()僅僅是打一個標記,此時程式中通過isInterrupt()方法能夠判定是否被髮起過中斷操作,如果被中斷了,那麼如何處理程式就是設計上的事情了。

舉個例子,如果程式碼執行是一個死迴圈,那麼在迴圈中可以這樣做:

while(true) {
    if(Thread.currentThread.isInterrupt()) {
     //可以做類似的break、return,丟擲InterruptedException達到某種目的,這完全由自己決定
    //如丟擲異常,通常包裝一層try catch異常處理,進一步做處理,如退出run方法或什麼也不做
   }
}

許多小夥伴認為這太麻煩了,為什麼不可以自動呢?

小夥伴們可以通過一些生活的溝通方式來理解一下:當你發現門外面有人呼叫你時,你自己是否搭理他是你的事情,胖哥認為這是一種有“愛”的溝通方式,反之是暴力地破門而入,把你強制“抓”出去的方式。

在JDK 1.6及以後的版本中,可以使用執行緒的interrupted()方法來判定執行緒是否已經被呼叫過中斷方法,表面上的效果與isInterrupted()方法的結果一樣,不過這個方法是一個靜態方法,直接通過Thread.interrupted()呼叫判定的就是當前執行緒。除此之外,更大的區別在於這個方法呼叫後將會重新將中斷狀態設定為false,這樣方便於迴圈利用執行緒,而不是中斷後狀態就始終為true,就無法將狀態修改回來了。類似的,判定執行緒的相關方法還有isAlive()、isDaemon(),分別用來判定執行緒是否還活著,以及是否為後臺執行緒。

†† 5.1.4 反面教材suspend()、resume()、stop()

雖然是反面教材,但是胖哥認為反面教材往往體現在自己寫程式碼時容易犯錯的地方。只有看清楚這些反面教材,自己寫程式碼時才會去多考慮一些細節性的問題。

suspend()、resume()、stop()這些API雖然Java一直保留著,但在程式碼中使用時會發現JVM已經不推薦使用了,它們都被加上了@Deprecated註解,表示它們已經過時了,保留只是為了相容而已。

關於suspend()/resume()這兩個方法類似於wait()/notify(),但是它們不是等待和喚醒執行緒。通過對它們的實驗會發現,suspend()後的執行緒處於RUNNING狀態,而不是WAITING狀態,但是執行緒本身在這裡已經掛起了,執行緒本身的狀態就開始對不上號了。

如果是在synchronized區域內部發生suspend()操作,那麼它並不會像發生wait() 那樣把鎖釋放出來,因為它自己還在執行中。而當發生resume()時,程式正常結束了,其實如果程式碼正常走過synchronized區域,鎖也會釋放的。但是很多資料上講解的是沒有釋放資源,這是怎麼回事呢?下面我們就寫個反面教材的例子。

程式碼清單5-2 反面例子

public class SuspendAndResume {

	private final static Object object =  new Object();

	static class ThreadA extends Thread {

		public void run() {
			synchronized(object) {
System.out.println("start...");
			Thread.currentThread().suspend();
System.out.println("thread end...");
			}
		}
	}

	public static void main(String []args) throws InterruptedException {
		ThreadA t1 = new ThreadA();
		ThreadA t2 = new ThreadA();
		t1.start();
		t2.start();
		Thread.sleep(100);
		System.out.println(t1.getState());
		System.out.println(t2.getState());
		t1.resume();
		t2.resume();
	}
}

輸出結果如下:
(6Z[F$`Z$UNO_39AH[XWNF4
程式碼中啟動了兩個子執行緒,這兩個子執行緒幾乎是同時啟動的,main方法所在的執行緒延遲100ms,目的是為了讓兩個子執行緒都進入執行的區域,至少其中一個發生了suspend()操作。

輸出時,首先會輸出一個“start…”,剛開始也只會輸出一個“start…”,因為這是由synchronized來保證的,此時第一個進入synchronized區域的執行緒呼叫了suspend()方法,此時它停止執行了。

然後輸出的兩個狀態是在main方法中打印出來的(因為一個執行緒在synchronized區域外部等待,另一個執行緒呼叫了suspend()方法而被掛起),這裡輸出的狀態一個是BLOCKED狀態,另一個是RUNNABLE(多次測試後結果相同),說明有一個執行緒被阻塞了,阻塞執行緒自然在synchronized區域外面等待進入,而一個執行緒肯定是已經進入synchronized區域的執行緒,並在呼叫suspend()方法時掛起,但是我們看到的狀態是RUNNABLE。

如果去掉synchronized動作,將會輸出兩個RUNNABLE,但是兩個執行緒都在suspend()方法時停止執行了,這說明什麼呢?suspend()/resume()並不需要synchronized的支援,因此不需要基於物件。

接下來輸出“thread end…”,說明有一個執行緒正常結束了,也說明resume()操作確實生效了,在它輸出後,緊接著會輸出一個“start…”,說明另一個執行緒進入了synchronized區域,但是神奇的事情發生了,另一個執行緒也被主執行緒呼叫過resume()方法,但實際情況是這個執行緒在這裡卡住了,沒有釋放掉,為何?

因為在這個例子中,main方法所在的執行緒對第2個進入synchronized區域的執行緒做的resume()操作很可能發生在它未進入synchronized區域之前,也自然發生在它呼叫suspend()操作之前,線上程沒有呼叫suspend()方法之前呼叫resume()是無效的,也不會使得執行緒在其後面呼叫suspend()方法直接被喚醒。當該執行緒被掛起時,相應持有的鎖就釋放不掉了(因為它的操作與鎖無關),而外部認為已經將這個執行緒釋放掉了,因為外部看到的狀態是RUNNING,而且已經呼叫過resume()方法了,由於這些資訊的不一致就導致了各種資源無法釋放的問題。

總的來說,問題應當出在執行緒狀態對外看到的是RUNNING狀態,外部程式並不知道這個執行緒掛起了需要去做resume()操作(如果有狀態判定還可以做檢測)。另外,它並不是基於物件來完成這個動作的,因此suspend()和wait()相關的順序性很難保證。所以suspend()/resume()不推薦使用了。

反過來想,這也更加說明了wait()和notify()為什麼要基於物件來做資料結構,因為它要控制生產者和消費者之間的關係,它需要一個臨界區來控制它們之間的平衡。它不是隨意地線上程上做操作來控制資源的,而是由資源反過來控制執行緒狀態的。當然wait()/notify()並非不會導致死鎖,只是它們的死鎖通常是程式設計不當導致的,並且在通常情況下是可以通過優化解決的。

關於stop(),胖哥認為它和interrupt()最大的區別如下:

interrupt()是相對友愛的行為,它不是破門而入,而stop()卻是這樣的,當你發起對某個執行緒的stop()操作時,如果這個執行緒處於RUNNING狀態,stop()將會導致這個執行緒直接丟擲一個java.lang.ThreadDeath的Error。這似乎沒有問題,那麼我們就來探討一下是否會有問題。

假如執行緒是一個死迴圈,被外部容器所複用,在業務程式碼中會通過多個步驟的計算將某些值賦予執行緒內的某些屬性或更大作用域的屬性,這些屬性可能是多個,當發起stop()時程式可能會進入try {} catch(Throwable e)區域,但是前面執行的計算和賦值只做了一半,而且做到那裡沒法找回來,這樣就可能會導致業務程式中上下文資料不一致的情況發生。

†† 5.1.5 排程優先順序

執行緒的優先順序就是對優先權的level設定,就像VIP專區,為何要設立VIP呢?因為資源有限才會存在特權,給予更多所以享有特權。

計算機也是這樣的,CPU資源是有限的,那麼在某些情況下,我們希望先保證某些VIP先被執行。任務沒有高低貴賤之分,但是有重要性、緊急性之分,因此會設立執行緒的優先順序,讓OS根據不同的優先順序進行排程,這樣在演算法策略上就不再是一視同仁“吃大鍋飯”了,可以使得排程更加靈活,達到區域性優化的目的。

執行緒排程的優先順序,每個OS有著不同的實現,而Java虛擬機器為了相容各種OS平臺設定了1~10個優先順序(理論上數字越大,優先順序越高),但這並不代表每個OS也有10個優先順序,某些OS可能只有3個或5個優先順序。因此,JVM會在相應的平臺上根據實際情況設定1~10這10個數字與OS的執行緒優先順序做一個對映關係,總體會保持順序化。通過這一點大家應該清楚,Java中連續的兩個數字所表示的優先順序在實際場景中可能是同一個優先順序。

作為程式設計師使用優先順序時,又不想脫離Java語言本身的限制,通常將優先順序設定為“普通”、“最大”、“最小”(如圖5-1所示,其定義在Thread類中),通常不會設定一些細節的數字,那樣設定可能根本達不到目的。
ETKC$57]QEEDJVT_~$]QYVP
圖5-1 執行緒優先順序程式碼截圖

建立一個執行緒時,預設的優先順序是Thread.NORM_PRIORITY,值為5。在程式中可以為指定執行緒設定優先順序,通過setPriority(int)方法來完成,呼叫這個方法時傳入上面描述的幾種值,就基本可以達到排程優先的目的。

在JVM中還有一種特殊的後臺執行緒,通過對執行緒呼叫setDaemon(boolean)標誌是否為後臺執行緒,它通常優先順序極低,也就是通常不會跟別人搶CPU,但是它可能在某些時候提升自己的優先順序來做一些事情。例如JVM的GC執行緒就是後臺執行緒,它很多時候不去和業務爭用CPU,而是在資源忙時會被提升優先順序來做事情。

這類執行緒貌似與普通執行緒沒有區別,因為普通執行緒也可以做到這一點。但是後臺執行緒有一個十分重要的特徵是,如果JVM程序中活著的執行緒只剩下後臺執行緒,那麼意味著就要結束整個程序。

大家可以做一個實驗來證明這個結論。在一個執行緒中做死迴圈,main方法啟動這個執行緒後就結束了,此時整個程序不會退出。如果將執行緒設定為後臺執行緒(setDaemon(boolean)),當main方法結束後,程序會立即結束。本書光碟中的src/chapter05/base/ThreadDaemonTest. java是一個簡單的測試例子,大家只需要將程式碼中的setDaemon(true)操作註釋掉或啟用就會得到不同的結果。

†† 5.1.6 執行緒合併(Join)

許多同學剛開始學Java多執行緒時可能不會關注Join這個動作,因為不知道它是用來做什麼的,而當需要用到類似的場景時卻有可能會說Java沒有提供這種功能。為此,胖哥就先說它的一些應用場景,再說怎麼用吧。

當我們將一個大任務劃分為多個小任務,多個小任務由多個執行緒去完成時,顯然它們完成的先後順序不可能完全一致。在程式中希望各個執行緒執行完成後,將它們的計算結果最終合併在一起,換句話說,要等待多個執行緒將子任務執行完成後,才能進行合併結果的操作。

這時就可以選擇使用Join了,Join可以幫助我們輕鬆地搞定這個問題,否則就需要用一個迴圈去不斷判定每個執行緒的狀態。

在實際生活中,就像把任務分解給多個人去完成其中的各個板塊,但老闆需要等待這些人全部都完成後才認為這個階段的任務結束了,也許每個人的板塊內部和別人還有相互的介面依賴,如果對方介面沒有寫好,自己的這部分也不算完全完成,就會發生類似於合併的動作(到底要將任務細化到什麼粒度,完全看實際場景和自己對問題的理解)。下面用一段簡單的程式碼來說明Join的使用。

程式碼清單5-3 Join的例子

public class ThreadJoinTest {

	static class Computer extends Thread {
		private int start;
		private int end;
		private int result;
		private int []array;

		public Computer(int []array , int start , int end) {
			this.array = array;
			this.start = start;
			this.end = end;
		}

		public void run() {
			for(int i = start; i < end ; i++) {
				result += array[i];
				if(result < 0) result &= Integer.MAX_VALUE;
			}
		}

		public int getResult() {
			return result;
		}
	}

	private final static int COUNTER = 10000000;

	public static void main(String []args) throws InterruptedException {
		int []array = new int[COUNTER];
		Random random = new Random();
		for(int i = 0 ; i < COUNTER ; i++) {
			array[i] = Math.abs(random.nextInt());
		}
		long start = System.currentTimeMillis();
		Computer c1 = new Computer(array , 0 , COUNTER / 2);
		Computer c2 = new Computer(array , COUNTER / 2 + 1 , COUNTER);
		c1.start();
		c2.start();
		c1.join();
		c2.join();
		System.out.println(System.currentTimeMillis() - start);
		//System.out.println(c1.getResult());
		System.out.println((c1.getResult() + c2.getResult())
& Integer.MAX_VALUE);
	}
}

這個例子或許不太好,只是1000萬個隨機數疊加,為了防止CPU計算過快,在計算中增加一些判定操作,最後再將計算完的兩個值輸出,也輸出運算時間。如果在有多個CPU的機器上做測試,就會發現資料量大時,多個執行緒計算具有優勢,但是這個優勢非常小,而且在資料量較小的情況下,單執行緒會更快一些。為何單執行緒可能會更快呢?

最主要的原因是執行緒在分配時就有開銷(每個執行緒的分配過程本身就需要執行很多條底層程式碼,這些程式碼的執行相當於很多條CPU疊加運算的指令),Join操作過程還有其他的各種開銷。

如果嘗試將每個執行緒疊加後做一些其他的操作,例如I/O讀寫、字串處理等操作,多執行緒的優勢一下子就出來了,因為這樣總體計算下來後,執行緒的建立時間是可以被忽略的,所以我們在考量系統的綜合性能時不能就一個點或某種測試就輕易得出一個最終結論,一定要考慮更多的變動因素。

要模擬單執行緒做許多相對時間較長的操作,也不一定非要用檔案讀寫、字串處理等操作,這樣設計測試比較麻煩,由於已經知道了關鍵點在於執行時間與執行緒建立時間的比重,所以可以讓每個執行緒迴圈時休眠一個隨機的毫秒值,這個時間其實不需要太長,例如10ms、20ms、30ms就可以模擬出效果了。

但這並不代表多執行緒就一定能提升效率,首先要檢測CPU是不是多核,如果不是,那麼使用多執行緒帶來更多的是上下文切換的開銷,多執行緒操作的共享物件還會有鎖瓶頸,否則就是非執行緒安全的。

綜合考量各種開銷因素、時間、空間,最後利用大量的場景測試來證明推理是有指導性的,如果只是一味地為了用多執行緒而使用多執行緒,則往往很多事情可能會適得其反。

Join只是語法層面的執行緒合併,其實它更像是當前執行緒處於BLOCKEN狀態時去等待其他執行緒結束的事件,而且是逐個去Join。換句話說,Join的順序並不一定是執行緒真正結束的順序,要保證執行緒結束的順序性,它還無法實現,即使在本例中它也不是唯一的實現方式,本章後面會提到許多基於併發程式設計工具的方式來實現會更加理想,管理也會更加體系化,能適應更多的業務場景需求。

†† 5.1.7 執行緒補充小知識

本小節的內容是一些小例子,簡單地講解執行緒棧的獲取,以及UncaughtExceptionHandler的簡單使用,大家只需要對照本書光碟中的例子來執行,以及本書的相應講解,就會清楚這些小例子的用途和意義。

(1)執行緒棧的獲取

在前文中多次提到過棧,尤其在第3章中介紹BTrace時,通過BTraceUtils的jstack()方法就可以輸出呼叫棧資訊。由此我們知道了程式碼切入是怎麼回事,但是執行緒棧如何獲取呢?其實很簡單,請看下面的例子。

程式碼清單5-4 獲取執行緒棧的簡單例子

public class ThreadStackTest {

	public static void main(String []args) {
		printStack(getStackByThread());
		printStack(getStackByException());
	}

	private static void printStack(StackTraceElement []stacks) {
		for(StackTraceElement stack : stacks) {
			System.out.println(stack);
		}
		System.out.println("\n");
	}

	private static StackTraceElement[] getStackByThread() {
		return Thread.currentThread().getStackTrace();
	}

	private static StackTraceElement[] getStackByException() {
		return new Exception().getStackTrace();
	}
}

這樣就通過兩種方式輸出執行緒棧了!

這麼簡單?

不信,我們就看看輸出結果:

java.lang.Thread.getStackTrace(Thread.java:1568)
chapter05.base.ThreadStackTest.getStackByThread(ThreadStackTest.java:23)
chapter05.base.ThreadStackTest.main(ThreadStackTest.java:11)

chapter05.base.ThreadStackTest.getStackByException(ThreadStackTest.java:27)
chapter05.base.ThreadStackTest.main(ThreadStackTest.java:12)

這和異常資訊很像,只是沒有異常型別而已。沒錯,在例子中大家也應當看到有通過異常來獲取執行緒棧的方式。對於該例子,大家可以方法套用方法,進行多層套用後看看輸出結果會是什麼樣子的。

獲取到的這個執行緒棧是一個數組,陣列的順序就是呼叫程式碼的來源路徑,陣列中的每個元素是一個java.lang.StackTraceElement型別的物件,它內部包含了相應的class、方法、檔名、行號資訊,我們可以通過這些資訊來追蹤程式碼、監控、定位異常、控制呼叫來源等。

對於呼叫來源的類,可以通過sun.reflect.Reflection的getCallerClass(int)來獲取,在JDK 1.7以後API有少量變化。

(2)UncaughtExceptionHandler的簡單使用

這是Java本身提供的一種對run()方法沒有捕獲到的異常、錯誤的一次補救,在這裡可以吃點後悔藥。通常我們不依賴這種方式,因為這是執行緒級別的,業務程式碼中通常不會關心這個層次,即使要關心也是在框架當中,通常我們希望在內層就將該異常處理掉,走到這個位置也意味著執行緒已經脫離了run()方法,會立即結束,不能再被執行緒所複用了。

不過,從學習Java的角度來講,也需要知道Java確實提供了這樣一種機制,請看下面的例子。

程式碼清單5-5 UncaughtExceptionHandler的測試

class TestExceptionHandler implements UncaughtExceptionHandler {
	@Override
	public void uncaughtException(Thread t, Throwable e) {
		System.out.printf("執行緒出現異常:");
		e.printStackTrace();
	}
}
public class ExceptionHandlerTest {

	public static void main(String []args) {
		Thread t = new Thread() {
			public void run() {
				Integer.parseInt("ABC");
			}
		};
		t.setUncaughtExceptionHandler(new TestExceptionHandler());
		t.start();
	}
}

程式碼中模擬了一個數字轉換的異常丟擲,在run()方法中並沒有捕獲此異常,最終會進入自定義的TestExceptionHandler中來處理(也可以直接throw new Error()丟擲,得到的結果也是類似的)。