1. 程式人生 > 實用技巧 >JAVA多執行緒(五) volatile原理分析

JAVA多執行緒(五) volatile原理分析

volatile: 能夠保證執行緒可見性,當一個執行緒修改主記憶體共享變數能夠保證對外一個執行緒可見性,但是他不能保證共享變數的原子性問題。

1. volatite特性

1.1 可見性

能夠保證執行緒可見性,當一個執行緒修改共享變數時,能夠保證對另外一個執行緒可見性,

1.2 順序性

程式執行程式按照程式碼的先後順序執行。

1.3 防止指令重排序

通過插入記憶體屏障在cpu層面防止亂序執行

2. volatile可見性

public class VolatileTest extends Thread {

    /**
     * volatile關鍵字底層通過 彙編 lock指令字首 強制修改值,並立即重新整理到主記憶體中,另外一個執行緒可以馬上看到重新整理的主記憶體資料
     
*/ private static volatile boolean FLAG = true; @Override public void run() { while (FLAG){ try { TimeUnit.MILLISECONDS.sleep(300); System.out.println("==== test volatile ===="); } catch (InterruptedException ignore) { } } }
public static void main(String[] args) throws InterruptedException { new VolatileTest().start(); TimeUnit.SECONDS.sleep(1); FLAG = false; } }

3. CPU多核硬體架構剖析

CPU的執行速度非常快,而對磁碟的讀寫IO速度卻很慢,為了解決這個問題,有了記憶體的誕生;而CPU的速度與記憶體的讀寫速度之比仍然有著100 : 1的差距,為了解決這個問題,CPU又在記憶體與CPU之間建立了多級別快取:暫存器、L1、L2、L3三級快取。

4.產生可見性的原因

因為我們CPU讀取主記憶體共享變數的資料時候,效率是非常低,所以對每個CPU設定對應的快取記憶體 L1、L2、L3 快取我們共享變數主記憶體中的副本。

相當於每個CPU對應共享變數的副本,副本與副本之間可能會存在一個數據不一致性的問題。比如執行緒執行緒B修改的某個副本值,執行緒A的副本可能不可見,導致可見性問題。

CPU的摩爾定律

https://baike.baidu.com/item/%E6%91%A9%E5%B0%94%E5%AE%9A%E5%BE%8B/350634?fr=aladdin

基本每隔18個月,可能CPU的效能會提高一倍。

5.JMM記憶體模型

Java記憶體模型定義的是一種抽象的概念,定義遮蔽java程式對不同的作業系統的記憶體訪問差異。

主記憶體:存放我們共享變數的資料

工作記憶體:每個CPU對共享變數(主記憶體)的副本。堆+方法區

6.JMM八大同步規範

(1)lock(鎖定):作用於 主記憶體的變數,把一個變數標記為一條執行緒獨佔狀態

(2)unlock(解鎖):作用於 主記憶體的變數,把一個處於鎖定狀態的變數釋放出來,釋放後的變數才可以被其他執行緒鎖定

(3)read(讀取):作用於 主記憶體的變數,把一個變數值從主記憶體傳輸到執行緒的 工作記憶體中,以便隨後的load動作使用

(4)load(載入):作用於 工作記憶體的變數,它把read操作從主記憶體中得到的變數值放入工作記憶體的變數副本中

(5)use(使用):作用於 工作記憶體的變數,把工作記憶體中的一個變數值傳遞給執行引擎

(6)assign(賦值):作用於 工作記憶體的變數,它把一個從執行引擎接收到的值賦給工作記憶體的變數

(7)store(儲存):作用於 工作記憶體的變數,把工作記憶體中的一個變數的值傳送到 主記憶體中,以便隨後的write的操作

(8)write(寫入):作用於 工作記憶體的變數,它把store操作從工作記憶體中的一個變數的值傳送到 主記憶體的變數中

7. volatile彙編lock指令字首

  1. 將當前處理器快取行資料立刻寫入主記憶體中。
  2. 寫的操作會觸發匯流排嗅探機制,同步更新主記憶體的值。

通過Idea工具檢視java彙編指令

1. jdk安裝包\jre\bin\server 放入 hsdis-amd64.dll

2. idea 配置 VM options, 最後一個引數 *xxxxx.* 就是一個我們的需要檢視彙編的class類

-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*VolatileTest.*

3. 檢視結果 ,會發現在 volatile 關鍵字 修飾的變數,在寫操作時,對應的彙編指令,都有一個lock指令字首

8. volatile的底層實現原理

通過彙編lock字首指令觸發底層鎖的機制,鎖的機制兩種:匯流排鎖/MESI快取一致性協議,主要幫助我們解決多個不同cpu之間快取之間資料同步的問題

8.1 匯流排鎖

當一個cpu(執行緒)訪問到我們主記憶體中的資料時候,往匯流排總髮出一個Lock鎖的訊號,其他的執行緒不能夠對該主記憶體做任何操作,變為阻塞狀態。該模式,存在非常大的缺陷,就是將並行的程式,變為序列,沒有真正發揮出cpu多核的好處。

8.2 MESI協議

1.M 修改 (Modified) 這行資料有效,資料被修改了,和主記憶體中的資料不一致,資料只存在於本Cache中。

2.E 獨享、互斥 (Exclusive) 這行資料有效,資料和主記憶體中的資料一致,資料只存在於本Cache中。

3.S 共享 (Shared) 這行資料有效,資料和主記憶體中的資料一致,資料存在於很多Cache中。

4.I 無效 (Invalid) 這行資料無效。

E: 獨享:當只有一個cpu執行緒的情況下,cpu副本資料與主記憶體資料如果,保持一致的情況下,則該cpu狀態為E狀態 獨享。

S: 共享:在多個cpu執行緒的情況了下,每個cpu副本之間資料如果保持一致的情況下,則當前cpu狀態為S

M: 如果當前cpu副本資料如果與主記憶體中的資料不一致的情況下,則當前cpu狀態為M

I: 匯流排嗅探機制發現 狀態為m的情況下,則會將該cpu改為i狀態 無效

如果狀態是M的情況下,則使用嗅探機制通知其他的CPU工作記憶體副本狀態為I無效狀態,則重新整理主記憶體資料到本地中,從而多核cpu資料的一致性。

該cpu快取主動獲取主記憶體的資料同步更新。

匯流排:維護解決cpu快取記憶體副本資料之間一致性問題。

9.volatile不能保證原子性原因

public class VolatileTest extends Thread {

    private static volatile int count = 0;

    public static void add() {
        count++;
    }

    public static void main(String[] args) throws InterruptedException {
        ArrayList<Thread> threads = new ArrayList<>();
       for (int i= 0;i<100;i++){
         Thread test =  new Thread(() -> {
             for (int k=0;k<1000;k++){
                 add();
             }
           });
           threads.add(test);
           test.start();
       }
        threads.forEach(v -> {
           try {
               v.join();
           } catch (InterruptedException ignore) { }
       });
        System.out.println("<><><><> count: "+ count);
    }
}

volatile為了能夠保證資料的可見性,但是不能夠保證原子性,及時的將工作記憶體的資料重新整理主記憶體中,導致其他的工作記憶體的資料變為無效狀態,其他工作記憶體做的count++操作等於就是無效丟失了,這是為什麼我們加上Volatile count結果在小於100000以內。

10. volatile存在的偽共享的問題

CPU會以快取行的形式讀取主記憶體中資料,快取行的大小為2的冪次數字節,一般的情況下是為64個位元組。如果該變數共享到同一個快取行,就會影響到整理效能。

例如:執行緒1修改了long型別變數A,long型別定義變數佔用8個位元組,在由於快取一致性協議,執行緒2的變數A副本會失效,執行緒2在讀取主記憶體中的資料的時候,以快取行的形式讀取,無意間將主記憶體中的共享變數B也讀取到記憶體中,而化主記憶體中的變數B沒有發生變化。

解決快取行解為共享問題,使用快取行填充方案避免為共享

@sun.misc.Contended

可以直接在類上加上該註解@sun.misc.Contended,啟動的時候需要加上該引數-XX:-RestrictContended,該方案在JDK8有效,JDK12中被優化掉了

例如 ConcurrentHashMap中的CounterCell,就是使用了快取行填充方案避免為共享

11. JMM中的重排序及記憶體屏障

public class ReorderThread {
    private static int a,b,x,y;

    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        while (true) {
            i++;
            a = 0;
            b = 0;
            x = 0;
            y = 0;

            Thread thread1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    a = 1;
                    x = b;
                }
            });
            Thread thread2 = new Thread(new Runnable() {
                @Override
                public void run() {
                    b = 1;
                    y = a;
                }
            });
            thread1.start();
            thread2.start();
            thread1.join();
            thread2.join();
            System.out.println("第" + i + "次(" + x + "," + y + ")");
            if (x == 0 & y == 0) {
                break;
            }
        }
    }
}

當我們的CPU寫入快取的時候發現快取區正在被其他cpu站有的情況下,為了能夠提高CPU處理的效能可能將後面的讀快取命令優先執行。注意:不是隨便重排序,需要遵循as-ifserial語義。

as-ifserial:不管怎麼重排序(編譯器和處理器為了提高並行的效率)單執行緒程式執行結果不會發生改變的。也就是我們編譯器與處理器不會對存在資料依賴的關係操作做重排序。

CPU指令重排序優化的過程存在問題

as-ifserial 單執行緒程式執行結果不會發生改變的,但是在多核多執行緒的情況下,指令邏輯無法分辨因果關係,可能會存在一個亂序中心問題,導致程式執行結果錯誤。

如同上面圖,所示會出現會有機會兩個執行緒中,A執行緒執行順序1邏輯,而B執行緒執行順序2邏輯。

11.1 記憶體屏障解決重排序

處理器提供了兩個記憶體遮蔽指令,解決以上存在的問題

1.寫記憶體屏障:在指令後插入Stroe Barrier ,能夠讓寫入快取中的最新資料更新寫入主記憶體中,讓其他執行緒可見。這種強制寫入主記憶體,這種現實呼叫CPU就不會因為效能的考慮對指令重排序。

2.讀記憶體屏障:在指令前插入load Barrier ,可以讓告訴快取中的資料失效,強制從新主記憶體載入資料強制讀取主記憶體,讓CPU快取與主記憶體保持一致,避免快取導致的一致性問題。

11.2 手動插入記憶體屏障

public class ReorderThread {
    private static int a,b,x,y;

    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        while (true) {
            i++;
            a = 0;
            b = 0;
            x = 0;
            y = 0;

            Thread thread1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    a = 1;
                    // 新增寫屏障
                    ReorderThread.getUnsafe().storeFence();
                    x = b;
                }
            });
            Thread thread2 = new Thread(new Runnable() {
                @Override
                public void run() {
                    b = 1;
                    // 新增寫屏障
                    ReorderThread.getUnsafe().storeFence();
                    y = a;
                }
            });
            thread1.start();
            thread2.start();
            thread1.join();
            thread2.join();
            System.out.println("第" + i + "次(" + x + "," + y + ")");
            if (x == 0 & y == 0) {
                break;
            }
        }
    }

    /**
     * 通過Unsafe 插入記憶體屏障
     * @return
     */
    public static Unsafe getUnsafe(){
        try {
            Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
            theUnsafe.setAccessible(true);
            return (Unsafe)theUnsafe.get(null);
        } catch (Exception e) {
            return null;
        }

    }
}

12. 雙重檢驗鎖為什麼需要加上volatile

public class Singleton雙重檢驗鎖3 {
    private volatile static Singleton雙重檢驗鎖3 singleton雙重檢驗鎖3;


    //建構函式私有化
    private Singleton雙重檢驗鎖3() {
    }


    public  static Singleton雙重檢驗鎖3 getSingleton雙重檢驗鎖3() {

        if(singleton雙重檢驗鎖3 == null){
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (Singleton雙重檢驗鎖3.class){
                if(singleton雙重檢驗鎖3 == null){
                    singleton雙重檢驗鎖3 = new Singleton雙重檢驗鎖3();
                }
            }
        }
        return singleton雙重檢驗鎖3;
    }

    public static void main(String[] args) {
 
        for (int i = 0; i < 20; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    Singleton雙重檢驗鎖3 雙重檢驗鎖 = Singleton雙重檢驗鎖3.getSingleton雙重檢驗鎖3();

                    System.out.println(Thread.currentThread().getName() + 雙重檢驗鎖);
                }
            }).start();

        }
    }

}

注意:在宣告private volatile static Singleton雙重檢驗鎖3 singleton雙重檢驗鎖3 中 ,如果去掉volatile關鍵字,我們在new操作存在重排序的問題。

getSingleton雙重檢驗鎖3()獲取物件過程精簡為3步如下

1. 分配物件的記憶體空間
2. 呼叫建構函式初始化
3. 將物件複製給變數

如果沒有volatile關鍵字修飾 singleton雙重檢驗鎖3 變數,則第2步和第3步流程存在重排序問題,有可能先執行將物件複製給變數,再執行呼叫建構函式初始化,導致另外一個執行緒獲取到該物件不為空,但是該改造函式沒有初始化的半初始化物件,會導致報錯 。就是另外一個執行緒拿到的是一個不完整的物件。

13. synchronized 與volatile存在的區別

1. volatile保證執行緒可見性,當工作記憶體中副本資料無效之後,主動讀取主記憶體中資料

2. volatile可以禁止重排序的問題,底層是記憶體屏障實現。

3.volatile不會導致執行緒阻塞,不能夠保證執行緒安全問題,synchronized 會導致執行緒阻塞能夠保證執行緒安全問題。

參考來源:

  CPU快取一致性協議MESIhttps://www.cnblogs.com/yanlong300/p/8986041.html

  CPU多級快取與指令重排https://blog.csdn.net/weixin_44129784/article/details/107135733?fps=1&locationNum=2