原文:Java 8 Concurrency Tutorial: Synchronization and Locks
譯者:飛龍
協議:CC BY-NC-SA 4.0java
歡迎閱讀個人Java8併發教程的第二部分。這份指南將會以簡單易懂的代碼示例來教給你如何在Java8中進行併發編程。這是一系列教程中的第二部分。在接下來的15分鐘,你將會學會如何經過同步關鍵字,鎖和信號量來同步訪問共享可變變量。git
第一部分:線程和執行器github
第二部分:同步和鎖編程
第三部分:原子操做和 ConcurrentMap安全
這篇文章中展現的中心概念也適用於Java的舊版本,然而代碼示例適用於Java 8,並嚴重依賴於lambda表達式和新的併發特性。若是你還不熟悉lambda,我推薦你先閱讀個人Java 8 教程。多線程
出於簡單的因素,這個教程的代碼示例使用了定義在這裏的兩個輔助函數sleep(seconds)
和 stop(executor)
。併發
在上一章中,咱們學到了如何經過執行器服務同時執行代碼。當咱們編寫這種多線程代碼時,咱們須要特別注意共享可變變量的併發訪問。假設咱們打算增長某個可被多個線程同時訪問的整數。函數
咱們定義了count
字段,帶有increment()
方法來使count
加一:post
int count = 0; void increment() { count = count + 1; }
當多個線程併發調用這個方法時,咱們就會遇到大麻煩:性能
ExecutorService executor = Executors.newFixedThreadPool(2); IntStream.range(0, 10000) .forEach(i -> executor.submit(this::increment)); stop(executor); System.out.println(count); // 9965
咱們沒有看到count
爲10000的結果,上面代碼的實際結果在每次執行時都不一樣。緣由是咱們在不一樣的線程上共享可變變量,而且變量訪問沒有同步機制,這會產生競爭條件。
增長一個數值須要三個步驟:(1)讀取當前值,(2)使這個值加一,(3)將新的值寫到變量。若是兩個線程同時執行,就有可能出現兩個線程同時執行步驟1,因而會讀到相同的當前值。這會致使無效的寫入,因此實際的結果會偏小。上面的例子中,對count
的非同步併發訪問丟失了35次增長操做,可是你在本身執行代碼時會看到不一樣的結果。
幸運的是,Java自從好久以前就經過synchronized
關鍵字支持線程同步。咱們可使用synchronized
來修復上面在增長count
時的競爭條件。
synchronized void incrementSync() { count = count + 1; }
在咱們併發調用incrementSync()
時,咱們獲得了count
爲10000的預期結果。沒有再出現任何競爭條件,而且結果在每次代碼執行中都很穩定:
ExecutorService executor = Executors.newFixedThreadPool(2); IntStream.range(0, 10000) .forEach(i -> executor.submit(this::incrementSync)); stop(executor); System.out.println(count); // 10000
synchronized
關鍵字也可用於語句塊:
void incrementSync() { synchronized (this) { count = count + 1; } }
Java在內部使用所謂的「監視器」(monitor),也稱爲監視器鎖(monitor lock)或內在鎖( intrinsic lock)來管理同步。監視器綁定在對象上,例如,當使用同步方法時,每一個方法都共享相應對象的相同監視器。
全部隱式的監視器都實現了重入(reentrant)特性。重入的意思是鎖綁定在當前線程上。線程能夠安全地屢次獲取相同的鎖,而不會產生死鎖(例如,同步方法調用相同對象的另外一個同步方法)。
併發API支持多種顯式的鎖,它們由Lock
接口規定,用於代替synchronized
的隱式鎖。鎖對細粒度的控制支持多種方法,所以它們比隱式的監視器具備更大的開銷。
鎖的多個實如今標準JDK中提供,它們會在下面的章節中展現。
ReentrantLock
ReentrantLock
類是互斥鎖,與經過synchronized
訪問的隱式監視器具備相同行爲,可是具備擴展功能。就像它的名稱同樣,這個鎖實現了重入特性,就像隱式監視器同樣。
讓咱們看看使用ReentrantLock
以後的上面的例子。
ReentrantLock lock = new ReentrantLock(); int count = 0; void increment() { lock.lock(); try { count++; } finally { lock.unlock(); } }
鎖能夠經過lock()
來獲取,經過unlock()
來釋放。把你的代碼包裝在try-finally
代碼塊中來確保異常狀況下的解鎖很是重要。這個方法是線程安全的,就像同步副本那樣。若是另外一個線程已經拿到鎖了,再次調用lock()
會阻塞當前線程,直到鎖被釋放。在任意給定的時間內,只有一個線程能夠拿到鎖。
鎖對細粒度的控制支持多種方法,就像下面的例子那樣:
executor.submit(() -> { lock.lock(); try { sleep(1); } finally { lock.unlock(); } }); executor.submit(() -> { System.out.println("Locked: " + lock.isLocked()); System.out.println("Held by me: " + lock.isHeldByCurrentThread()); boolean locked = lock.tryLock(); System.out.println("Lock acquired: " + locked); }); stop(executor);
在第一個任務拿到鎖的一秒以後,第二個任務得到了鎖的當前狀態的不一樣信息。
Locked: true Held by me: false Lock acquired: false
tryLock()
方法是lock()
方法的替代,它嘗試拿鎖而不阻塞當前線程。在訪問任何共享可變變量以前,必須使用布爾值結果來檢查鎖是否已經被獲取。
ReadWriteLock
ReadWriteLock
接口規定了鎖的另外一種類型,包含用於讀寫訪問的一對鎖。讀寫鎖的理念是,只要沒有任何線程寫入變量,併發讀取可變變量一般是安全的。因此讀鎖能夠同時被多個線程持有,只要沒有線程持有寫鎖。這樣能夠提高性能和吞吐量,由於讀取比寫入更加頻繁。
ExecutorService executor = Executors.newFixedThreadPool(2); Map<String, String> map = new HashMap<>(); ReadWriteLock lock = new ReentrantReadWriteLock(); executor.submit(() -> { lock.writeLock().lock(); try { sleep(1); map.put("foo", "bar"); } finally { lock.writeLock().unlock(); } });
上面的例子在暫停一秒以後,首先獲取寫鎖來向映射添加新的值。在這個任務完成以前,兩個其它的任務被啓動,嘗試讀取映射中的元素,並暫停一秒:
Runnable readTask = () -> { lock.readLock().lock(); try { System.out.println(map.get("foo")); sleep(1); } finally { lock.readLock().unlock(); } }; executor.submit(readTask); executor.submit(readTask); stop(executor);
當你執行這一代碼示例時,你會注意到兩個讀任務須要等待寫任務完成。在釋放了寫鎖以後,兩個讀任務會同時執行,並同時打印結果。它們不須要相互等待完成,由於讀鎖能夠安全同步獲取,只要沒有其它線程獲取了寫鎖。
StampedLock
Java 8 自帶了一種新的鎖,叫作StampedLock
,它一樣支持讀寫鎖,就像上面的例子那樣。與ReadWriteLock
不一樣的是,StampedLock
的鎖方法會返回表示爲long
的標記。你可使用這些標記來釋放鎖,或者檢查鎖是否有效。此外,StampedLock
支持另外一種叫作樂觀鎖(optimistic locking)的模式。
讓咱們使用StampedLock
代替ReadWriteLock
重寫上面的例子:
ExecutorService executor = Executors.newFixedThreadPool(2); Map<String, String> map = new HashMap<>(); StampedLock lock = new StampedLock(); executor.submit(() -> { long stamp = lock.writeLock(); try { sleep(1); map.put("foo", "bar"); } finally { lock.unlockWrite(stamp); } }); Runnable readTask = () -> { long stamp = lock.readLock(); try { System.out.println(map.get("foo")); sleep(1); } finally { lock.unlockRead(stamp); } }; executor.submit(readTask); executor.submit(readTask); stop(executor);
經過readLock()
或 writeLock()
來獲取讀鎖或寫鎖會返回一個標記,它能夠在稍後用於在finally
塊中解鎖。要記住StampedLock
並無實現重入特性。每次調用加鎖都會返回一個新的標記,而且在沒有可用的鎖時阻塞,即便相同線程已經拿鎖了。因此你須要額外注意不要出現死鎖。
就像前面的ReadWriteLock
例子那樣,兩個讀任務都須要等待寫鎖釋放。以後兩個讀任務同時向控制檯打印信息,由於多個讀操做不會相互阻塞,只要沒有線程拿到寫鎖。
下面的例子展現了樂觀鎖:
ExecutorService executor = Executors.newFixedThreadPool(2); StampedLock lock = new StampedLock(); executor.submit(() -> { long stamp = lock.tryOptimisticRead(); try { System.out.println("Optimistic Lock Valid: " + lock.validate(stamp)); sleep(1); System.out.println("Optimistic Lock Valid: " + lock.validate(stamp)); sleep(2); System.out.println("Optimistic Lock Valid: " + lock.validate(stamp)); } finally { lock.unlock(stamp); } }); executor.submit(() -> { long stamp = lock.writeLock(); try { System.out.println("Write Lock acquired"); sleep(2); } finally { lock.unlock(stamp); System.out.println("Write done"); } }); stop(executor);
樂觀的讀鎖經過調用tryOptimisticRead()
獲取,它老是返回一個標記而不阻塞當前線程,不管鎖是否真正可用。若是已經有寫鎖被拿到,返回的標記等於0。你須要老是經過lock.validate(stamp)
檢查標記是否有效。
執行上面的代碼會產生如下輸出:
Optimistic Lock Valid: true Write Lock acquired Optimistic Lock Valid: false Write done Optimistic Lock Valid: false
樂觀鎖在剛剛拿到鎖以後是有效的。和普通的讀鎖不一樣的是,樂觀鎖不阻止其餘線程同時獲取寫鎖。在第一個線程暫停一秒以後,第二個線程拿到寫鎖而無需等待樂觀的讀鎖被釋放。此時,樂觀的讀鎖就再也不有效了。甚至當寫鎖釋放時,樂觀的讀鎖還處於無效狀態。
因此在使用樂觀鎖時,你須要每次在訪問任何共享可變變量以後都要檢查鎖,來確保讀鎖仍然有效。
有時,將讀鎖轉換爲寫鎖而不用再次解鎖和加鎖十分實用。StampedLock
爲這種目的提供了tryConvertToWriteLock()
方法,就像下面那樣:
ExecutorService executor = Executors.newFixedThreadPool(2); StampedLock lock = new StampedLock(); executor.submit(() -> { long stamp = lock.readLock(); try { if (count == 0) { stamp = lock.tryConvertToWriteLock(stamp); if (stamp == 0L) { System.out.println("Could not convert to write lock"); stamp = lock.writeLock(); } count = 23; } System.out.println(count); } finally { lock.unlock(stamp); } }); stop(executor);
第一個任務獲取讀鎖,並向控制檯打印count
字段的當前值。可是若是當前值是零,咱們但願將其賦值爲23
。咱們首先須要將讀鎖轉換爲寫鎖,來避免打破其它線程潛在的併發訪問。tryConvertToWriteLock()
的調用不會阻塞,可是可能會返回爲零的標記,表示當前沒有可用的寫鎖。這種狀況下,咱們調用writeLock()
來阻塞當前線程,直到有可用的寫鎖。
除了鎖以外,併發API也支持計數的信號量。不過鎖一般用於變量或資源的互斥訪問,信號量能夠維護總體的准入許可。這在一些不一樣場景下,例如你須要限制你程序某個部分的併發訪問總數時很是實用。
下面是一個例子,演示瞭如何限制對經過sleep(5)
模擬的長時間運行任務的訪問:
ExecutorService executor = Executors.newFixedThreadPool(10); Semaphore semaphore = new Semaphore(5); Runnable longRunningTask = () -> { boolean permit = false; try { permit = semaphore.tryAcquire(1, TimeUnit.SECONDS); if (permit) { System.out.println("Semaphore acquired"); sleep(5); } else { System.out.println("Could not acquire semaphore"); } } catch (InterruptedException e) { throw new IllegalStateException(e); } finally { if (permit) { semaphore.release(); } } } IntStream.range(0, 10) .forEach(i -> executor.submit(longRunningTask)); stop(executor);
執行器可能同時運行10個任務,可是咱們使用了大小爲5的信號量,因此將併發訪問限制爲5。使用try-finally
代碼塊在異常狀況中合理釋放信號量十分重要。
執行上述代碼產生以下結果:
Semaphore acquired Semaphore acquired Semaphore acquired Semaphore acquired Semaphore acquired Could not acquire semaphore Could not acquire semaphore Could not acquire semaphore Could not acquire semaphore Could not acquire semaphore
信號量限制對經過sleep(5)
模擬的長時間運行任務的訪問,最大5個線程。每一個隨後的tryAcquire()
調用在通過最大爲一秒的等待超時以後,會向控制檯打印不能獲取信號量的結果。
這就是個人系列併發教程的第二部分。之後會放出更多的部分,因此敬請等待吧。像之前同樣,你能夠在Github上找到這篇文檔的全部示例代碼,因此請隨意fork這個倉庫,並本身嘗試它。
我但願你能喜歡這篇文章。若是你還有任何問題,在下面的評論中向我反饋。你也能夠在Twitter上關注我來獲取更多開發相關的信息。
第一部分:線程和執行器
第二部分:同步和鎖
第三部分:原子操做和 ConcurrentMap