理解並正確使用synchronized和volatile

classfly發表於2018-06-11

執行緒安全需要同時滿足三個條件:

  • 原子性
    某個操作是不可中斷的,且要麼全部做完要麼沒有執行。
  • 可見性
    通過volatile關鍵字修飾變數實現。讀取volatile變數時,先失效本地快取再讀取主存中的最新值;更新volatile變數會立即將最新值刷回主存。
  • 有序性
    JMM的happens-before原則。

volatile能保證其修飾的變數的執行緒可見性但無法保證操作原子性,只能用於”多個變數之間或者某個變數的當前值與修改後值之間沒有約束”的場景。在實現計數器(++count)和由多個變數組成的不變表示式方面,volatile無法勝任。

volatile的底層實現機制是什麼?被volatile修飾的變數在進行寫操作時,處理器會插入一條lock字首的彙編程式碼,做了層”記憶體屏障”,其作用為:

    1. 重排序時不能把後面的指令重排序到記憶體屏障之前的位置
    1. 將當前處理器快取行的資料會寫回到系統記憶體。
    1. 這個寫回記憶體的操作會引起在其他CPU裡快取了該記憶體地址的資料無效。

通過處理器之間的快取一致性協議,當(處理器本)地快取過期後會失效本地快取,當更新該快取時處理器重新從主存load資料到本地快取並執行計算邏輯。

什麼場景下別用volatile?

非執行緒安全的計數器類

自增操作並不是原子的,比如”++count”操作就是三個原子操作的集合:

  1. 讀取count舊值
  2. 執行緒上下文彙總設定count新值: count+1
  3. 將count新值刷回主存並失效其他執行緒上下文的count值

假設thread1和thread2均執行”++count”計數操作,thread1和thread2均執行完2但未執行3,此時thread1和thread2先後將count新值刷回主存,這就產生了執行緒不安全的現象。

只有在狀態真正獨立於程式內其他內容時才能使用volatile。

// 引用
volatile操作不會像鎖一樣造成阻塞,因此,在能夠安全使用volatile的情況下,volatile 可以提供一些優於鎖的可伸縮特性。如果讀操作的次數要遠遠超過寫操作,與鎖相比,volatile 變數通常能夠減少同步的效能開銷。

非執行緒安全的數值範圍類

// 程式碼來源於Brian Goetz的《正確使用 Volatile 變數》一文,本文稍作修改
@NotThreadSafe 
public class NumberRange {
    private volatile int lower, upper;
 
    public int getLower() { return lower; }
    public int getUpper() { return upper; }
 
    public void setLower(int value) { 
        if (value > upper) 
            throw new IllegalArgumentException(...);
        lower = value;
    }
 
    public void setUpper(int value) { 
        if (value < lower) 
            throw new IllegalArgumentException(...);
        upper = value;
    }
}

假設NumberRange初始化後lower/upper分別為0和4,即數值範圍為[0,4]。此時thread1和thread2分別執行setLower(3)和setUpper(2),最終lower/upper分別被thread1和thread2設定為3和2,數值範圍被更新為[3,2],某種意義上看是一個無效的數值範圍。

什麼場景下可以使用volatile?

1、對變數的寫入操作不依賴變數的當前值,或者你能確保只有單個執行緒更新變數的值。
2、該變數沒有包含在具有其他變數的不變式中。

Case1: 狀態標誌

這種型別的狀態標記的一個公共特性是:通常只有一種狀態轉換。

// 程式碼來源於Brian Goetz的《正確使用 Volatile 變數》一文,本文稍作修改
volatile boolean shutdownRequested;
public void shutdown() { shutdownRequested = true; }
public void doWork() { 
    while (!shutdownRequested) { 
        // do stuff
    }
}

Case2: 一次性安全釋出 – One-time Safe Publication

// 程式碼來源於Brian Goetz的《正確使用 Volatile 變數》一文,本文稍作修改
public class Floor {
    public Floor() {
        // initialization...
    }
}
public class Loader {
    public volatile Floor floor;
    public void init() {
        // this is the only write to floor
        floor = new Floor();
        // initialize floor object here ...
    }
    
    public Floor getFloor() {
        return this.floor;
    }
}
 
public class SomeOtherClass {
private Loader loader;
    public void doWork() {
        while (true) { 
            // use "floor" only if it is ready
            if (loader != null)  // 步驟1
                doSomething(loader.getFloor()); // 步驟2
        }
    }
}

這是一個典型的讀寫衝突例子,引文中提到“如果Floor引用不是 volatile 型別,doWork() 中的程式碼在解除對Floor的引用時,將會得到一個不完全構造的Floor”。我對這句理解的是:thread2執行完步驟2後釋放對Floor的引用時,thread1可能正在呼叫init方法初始化floor,此時thread2拿到的是還未被thread1完全初始化的Floor物件。如果doWork內部主動解除對floor物件的引用,則可能拿到未初始化完全的floor物件的引用。

即使floor物件完成初始化,對floor成員域的修改仍然是執行緒不可見的。

volatile引用可以保證任意執行緒都可以看到這個物件引用的最新值,但不保證能看到被引用物件的成員域的最新值。

因為volatile修飾的是floor物件的引用,如果thread1執行到步驟1時,thread3修改了floor成員域,其修改對thread1並不可見。思考:如果floor成員域均被volatile鎖修飾,其成員域的修改是否對thread3可見

Case3: 多個獨立觀察結果的釋出

// 程式碼來源於Brian Goetz的《正確使用 Volatile 變數》一文,本文稍作修改
public class UserManager {
    public volatile String lastUser;
    public boolean authenticate(String user, String password) {
        boolean valid = passwordIsValid(user, password);
        if (valid) {
            User u = new User();
            activeUsers.add(u);
            lastUser = user;
        }
        return valid;
    }
}

這個模式要求被髮布的值(lastUser)是有效不可變的 —— 即值的狀態在釋出後不會更改。與Case1中更新floor物件成員域不同,對String類的操作是在新的String例項上進行的,String物件本身的狀態並未改變。String類的這種實現方式天然地提供了執行緒隔離性。

volatile並非用來解決高併發場景下資料競爭衝突的方案,它只是實現執行緒可見性的一種方式!如果多個執行緒同時更新volatile變數,需要採用同步機制解決資料競爭,如CAS或者鎖等。

Case4: “volatile bean” 模式

該模式中,Java Bean成員變數均被volatile修飾,且引用型別的成員變數也必須是有效不可變(Collection的子類如List, Set, Queue等有陣列值的成員變數,volatile修飾的是陣列引用並非陣列元素!)。

引用文章


相關文章