1. 遺漏的 unlock/release:粗心者的陷阱
在使用現代同步化工具(例如 ReentrantLock 或 Semaphore)時,最陰險的錯誤之一,就是忘記呼叫 unlock() 或 release()。如果你不釋放鎖,其他執行緒將會一直等待它被釋放……永遠。程式會掛住,而你會盯著螢幕,苦思為何什麼都沒發生。
以 ReentrantLock 為例:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Counter {
private int count = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
// 糟了!忘了呼叫 unlock()——大家都會卡住!
count++;
}
}
看起來毫無問題,但若從不同執行緒多次呼叫 increment(),在第一次呼叫之後,其餘執行緒會無限期地等待鎖被釋放。
為避免此情況,請使用結構 try-finally:
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
如此一來,即使在方法中途發生例外,鎖也會被保證釋放。
這就像有人占了廁所(從裡面反鎖),然後忘了開門就從窗戶離開。其他人會一直等著那個人出來……別這麼做!
2. 在錯誤的物件上同步化:「哎呀,鎖掛錯地方了!」
在 Java 中,關鍵字 synchronized 可以鎖定對某個物件的存取。但如果你選錯了用來鎖定的物件,實際的同步化行為就不會如你所願。
錯誤 #1:針對區域變數進行同步化
public void doSomething() {
Object lock = new Object();
synchronized (lock) {
// 每次都是新物件——根本沒有同步化!
// 執行緒彼此不會等待。
// 臨界區沒有受到保護!
}
}
此處每個執行緒都會建立自己的 lock 物件。結果根本不會產生真正的鎖定——執行緒會同時進入臨界區。
正確做法:
private final Object lock = new Object();
public void doSomething() {
synchronized (lock) {
// 現在所有執行緒都使用同一個 lock 物件
// 並且真的會彼此等待。
}
}
錯誤 #2:在字串常值上同步化
public void doSomething() {
synchronized ("lock") {
// 字串常值會被內駐(intern):程式的不同部分可能
// 不小心都在同一個字串上同步化!
}
}
結論:
只在私有、專門為此目的建立且不會在其他地方使用的物件上進行同步化。
3. 雙重鎖定(deadlock):「你等我—我等你,雙雙卡住」
Deadlock(互相封鎖)是經典問題。兩個(或更多)執行緒先後取得不同的鎖,然後彼此等待,直到程式卡死。
範例:
public class DeadlockExample {
private final Object lockA = new Object();
private final Object lockB = new Object();
public void method1() {
synchronized (lockA) {
// 為了示範更明顯,稍微等待一下
try { Thread.sleep(50); } catch (InterruptedException e) {}
synchronized (lockB) {
// ...
}
}
}
public void method2() {
synchronized (lockB) {
try { Thread.sleep(50); } catch (InterruptedException e) {}
synchronized (lockA) {
// ...
}
}
}
}
如果一個執行緒呼叫 method1(),而另一個呼叫 method2(),第一個執行緒會取得 lockA 並等待 lockB,第二個則相反。結果兩者會無限期地互相等待。
如何避免?
- 在所有執行緒中,以相同的順序取得鎖。
- 盡量減少同時持有的鎖數量。
- 當程式卡住時,使用診斷工具(例如 jstack)。
類比:
這就像兩個人在狹窄的走廊相遇,彼此都決定要讓路,但前提是對方要先讓。結果兩人就僵在那裡,等著看誰先讓步。
4. 過度同步化:「寧可多鎖,毋寧少鎖?」 — 不一定!
有時開發者因害怕錯誤,對所有東西都加鎖。結果效能下降,卻毫無助益。
範例:
public synchronized void add(int value) {
// 這裡只有一行,根本不需要同步化!
System.out.println("已加入: " + value);
}
在這種情況下不需要同步化:透過 System.out.println 的輸出本身就已具備執行緒安全,而方法本身也沒有操作共用資源。
什麼時候會很嚴重?
如果你對經常被呼叫且不需要保護的方法進行同步化,會大幅降低程式效能。執行緒會排隊等鎖,明明可以並行工作。
Best practice:
只同步化真正需要的部分。臨界區應該越小越好。
5. 錯誤使用 volatile:「有可見性,沒有原子性!」
在 Java 中,修飾詞 volatile 保證變更對所有執行緒可見,但它不保證操作的原子性。
錯誤:
private volatile int counter = 0;
public void increment() {
counter++; // 非原子操作!
}
counter++ 由讀取、遞增、再寫回組成。如果兩個執行緒同時執行這段程式碼,最終數值可能小於預期。
正確做法:
對需要原子性的操作,使用 synchronized、AtomicInteger 或其他具備執行緒安全的類別。
import java.util.concurrent.atomic.AtomicInteger;
private final AtomicInteger counter = new AtomicInteger();
public void increment() {
counter.incrementAndGet();
}
何時該用 volatile?
用於簡單的旗標(例如「結束工作」),當不需要原子性時。
GO TO FULL VERSION