先回答一個問題:你程式碼裡有沒有哪一段,等鎖等太久就應該放棄,去走別的路?

想不出來的話,這篇後面的內容你可以不用看。繼續用 synchronized,你沒有損失任何東西。

ReentrantLock 在很多團隊裡被當成「比較進階的鎖」。review 的時候看到有人寫 synchronized,還會有人留言問要不要換成 lock。這個直覺是錯的,而且錯得有點久了。

名字騙了你,可重入從來不是賣點

ReentrantLock 這個名字翻成中文是「可重入鎖」。可重入的意思是:同一個執行緒已經拿到這把鎖了,再進來一次不會把自己卡死。

聽起來很厲害。問題是 synchronized 也可重入。

1
2
3
4
5
6
7
public synchronized void outer() {
inner(); // 同一個執行緒再拿一次同一把鎖,不會死鎖
}

public synchronized void inner() {
// ...
}

這段程式碼跑得好好的,JDK 1.0 時代就是這樣。所以如果你選 ReentrantLock 的理由是「它可以重入」,那你等於是為了一個免費的東西付了錢。

那名字為什麼要這樣取?因為它是在標示「這把鎖跟你已經熟悉的那把行為一致」,不是在標示差異。取名的人想講的是「別怕,語意跟 synchronized 一樣」,結果一堆人讀成「這是 synchronized 沒有的功能」。

真正的差異在別的地方,總共四條。

需求一:等不到就要走人

synchronized 只有一種等法,就是等到天荒地老。你沒有任何機會告訴 JVM「等 200 毫秒就好,拿不到我自己想辦法」。

ReentrantLock 有:

1
2
3
4
5
6
7
8
9
10
11
12
private final ReentrantLock lock = new ReentrantLock();

public Report generate() throws InterruptedException {
if (lock.tryLock(200, TimeUnit.MILLISECONDS)) {
try {
return buildFromCache(); // 拿到鎖,走快路
} finally {
lock.unlock();
}
}
return buildFromScratch(); // 200 毫秒拿不到,走慢路但至少會回應
}

這個差別在對外服務上是生死線。一個 API 如果搶不到鎖就無限期掛在那,佔住的不只是它自己那條執行緒,還有上游的連線。連線池被吃光之後,倒的不是這支 API,是所有共用那個池子的功能。有 timeout,最壞情況是變慢;沒 timeout,最壞情況是整片一起停。

而且這種故障最難查的地方在於,現場看起來完全沒有錯誤。沒有例外、沒有 stack trace,只有一堆執行緒安安靜靜地站在門口等,監控上只看得到延遲爬升。等你想到要去抓 thread dump,通常已經過了半小時。

順帶一提 tryLock() 那個不帶參數的版本有個坑:就算你建的是公平鎖,它照樣插隊。

1
2
ReentrantLock fair = new ReentrantLock(true);   // 說好要排隊的
fair.tryLock(); // 但這一行不排,看到沒人佔就直接搶

Javadoc 寫得很白,無參數的 tryLock() 刻意設計成不遵守公平性。想要排隊語意,得用 tryLock(0, TimeUnit.SECONDS),帶超時參數的那個版本才會照隊伍走。這種事沒踩過不會知道。

需求二:等鎖的時候要能被叫回來

第二條跟第一條長得像,但解的是不同的問題。

執行緒卡在 synchronized 前面等鎖的時候,你對它 interrupt() 是沒有用的。它不理你,它就是要等到拿到為止。這代表如果這個任務被取消了、如果整個服務要優雅關機,這條執行緒你叫不動。

lockInterruptibly() 解掉這件事:

1
2
3
4
5
6
7
8
9
10
11
12
13
public void handle(Task task) {
try {
lock.lockInterruptibly(); // 等的過程中可以被 interrupt 打斷
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return; // 任務取消了,不用再等了
}
try {
process(task);
} finally {
lock.unlock();
}
}

ExecutorService 而且會呼叫 shutdownNow() 的服務,這條特別有感。shutdownNow() 做的事就是對所有 worker 送 interrupt,如果 worker 全卡在 synchronized 上,你那個「優雅關機」就只是名字好聽而已。

需求三:一個房間裡等的是兩種不同的人

一間只有一台叫號機的餐廳,內用要等位子,外帶要等餐點,兩種客人共用同一組號碼。廚房做好一份外帶餐按下叫號,整間店的人全部抬頭,發現不是叫自己,再坐回去。

synchronizedwait() / notifyAll() 就是這台機器。一把鎖只配一組等待佇列,你只能全部叫醒,讓大家自己判斷該不該醒。

ReentrantLock 可以生出多台叫號機:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();

public void put(E item) throws InterruptedException {
lock.lock();
try {
while (count == items.length) {
notFull.await(); // 只有「等空位」的人睡在這條佇列
}
enqueue(item);
notEmpty.signal(); // 只精準叫醒一個「等資料」的人
} finally {
lock.unlock();
}
}

生產者只吵醒消費者,消費者只吵醒生產者,互不打擾。佇列一深、執行緒一多,notifyAll() 那種全場點名的做法會把 CPU 浪費在「醒來、發現不是我、再睡」這個循環上。這叫驚群效應(thundering herd),跟 spurious wakeup 是兩回事,後者指的是沒有任何人叫你、你也會自己醒過來。順帶一提,正因為 spurious wakeup 真的存在,等待條件才一定要包在 while 裡而不是 if

不過話說回來,如果你正在手刻生產者消費者佇列,先停一下,去看看 ArrayBlockingQueue 是不是就夠用了。它內部做的就是上面那段程式碼,而且已經被幾億次生產環境跑過。自己刻的理由要比「想練習」更強一點。

需求四:這條是虛擬執行緒帶進來的,而且有時效性

前三條是 JDK 5 就有的老知識,任何一本併發書都寫過。第四條不一樣,它是虛擬執行緒(virtual thread)帶進來的,而且它會隨著你升版而失效。

虛擬執行緒的整套設計建立在一件事上:它阻塞的時候,要能從底下那條真正的作業系統執行緒(carrier thread)身上卸下來,讓別人先用。卸得掉,你才能開幾十萬條虛擬執行緒;卸不掉,那些 carrier thread 就被佔著,池子一乾就全體停擺。這個卸不掉的狀態叫 pinning。

而在 JDK 21 到 23,synchronized 區塊裡的阻塞正好就卸不掉。java.util.concurrent 那套鎖則不會有這個問題,所以官方當時給的建議就是把熱點路徑上的 synchronized 換成 ReentrantLock

JEP 491 在 JDK 24 把這件事修掉了,synchronized 也能正常卸下,官方原文直接寫這個遷移「no longer necessary」。

但這裡有個很多人會漏掉的細節:JDK 24 不是 LTS。 最近一個內含這項修正的長期支援版本是 JDK 25,2025 年 9 月才 GA。也就是說,如果你的服務跟大多數團隊一樣還蹲在 JDK 21 LTS 上,又剛好用了虛擬執行緒,這條理由對你依然成立,而且會一直成立到你升上 25 為止。

還有一個配套要記。JEP 491 把 jdk.tracePinnedThreads 這個系統屬性拿掉了,設了也沒反應。要診斷 pin,改看 JFR 的 jdk.VirtualThreadPinned 事件,它現在會告訴你 pin 的原因和是哪條 carrier thread 被佔住。

換過去要付的代價:JVM 不再幫你收尾

上面講了四個該換的理由,現在講為什麼預設不該換。

synchronized 是語言層級的結構。程式碼跑出那個大括號,鎖就釋放,不管你是正常跑完、return 提早離開、還是拋了例外。這件事由 JVM 保證,你不用寫任何一行程式碼。

ReentrantLock 是一個普通的物件,JVM 不知道你在幹嘛。忘了 unlock(),那把鎖就永遠不會回來,後面所有要用的執行緒全部堆在門口。

1
2
3
lock.lock();
doSomething(); // 這行拋例外的話……
lock.unlock(); // ……這行永遠不會執行,整個服務從這一刻開始慢性死亡

所以 try / finally 不是風格建議,是強制規範。而且 lock() 要寫在 try 外面:寫在裡面的話,萬一 lock() 自己失敗了,finally 還是會去 unlock() 一把你根本沒拿到的鎖,直接送你一個 IllegalMonitorStateException

這種「靠人記得」的東西在小專案裡沒事,在四十個人的 codebase 裡就是遲早會爆的定時炸彈。你可以靠 code review 擋,但 code review 會累,JVM 不會。

反過來說,這個缺點偶爾也是唯一解。synchronized 強制鎖跟解鎖必須在同一個區塊裡(Javadoc 用的字是 block-structured),而 ReentrantLock 允許你在 A 方法上鎖、在 B 方法解鎖。手刻鏈式鎖交接(hand-over-hand locking)之類的資料結構會需要這個,但那是很稀有的場景,正常業務程式碼碰到的機率接近零。

效能這個理由,早就過期了

還有一個常常被拿出來講的理由:ReentrantLock 比較快。

這句話在 2005 年是對的。那時 synchronized 每次都要跟作業系統要 mutex,代價高到工程師會刻意繞路。後來 JVM 陸續補上偏向鎖、輕量級鎖、自旋這些優化,低競爭情況下的差距被壓到幾乎看不出來。至於偏向鎖後來為什麼又被拿掉、鎖升級這條路是怎麼走完的,我在另一篇拆過完整的時間線。

現在的實務結論很簡單:低競爭下兩者差不多,高競爭下你該做的是把鎖的範圍縮小、或者乾脆想辦法不共享,而不是換一把鎖。真的想不共享,ThreadLocal 那條路通常比換鎖有效得多。

換句話說,把 ReentrantLock 當效能優化來用,是拿一個功能選項去解一個結構問題。

所以到底怎麼選

synchronized 像旅館房門,走出去門自己鎖上,你不用管。ReentrantLock 像你自己帶去健身房的掛鎖,能決定要不要等、能設鬧鐘、能一次管好幾排櫃子,代價是忘記鎖回去就是你家的事。

所以判準只有一句:預設寫 synchronized,直到你講得出上面四條裡的哪一條。 講不出來就別換,那不叫進階,那叫多背一個維護風險。

素材來源:個人 Notion 開發筆記(ReentrantLock 可重複取得鎖),內容經改寫與更新至 2026 年的 JDK 現況。