重新學習Java執行緒原語

信碼由韁發表於2023-04-21
Synchronized曾經是一個革命性的技術,在當前仍然有重要的用途。但是,現在是時候轉向更新的Java執行緒原語,同時重新考慮我們的核心邏輯。

自從Java第一個測試版以來,我就一直在使用它。從那時起,執行緒就是我最喜歡的特性之一。Java是第一種在程式語言本身中引入執行緒支援的語言。那是一個具有爭議的決定。在過去的十年中,每種程式語言都競相引入async/await,甚至Java也有一些第三方支援......但是Java選擇了引入更優越的虛擬執行緒(Loom專案)。本文並不討論這個問題。

我覺得這很好,證明了Java的核心實力。Java不僅僅是一種語言,還是一種文化。這種文化注重深思熟慮的變革,而不是盲目跟隨時尚潮流。

在本文中,我想重新探討Java中的執行緒程式設計舊方法。我習慣使用synchronized、wait、notify等技術。但是, “然而,這些方法已經不再是Java中執行緒處理的最佳方式。 我也是問題的一部分。我還是習慣於使用這些技術,發現很難適應自Java 5以來就存在的一些API。這是一種習慣的力量。 雖然可以討論許多處理執行緒的出色API,但我想在這裡專注討論鎖,因為它們是基礎但極為重要的。

Synchronized 與 ReentrantLock

我猶豫放棄使用 synchronized 的原因是,並沒有更好的替代方案。現在棄用 synchronized 的主要原因是,它可能會在 Loom 中觸發執行緒固定,這並不理想。JDK 21 可能會修復這個問題(當 Loom 正式釋出時),但還有一些理由棄用它。

synchronized 的直接替代品是 ReentrantLock。不幸的是,ReentrantLock 相比 synchronized 很少有優勢,因此遷移的好處最多是存疑的。事實上,它有一個主要的缺點。為了瞭解這一點,讓我們看一個例子。下面是我們如何使用 synchronized:

synchronized(LOCK) {
    // safe code
}

LOCK.lock();
try {
    // safe code
} finally {
    LOCK.unlock();
}

ReentrantLock 的第一個缺點是冗長。我們需要try塊,因為如果在塊內部發生異常,鎖將保持。而 synchronized 則會自動處理異常。

有些人會使用 AutoClosable 對鎖進行封裝,大概是這樣的:

public class ClosableLock implements AutoCloseable {
   private final ReentrantLock lock;

   public ClosableLock() {
       this.lock = new ReentrantLock();
   }

   public ClosableLock(boolean fair) {
       this.lock = new ReentrantLock(fair);
   }

   @Override
   public void close() throws Exception {
       lock.unlock();
   }

   public ClosableLock lock() {
       lock.lock();
       return this;
   }

   public ClosableLock lockInterruptibly() throws InterruptedException {
       lock.lock();
       return this;
   }

   public void unlock() {
       lock.unlock();
   }
}

注意,我沒有實現 Lock 介面,這本來是最理想的。這是因為 lock 方法返回了可自動關閉的實現,而不是 void。

一旦我們這樣做了,我們就可以編寫更簡潔的程式碼,比如這樣:

try(LOCK.lock()) {
 // safe code
}

我喜歡程式碼更簡潔的寫法,但是這個方法存在一些問題,因為 try-with-resource 語句是用於清理資源的,而我們正在重複使用鎖物件。雖然呼叫了 close 方法,但是我們會再次在同一個物件上呼叫它。我認為,將 try-with-resource 語法擴充套件到支援鎖介面可能是個好主意。但在此之前,這個技巧可能不值得采用。

ReentrantLock 的優勢

使用ReentrantLock的最大原因是Loom支援。其他的優點也不錯,但沒有一個是“殺手級功能”。

我們可以在方法之間使用它,而不是在一個連續的程式碼塊中使用。但是這可能不是一個好主意,因為你希望儘量減少鎖定區域,並且失敗可能會成為一個問題。我不認為這個特性是一個優點。

ReentrantLock提供了公平鎖(fairness)的選項。這意味著它會先服務於最先停在鎖上的執行緒。我試圖想到一個現實而簡單的使用案例,但卻無從下手。如果您正在編寫一個複雜的排程程式,並且有許多執行緒不斷地排隊等待資源,您可能會發現一個執行緒由於其他執行緒不斷到來而被“飢餓”。但是,這種情況可能更適合使用併發包中的其他選項。也許我漏掉了什麼……

lockInterruptibly() 方法允許我們線上程等待鎖時中斷它。這是一個有趣的特性,但是很難找到一個真正實際應用場景。如果你編寫的程式碼需要非常快速響應中斷,你需要使用 lockInterruptibly() API 來獲得這種能力。但是,你通常在 lock()方法內部花費多長時間呢?

這種情況可能只在極端情況下才會有影響,大多數人在編寫高階多執行緒程式碼時可能不會遇到這種情況。

ReadWriteReentrantLock

更好的方法是使用ReadWriteReentrantLock。大多數資源都遵循頻繁讀取、少量寫入的原則。由於讀取變數是執行緒安全的,除非正在寫入變數,否則沒有必要加鎖。這意味著我們可以將讀取操作進行極致最佳化,同時稍微降低寫操作的速度。

假設這是你的使用情況,你可以建立更快的程式碼。使用讀寫鎖時,我們有兩個鎖,一個讀鎖,如下圖所示。它允許多個執行緒透過,實際上是“自由競爭”的。

img

一旦我們需要寫入變數,我們需要獲得寫鎖,如下圖所示。我們嘗試請求寫鎖,但仍有執行緒從變數中讀取,因此我們必須等待。

img

一旦所有執行緒完成讀取,所有讀取操作都會阻塞,寫入操作只能由一個執行緒執行,如下圖所示。一旦釋放寫鎖,我們將回到第一張圖中的“自由競爭”狀態。

img

這是一種強大的模式,我們可以利用它使集合變得更快。一個典型的同步列表非常慢。它同步所有的操作,包括讀和寫。我們有一個CopyOnWriteArrayList,它對於讀取操作非常快,但是任何寫入操作都很慢。

如果可以避免從方法中返回迭代器,你可以封裝列表操作並使用這個API。例如,在以下程式碼中,我們將名字列表暴露為只讀,但是當需要新增名字時,我們使用寫鎖。這可以輕鬆超過synchronized列表的效能:

private final ReadWriteLock LOCK = new ReentrantReadWriteLock();
private Collection<String> listOfNames = new ArrayList<>();
public void addName(String name) {
   LOCK.writeLock().lock();
   try {
       listOfNames.add(name);
   } finally {
       LOCK.writeLock().unlock();
   }
}
public boolean isInList(String name) {
   LOCK.readLock().lock();
   try {
       return listOfNames.contains(name);
   } finally {
       LOCK.readLock().unlock();
   }
}

這個方案可行,因為synchronized是可重入的。我們已經持有鎖,所以從methodA()進入methodB()不會阻塞。這在使用ReentrantLock時也同樣適用,只要我們使用相同的鎖或相同的synchronized物件。

StampedLock返回一個戳記(stamp),我們用它來釋放鎖。因此,它有一些限制,但它仍然非常快和強大。它也包括一個讀寫戳記,我們可以用它來保護共享資源。但ReadWriteReentrantLock不同的是,它允許我們升級鎖。為什麼需要這樣做呢?

看一下之前的addName()方法...如果我用"Shai"兩次呼叫它會怎樣?

是的,我可以使用Set...但是為了這個練習的目的,讓我們假設我們需要一個列表...我可以使用ReadWriteReentrantLock編寫那個邏輯:

public void addName(String name) {
   LOCK.writeLock().lock();
   try {
       if(!listOfNames.contains(name)) {
           listOfNames.add(name);
       }
   } finally {
       LOCK.writeLock().unlock();
   }
}

這很糟糕。我“付出”寫鎖只是為了在某些情況下檢查contains()(假設有很多重複項)。我們可以在獲取寫鎖之前呼叫isInList(name)。然後我們會:

  • 獲取讀鎖
  • 釋放讀鎖
  • 獲取寫鎖
  • 釋放寫鎖

在兩種情況下,我們可能會排隊, 這樣可能會增加額外的麻煩,不一定值得。

有了StampedLock,我們可以將讀鎖更新為寫鎖,並在需要的情況下立即進行更改,例如:

public void addName(String name) {
   long stamp = LOCK.readLock();
   try {
       if(!listOfNames.contains(name)) {
           long writeLock = LOCK.tryConvertToWriteLock(stamp);
           if(writeLock == 0) {
               throw new IllegalStateException();
           }
           listOfNames.add(name);
       }
   } finally {
       LOCK.unlock(stamp);
   }
}

這是針對這些情況的一個強大的最佳化。

終論

我經常不假思索地使用 synchronized 集合,這有時可能是合理的,但對於大多數情況來說,這可能是次優的。透過花費一點時間研究與執行緒相關的原語,我們可以顯著提高效能。特別是在處理 Loom 時,其中底層爭用更為敏感。想象一下在 100 萬併發執行緒上擴充套件讀取操作的情況...在這些情況下,減少鎖爭用的重要性要大得多。

你可能會想,為什麼 synchronized 集合不能使用 ReadWriteReentrantLock 或者是 StampedLock 呢?

這是一個問題,因為API的可見介面範圍非常大,很難針對通用用例進行最佳化。這就是控制低階原語的地方,可以使高吞吐量和阻塞程式碼之間的差異。


【注】本文譯自: Relearning Java Thread Primitives - DZone

相關文章