CodeGym /課程 /JAVA 25 SELF /synchronized, volatile:語法與應用

synchronized, volatile:語法與應用

JAVA 25 SELF
等級 52 , 課堂 1
開放

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
實際值: 可能更小(例如 19951987……),而且每次執行結果都可能不同。

為什麼?因為 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 本身並不具原子性(除了對 booleanint 等基礎型別的單純讀/寫)。只要你做的是比指派更複雜的操作——就需要同步。

範例:終止旗標

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. 使用 synchronizedvolatile 的常見錯誤

錯誤 №1:在錯誤的物件上同步。 如果你在區域變數上同步,或每個執行緒使用不同的物件來同步——就起不了任何保護作用。

Object lock = new Object();
synchronized (lock) {
    // ...
}

如果每個執行緒各自建立自己的 lock——就沒有意義。需要的是單一的同步點,即所有執行緒共用的物件。

錯誤 №2:期待 volatile 具備原子性。 volatile 只保證可見性,並不提供原子性。像 count++ 這類操作在沒有同步時仍然不安全。

錯誤 №3:同步的程式碼範圍太大。 如果你把整個方法都同步,但其實只需要同步一行,會不必要地阻塞其他執行緒並降低效能。請盡量縮小「臨界區」。

錯誤 №4:對靜態資料忘了使用「靜態」同步。 如果你使用的是靜態變數,卻在 this 上同步,是沒有用的。對於靜態資料必須在類別層級同步:synchronized(ClassName.class)。

錯誤 №5:在字串常值上同步。 在字串上同步很危險,因為相同的字串常值會被 JVM 內部駐留(intern)。你可能會在程式的不同部分意外共享同一把鎖。

留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION