當前位置:
首頁 > 知識 > Java 8 並發教程:同步和鎖

Java 8 並發教程:同步和鎖

原文:Java 8 Concurrency Tutorial: Synchronization and Locks

譯者:飛龍

協議:CC BY-NC-SA 4.0

歡迎閱讀我的 Java8 並發教程的第二部分。這份指南將會以簡單易懂的代碼示例來教給你如何在 Java8 中進行並發編程。這是一系列教程中的第二部分。在接下來的 15 分鐘,你將會學會如何通過同步關鍵字,鎖和信號量來同步訪問共享可變變數。

第一部分:線程和執行器

第二部分:同步和鎖

第三部分:原子操作和 ConcurrentMap

這篇文章中展示的中心概念也適用於Java的舊版本,然而代碼示例適用於Java 8,並嚴重依賴於 lambda 表達式和新的並發特性。如果你還不熟悉 lambda,我推薦你先閱讀我的 Java 8 教程。

出於簡單的因素,這個教程的代碼示例使用了定義在這裡的兩個輔助函數 和 。

同步

在上一章中,我們學到了如何通過執行器服務同時執行代碼。當我們編寫這種多線程代碼時,我們需要特別注意共享可變變數的並發訪問。假設我們打算增加某個可被多個線程同時訪問的整數。

我們定義了欄位,帶有方法來使加一:

當多個線程並發調用這個方法時,我們就會遇到大麻煩:

我們沒有看到為 10000 的結果,上面代碼的實際結果在每次執行時都不同。原因是我們在不同的線程上共享可變變數,並且變數訪問沒有同步機制,這會產生競爭條件。

增加一個數值需要三個步驟:(1)讀取當前值,(2)使這個值加一,(3)將新的值寫到變數。如果兩個線程同時執行,就有可能出現兩個線程同時執行步驟1,於是會讀到相同的當前值。這會導致無效的寫入,所以實際的結果會偏小。上面的例子中,對的非同步並發訪問丟失了35次增加操作,但是你在自己執行代碼時會看到不同的結果。

幸運的是,Java自從很久之前就通過關鍵字支持線程同步。我們可以使用來修復上面在增加時的競爭條件。

在我們並發調用時,我們得到了為10000的預期結果。沒有再出現任何競爭條件,並且結果在每次代碼執行中都很穩定:

關鍵字也可用於語句塊:

Java在內部使用所謂的「監視器」(monitor),也稱為監視器鎖(monitor lock)或內在鎖( intrinsic lock)來管理同步。監視器綁定在對象上,例如,當使用同步方法時,每個方法都共享相應對象的相同監視器。

所有隱式的監視器都實現了重入(reentrant)特性。重入的意思是鎖綁定在當前線程上。線程可以安全地多次獲取相同的鎖,而不會產生死鎖(例如,同步方法調用相同對象的另一個同步方法)。

並發 API 支持多種顯式的鎖,它們由介面規定,用於代替的隱式鎖。鎖對細粒度的控制支持多種方法,因此它們比隱式的監視器具有更大的開銷。

鎖的多個實現在標準 JDK 中提供,它們會在下面的章節中展示。

類是互斥鎖,與通過訪問的隱式監視器具有相同行為,但是具有擴展功能。就像它的名稱一樣,這個鎖實現了重入特性,就像隱式監視器一樣。

讓我們看看使用之後的上面的例子。

鎖可以通過來獲取,通過來釋放。把你的代碼包裝在代碼塊中來確保異常情況下的解鎖非常重要。這個方法是線程安全的,就像同步副本那樣。如果另一個線程已經拿到鎖了,再次調用會阻塞當前線程,直到鎖被釋放。在任意給定的時間內,只有一個線程可以拿到鎖。

鎖對細粒度的控制支持多種方法,就像下面的例子那樣:

在第一個任務拿到鎖的一秒之後,第二個任務獲得了鎖的當前狀態的不同信息。

方法是方法的替代,它嘗試拿鎖而不阻塞當前線程。在訪問任何共享可變變數之前,必須使用布爾值結果來檢查鎖是否已經被獲取。

介面規定了鎖的另一種類型,包含用於讀寫訪問的一對鎖。讀寫鎖的理念是,只要沒有任何線程寫入變數,並發讀取可變變數通常是安全的。所以讀鎖可以同時被多個線程持有,只要沒有線程持有寫鎖。這樣可以提升性能和吞吐量,因為讀取比寫入更加頻繁。

上面的例子在暫停一秒之後,首先獲取寫鎖來向映射添加新的值。在這個任務完成之前,兩個其它的任務被啟動,嘗試讀取映射中的元素,並暫停一秒:

當你執行這一代碼示例時,你會注意到兩個讀任務需要等待寫任務完成。在釋放了寫鎖之後,兩個讀任務會同時執行,並同時列印結果。它們不需要相互等待完成,因為讀鎖可以安全同步獲取,只要沒有其它線程獲取了寫鎖。

Java 8 自帶了一種新的鎖,叫做,它同樣支持讀寫鎖,就像上面的例子那樣。與不同的是,的鎖方法會返回表示為的標記。你可以使用這些標記來釋放鎖,或者檢查鎖是否有效。此外,支持另一種叫做樂觀鎖(optimistic locking)的模式。

讓我們使用代替重寫上面的例子:

通過 或 來獲取讀鎖或寫鎖會返回一個標記,它可以在稍後用於在塊中解鎖。要記住並沒有實現重入特性。每次調用加鎖都會返回一個新的標記,並且在沒有可用的鎖時阻塞,即使相同線程已經拿鎖了。所以你需要額外注意不要出現死鎖。

就像前面的例子那樣,兩個讀任務都需要等待寫鎖釋放。之後兩個讀任務同時向控制台列印信息,因為多個讀操作不會相互阻塞,只要沒有線程拿到寫鎖。

下面的例子展示了樂觀鎖:

樂觀的讀鎖通過調用獲取,它總是返回一個標記而不阻塞當前線程,無論鎖是否真正可用。如果已經有寫鎖被拿到,返回的標記等於0。你需要總是通過檢查標記是否有效。

執行上面的代碼會產生以下輸出:

樂觀鎖在剛剛拿到鎖之後是有效的。和普通的讀鎖不同的是,樂觀鎖不阻止其他線程同時獲取寫鎖。在第一個線程暫停一秒之後,第二個線程拿到寫鎖而無需等待樂觀的讀鎖被釋放。此時,樂觀的讀鎖就不再有效了。甚至當寫鎖釋放時,樂觀的讀鎖還處於無效狀態。

所以在使用樂觀鎖時,你需要每次在訪問任何共享可變變數之後都要檢查鎖,來確保讀鎖仍然有效。

有時,將讀鎖轉換為寫鎖而不用再次解鎖和加鎖十分實用。為這種目的提供了方法,就像下面那樣:

第一個任務獲取讀鎖,並向控制台列印欄位的當前值。但是如果當前值是零,我們希望將其賦值為。我們首先需要將讀鎖轉換為寫鎖,來避免打破其它線程潛在的並發訪問。的調用不會阻塞,但是可能會返回為零的標記,表示當前沒有可用的寫鎖。這種情況下,我們調用來阻塞當前線程,直到有可用的寫鎖。

信號量

除了鎖之外,並發 API 也支持計數的信號量。不過鎖通常用於變數或資源的互斥訪問,信號量可以維護整體的准入許可。這在一些不同場景下,例如你需要限制你程序某個部分的並發訪問總數時非常實用。

下面是一個例子,演示了如何限制對通過模擬的長時間運行任務的訪問:

執行器可能同時運行 10 個任務,但是我們使用了大小為5的信號量,所以將並發訪問限制為5。使用代碼塊在異常情況中合理釋放信號量十分重要。

執行上述代碼產生如下結果:

信號量限制對通過模擬的長時間運行任務的訪問,最大5個線程。每個隨後的調用在經過最大為一秒的等待超時之後,會向控制台列印不能獲取信號量的結果。

這就是我的系列並發教程的第二部分。以後會放出更多的部分,所以敬請等待吧。像以前一樣,你可以在Github上找到這篇文檔的所有示例代碼,所以請隨意fork這個倉庫,並自己嘗試它。

我希望你能喜歡這篇文章。如果你還有任何問題,在下面的評論中向我反饋。你也可以在 Twitter 上關注我來獲取更多開發相關的信息。

點擊展開全文

喜歡這篇文章嗎?立刻分享出去讓更多人知道吧!

本站內容充實豐富,博大精深,小編精選每日熱門資訊,隨時更新,點擊「搶先收到最新資訊」瀏覽吧!


請您繼續閱讀更多來自 程序源 的精彩文章:

JVM GC參數以及GC演算法的應用
PHP 底層的運行機制與原理解析
程序員創業三年,然後
PHP哈希表碰撞攻擊原理

TAG:程序源 |