1. 關鍵字 synchronized:為什麼以及如何使用
在 Java 中,關鍵字 synchronized 很像廁所門上的「佔用中!」牌子:當一個執行緒進入「臨界區」時,其他執行緒就會禮貌地排隊等待。只有當第一個離開後,下一個才能進入並執行自己的程式碼。
語法:區塊與方法
同步區塊
synchronized (object) {
// 臨界區
}
- object 是你想「上鎖」的任何物件。當一個執行緒在執行這個區塊時,其他也想進入以相同物件作為鎖的區塊的執行緒,將會等待。
同步方法
public synchronized void increment() {
// 臨界區
}
- 這裡的「鎖」是加在當前物件(this)上。也就是說,同一時間只能有一個執行緒執行此物件的任何同步方法。
靜態同步方法
public static synchronized void foo() {
// 臨界區
}
- 這裡的鎖是在類別層級(ClassName.class),而不是特定的物件實例。
底層是如何運作的
當執行緒進入同步區塊或方法時,它會取得該物件的「監視器」(對於靜態方法則是類別的監視器)。如果監視器已被占用,該執行緒就等待。當監視器被釋放後,下一個執行緒才能進入。
2. 範例:有無同步的計數器遞增
未同步
public class Counter {
private int count = 0;
public void increment() {
count++;
}
public int getCount() {
return count;
}
}
public class CounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("最終值: " + counter.getCount());
}
}
預期值: 2000
實際值: 可能更小(例如 1995、1987……),而且每次執行結果都可能不同。
為什麼?因為 count++ 不是原子操作:它會分成三個步驟——讀取值、遞增、再寫回。如果兩個執行緒同時做這件事,它們可能會互相覆蓋彼此的結果。
解法:synchronized
public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public int getCount() {
return count;
}
}
現在同一時間只有一個執行緒可以執行 increment() 方法。最終值將會一直是 2000。
替代方案:同步區塊
public class Counter {
private int count = 0;
public void increment() {
synchronized (this) {
count++;
}
}
}
結果相同。你可以不鎖整個方法,只鎖需要的那一段程式碼。
3. 物件監視器(monitor)簡介
監視器是 Java 中每個物件內建的「鎖」。當你寫下 synchronized(object) 時,執行緒會嘗試「鎖住」該物件。如果鎖是空閒的——執行緒取得鎖;否則就依序等待。當執行緒離開區塊時,鎖會被釋放。
重要! 如果你在不同的物件上進行同步,執行緒彼此不會互相等待。因此,選對同步所用的物件非常重要。
靜態同步方法
有時候共用資源不是單一物件,而是所有類別實例共用的東西(例如靜態變數)。這種情況下,應在類別層級上進行同步。
public class StaticCounter {
private static int count = 0;
public static synchronized void increment() {
count++;
}
public static int getCount() {
return count;
}
}
這相當於:
public static void increment() {
synchronized (StaticCounter.class) {
count++;
}
}
監視器是加在類別物件(Class)上,而不是特定的實例。
4. 關鍵字 volatile:它是什麼、為何需要
執行緒之間的可見性問題
在 Java 中,每個執行緒都可能為了加速而快取變數的值。這代表即使某個執行緒改變了變數,另一個執行緒也可能「沒看見」,而是一直從自己的本地快取讀取。這對用來彼此傳遞訊號的旗標變數尤其關鍵。
volatile 如何運作
如果變數宣告為 volatile,表示:
- 所有執行緒讀寫它時都直接針對主記憶體,不經過快取。
- 任何對該變數的變更都會立刻對所有執行緒可見。
但要注意! 使用 volatile 本身並不具原子性(除了對 boolean、int 等基礎型別的單純讀/寫)。只要你做的是比指派更複雜的操作——就需要同步。
範例:終止旗標
public class Worker extends Thread {
private volatile boolean running = true;
public void run() {
while (running) {
// 做些有用的工作
}
System.out.println("執行緒已結束");
}
public void shutdown() {
running = false;
}
}
Worker w = new Worker();
w.start();
// ... 過一段時間之後
w.shutdown();
沒有 volatile 的話,執行緒可能「看不見」旗標的變更而無限迴圈(特別是在多核心系統上)。加上 volatile 就能如預期運作。
5. volatile 的限制:非原子性
許多新手會想:「如果把 int 做成 volatile,就能直接寫 count++ 而不用擔心了。」可惜並非如此:
private volatile int count = 0;
public void increment() {
count++;
}
錯誤! count++ 依然不是原子操作——它包含三個步驟:(1) 讀取,(2) 遞增,(3) 寫回。如果兩個執行緒同時讀到相同的值,兩者都會遞增並寫回相同結果——其中一次遞增就「消失」了。
結論: volatile 只保證可見性,無法在複雜操作時防止競態條件。
6. 何時用 synchronized,何時用 volatile
- volatile:當你只有一個簡單的旗標(例如 boolean),由一個執行緒寫、另一個執行緒讀。例:結束執行緒、事件訊號。
- synchronized:當需要確保複雜操作的原子性(例如遞增、同時變更多個變數、操作資料結構)。
記憶小表
| 情境 | volatile | synchronized |
|---|---|---|
| 在執行緒之間傳遞訊號 | ✔ | ✔ |
| 原子操作(遞增) | ✖ | ✔ |
| 臨界區內的多步操作 | ✖ | ✔ |
| 只保證可見性 | ✔ | ✔ |
7. 使用 synchronized 與 volatile 的常見錯誤
錯誤 №1:在錯誤的物件上同步。 如果你在區域變數上同步,或每個執行緒使用不同的物件來同步——就起不了任何保護作用。
Object lock = new Object();
synchronized (lock) {
// ...
}
如果每個執行緒各自建立自己的 lock——就沒有意義。需要的是單一的同步點,即所有執行緒共用的物件。
錯誤 №2:期待 volatile 具備原子性。 volatile 只保證可見性,並不提供原子性。像 count++ 這類操作在沒有同步時仍然不安全。
錯誤 №3:同步的程式碼範圍太大。 如果你把整個方法都同步,但其實只需要同步一行,會不必要地阻塞其他執行緒並降低效能。請盡量縮小「臨界區」。
錯誤 №4:對靜態資料忘了使用「靜態」同步。 如果你使用的是靜態變數,卻在 this 上同步,是沒有用的。對於靜態資料必須在類別層級同步:synchronized(ClassName.class)。
錯誤 №5:在字串常值上同步。 在字串上同步很危險,因為相同的字串常值會被 JVM 內部駐留(intern)。你可能會在程式的不同部分意外共享同一把鎖。
GO TO FULL VERSION