Java核心知識體系8:Java如何保證執行緒安全性

2023-12-15 15:00:13

Java核心知識體系1:泛型機制詳解
Java核心知識體系2:註解機制詳解
Java核心知識體系3:異常機制詳解
Java核心知識體系4:AOP原理和切面應用
Java核心知識體系5:反射機制詳解
Java核心知識體系6:集合框架詳解
Java核心知識體系7:執行緒不安全分析

1 Java記憶體模型(JMM) 如何解決並行問題

維度1:使用關鍵字、屬性進行優化
JMM本質實際就是:Java 記憶體模型規範了 JVM 如何提供按需禁用快取和編譯優化的方法。這些方法包括了:

  • volatile、synchronized 和 final 關鍵字
  • Happens-Before 規則

維度2:從 順序一致性、可見性、有序性、原子性角度

  • 順序一致性

一個執行緒中的所有操作按照程式的順序執行,不受其他執行緒的影響。

  • 原子性

Java程式中,對資料的讀和寫操作是原子性操作,即這些操作是不可被中斷的,要麼執行,要麼不執行,否則會產生問題。
通過下面的案例可以看出,哪些是原子操作,哪些是非原子操作:

// 1個動作,執行緒直接將值賦給idx,也就是直接寫到記憶體中
idx = 100

// 3個動作:先定義 jdx,再讀取idx的值,最後賦值給jdx
jdx := idx

// 3個動作:讀取jdx的值,進行加1操作,然後新值重新寫入新的值
jdx ++

從上面的案例中可以看中,只有第一個例子才是具備原子性的,因為他只有一個存的動作。至於其他的例子,包含讀取、操作、賦值等多個動作,有一個動作失敗則不成立。
所以,基本讀取和賦值,Java記憶體模型可以保證原子性操作,如果要實現更大範圍、步驟更多的操作的原子性,則需要通過synchronized或者Lock來實現。
synchronized和Lock的存在是為了夠保證任一時刻只有一個執行緒能夠執行該程式碼塊,這樣也就解決了原子性。

  • 可見性

Java提供了volatile關鍵字來保證可見性,使用volatile來修飾共用變數,可以保證修改的值立即更新到主記憶體中。這樣其他執行緒讀取資料時,始終都會從記憶體中讀取到新值。
而普通的共用變數不能保證可見性,因為修改之後,不確定什麼時候被寫入主記憶體,當其他Thread去讀取時,記憶體中很有可能還是原來的舊值,所以無法保證可見性。
另外,通過synchronized關鍵字和Lock功能也能夠保證可見性,因為能限制同一時刻只有一個執行緒獲取鎖然後執行同步程式碼,且在釋放之前會將變數的修改更新到主記憶體中。所以實時可見。

  • 有序性

在Java裡面,可以通過volatile關鍵字來保證一定的「有序性」。
另外,通過synchronized關鍵字和Lock功能也能夠保證可見性,因為能限制同一時刻只有一個執行緒獲取鎖然後執行同步程式碼,相當於是讓執行緒順序執行同步程式碼,自然就保證了有序性。
注:JMM是通過Happens-Before 規則來保證Thread操作有序性。

2.1 關鍵字: volatile、synchronized 和 final

在Java中,volatile、synchronized和final是三個非常重要的關鍵字,它們都與並行程式設計密切相關。下面是對這三個關鍵字的詳細介紹:

2.1.1 volatile

volatile是Java中的一種修飾符,它用於宣告一個共用變數,以確保多個執行緒對該變數的存取是可見的和有序的。volatile關鍵字的作用是禁止指令重排和強制重新整理快取,以保證操作的順序性和可見性。
當一個變數被宣告為volatile時,它表示該變數的值可能會被意想不到地改變。編譯器和處理器會注意到這個變數的特殊性,並採取相應的措施來保證多個執行緒對該變數的存取是正確的。具體來說,volatile關鍵字會禁止編譯器對volatile變數進行優化,每次讀取該變數時都會直接從它的記憶體地址中讀取,而不是從暫存器或快取中讀取。同時,volatile關鍵字也會強制處理器在每個操作該變數的指令之後立即重新整理快取,以保證其他執行緒能夠看到最新的值。
需要注意的是,雖然volatile關鍵字可以保證可見性和有序性,但它並不能保證原子性。也就是說,如果一個操作包含多個步驟,而這些步驟不能被一個指令替換,那麼這個操作就不能被保證為原子性。在這種情況下,需要使用鎖或者其他同步機制來保證原子性。

2.1.2 synchronized

synchronized是Java中的一種關鍵字,它用於實現同步程式碼塊和方法。synchronized關鍵字可以保證同一時刻只有一個執行緒能夠執行被synchronized修飾的程式碼塊或方法。synchronized關鍵字會建立一個鎖物件或鎖識別符號,當一個執行緒獲取了這個鎖物件或鎖識別符號後,其他執行緒就不能再獲取這個鎖物件或鎖識別符號,直到第一個執行緒釋放了這個鎖物件或鎖識別符號。
synchronized關鍵字可以保證多個執行緒對共用變數的存取是互斥的,也就是說在同一時刻只有一個執行緒能夠存取共用變數。這樣可以避免多個執行緒同時修改共用變數而導致資料不一致的問題。同時,synchronized關鍵字還可以保證多個執行緒之間的操作是有序的,即一個執行緒在執行synchronized程式碼塊或方法之前必須等待其他執行緒完成之前的操作。
需要注意的是,synchronized關鍵字雖然可以保證互斥性和有序性,但它並不能保證原子性。也就是說,如果一個操作包含多個步驟,而這些步驟不能被一個指令替換,那麼這個操作就不能被保證為原子性。在這種情況下,需要使用其他同步機制來保證原子性。

2.1.3 final

final是Java中的一種修飾符,它用於宣告一個最終變數或方法。final關鍵字表示該變數或方法不能被修改或重寫。具體來說,final關鍵字可以用於宣告一個常數,該常數的值不能被修改;也可以用於宣告一個方法,該方法不能被重寫。
final關鍵字在並行程式設計中也有著重要的作用。final關鍵字可以保證一個共用變數的值只被一個執行緒修改,這樣可以避免多個執行緒同時修改共用變數而導致資料不一致的問題。同時,final關鍵字還可以保證一個方法的執行不會被其他執行緒中斷或干擾,這樣可以保證方法的原子性和可見性。
需要注意的是,final關鍵字並不能保證多個執行緒之間的操作是有序的。也就是說,在一個執行緒中執行final方法時,其他執行緒可能會同時執行自己的操作,而這些操作之間是沒有順序關係的。在這種情況下,需要使用其他同步機制來保證操作的順序性。

2.2 Happens-Before 規則

上面提到了可以用 volatile 和 synchronized 來保證有序性。除此之外,在JVM 中還有Happens-Before規則,用來確定並行操作之間的順序關係。
Happens-Before規則定義了以下幾種順序關係:

2.2.1 程式順序規則(Program Order Rule)

在一個程式中,按照程式碼的順序,先執行的操作Happens-Before後執行的操作。這意味著在程式中,如果一個操作先於另一個操作執行,那麼這個操作的結果對後續操作是可見的。

2.2.2 管程鎖定規則(Monitor Lock Rule)

一個unlock操作先行發生於後面對同一個鎖的lock操作。

2.2.3 volatile變數規則(Volatile Variable Rule)

對一個 volatile 變數的寫操作先行發生於後面對這個變數的讀操作,先寫後讀。

2.2.4 執行緒啟動規則(Thread Start Rule)

Thread 物件的 start() 方法呼叫先行發生於此執行緒的每一個動作。

2.2.5 執行緒加入規則((Thread Join Rule)

Thread 物件的結束先行發生於 join() 方法返回。

2.2.6 執行緒終止規則(Thread Termination Rule)

執行緒中的所有操作都先行發生於對此執行緒的終止檢測,我們可以通過Thread.join()方法和Thread.isAlive()的返回值等手段檢測執行緒是否已經終止執行

2.2.7 執行緒中斷規則( Thread Interruption Rule)

對執行緒 interrupt() 方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷事件的發生,可以通過 interrupted() 方法檢測到是否有中斷髮生。

2.2.8 物件終結規則(Finalizer Rule)

一個物件的初始化完成(建構函式執行結束)先行發生於它的 finalize() 方法的開始。

2.2.9 傳遞性(Transitivity)

如果操作A先行發生於操作B,操作B先行發生於操作C,那就可以得出操作A先行發生於操作C的結論。

3 執行緒安全效能討論

在多執行緒環境中,一個類或者一個函數不管在何種執行時環境或交替執行方式,都能保證正確的行為,被安全的呼叫,就說明執行緒是安全的。
這個「正確的行為」通常包括原子性、可見性和有序性。
但是執行緒安全不是非真即假,共用資料按照安全程度的強弱順序可以分成以下五類:

  • 不可變
  • 絕對執行緒安全
  • 相對執行緒安全
  • 執行緒相容
  • 執行緒對立

按照執行緒安全性的強弱順序,不可變 > 絕對執行緒安全 > 相對執行緒安全 > 執行緒相容 > 執行緒對立。

3.1 不可變(Immutable)

不可變的物件在建立後其狀態就不能被修改,因此它們自然是執行緒安全的。任何執行緒在任何時候存取這些物件,都會看到相同的資料。
多執行緒環境下,應當儘量使物件成為不可變,來滿足執行緒安全。
不可變的型別包括:

  • final 關鍵字修飾的基本資料型別
  • String
  • 列舉型別
  • Number 部分子類,如 Long 和 Double 等數值包裝型別,BigInteger 和 BigDecimal 等巨量資料型別。但同為 Number 的原子類 AtomicInteger 和 AtomicLong 則是可變的

對於集合型別,可以使用 Collections.unmodifiableXXX() 方法來獲取一個不可變的集合。

XXX 可以是Map、List、Set

public class ImmutableClass {
    public static void main(String[] args) {
        Map<String, Integer> testMap = new HashMap<>();
        Map<String, Integer> testUnmodifiable = Collections.unmodifiableMap(testMap);
        testUnmodifiable.put("input-a", 1);
    }
}

執行時丟擲異常

Exception in thread "main" java.lang.UnsupportedOperationException
    at java.util.Collections$testUnmodifiable.put(Collections.java:1523)
    at ImmutableExample.main(ImmutableClass.java:9)

不可變狀態還可以這麼理解,外部無法對資料狀態進行修改,比如

public class ImmutableClass {  
    private final int value;  
  
    public ImmutableClass(int value) {  
        this.value = value;  
    }  
  
    public int getValue() {  
        return value;  
    }  
}

在這個例子中,ImmutableClass是不可變的,因為它的建構函式是私有的,外部無法修改其狀態。因此,多個執行緒同時存取和獲取ImmutableClass物件的值時,不會出現資料不一致的問題。

3.2 絕對執行緒安全(Absolute Thread Safety)

絕對執行緒安全的物件無論執行時環境如何,呼叫者都不需要任何額外的同步措施。這通常需要付出較大的代價來實現。

public class ThreadSafeClass {  
    private int value;  
  
    public synchronized void setValue(int value) {  
        this.value = value;  
    }  
  
    public synchronized int getValue() {  
        return value;  
    }  
}

在這個例子中,ThreadSafeClass的每個方法都使用了synchronized關鍵字進行同步。這保證了無論多少個執行緒同時存取ThreadSafeClass的物件,每個執行緒的操作都會被序列執行,不會出現資料競爭的問題。

3.3 相對執行緒安全(Relative Thread Safety)

相對執行緒安全的物件需要保證單個操作是執行緒安全的,在呼叫的時候不需要做額外的保障措施。但在連續呼叫時可能需要額外的同步措施來保證呼叫的正確性。
Java 語言中,大部分的執行緒安全類都屬於這種型別,例如 Vector、HashTable、Collections 的 synchronizedCollection() 方法包裝的集合等。
以Hashtable為例,因為它的每個方法都是同步的。但是,如果多個執行緒連續呼叫Hashtable的不同方法(如put和get),仍然可能出現競態條件。為了避免這種情況,呼叫者需要在外部進行額外的同步。

在下面程式碼中,如果Vector中的一個元素被執行緒A刪除,而執行緒B試圖獲取一個已經被刪除的元素,那麼就會丟擲 ArrayIndexOutOfBoundsException。

public class VectorUnsafeExample {
    private static Vector<Integer> vector = new Vector<>();

    public static void main(String[] args) {
        while (true) {
            for (int i = 0; i < 100; i++) {
                vector.add(i);
            }
            ExecutorService executorService = Executors.newCachedThreadPool();
            executorService.execute(() -> {
                for (int i = 0; i < vector.size(); i++) {
                    vector.remove(i);
                }
            });
            executorService.execute(() -> {
                for (int i = 0; i < vector.size(); i++) {
                    vector.get(i);
                }
            });
            executorService.shutdown();
        }
    }
}
Exception in thread "Thread-159738" java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 3
    at java.util.Vector.remove(Vector.java:831)
    at VectorUnsafeExample.lambda$main$0(VectorUnsafeExample.java:14)
    at VectorUnsafeExample$$Lambda$1/713338599.run(Unknown Source)
    at java.lang.Thread.run(Thread.java:745)

如果要保證上面的程式碼能正確執行下去,就需要對刪除元素和獲取元素的程式碼進行同步。

# 獨立執行緒A執行刪除操作
executorService.execute(() -> {
    synchronized (vector) {
        for (int i = 0; i < vector.size(); i++) {
            vector.remove(i);
        }
    }
});
# 獨立執行緒B執行讀取操作
executorService.execute(() -> {
    synchronized (vector) {
        for (int i = 0; i < vector.size(); i++) {
            vector.get(i);
        }
    }
});

3.4 執行緒相容(Thread Compatibility)

執行緒相容的物件本身不是執行緒安全的,但可以通過在呼叫端新增額外的同步措施來保證在多執行緒環境下的安全使用。
Java API 中大部分的類都是屬於執行緒相容的,比如ArrayList類就不是執行緒安全的。如果多個執行緒同時修改ArrayList,可能會導致資料不一致。但是,如果呼叫者在修改ArrayList時使用synchronized塊或其他同步機制進行同步,就可以保證執行緒安全。

public class ThreadCompatibleClass {  
    private int value;  
  
    public void setValue(int value) {  
        this.value = value;  
    }  
  
    public int getValue() {  
        return value;  
    }  
}

在這個例子中,ThreadCompatibleClass的方法沒有使用synchronized關鍵字進行同步。因此,如果多個執行緒同時修改ThreadCompatibleClass的物件,可能會導致資料不一致。

3.5 執行緒對立(Thread Hostility)

執行緒對立的物件無論如何都無法在多執行緒環境下並行使用,即使採取了同步措施。
一個典型的例子是Java中的ThreadLocalRandom類。這個類用於生成亂數,並且每個執行緒都有其自己的亂數生成器範例。由於每個執行緒使用不同的範例,因此無需擔心執行緒安全問題。但是,如果嘗試在沒有正確初始化ThreadLocalRandom的情況下跨執行緒使用它,就可能導致問題。
這種情況下,即使新增了同步措施也無法保證執行緒安全。

4 如何實現執行緒安全

4.1 synchronized關鍵字/ReentrantLock特性

  • synchronized關鍵字

在Java中,synchronized關鍵字是一種內建的同步機制,用於控制多個執行緒對共用資源的存取。它用於在並行環境中保護程式碼塊,確保同一時刻只有一個執行緒可以執行該程式碼塊。
synchronized關鍵字可以應用於方法或程式碼塊。當它應用於方法時,它將鎖住該方法的物件。當它應用於程式碼塊時,它將鎖住指定的鎖物件。

public class SynchronizedExample {  
    private int count = 0;  
  
    public synchronized void incrementCount() {  
        count++;  
    }  
}

上面這個例子中,incrementCount()方法使用了synchronized關鍵字。這意味著在任何時刻,只有一個執行緒可以執行該方法。如果有其他執行緒試圖同時執行該方法,它們將會被阻塞,直到當前執行緒完成該方法的執行。

  • ReentrantLock特性

ReentrantLock 是 Java 中的一個可重入鎖,它是一種比 synchronized 關鍵字更靈活的執行緒同步機制。ReentrantLock 允許一個執行緒多次獲取同一個鎖,而不會產生死鎖。它也支援公平鎖和非公平鎖,可以根據實際需求進行選擇。
下面是一個使用 ReentrantLock 的範例:

import java.util.concurrent.locks.ReentrantLock;  
  
public class ReentrantLockExample {  
    private final ReentrantLock lock = new ReentrantLock();  
    private int count = 0;  
  
    public void incrementCount() {  
        lock.lock();  
        try {  
            count++;  
        } finally {  
            lock.unlock();  
        }  
    }  
  
    public int getCount() {  
        return count;  
    }  
}

在上面的這個例子中,我們定義了一個 ReentrantLock 和一個計數器 count。
incrementCount() 方法使用 lock.lock() 獲取鎖,然後增加計數器的值,最後使用 lock.unlock() 釋放鎖。
getCount() 方法直接返回計數器的值,無需獲取鎖。
這種方式比使用 synchronized 關鍵字更靈活,因為它可以細粒度地控制需要同步的程式碼塊,而不是整個方法。

★ 後續的章節會詳細的介紹 synchronized關鍵字和ReentrantLock特性,敬請期待

4.2 非阻塞同步

在JAVA中,互斥同步最主要的問題就是執行緒阻塞和喚醒所帶來的開銷導致的效能問題,這種同步也稱為阻塞同步,是一種悲觀的並行策略,無論共用資料是否真的會出現競爭,它都要進行加鎖,
這樣 使用者態核心態轉換、維護鎖計數器和阻塞檢查、執行緒喚醒等操作都會產生大量的開銷。
非阻塞同步是指在多執行緒環境下,不需要使用阻塞等待的方式來實現同步控制,執行緒可以一直進行計算操作,而不會被阻塞。下面介紹幾種手段實現非阻塞同步。

  1. CAS
    隨著硬體指令集水平的發展,我們經常使用基於衝突檢測的樂觀並行策略: 先執行操作,如果沒有其它執行緒爭用共用資料,那操作就成功了,否則採取補償措施(始終重試,直至成功)。這種樂觀的並行策略的許多實現都不需要將執行緒阻塞,因此這種同步操作稱為非阻塞同步。
    樂觀鎖需要操作和衝突檢測這兩個步驟具備原子性,這裡就不能再使用互斥同步來保證了,只能靠硬體來完成。硬體支援的原子性操作最典型的是: 比較並交換(Compare-and-Swap,CAS)。
    CAS操作包含三個運算元 —— 記憶體位置(V)、預期原值(A)和新值(B)。如果記憶體位置V的值與預期原值A相匹配,則將記憶體位置的值更新為B,否則不進行任何操作。在並行環境中,CAS操作可以保證資料的一致性和執行緒安全性。

  2. AtomicInteger

AtomicInteger是Java中的一個原子整數類,它提供了原子操作的更新方法,可以在多執行緒環境下安全地更新共用的整數變數。
AtomicInteger的更新方法包括incrementAndGet()、getAndIncrement()、decrementAndGet()、getAndDecrement()、compareAndSet()等,它們使用了 Unsafe 類的 CAS 操作,保證對共用變數的操作是原子性的。

以下程式碼使用了 AtomicInteger 執行了計數操作。

import java.util.concurrent.atomic.AtomicInteger;  
  
public class AtomicIntegerExample {  
    private static AtomicInteger counter = new AtomicInteger(0);  
  
    public static void main(String[] args) {  
        // 啟動10個執行緒,每個執行緒將計數器加10  
        for (int i = 0; i < 10; i++) {  
            new Thread(() -> {  
                for (int j = 0; j < 10; j++) {  
                    counter.incrementAndGet();  
                }  
            }).start();  
        }  
  
        // 等待所有執行緒執行完畢  
        try {  
            Thread.sleep(1000);  
        } catch (InterruptedException e) {  
            e.printStackTrace();  
        }  
  
        // 輸出計數器的值  
        System.out.println("Counter: " + counter);  
    }  
}

在這個範例中,我們使用AtomicInteger來維護一個計數器的值,並啟動了10個執行緒,每個執行緒將計數器加10次。由於AtomicInteger提供了原子操作的更新方法,因此即使多個執行緒同時更新計數器的值,也不會出現執行緒安全問題。最後,我們輸出計數器的值,可以看到它應該是100(10個執行緒每個執行緒執行10次計數器加1操作)。

  1. ABA
    如果某個執行緒將變數A更改為B後再更改為A,那麼另一個等待CAS操作的執行緒會認為該變數沒有發生過改變,仍然是A,然後執行CAS操作。這樣就可能導致資料的不一致。
    J.U.C 包提供了一個帶有標記的原子參照類 AtomicStampedReference 來解決這個問題,它可以通過控制變數值的版本來保證 CAS 的正確性。大部分情況下 ABA 問題不會影響程式並行的正確性,如果需要解決 ABA 問題,改用傳統的互斥同步可能會比原子類更高效。
    另外,Java 8引入了一種新的原子類:LongAdder和LongAccumulator,它們內部採用了分段化的思想來解決高並行下的ABA問題。它們將內部變數分為一個陣列,每個執行緒更新自己的分段,最後再合併結果。這種方式既解決了ABA問題,又提高了並行效能。

4.3 無同步方案

換一個思路,如果沒有方法的計算不涉及共用資料,不需要進行同步,是不是就不需要任何同步措施去保證正確性,也就沒有執行緒安全的問題。

  • 棧封閉:多個執行緒存取同一個方法的區域性變數時,不會出現執行緒安全問題,因為區域性變數儲存在虛擬機器器棧中,屬於執行緒私有的。
  • 執行緒本地儲存(Thread Local Storage):如果一段程式碼中所需要的資料必須與其他程式碼共用,那就看看這些共用資料的程式碼是否能保證在同一個執行緒中執行。如果能保證,我們就可以把共用資料的可見範圍限制在同一個執行緒之內,這樣,無須同步也能保證執行緒之間不出現資料爭用的問題。
  • 可重入程式碼(Reentrant Code):可以在程式碼執行的任何時刻中斷它,轉而去執行另外一段程式碼(包括遞迴呼叫它本身),而在控制權返回後,原來的程式不會出現任何錯誤。

這塊簡單介紹,後續會有專門的章節進行學習

5 總結

  • 瞭解了多執行緒產生的原因,以及執行緒不安全的原因
  • 從 可見性,原子性和有序性 來闡述並行狀態下執行緒不安全的原因
  • 分析了Java是怎麼解決並行問題的