Java多執行緒通關——基礎知識挑戰
等掌握了基礎知識之後,才有資格說基礎知識沒用這樣的話。否則就老老實實的開始吧。
對象的監視器
每一個Java對象都有一個監視器。並且規定,每個對象的監視器每次只能被一個執行緒擁有,只有擁有它的執行緒把它釋放之後,這個監視器才會被其它執行緒擁有。
其實就是說,對象的監視器對於多執行緒來說是互斥的,即一個執行緒從拿到它之後到釋放它之前這段時間內,其它執行緒是絕對不可能再拿到它的。這是由JVM保證的。
這樣一來,對象的監視器就可以用來保護那種每次只允許一個執行緒執行的方法或程式碼片段,就是我們常說的同步方法或同步程式碼塊。
Java包括兩種範疇的對象(當然,這樣講可能不準確,主要用於幫助理解),一種就是普通的對象,比如new Object()。一種就是描述類型資訊的對象,即Class<?>類型的對象。
這兩類都是Java對象,這毋庸置疑,所以它們都有監視器。但這兩類對象又有明顯的不同,所以它們的監視器對外表現的行為也是不同的。
請看下面表達式:
Object o1 = new Object();
Object o2 = new Object();
o1 == o2; //false
o1和o2是分別new出來的兩個對象,它們肯定不相同。又因為監視器是和對象關聯的,所以o1的監視器和o2的監視器也是不同的,且它們沒有任何關係。
所以必須是同一個對象的監視器才行,不同對象的監視器達不到預期的效果,這一點要切記。
再看下面的表達式:
o1.getClass() == o2.getClass(); //true
o1.getClass() == Object.class; //true
但是o1的類型資訊對象(o1.getClass())和o2的類型資訊對象(o2.getClass())是同一個,且和Object類的類型資訊對象(Object.class)也是同一個。這不廢話嘛,o1和o2都是從Object類new出來的。哈哈。
類型資訊對象本身的類型是Class<?>,在類載入器(ClassLoader)載入一個類後,就會生成一個和該類相關的Class<?>類型的對象,該對象會被快取起來,所以類型資訊對象是全局(同一個JVM同一個類載入器)唯一的。
這也就說明了,為什麼同一個類new出來的多個對象是不同的,但是它們的類型資訊對象卻是同一個,且可以使用「類.class」直接獲取到它。
Java語言規定,使用synchronized關鍵字可以獲取對象的監視器。下面分別來看這兩類對象的監視器用法。
普遍對象的監視器:
class SyncA {
//方法A
public synchronized void methodA() {
//同步方法,當前對象的監視器
}
//方法B
public void methodB() {
synchronized(this) {
//同步程式碼塊,當前對象的監視器
}
}
}
class SyncB {
//對象
private SyncA syncA;
public SyncB(SyncA syncA) {
this.syncA = syncA;
}
//方法C
public void methodC() {
synchronized(syncA) {
//同步程式碼塊,syncA對象的監視器
}
}
}
//new一個對象
SyncA syncA = new SyncA();
//把該對象傳進去
SyncB syncB = new SyncB(syncA);
//A、B、C這三個方法都要擁有syncA這個對象的監視器才能執行
new Thread(syncA::methodA).start();
new Thread(syncA::methodB).start();
new Thread(syncB::methodC).start();
這三個執行緒都去獲取同一個對象(即syncA)的監視器,因為一個對象的監視器一次只能被一個執行緒擁有,所以這三個執行緒是逐次獲取到的,因此這三個方法也是逐次執行的。
這個示例告訴我們,利用對象的監視器可以做到的,並不只是同一個方法不能同時被多個執行緒執行,多個不同的方法也可以不能同時被多個執行緒執行,只要它們用到的是同一個對象的監視器。
類型資訊對象的監視器:
class SyncC {
//靜態方法A
public static synchronized void methodA() {
//同步方法,類型資訊對象的監視器
}
//靜態方法B
public static void methodB() {
synchronized(SyncC.class) {
//同步程式碼塊,類型資訊對象的監視器
}
}
}
class SyncD {
//類型資訊對象
private Class<SyncC> syncClass;
public SyncD(Class<SyncC> syncClass) {
this.syncClass = syncClass;
}
//方法C
public void methodC() {
synchronized(syncClass) {
//同步程式碼塊,SyncC類的類型資訊對象的監視器
}
}
//方法D
public void methodD() {
synchronized(syncClass) {
//同步程式碼塊,SyncC類的類型資訊對象的監視器
}
}
}
//A、B、C、D這四個方法都要擁有SyncC類的類型資訊對象的監視器才能執行
new Thread(SyncC::methodA).start();
new Thread(SyncC::methodB).start();
new Thread(new SyncD(SyncC.class)::methodC).start();
new Thread(new SyncD((Class<SyncC>)new SyncC().getClass())::methodD).start();
因為一個類的類型資訊對象只有一個,所以這四個執行緒其實是在競爭同一個對象的監視器,因此這四個方法也是逐次執行的。
通過這個示例,再次強調一下,不管是方法還是程式碼塊,不管是靜態的還是實例的,也不管是屬於同一個類的還是多個類的,只要它們共用同一個對象的監視器,那麼這些方法或程式碼塊在多執行緒下是無法並發運行的,只能逐個運行,因為同一個對象的監視器每次只能被一個執行緒所擁有,其它執行緒此時只能被阻塞著。
註:在實際使用中,一定要確保是同一個對象,尤其是使用字元串類型或數字類型的對象時,一定要注意。
幾個重要的方法
首先是Object類的wait/notify/notifyAll方法,因為Java中的所有類最終都繼承自Object類,所以,可以使用任何Java對象來調用這三個方法。
不過Java規定,要在某個對象上調用這三個方法,必須先獲取那個對象的監視器才行。再次提醒,監視器是和對象關聯的,不同的對象監視器也是不同的。
請看下面的用法:
//new一個對象
Object obj = new Object();
//獲取對象的監視器
synchronized(obj) {
//在對象上調用wait方法
obj.wait();
}
很多人首次接觸這一部分的時候一般都會比較懵,主要是因為搞不清人物關係。
這裡的wait方法雖然是在對象(即obj)上調用的,但卻不是讓這個對象等待的。而是讓執行這行程式碼(即obj.wati())的執行緒(即Thread)在這個對象(即obj)上等待的。
這裡的執行緒是等待的「主體」,對象是等待的「位置」。比如學校開運動會時,會在操場上為每班劃定一個位置,並插上一個牌子,寫上班級名稱。
這個牌子就相當於對象obj,它表示一個位置資訊。當學生看到本班牌子之後,就會自動去牌子後面排隊等待。
學生就相當於執行緒,當學生看到牌子就相當於當執行緒執行到obj.wait(),學生去牌子後面排隊等待就相當於執行緒在對象obj上等待。
當執行緒執行完obj.wait()後,就會釋放掉對象obj的監視器,轉而進入對象obj的等待集合中進行等待,執行緒由運行狀態變為等待(WAITING)狀態。此後這個執行緒將不再被執行緒調度器調度。
(說明一點,當多個執行緒去競爭同一個對象的監視器而沒有競爭上時,執行緒會變為阻塞(BLOCKED)狀態,而非等待狀態。)
執行緒選擇等待的原因大多都是因為需要的資源暫時得不到,那什麼時候資源能就位讓執行緒再次執行呢?其實是不太好確定的,那乾脆就到資源OK時通知它一聲吧。
請看下面的方法:
//獲取對象(還是上面那個)的監視器
synchronized(obj) {
//在對象上調用notify方法
obj.notify();
}
有了上面的基礎,現在就好理解多了。程式碼的意思就是通知在對象obj上等待的執行緒,把其中一個喚醒。即把這個執行緒從對象obj的等待集合中移除。此後這個執行緒就又可以被執行緒調度器調度了。可能有一部分人覺得現在被喚醒的那個執行緒就可以執行了,其實不然。
當前執行緒執行完notify方法後,必須要釋放掉對象obj的監視器,這樣被喚醒的那個執行緒才能重新獲取對象obj的監視器,這樣才可以繼續執行。
就是當一個執行緒想要通過wait進入等待時,需要獲取對象的監視器。當別的執行緒通過notify喚醒這個執行緒時,這個執行緒想要繼續執行,還需要獲取對象的監視器。
notifyAll方法的用法和notify是一樣的,只是含義不同,表示通知對象obj上所有等待的執行緒,把它們全部都喚醒。雖然是全部喚醒,但也只能有一個執行緒可以運行,因為每次只有一個執行緒能獲取到對象obj的監視器。
還有一種wait方法是帶有超時時間的,它表示執行緒進入等待的時間達到超時時間後還沒有被喚醒時,它會自動醒來(也可以認為是被系統喚醒的)。
這種情況下沒有超時異常拋出,雖然執行緒是自動醒來,但想要繼續執行的話,同樣需要先獲取對象obj的監視器才行。
註:執行緒通過wait進入等待時,只會釋放和這個wait相關的那個對象的監視器。如果此時執行緒還擁有其它對象的監視器,並不會去釋放它們,而是在等待期間一直擁有。這塊一定要注意,避免死鎖。
使用須知:
處在等待狀態的執行緒,可能會被意外喚醒,即此時條件並不滿足,但是卻被喚醒了。當然,這種情況在實踐中很少發生。但是我們還是要做一些措施來應對,那就是再次檢測條件是否滿足,不滿足的話再次進入等待。
可見這是一個具有重複性的邏輯,因此把它放到一個循環里是最合適的,如下這樣:
//獲取對象的監視器
synchronized(obj) {
//判斷條件是否滿足
while(condition is not satisfied) {
//在對象上調用wait方法
obj.wait();
}
}
這樣一來,即使被意外喚醒,還會再次進入等待。直到條件滿足後,才會退出while循環,執行後面的邏輯。
多執行緒的話題怎麼能少了主角呢,下面有請主角上場,哈哈,就是Thread類啦。關於執行緒,我在上一篇文章中已經談過,這裡再贅述一遍,希望加深一下印象。
執行緒是可以獨立運行的「個體」,這就導致我們對它的「控制能力」變弱了。當我們想讓一個執行緒暫停或停止時,如果強制去執行,會產生兩方面的問題,一是使正在執行的業務中斷,導致業務出現不一致性。二是使正在使用的資源得不到釋放,導致記憶體泄漏或死鎖。可見,強制這種方式不可取。(看看Thread類的那些廢棄方法便知)
所以,只能採取柔和的方式,就是你對一個執行緒說,「大哥,停下來歇會吧」,或者是,「大哥,停止吧,不用再執行了」。雖然聽著是噁心了點,但意思就是這樣的。那麼當執行緒接收到這個「話語」時,它必須要做出反應,自己讓自己停止,當然,執行緒也可以根據自己的需要,選擇不停止而繼續執行。
這才是和執行緒交互最安全的方式,就像一個高速行駛的汽車,只有自己慢慢停下來才是最好的方式,直接通過外力干預,很大概率是車毀人亡。
這種柔和的處理方式,在電腦里有個專用名詞,叫中斷。這是一種交互方式,你對別人發送一個中斷,別人要響應這個中斷並做出相應的處理。如果別人不響應你的這個中斷,那隻能是「熱臉貼冷屁股」,完全沒了面子。可見,參與中斷的雙方必須要提前約定好,你怎麼發送,別人怎麼處理,否則只能是雞同鴨講。
Thread類和中斷相關的方法有三個:
實例方法,void interrupt(),表示中斷執行緒,要中斷哪個執行緒就在哪個執行緒的對象上調用該方法。
Thread t = new Thread(() -> {doSomething();});
t.start();
t.interrupt();
new一個執行緒,啟動它,然後中斷它。
當一個執行緒被其它執行緒中斷後,這個執行緒必須要能檢測到自己被中斷了才行,於是就有了下面這個方法。
實例方法,boolean isInterrupted(),返回一個執行緒是否被中斷。常用於一個執行緒檢測自己是否被中斷。
if(Thread.currentThread().isInterrupted()) {
doSomething();
return;
}
如果執行緒發現自己被中斷,做一些事情,然後退出。該方法只會去讀取執行緒的中斷狀態,而不會去修改它,所以多次調用返回同樣的結果。
執行緒在處理中斷前,需要將中斷狀態清除一下,即將它設置成false。否則下次檢測時還是true,以為又中斷了呢,實則不是。
靜態方法,static boolean interrupted(),該方法有兩個作用,一是返回執行緒是否被中斷,二是如果中斷則清除中斷狀態。
Thread.interrupted();
由於這個方法是靜態方法,所以只能用於當前執行緒,即執行緒自己清除自己的中斷狀態。
由於這個方法會清除中斷狀態,所以,如果第一次調用返回true的話,緊接著再調用一次應該返回false,除非在兩次調用之間執行緒真的又被中斷了。
還有一種特殊情況就是,在你中斷一個執行緒時,這個執行緒恰巧沒有在運行,它可能是因為競爭對象的監視器「失敗」(即沒有爭取上)而處於阻塞狀態,可能是因為條件不滿足而處於等待狀態,可能是因為在睡眠中。總之,執行緒目前沒有在執行程式碼。
由於執行緒目前沒有在執行程式碼,所以根本就無法去檢測這個中斷狀態,也就是無法響應中斷了,這樣肯定是不行的。所以設計者們此時選擇了拋異常。
因此,不管是由於阻塞/等待/睡眠,只要一個執行緒處於「停止」(即沒有在運行)時,此時去中斷它,執行緒會被喚醒,接著同樣要去再次獲取監視器,然後就收到了InterruptedException異常了,我們可以捕獲這個異常並處理它,使執行緒可以繼續正常運行。此時既然已經收到異常了,所以中斷狀態也就同時給清除了,因為中斷異常已經足夠表示中斷了。
仔細想想這種設計其實頗具人性化。就好比一個人,在他醒著的時候,跟他說話,他一定會回應你。當他睡著時,跟他說話,其實他是聽不到的,自然無法回應你。此時應該採取稍微暴力一點的手段,比如把他搖晃醒。
所以,一個執行緒正在運行時,去中斷它,是不會拋異常的,只是設置中斷狀態。此時中斷狀態就表示了中斷。一個執行緒在沒有運行時(阻塞/等待/睡眠),去中斷它,會拋出中斷異常,同時清除中斷狀態。此時中斷異常就表示了中斷。
然後就是sleep方法,表示執行緒臨時停止執行一段時間,這裡只有一個要點,就是在睡眠期間,執行緒擁有的所有對象的監視器都不會被釋放。
Thread.sleep(1000);
由於sleep是靜態方法,所以,一個執行緒只能讓自己睡眠,而沒有辦法讓別的執行緒睡眠,這是完全正確的,符合我們一直在闡述的思想。一個執行緒的行為應該由自己掌控,別的執行緒頂多可以給你一個中斷而已,而且你還可以選擇處理它或忽略它。
最後一個方法是join,它是一個實例方法,所以需要在一個執行緒對象上調用它,如下:
Thread t = new Thread(() -> {doSomething();});
t.start();
t.join();
表示當前執行緒執行完t.join()程式碼後,就會進入等待,直到執行緒t死亡後,當前執行緒才會重新恢復執行。我在上一篇文章中把它比喻為插隊,執行緒t插到了當前執行緒的前面,所以必須等執行緒t執行完後,當前執行緒才會接著執行。
這裡主要是想說下它的源碼實現,join方法標有synchronized關鍵字,所以是同步方法,而且在方法體內調用了從Object類繼承來的wait方法。
所以join方法可以這樣來解釋,當前執行緒獲取到執行緒對象t的監視器,然後執行t.wait(),使當前執行緒在執行緒對象t上等待,當前執行緒從運行狀態進入到等待狀態。由於對象t是一個執行緒,這是非常特殊的,因為執行緒執行完是會終止的,且在終止時會自動調用notifyAll方法進行通知。
有句話是這樣講的,「鳥之將死,其鳴也哀;人之將死,其言也善」。因此,一個執行緒都快要死了,是不是應該通知在自己身上等待的其它所有執行緒,把大夥都喚醒。總不能讓所有人都給自己「陪葬」吧,哈哈。
因此,在執行緒t執行結束後,會自動執行t.notifyAll()來通知所有在t上等待的執行緒,並把它們全部喚醒。所以當前執行緒會繼續接著執行。
為什麼說notifyAll()是自動執行的呢?因為源碼中並沒有去調用它,而實際卻執行了,所以只能是系統自動調用了。
所以,從宏觀上看,就是當前執行緒在等待執行緒t的死亡。
任何Java對象都有監視器,所以執行緒對象也有監視器,但執行緒對象確實比較特殊,所以它的wait/notify方法也會有特殊的地方,因此官方建議我們不要隨意去玩Thread類的這些方法。
完整示例源碼:
//github.com/coding-new-talking/java-code-demo.git
如果以上內容閣下全部都知道,而且理解到位,那已經很厲害了,請等待下篇多線的文章吧。
(END)
作者是工作超過10年的碼農,現在任架構師。喜歡研究技術,崇尚簡單快樂。追求以通俗易懂的語言解說技術,希望所有的讀者都能看懂並記住。下面是公眾號的二維碼,歡迎關注!