面試:為了進阿里,重新翻閱了Volatile與Synchronized

Ccww技術部落格發表於2020-09-12

該系列文章收錄在公眾號【Ccww技術部落格】,原創技術文章早於部落格推出

在深入理解使用Volatile與Synchronized時,應該先理解明白Java記憶體模型 (Java Memory Model,JMM)


Java記憶體模型(Java Memory Model,JMM)

Java記憶體(JMM)模型是在硬體記憶體模型基礎上更高層的抽象,它遮蔽了各種硬體和作業系統對記憶體訪問的差異性,從而實現讓Java程式在各種平臺下都能達到一致的併發效果。

JMM的內部工作機制

 

 

  • 主記憶體:儲存共享的變數值(例項變數和類變數,不包含區域性變數,因為區域性變數是執行緒私有的,因此不存在競爭問題)

  • 工作記憶體:CPU中每個執行緒中保留共享變數的副本,執行緒的工作記憶體,執行緒在變更修改共享變數後同步回主記憶體,在變數被讀取前從主記憶體重新整理變數值來實現的。

  • 記憶體間的互動操作:不同執行緒之間不能直接訪問不屬於自己工作記憶體中的變數,執行緒間變數的值的傳遞需要通過主記憶體中轉來完成。(lock,unlock,read,load,use,assign,store,write)

JMM內部會有指令重排,並且會有af-if-serial跟happen-before的理念來保證指令的正確性

  • 為了提高效能,編譯器和處理器常常會對既定的程式碼執行順序進行指令重排序

  • af-if-serial:不管怎麼重排序,單執行緒下的執行結果不能被改變

  • 先行發生原則(happen-before):先行發生原則有很多,其中程式次序原則,在一個執行緒內,按照程式書寫的順序執行,書寫在前面的操作先行發生於書寫在後面的操作,準確地講是控制流順序而不是程式碼順序

Java記憶體模型為了解決多執行緒環境下共享變數的一致性問題,包含三大特性,

  • 原子性:操作一旦開始就會一直執行到底,中間不會被其它執行緒打斷(這操作可以是一個操作,也可以是多個操作),在記憶體中原子性操作包括read、load、user、assign、store、write,如果需要一個更大範圍的原子性可以使用synchronized來實現,synchronized塊之間的操作。

  • 可見性:一個執行緒修改了共享變數的值,其它執行緒能立即感知到這種變化,修改之後立即同步回主記憶體,每次讀取前立即從主記憶體重新整理,可以使用volatile保證可見性,也可以使用關鍵字synchronized和final。

  • 有序性:在本執行緒中所有的操作都是有序的;在另一個執行緒中,看來所有的操作都是無序的,就可需要使用具有天然有序性的volatile保持有序性,因為其禁止重排序。

在理解了JMM的時,來講講Volatile與Synchronized的使用,Volatile與Synchronized到底有什麼作用呢?


Volatile

Volatile 的特性

  • 保證了不同執行緒對這個變數進行操作時的可見性,即一個執行緒修改了某個變數的值,這新值對其他執行緒來說是立即可見的。(實現可見性)

  • 禁止進行指令重排序。(實現有序性)

  • volatile 只能保證對單次讀/寫的原子性,i++ 這種操作不能保證原子性

Volatile可見性

當寫一個volatile變數時,JMM會把該執行緒對應的工作記憶體中的共享變數值更新後重新整理到主記憶體,

當讀取一個volatile變數時,JMM會把該執行緒對應的工作記憶體置為無效,執行緒會從主記憶體中讀取共享變數。

寫操作:

讀操作:

Volatile 禁止指令重排

JMM對volatile的禁止指令重排採用記憶體屏障插入策略:

在每個volatile寫操作的前面插入一個StoreStore屏障。在每個volatile寫操作的後面插入一個StoreLoad屏障

在每個volatile讀操作的後面插入一個LoadLoad屏障。在每個volatile讀操作的後面插入一個LoadStore屏障


Synchronized

Synchronized是Java中解決併發問題的一種最常用的方法,也是最簡單的一種方法。Synchronized的作用主要有三個:

  • 原子性:確保執行緒互斥的訪問同步程式碼;

  • 可見性:保證共享變數的修改能夠及時可見,其實是通過Java記憶體模型中的 “對一個變數unlock操作之前,必須要同步到主記憶體中;如果對一個變數進行lock操作,則將會清空工作記憶體中此變數的值,在執行引擎使用此變數前,需要重新從主記憶體中load操作或assign操作初始化變數值” 來保證的

  • 有序性:有效解決重排序問題,即 “一個unlock操作先行發生(happen-before)於後面對同一個鎖的lock操作”;

Synchronized總共有三種用法:

  1. 當synchronized作用在例項方法時,監視器鎖(monitor)便是物件例項(this);

  2. 當synchronized作用在靜態方法時,監視器鎖(monitor)便是物件的Class例項,因為Class資料存在於永久代,因此靜態方法鎖相當於該類的一個全域性鎖;

  3. 當synchronized作用在某一個物件例項時,監視器鎖(monitor)便是括號括起來的物件例項;

更加詳細的解析看Java併發之Synchronized

理解了Volatile與Synchronized後,那我們來看看如何使用Volatile與Synchronized優化單例模式


單例模式優化-雙重檢測DCL(Double Check Lock)

先來看看一般模式的單例模式:

class Singleton{
    private static Singleton singleton;    
    private Singleton(){}
​
    public static Singleton getInstance(){
            if(singleton == null){
                singleton = new Singleton();   // 建立例項
        }
        return singleton;
    }
​
}

可能出現問題:當有兩個執行緒A和B,

  • 執行緒A判斷if(singleton == null)準備執行建立例項時,執行緒掛起,

  • 此時執行緒B也會判斷singleton為空,接著執行建立例項物件返回;

  • 最後,由於執行緒A已進入也會建立了例項物件,這就導致多個單例物件的情況

首先想到是那就在使用synchronized作用在靜態方法:

public class Singleton {
    private static Singleton singleton;
    private Singleton(){}
    public static synchronized Singleton getInstance(){
        if(singleton == null){
             singleton = new Singleton();
        }
        return singleton;
    }
}

雖然這樣簡單粗暴解決,但會導致這個方法比較效率低效,導致程式效能嚴重下降,那是不是還有其他更優的解決方案呢?

可以進一步優化建立了例項之後,執行緒再同步鎖之前檢驗singleton非空就會直接返回物件引用,而不用每次都在同步程式碼塊中進行非空驗證,

如果只有synchronized前加一個singleton非空,就會出現第一種情況多個執行緒同時執行到條件判斷語句時,會建立多個例項

因此需要在synchronized後加一個singleton非空,就不會出現會建立多個例項,

class Singleton{
    private static Singleton singleton;    
    private Singleton(){}
    
    public static Singleton getInstance(){
        if(singleton == null){
            synchronized(Singleton.class){
                if(singleton == null)
                    singleton = new Singleton();   
            }
        }
        return singleton;
    }
}

這個優化方案雖然解決了只建立單個例項,由於存在著指令重排,會導致在多執行緒下也是不安全的(當發生了重排後,後續的執行緒發現singleton不是null而直接使用的時候,就會出現意料之外的問題。)。導致原因singleton = new Singleton()新建物件會經歷三個步驟:

  • 1.記憶體分配

  • 2.初始化

  • 3.返回物件引用

由於重排序的緣故,步驟2、3可能會發生重排序,其過程如下:

  • 1.分配記憶體空間

  • 2.將記憶體空間的地址賦值給對應的引用

  • 3.初始化物件

那麼問題找到了,那怎麼去解決呢?那就禁止不允許初始化階段步驟2 、3發生重排序,剛好Volatile 禁止指令重排,從而使得雙重檢測真正發揮作用。

public class Singleton {
    //通過volatile關鍵字來確保安全
    private volatile static Singleton singleton;
    private Singleton(){}
    public static Singleton getInstance(){
        if(singleton == null){
           synchronized (Singleton.class){
                if(singleton == null){
                singleton = new Singleton();
            }
        }
    }
    return singleton;
    }
}

最終我們這個完美的雙重檢測單例模式出來了


總結

  • volatile本質是在告訴jvm當前變數在暫存器(工作記憶體)中的值是不確定的,需要從主存中讀取; synchronized則是鎖定當前變數,只有當前執行緒可以訪問該變數,其他執行緒被阻塞住。

  • volatile僅能使用在變數級別;synchronized則可以使用在變數、方法、和類級別的

  • volatile僅能實現變數的修改可見性,不能保證原子性;而synchronized則可以保證變數的修改可見性和原子性

  • volatile不會造成執行緒的阻塞;synchronized可能會造成執行緒的阻塞。

  • volatile標記的變數不會被編譯器優化;synchronized標記的變數可以被編譯器優化

  • 使用volatile而不是synchronized的唯一安全的情況是類中只有一個可變的域

各位看官還可以嗎?喜歡的話,動動手指點個?,點個關注唄!!謝謝支援!
歡迎掃碼關注,原創技術文章第一時間推出

相關文章