更新時間:2025-05-09 09:13:12作者:佚名
翻譯:費隆
協議:CC BY-NC-SA 4.0
歡迎來到我的Java8并發教程的第二部分。本指南將教您如何在Java 8中使用簡單易懂的代碼示例在Java 8中進行編程。這是一系列教程的第二部分。在接下來的15分鐘內,您將學習如何通過同步關鍵字,鎖和信號量同步訪問共享變量。
本文中顯示的中心概念也適用于Java的較舊版本,但是代碼示例適用于Java 8permit是什么意思?怎么讀,并嚴重依賴Lambda表達式和新的并發功能。如果您還不熟悉Lambda,我建議您先閱讀我的Java 8教程。
為簡單起見,本教程的代碼示例使用此處定義的兩個輔助功能睡眠(秒)和停止(執行程序)。
同步
在上一章中,我們學會了如何通過執行器服務同時執行代碼。當我們編寫這種多線程代碼時,我們需要特別注意同時訪問共享變量。假設我們打算增加一個可以通過多個線程同時訪問的整數。
我們使用rezement()方法定義計數字段以添加計數:
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
我們看不到計數為10000的結果,并且每次執行上述代碼的實際結果都不同。原因是我們在不同的線程上共享可變變量,并且沒有用于可變訪問的同步機制,從而創造了種族條件。
添加一個值需要三個步驟:(1)讀取當前值,(2)將此值添加到一個,(3)將新值寫入變量。如果兩個線程同時執行,則有可能同時執行兩個線程,并且將讀取相同的當前值。這將導致寫作無效,因此實際結果將很小。在上面的示例中,異步對計數的并發訪問丟失了35個增量操作,但是在自己執行代碼時,您會看到不同的結果。
幸運的是,Java很久以前就支持了與同步關鍵字的線程同步。在增加計數時,我們可以使用同步固定上述比賽條件。
synchronized void incrementSync() {
? ?count = count + 1;
}
當我們同時調用regrementSync()時,我們獲得了10000的預期結果。不再次出現比賽條件,并且在每個代碼執行中的結果穩定:
ExecutorService executor = Executors.newFixedThreadPool(2);
IntStream.range(0, 10000)
? ?.forEach(i -> executor.submit(this::incrementSync));
stop(executor);
System.out.println(count); ?// 10000
同步關鍵字也可以在語句塊中使用:
void incrementSync() {
? ?synchronized (this) {
? ? ? ?count = count + 1;
? ?}
}
Java在內部使用所謂的“監視器”(也稱為顯示器鎖或固有鎖)來管理同步。監視器綁定到對象,例如,當使用同步方法時,每個方法都會為相應的對象共享同一監視器。
所有隱式監視器都實現了重進入功能。重新進入意味著鎖定與當前線綁定。線程可以安全地獲取相同的鎖多次,而無需創建僵局(例如,同步方法調用同一對象的另一種同步方法)。
鎖
并發API支持各種顯式鎖,由鎖定接口指定這些鎖以替換同步隱式鎖。鎖支持多種細粒度控制方法,因此它們比隱式監視器具有更多的開銷。
標準JDK中提供了一些鎖的實現,并在以下各章中顯示。
重新進入
重新輸入鎖類是一種靜音類,其行為與通過同步但功能擴展的隱式監視器相同。就像其名稱一樣,此鎖會像隱式監視器一樣實現重新進入功能。
使用Reentrantlock后,讓我們看一下上面的示例。
ReentrantLock lock = new ReentrantLock();
int count = 0;
void increment() {
? ?lock.lock();
? ?try {
? ? ? ?count++;
? ?} finally {
? ? ? ?lock.unlock();
? ?}
}
可以通過鎖()獲得鎖,并通過unlock()釋放。將您的代碼包裹在一個嘗試的障礙物中以確保在特殊情況下解鎖,這一點非常重要。此方法是線程安全的,就像同步復制品一樣。如果另一個線程已經收到鎖,則再次調用鎖()將阻止當前線程,直到鎖定鎖定為止。只有一個線程可以在任何給定時間內獲取鎖。
鎖定到顆粒控件支持多種方法,如以下示例:
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()方法是鎖定()方法的替代方法,該方法試圖在不阻止當前線程的情況下固定鎖定。在訪問任何共享的可突變變量之前,必須使用布爾結果來檢查是否已獲取鎖定。
ReadWritelock
ReadWritelock接口指定了另一種類型的鎖定,包括一對鎖定鎖,用于讀寫訪問。讀寫鎖的想法是,只要沒有線程編寫變量,同時讀取可變變量通常是安全的。因此,只要沒有螺紋固定寫鎖定,就可以同時由多個線程保存讀取鎖。這可以改善性能和吞吐量,因為讀取比寫作更頻繁。
ExecutorService executor = Executors.newFixedThreadPool(2);
Map 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);
執行此代碼示例時permit是什么意思?怎么讀,您會注意到兩個讀取任務需要等待寫任務完成。寫入鎖定后,將同時執行兩個讀取任務,并同時打印結果。他們不需要等待彼此完成,因為只要沒有其他線程獲得寫鎖,就可以同步獲得讀取鎖。
Stampedlock
Java 8帶有一個名為Stampedlock的新鎖,它也支持讀寫鎖,就像上面的示例一樣。與ReadWritelock不同,Stampedlock的鎖定方法返回表示為長的標記。您可以使用這些標記釋放鎖定,或檢查鎖是否有效。此外,Stampedlock支持另一種稱為樂觀鎖定的模式。
讓我們使用Stampedlock而不是ReadWritelock重寫上面的示例:
ExecutorService executor = Executors.newFixedThreadPool(2);
Map 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()獲取讀取鎖或寫鎖定的標簽,該標簽可在以后在最后塊中解鎖。請記住,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。您需要始終檢查標記是否有效。
執行上述代碼將產生以下輸出:
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);
第一個任務將獲取讀取鎖,并將計數字段的當前值打印到控制臺。但是,如果當前值為零,我們希望將其分配給23。我們首先需要將讀取鎖轉換為寫入鎖定,以避免從其他線程中破壞潛在的并發訪問。對TryConvertTowriteLock()的調用不會阻止,但可能會返回零標記,表明當前沒有寫鎖。在這種情況下,我們調用Writelock()阻止當前線程,直到有一個可用的寫鎖。
信號
除鎖外,并發API還支持計數信號量。但是,鎖通常用于互斥變量或資源的互斥訪問,信號量可以維護整體訪問權限。這在某些不同的情況下非常有用,例如,當您需要限制程序的一部分并發訪問總數時。
這是一個示例,演示了如何限制通過睡眠模擬的長期運行任務的訪問(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個任務,但是我們使用Size 5的信號量,因此我們將同時訪問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
信號量限制了對長達5個線程模擬(5)模擬的長期運行任務的訪問。隨后的每個TryAcquire()調用將打印結果,該結果無法在等待一秒鐘的等待時間之后獲得控制臺的信號量。
這是我系列并發教程的第二部分。將來會發布更多零件,因此請等待。和以前一樣,您可以在GitHub上找到此文檔的所有示例代碼,因此請隨時訂購此存儲庫并自己嘗試。