死磕Java——volatile的理解

讀書遛狗遠方發表於2019-05-07

一、死磕Java——volatile的理解

1.1.JMM記憶體模型

理解volatile的相關知識前,先簡單的認識一下JMM(Java Memory Model),JMMjdk5引入的一種jvm的一種規範,本身是一種抽象的概念,並不真實存在,它遮蔽了各種硬體和作業系統的訪問差異,它的目的是為了解決由於多執行緒通過共享資料進行通訊時,存在的本地記憶體資料不一致、編譯器會對程式碼進行指令重排等問題。

JMM有關同步的規定:

  • 執行緒解鎖前,必須把共享變數的值重新整理回主記憶體;
  • 執行緒加鎖前,必須讀取主記憶體的最新值到自己的工作記憶體中;
  • 加鎖和解鎖使用的是同一把鎖;

關於上述規定如下圖解:

image-20190502153724126

**說明:**當我們在程式中new一個user物件的時候,這個物件就存在我們的主記憶體中,當多個執行緒操作主記憶體的name變數的時候,會先將user物件中的name屬性進行拷貝一份到自己執行緒的工作記憶體中,自己修改自己工作記憶體中的屬性後,再將修改後的屬性值重新整理回主記憶體,這就會存在一些問題,例如,一個執行緒寫完,還沒有寫回到主記憶體,另一個執行緒先修改後寫入到主記憶體,就會存在資料的丟失或者髒資料。所以,JMM就存在如下規定:

  • 可見性
  • 原子性
  • 有序性

1.2.Volatile關鍵字

volatilejava虛擬機器提供的一種輕量級的同步機制,比較與synchronized。我們知道的事volatile的三大特性:

  • 可見性
  • 不保證原子性
  • 禁止指令重排

1.2.1.Volatile如何保證可見性

可見性就是當多個執行緒操作主記憶體的共享資料的時候,當其中一個執行緒修改了資料寫回主記憶體的時候,回立刻通知其他執行緒,這就是執行緒的可見性。先看一個簡單的例子:

class MyDataDemo {
    int num = 0;

    public void updateNum() {
        this.num = 60;
    }
}

public class VolatileDemo {

    public static void main(String[] args) {

        MyDataDemo myData = new MyDataDemo();

        new Thread(() -> {
            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            myData.updateNum();
            System.out.println("num的值:" + myData.num);
        }, "子執行緒").start();

        while (myData.num == 0) {}
        System.out.println("程式執行結束");
    }
}
複製程式碼

這是一個簡單的示例程式,存在一個兩個執行緒,一個子執行緒修改主記憶體的共享資料num的值,main執行緒使用while時時檢測自己是否是道主記憶體的num的值是否被改變,執行程式程式執行結束並不會被列印,同時,程式也不會停止。這就是執行緒之間的不可見問題,解決方法就是可以新增volatile關鍵字,修改如下:

volatile int num = 0;
複製程式碼

1.2.2.Volatile保證可見性的原理

Java程式生成彙編程式碼的時候,我們可以看見,當我們對新增了volatile關鍵字修飾的變數時候,會多出一條Lock字首的的指令。我們知道的是cpu不直接與主記憶體進行資料交換,中間存在一個快取記憶體區域,通常是一級快取、二級快取和三級快取,而新增了volatile關鍵字進行操作時候,生成的Lock字首的彙編指令主要有以下兩個作用:

  • 將當前處理器快取行的資料寫回系統記憶體;
  • 這個寫回記憶體的操作會使得其他CPU裡快取了該記憶體地址的資料無效;

Idea檢視程式的彙編指令在VM啟動引數配上-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly即可;

參考:wiki.openjdk.java.net/display/Hot…

在多處理器下,為了保證各個處理器的快取是一致的,就會實現快取一致性協議,每個處理器通過嗅探在匯流排上傳播的資料來檢查自己快取的值是不是過期了,當處理器發現自己快取行對應的記憶體地址被修改,就會將當前處理器的快取行設定成無效狀態,當處理器對這個資料進行修改操作的時候,會重新從系統記憶體中把資料讀到處理器快取裡。

總結:Volatile通過快取一致性保證可見性。

1.2.3.Volatile不保證原子性

**原子性:**也可以說是保持資料的完整一致性,也就是說當某一個執行緒操作每一個業務的時候,不能被其他執行緒打斷,不可以被分割操作,即整體一致性,要麼同時成功,要麼同時失敗。

class MyDataDemo {
    volatile int num = 0;

    public void addNum() {
        num++;
    }
}
public class VolatileDemo {

    public static void main(String[] args) {
        MyDataDemo data = new MyDataDemo();
        for(int i = 1; i <= 20; i++) {
            new Thread(() -> {
                for (int j = 1; j < 1000; j++) {
                    data.addNum();
                }
            }, "當前子執行緒為執行緒" + String.valueOf(i)).start();
        }
        // 等待所有執行緒執行結束
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        System.out.println("最終結果:" + data.num);
    }
}
複製程式碼

上述程式碼就是在共享資料前新增了volatile關鍵字,當時,列印的最終結果幾乎很難為20000,這就很充分的說明了volatile並不能保證資料的原子性,這裡的num++操作,雖然只有一行程式碼,但是實際是三步操作,這也是為什麼i++在多執行緒下是非執行緒安全的。

1.2.4.為什麼Volatile不保證原子性

可以參考JMM模型的那一張圖,就是主記憶體中存在一個num = 0,當其中一個執行緒將其修改為1,然後將其寫回主記憶體的時候,就被掛起了,另外一個執行緒也將主記憶體的num = 0修改為1,然後寫入後,之前的執行緒被喚醒,快速的寫入主記憶體,覆蓋了已經寫入的1,造成了資料丟失操作,兩次操作最終結果應該為2,但是為1,這就是為什麼會造成資料丟失。再來看i++對應的位元組碼

image-20190502175617528

簡單翻譯一下位元組碼的操作:

  • aload_0:從區域性變數表的相應位置裝載一個物件引用到運算元棧的棧頂;
  • dup:複製棧頂元素;
  • getfield:先獲得原始值;
  • iadd:進行+1操作;
  • putfield:再把累加後的值寫回主記憶體操作;

1.2.5.解決Volatile不保證原子性的問題

使用AtomicInteger來保證原子性,有關AtomicInteger的詳細知識,後面在死磕,官方文件截圖如下:

image-20190502182016318

修改之前的不保證原子性的程式碼如下:

class MyDataDemo {
    AtomicInteger atomicInteger = new AtomicInteger();

    public void addAtomicInteger() {
        atomicInteger.getAndIncrement();
    }
}
public class VolatileDemo {

    public static void main(String[] args) {
        MyDataDemo data = new MyDataDemo();
        for(int i = 1; i <= 20; i++) {
            new Thread(() -> {
                for (int j = 1; j <= 1000; j++) {
                    data.addAtomicInteger();
                }
            }, "當前子執行緒為執行緒" + String.valueOf(i)).start();
        }
        // 等待所有執行緒執行結束
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        System.out.println("最終結果:" + data.atomicInteger);
    }
}
複製程式碼

1.2.6.Volatile的禁止指令重排序

首先,假如寫了如下程式碼

carbon

在程式中,我們覺得是會依次順序執行,但是在計算機在執行程式的時候,為了提高效能,編譯器和和處理器通常會對指令進行指令重排序,可能執行順序為:2—1—3—4,也可能是:1—3—2—4,一般分為下面三種:

image-20190502184808400

雖然處理器會對指令進行重排,但是同時也會遵守一些規則,例如上述程式碼不可能重排後將第四句程式碼第一個執行,所以,單執行緒下確保程式的最終執行結果和順序執行結一致,這就是處理器在進行指令重排序時候必須考慮的就是指令之間的資料依賴性

但是,在多執行緒環境下,由於編譯器重排的存在,兩個執行緒使用的變數能否保證一致性無法確定,所以結果就無法一致。在看一個示例:

http://image.luokangyuan.com/2019-05-02-113323.png

在多執行緒環境下,第一種就是順序執行init方法,先將num進行賦值操作,在執行update方法,結果:num為6,但是存在編譯器重排,那麼可能先執行falg = true;再執行num = 1;,最終num為5;

1.2.7.Volatile禁止指令重排序的原理

前面說到了volatile禁止指令重排優化,從而避免在多執行緒環境下出現結果錯亂的現象。這是因為在volatile會在指令之間插入一條記憶體屏障指令,通過記憶體屏障指令告訴CPU和編譯器不管什麼指令,都不進行指令重新排序。也就說說通過插入的記憶體屏障禁止在記憶體屏障前後的指令執行指令重新排序優化

什麼是記憶體屏障

記憶體屏障是一個CPU指令,他的作用有兩個:

  • 保證特定操作的執行順序;
  • 保證某些變數的記憶體可見性;

將上述程式碼修改為:

volatile int num = 0;

volatile boolean falg = false;
複製程式碼

這樣就保證執行init方法的時候一定是先執行num = 1;再執行falg = true;,就避免的了結果出錯的現象。

1.3.Volatile的單例模式

public class SingletonDemo {

    private static volatile SingletonDemo instance = null;

    private SingletonDemo(){};

    public static SingletonDemo getInstance() {
        if (instance == null) {
            synchronized (SingletonDemo.class) {
                if (instance == null) {
                    instance = new SingletonDemo();
                }
            }
        }
        return instance;
    }
}
複製程式碼

相關文章