Java併發程式設計實戰————可重入內建鎖
引言
在《Java Concurrency in Practice》的加鎖機制一節中作者提到:
Java提供一種內建的鎖機制來支援原子性:同步程式碼塊。“重入”意味著獲取鎖的操作的粒度是“執行緒”,而不是呼叫。當某個執行緒請求一個由其他執行緒持有的鎖時,發出請求的執行緒就會阻塞。然而,由於內建鎖時可重入的,因此如果某個執行緒試圖獲得一個已經由它自己持有的鎖,那麼這個請求就會成功。————《Java Concurrency in Practice》
關於上面引述的這段話,出自書中第21頁,但是從書中給出的例子來看,對於這個概念真的很難理解,有很多問題blowed my mind。而最重要的關鍵,當然是“同一個執行緒”。
而鎖這個東西,我們在程式中需要通過其他的執行緒來感知鎖的存在,否則如果我用一個執行緒執行了一個用synchronized修飾的方法,誰來證明鎖的存在?這也是測試程式碼的書寫難點。
測試程式碼
public class Test { public static void main(String[] args) throws InterruptedException { // 建立一個子類物件 final Child widget = new Child(); // 定義執行緒1 Thread th1 = new Thread("th1") { @Override public void run() { System.out.println(super.getName() + ":start..."); widget.doSometing(); } }; // 定義執行緒2 Thread th2 = new Thread("th2") { @Override public void run() { System.out.println(super.getName() + ":start..."); /** * 下行在th1剛剛呼叫子類重寫的父類加鎖方法doSometing()時, * 另一個執行緒th2直接呼叫父類的其他加鎖方法會出現等待現象,說明th1呼叫子類中重寫的加鎖方法會立刻持有父類鎖, * 此時不允許呼叫父類其他的加鎖方法 */ widget.doAnother(); /** * 下行在th1開始呼叫子類重寫的父類加鎖方法後,立刻通過另一個執行緒th2呼叫父類的未加鎖方法doNother(), * th2會立刻執行完畢,不需要等待,也就 證明了內建鎖對那些沒有加鎖的方法是不起作用的,也就是說這些沒有加鎖的方法, * 不會因為其他執行緒持有該類的內建鎖就處於等待或阻塞的狀態而無法執行 */ // widget.doNother(); /** * 如果說呼叫doAnother證明了呼叫重寫的父類加鎖方法會直接持有父類鎖的話, * 那麼下行就證明了呼叫子類的加鎖方法也一定會獲得該類的內建鎖,就算這個方法已經 * 持有了父類鎖,也就是說執行緒th1在執行doSomething()之初就持有了子類鎖和父類鎖兩個鎖, */ // widget.doMyLike(); /** th2呼叫doSometing()是需要等待的,並不是繼承的關係,不是重入,重入是發生在一個執行緒中的 */ // widget.doSometing(); } }; th1.start(); Thread.sleep(100); th2.start(); } } class Father { /** 唯一被子類Child重寫的方法 */ public synchronized void doSometing() { System.out.println(Thread.currentThread().getName() + ":Father ... do something..."); } public synchronized void doAnother() { System.out.println(Thread.currentThread().getName() + ":Father... do another thing..."); } public void doNother() { System.out.println(Thread.currentThread().getName() + ":Father... do Nothing..."); } } class Child extends Father { @Override public synchronized void doSometing() { try { System.out.println(Thread.currentThread().getName() + ":Child do something..."); Thread.sleep(5000); System.out.println(Thread.currentThread().getName() + ":end Child do something..."); } catch (InterruptedException e) { e.printStackTrace(); } super.doSometing(); } public synchronized void doMyLike() { System.out.println(Thread.currentThread().getName() + ":Child do my like..."); } }
這是補充了網上程式碼的測試code,重要的地方都寫了註釋,原版程式碼可以參考這篇文章《java內建鎖synchronized的可重入性》
這段程式碼其實是書中程式清單2-7的程式碼擴充套件和補充,參考文章的例子非常給力,不過還是需要花點頭腦去理解和總結(不過諸位放心,本篇文章絕對不是簡單的拿來主義)。
測試code想要表達什麼?在main方法中聲明瞭兩個執行緒,用th1去製造“可重入”的條件,而th2用於感知鎖的存在。我們說可重入是指同一個執行緒而言。但是同一個執行緒如何才可以持有了一個類的鎖後又去呼叫加鎖程式碼塊?
就像上述程式碼所示:父類的加鎖方法doSomething()被子類重寫了,不僅重寫了,還又加了個鎖。那麼th1在呼叫子類doSomething()方法的時候,
而一個執行緒持有了這個類的內建鎖導致的結果是,其他執行緒需要等待或阻塞執行該類中其他任何加鎖方法,但未加鎖方法不受影響!
因此,根據這個程式碼規則,th1此時持有了子類鎖和父類鎖,那麼th2在執行父類的doAnother()加鎖方法時就會出現阻塞執行的情況。
但是我突然又有一個想法,為何一個執行緒同時獲得子類和父類的雙重鎖的條件這麼多?如果子類的重寫方法沒有鎖呢?如果父類的方法沒有鎖,子類重寫的方法有鎖呢?這些情況又會是怎樣的執行結果?於是我調整了程式碼,將這兩種情況重現測試了一下:
情況一:父類加鎖,子類未加鎖,th1呼叫子類重寫方法:
父類:
子類:
執行結果:
情況二:父類方法未加鎖,子類重寫後加鎖,th1呼叫子類重寫的該方法。
父類:
子類:
執行結果:
總結:子類重寫了父類方法時,如果子類該方法有同步鎖,那麼不論父類該方法是否加鎖,執行緒在呼叫子類的這個方法時都會同時獲得子類和父類雙重鎖,從而影響其他執行緒呼叫子類和父類中任何加鎖方法。
(說實話,如果是gif動圖,效果會更明顯一些,因為在子類重寫的doSomething()中,有一個5秒的執行緒睡眠時間,這樣的測試效果是比較直觀的。)
以上就是關於“重入”的引申理解,即關於執行緒獲得雙重鎖的知識總結,可能有些繞,而且後面的擴充套件總結也比較難想到,筆者建議將上面的完整程式碼考下來執行一下,體會一下。而且筆者在必要的輸出語句上補充了執行緒資訊,類名資訊,便於區分輸出結果和執行順序,執行緒的睡眠時間也做了調整,便於理解,註釋也儘量做到嚴謹概括。其中最重要的是th2定義中的4種情況,當然可能還有其他的情況我沒有列舉出來,比如子類新增一個同步方法,在th1中呼叫的時候是否也會獲得父類的內建鎖呢?大家可以去嘗試一下。
總之,再次強調,一旦執行緒獲得了某個類的內建鎖,其他執行緒便會阻塞執行該類中的任何同步方法,但該類的非同步方法是不受影響的。
如有疑問,歡迎文末留言!
=======2018.6.21 清晨更新=======================================================
經過檢驗測試,如果子類中新增一個同步方法,例如前面程式碼中的synchronized doMyLike(),執行緒th1呼叫之後依然獲取了子類和父類雙重鎖。
這樣我們就將前面的概念上升到了更廣泛的高度上:
如果一個執行緒呼叫了一個物件的同步方法,那麼這個執行緒不僅持有該類物件的鎖,由於子類物件同時也是父類物件,因此其他執行緒不能訪問父類中其他的同步方法,使其他執行緒進入阻塞狀態。