CodeGym /課程 /JAVA 25 SELF /共享資源與同步:存取問題

共享資源與同步:存取問題

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

1. 什麼是共享資源

你早就遇過共享資源。住家是家庭的共享資源。辦公室的冰箱是所有同事的共享資源。想必你已經理解這個概念。

在程式設計中,共享資源是可被多個執行緒同時存取的變數、物件或資料結構。可能包括:

  • 用來統計已處理訂單數量的計數變數。
  • 由某些執行緒填入、另一些執行緒處理的請求清單。
  • 由多個執行緒寫入的開啟檔案。
  • 由程式不同部分共用的資料庫連線。

在 Java 中,任何可能被多個執行緒存取的物件或變數,都可能成為「共享資源」。

共享資源範例:全域計數器

public class Counter {
    public int value = 0;
}

如果多個執行緒會遞增這個計數器,它們都會存取同一個變數 value——這就是共享資源。

2. 同時存取帶來的問題

在單執行緒程式裡一切很單純:一條執行緒——一個執行者——它沿著程式碼走,就像火車沿著鐵軌。可是一旦多執行緒加入,便開始真正的「軍刀之舞」:執行緒可能在各種意想不到的地方互相干擾。

Race condition(競態條件)

Race condition 是指程式的結果取決於執行緒動作如何「交錯」。也就是說,同一個程式多次執行,結果可能不同——這不是 bug,而是多執行緒的「特性」。

經典例子:兩個執行緒遞增計數器

來模擬一個簡單情境:我們有一個共享計數器,兩個執行緒各自把它遞增一千次。

public class Counter {
    public int value = 0;
}

public class CounterDemo {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Runnable incrementTask = () -> {
            for (int i = 0; i < 1000; i++) {
                counter.value++; // 危險位置!
            }
        };

        Thread t1 = new Thread(incrementTask);
        Thread t2 = new Thread(incrementTask);

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println("預期值: 2000");
        System.out.println("實際值: " + counter.value);
    }
}

畫面上會看到什麼?

有時是 2000,有時是 1985,有時是 1937……為什麼?因為操作 counter.value++ 不是原子性的!它由三個步驟組成:

  1. 讀取目前的 counter.value
  2. 把它加 1。
  3. 再寫回去。

如果兩個執行緒同時讀到相同的值,兩者都會把它遞增,然後各自寫回——結果有一次遞增會「消失」。這就是 lost update——遺失更新。

物件的不一致狀態

若你有一個由多個欄位組成的複雜物件,而執行緒同時改變不同欄位,物件可能陷入「奇怪」或不一致的狀態。比如,帳戶金額減少了,但操作歷史沒有更新——客戶慌了,會計也傻眼。

3. 為什麼需要同步

同步 是一種對程式下達的指示:「停,這段程式碼同一時間只能由一個執行緒執行!其他人——請等一下!」就像廁所門上的「清潔中!請勿進入!」:當一個人裡面時,其他人會在外面等(並在心裡暗罵那位久不出來的人)。

資料完整性的保證

如果我們希望計數器每次都正確遞增,就必須禁止多個執行緒同時修改它的值。

範例:讓計數器遞增同步化

public class Counter {
    public int value = 0;

    public synchronized void increment() {
        value++;
    }
}

現在,如果兩個執行緒呼叫 increment(),同一時間只有其中一個能執行這個方法。另一個會等待,直到第一個結束。

示意:同步時發生了什麼

+-------------------+
|  執行緒 1         |    --\
+-------------------+      \
      |                      \
      V                       \
+-------------------+          > [ synchronized increment() ]
|  執行緒 2         |    --/  /
+-------------------+      / /
      |                    / /
      V                   / /
+-------------------+    / /
|  執行緒 3         | --/ /
+-------------------+    /
      |                 /
      V                /
+-------------------+ /
|  執行緒 N         |/
+-------------------+

所有執行緒都會排隊進入受保護的程式段。同一時間只有一個執行緒能在「臨界區」(synchronized 區塊)內。

4. 同步方法概覽

在 Java 中,同步不是單一作法,而是一整套「武器庫」,能保護共享資源免於同時存取。

關鍵字 synchronized

這是 Java 中最基本的同步工具。它可以用兩種方式:

同步方法

public synchronized void increment() {
    value++;
}

同步區塊

public void increment() {
    synchronized (this) {
        value++;
    }
}

這裡的 this 是用來加鎖的物件。當某個執行緒在執行這個區塊時,其他想進入對同一物件加鎖之相同區塊的執行緒會等待。

來自 java.util.concurrent 的專用類別

  • LockReentrantLock——比 synchronized 更彈性的替代方案。
  • ReadWriteLock——將鎖區分為讀與寫。
  • Semaphore——限制同時執行某段程式碼的執行緒數量。
  • CountDownLatchCyclicBarrier 等——用於協調執行緒的工作。

重要:今天我們只做基礎認識——關於這些類別稍後再談。

5. 實作範例:多執行緒計數器應用

假設我們要為某個服務統計使用者請求的次數。每個執行緒代表一位使用者,會遞增共享計數器。

未使用同步

public class Counter {
    public int value = 0;
}

public class MultiThreadCounterDemo {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Runnable user = () -> {
            for (int i = 0; i < 10000; i++) {
                counter.value++;
            }
        };

        Thread t1 = new Thread(user);
        Thread t2 = new Thread(user);
        Thread t3 = new Thread(user);

        t1.start();
        t2.start();
        t3.start();

        t1.join();
        t2.join();
        t3.join();

        System.out.println("預期值: 30000");
        System.out.println("實際值: " + counter.value);
    }
}

結果:幾乎總是小於 30000。有時還會小很多!為什麼?因為執行緒彼此在「打斷」對方。

加入同步:修正錯誤

public class Counter {
    public int value = 0;

    public synchronized void increment() {
        value++;
    }
}

public class MultiThreadCounterDemo {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Runnable user = () -> {
            for (int i = 0; i < 10000; i++) {
                counter.increment();
            }
        };

        Thread t1 = new Thread(user);
        Thread t2 = new Thread(user);
        Thread t3 = new Thread(user);

        t1.start();
        t2.start();
        t3.start();

        t1.join();
        t2.join();
        t3.join();

        System.out.println("預期值: 30000");
        System.out.println("實際值: " + counter.value);
    }
}

結果:總是 30000。太好了,同步有效!

6. 實用細節

視覺化:race condition 是怎麼發生的

我們用一個小表格示意兩個執行緒如何「遺失」一次遞增:

步驟 執行緒 1 執行緒 2 value 的值
1 讀取 value=0 0
2 讀取 value=0 0
3 遞增為 1 0
4 遞增為 1 0
5 寫入 1 1
6 寫入 1 1

何時需要同步

同步不是每次都需要。當變數只在自己的小世界中,且只有單一執行緒操作時——可以放心。但一旦與其他執行緒共享,就離不開同步。即使你覺得應該沒事——也別相信。競態條件很狡猾:它可能潛伏很久,卻會在最不適合的時刻突然爆發。

預告:還有哪些同步工具

今天我們只認識了最基本的工具——synchronized。接下來的課程我們會介紹:

  • 物件監視器(monitor)如何運作,以及鎖的類型。
  • 什麼是靜態同步方法(static + synchronized)。
  • volatile 關鍵字如何運作,以及它的用途。
  • 有哪些現代的同步類別(LockSemaphore 等)。

7. 操作共享資源的常見錯誤

錯誤 1:忽視多執行緒。
最常見的錯誤之一,就是沒有意識到某個變數可能被多個執行緒存取。即使現在程式是單執行緒,之後有人加入多執行緒——bug 會「無中生有」。

錯誤 2:同步不足或過度同步。
如果不對共享資源做同步——你會遇到 race condition 與不一致的資料。若把所有東西都同步,程式會被鎖拖垮而變慢。請盡量只同步真正需要的部分。

錯誤 3:對錯誤的物件加鎖。
如果在不同的物件上同步(例如在區域變數或字串常值上),並不能保護共享資源。所有執行緒都必須在同一個物件上同步。

錯誤 4:指望非原子操作具有原子性。
操作 i++ 不是原子!即使將變數宣告為 volatile,也不會讓遞增變成原子操作。此類操作需要同步。

錯誤 5:「我很幸運,反正在我這裡都能跑」。
Race condition 可能在你的電腦上沒有重現,但一定會在伺服器或使用者端出現。千萬別在多執行緒程式中寄望僥倖!

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