深入理解Volatile

轉(zhuǎn)自:https://juejin.im/editor/drafts/5acda6976fb9a028d937821f

一旦一個共享變量(類的成員變量、 類的靜態(tài)成員變量) 被 volatile 修飾之后, 那么就具備了兩層語義:

  1. 保證了不同線程對這個變量進行讀取時的可見性,即一個線程修改了某個變量的值,這新值對其他線程來說是立即可見的。 (volatile 解決了線程間共享變量的可見性問題)。

  2. 禁止進行指令重排序, 阻止編譯器對代碼的優(yōu)化。

內(nèi)存可見性:

  • 第一: 使用 volatile 關(guān)鍵字會強制將修改的值立即寫入主存;

  • 第二: 使用 volatile 關(guān)鍵字的話, 當線程 2 進行修改時, 會導致線程 1 的工作內(nèi)存中緩存變量 stop 的緩存行無效(反映到硬件層的話, 就是 CPU 的 L1或者 L2 緩存中對應的緩存行無效) ;

  • 第三: 由于線程 1 的工作內(nèi)存中緩存變量 stop 的緩存行無效, 所以線程 1再次讀取變量 stop 的值時會去主存讀取。

那么, 在線程 2 修改 stop 值時(當然這里包括 2 個操作, 修改線程 2 工作內(nèi)存中的值, 然后將修改后的值寫入內(nèi)存) , 會使得線程 1 的工作內(nèi)存中緩存變量 stop 的緩存行無效, 然后線程 1 讀取時, 發(fā)現(xiàn)自己的緩存行無效, 它會等待緩存行對應的主存地址被更新之后, 然后去對應的主存讀取最新的值。

具體內(nèi)容參考我的另外一篇博客:

禁止重排序:

volatile 關(guān)鍵字禁止指令重排序有兩層意思:

  • 當程序執(zhí)行到 volatile 變量的讀操作或者寫操作時, 在其前面的操作的更改肯定全部已經(jīng)進行, 且結(jié)果已經(jīng)對后面的操作可見; 在其后面的操作肯定還沒有進行

  • 在進行指令優(yōu)化時, 不能把 volatile 變量前面的語句放在其后面執(zhí)行,也不能把 volatile 變量后面的語句放到其前面執(zhí)行。

為了實現(xiàn) volatile 的內(nèi)存語義, 加入 volatile 關(guān)鍵字時, 編譯器在生成字節(jié)碼時,會在指令序列中插入內(nèi)存屏障, 會多出一個 lock 前綴指令。 內(nèi)存屏障是一組處理器指令, 解決禁止指令重排序和內(nèi)存可見性的問題。 編譯器和 CPU 可以在保證輸出結(jié)果一樣的情況下對指令重排序, 使性能得到優(yōu)化。 處理器在進行重排序時是會考慮指令之間的數(shù)據(jù)依賴性。

內(nèi)存屏障, 有 2 個作用:

  • 1.先于這個內(nèi)存屏障的指令必須先執(zhí)行, 后于這個內(nèi)存屏障的指令必須后執(zhí)行。

  • 2.使得內(nèi)存可見性。 所以, 如果你的字段是 volatile, 在讀指令前插入讀屏障, 可以讓高速緩存中的數(shù)據(jù)失效, 重新從主內(nèi)存加載數(shù)據(jù)。 在寫指令之后插入寫屏障, 能讓寫入緩存的最新數(shù)據(jù)寫回到主內(nèi)存。

Lock 前綴指令在多核處理器下會引發(fā)了兩件事情:

  1. 將當前處理器中這個變量所在緩存行的數(shù)據(jù)會寫回到系統(tǒng)內(nèi)存。 這個寫回內(nèi)存的操作會引起在其他 CPU 里緩存了該內(nèi)存地址的數(shù)據(jù)無效。 但是就算寫回到內(nèi)存, 如果其他處理器緩存的值還是舊的, 再執(zhí)行計算操作就會有問題, 所以在多處理器下, 為了保證各個處理器的緩存是一致的, 就會實現(xiàn)緩存一致性協(xié)議, 每個處理器通過嗅探在總線上傳播的數(shù)據(jù)來檢查自己緩存的值是不是過期了, 當處理器發(fā)現(xiàn)自己緩存行對應的內(nèi)存地址被修改, 就會將當前處理器的緩存行設(shè)置成無效狀態(tài), 當處理器要對這個數(shù)據(jù)進行修改操作的時候, 會強制重新從系統(tǒng)內(nèi)存里把數(shù)據(jù)讀到處理器緩存里。

  2. 它確保指令重排序時不會把其后面的指令排到內(nèi)存屏障之前的位置, 也不會把前面的指令排到內(nèi)存屏障的后面; 即在執(zhí)行到內(nèi)存屏障這句指令時, 在它前面的操作已經(jīng)全部完成。

深入理解緩存一致性問題:

當程序在運行過程中, 會將運算需要的數(shù)據(jù)從主存復制一份到 CPU 的高速緩存當中, 那么 CPU 進行計算時就可以直接從它的高速緩存讀取數(shù)據(jù)和向其中寫入數(shù)據(jù), 當運算結(jié)束之后, 再將高速緩存中的數(shù)據(jù)刷新到主存當中。 舉個簡單的例子, 比如下面的這段代碼:

i = i+1

當線程執(zhí)行這個語句時, 會先從主存當中讀取 i 的值, 然后復制一份到高速緩存當中, 然后 CPU 執(zhí)行指令對 i 進行加 1 操作, 然后將數(shù)據(jù)寫入高速緩存,最后將高速緩存中 i 最新的值刷新到主存當中。這個代碼在單線程中運行是沒有任何問題的, 但是在多線程中運行就會有問題了。 在多核 CPU 中, 每條線程可能運行于不同的 CPU 中, 因此每個線程運行時有自己的高速緩存(對單核 CPU 來說, 其實也會出現(xiàn)這種問題, 只不過是以線程調(diào)度的形式來分別執(zhí)行的) 。

本文我們以多核 CPU 為例比如同時有 2 個線程執(zhí)行這段代碼, 假如初始時 i 的值為 0, 那么我們希望兩個線程執(zhí)行完之后 i 的值變?yōu)?2。 但是事實會是這樣嗎?

可能存在下面一種情況: 初始時, 兩個線程分別讀取 i 的值存入各自所在的CPU 的高速緩存當中, 然后線程 1 進行加 1 操作, 然后把 i 的最新值 1 寫入到內(nèi)存。 此時線程 2 的高速緩存當中 i 的值還是 0, 進行加 1 操作之后, i 的值為1, 然后線程 2 把 i 的值寫入內(nèi)存。最終結(jié)果 i 的值是 1, 而不是 2。 這就是著名的緩存一致性問題。 通常稱這種被多個線程訪問的變量為共享變量。也就是說, 如果一個變量在多個 CPU 中都存在緩存(一般在多線程編程時才會出現(xiàn)) , 那么就可能存在緩存不一致的問題。

如何解決緩存一致性的問題:為了解決緩存不一致性問題, 通常來說有以下 2 種解決方法:1) 通過在總線加 LOCK#鎖的方式2) 通過緩存一致性協(xié)議

通過在總線加 LOCK#鎖的方式:在早期的 CPU 當中, 是通過在總線上加 LOCK#鎖的形式來解決緩存不一致的問題。 因為 CPU 和其他部件進行通信都是通過總線來進行的, 如果對總線加 LOCK#鎖的話, 也就是說阻塞了其他 CPU 對其他部件訪問(如內(nèi)存) ,從而使得只能有一個 CPU 能使用這個變量的內(nèi)存。 比如上面例子中 如果一個線程在執(zhí)行 i = i +1, 如果在執(zhí)行這段代碼的過程中, 在總線上發(fā)出了 LCOK#鎖的信號, 那么只有等待這段代碼完全執(zhí)行完畢之后, 其他 CPU 才能從變量 i所在的內(nèi)存讀取變量, 然后進行相應的操作。 這樣就解決了緩存不一致的問題。但是上面的方式會有一個問題, 由于在鎖住總線期間, 其他 CPU 無法訪問內(nèi)存, 導致效率下。

但是上面的方式會有一個問題, 由于在鎖住總線期間, 其他 CPU 無法訪問內(nèi)存, 導致效率低下。

通過緩存一致性協(xié)議:所以就出現(xiàn)了緩存一致性協(xié)議。 該協(xié)議保證了每個緩存中使用的共享變量的副本是一致的。

它核心的思想是: 當 CPU 向內(nèi)存寫入數(shù)據(jù)時, 如果發(fā)現(xiàn)操作的變量是共享變量, 即在其他 CPU 中也存在該變量的副本, 會發(fā)出信號通知其他 CPU 將該變量的緩存行置為無效狀態(tài), 因此當其他 CPU 需要讀取這個變量時, 發(fā)現(xiàn)自己緩存中緩存該變量的緩存行是無效的, 那么它就會從內(nèi)存重新讀取。

內(nèi)存屏障可以被分為以下幾種類型:

  • LoadLoad 屏障: 對于這樣的語句 Load1; LoadLoad; Load2, 在 Load2 及后續(xù)讀取操作要讀取的數(shù)據(jù)被訪問前, 保證 Load1 要讀取的數(shù)據(jù)被讀取完畢。

  • StoreStore 屏障: 對于這樣的語句 Store1; StoreStore; Store2, 在 Store2 及后續(xù)寫入操作執(zhí)行前, 保證 Store1 的寫入操作對其它處理器可見。

  • LoadStore 屏障: 對于這樣的語句 Load1; LoadStore; Store2, 在 Store2 及后續(xù)寫入操作被刷出前, 保證 Load1 要讀取的數(shù)據(jù)被讀取完畢。

  • StoreLoad 屏障: 對于這樣的語句 Store1; StoreLoad; Load2, 在 Load2 及后續(xù)所有讀取操作執(zhí)行前, 保證 Store1 的寫入對所有處理器可見。 它的開銷是四種屏障中最大的。 在大多數(shù)處理器的實現(xiàn)中, 這個屏障是個萬能屏障, 兼具其它三種內(nèi)存屏障的功能。

這里用一張圖來詳細分析指令的執(zhí)行順序:

image

StoreStore屏障可以保證在volatile寫之前,其前面的所有普通寫操作已經(jīng)對任意處理器可見了,因為StoreStore屏障將保障上面所有的普通寫在volatile寫之前刷新到主內(nèi)存

x86處理器僅僅會對寫-讀操作做重排序

因此會省略掉讀-讀、讀-寫和寫-寫操作做重排序的內(nèi)存屏障

在x86中,JMM僅需在volatile后面插入一個StoreLoad屏障即可正確實現(xiàn)volatile寫-讀的內(nèi)存語義

這意味著在x86處理器中,volatile寫的開銷比volatile讀的大,因為StoreLoad屏障開銷比較大

對于Volatile的使用,附上代碼講解:

/**
 * 一、volatile 關(guān)鍵字:當多個線程進行操作共享數(shù)據(jù)時,可以保證內(nèi)存中的數(shù)據(jù)可見。 
 * 相較于 synchronized 是一種較為輕量級的同步策略。 
 * 注意: 
 * 1\. volatile 不具備“互斥性” 
 * 2\. volatile 不能保證變量的“原子性” 
 */  
 public class TestVolatile {//main線程和ThreadDemo線程對flag屬性的可見性問題  
?
    public static void main(String[] args) {  
        ThreadDemo td = new ThreadDemo();  
        new Thread(td).start();  
?
        while(true){  
            if(td.isFlag()){  
                System.out.println("------------------");  
                break;  
            }  
        }  
?
    }  
?
}  
?
class ThreadDemo implements Runnable {  
?
    //不添加volatile將不會輸出--------------------  
    private volatile boolean flag = false;  

    @Override  
    public void run() {  

        try {  
            Thread.sleep(200);  
        } catch (InterruptedException e) {  
        }  

        flag = true;  

        System.out.println("flag=" + isFlag());  

    }  

    public boolean isFlag() {  
        return flag;  
    }  

    public void setFlag(boolean flag) {  
        this.flag = flag;  
    }  
}
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

友情鏈接更多精彩內(nèi)容