Java並發volatile三大特性探究


volatile是Java虛擬機提供的一種輕量級的同步機制,在並發編程中,它也扮演着比較重要的角色。同synchronized相比(synchronized通常稱為重量級鎖),volatile更輕量級。

volatile具有三大特性:

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

1. JMM(Java內存模型)

Java虛擬機有自己的內存模型(Java Memory Model,JMM),JMM可以屏蔽掉各種硬件和操作系統的內存訪問差異,以實現讓Java程序在各種平台下都能達到一致的內存訪問效果。

JMM決定一個線程對共享變量的寫入何時對其他線程可見,JMM定義了線程和主內存之間的抽象關系:共享變量存儲在主內存(Main Memory)中,每個線程都有一個私有的本地內存(Local Memory),本地內存保存了被該線程使用到的主內存的副本拷貝,線程對變量的所有操作都必須在工作內存中進行,而不能直接讀寫主內存中的變量。這三者之間的交互關系如下:

2. volatile保證可見性

2.1 可見性

通過前面對JMM的介紹,我們知道,各個線程對主內存中共享變量的操作都是各個線程各自拷貝到自己的工作內存進行操作后在寫回到主內存中的。

這就可能存在一個線程A修改了共享變量X的值,但是還未寫入主內存時,另外一個線程B又對主內存中同一共享變量X進行操作,但此時A線程工作內存中共享變量X對線程B來說是不可見,這種工作內存與主內存同步延遲現象就造成了可見性問題。

public class TestVolatile {
    boolean status = false;

    /**
     * 狀態切換為true
     */
    public void changeStatus(){
        status = true;
    }

    /**
     * 若狀態為true,則running。
     */
    public void run(){
        if(status){
            System.out.println("running....");
        }
    }
}

上面這個例子,在多線程環境里,假設線程A執行changeStatus()方法后,線程B運行run()方法,可以保證輸出"running....."嗎?

答案是NO! 

這個結論會讓人有些疑惑,可以理解。因為倘若在單線程模型里,先運行changeStatus方法,再執行run方法,自然是可以正確輸出"running...."的;但是在多線程模型中,是沒法做這種保證的。因為對於共享變量status來說,線程A的修改,對於線程B來講,是"不可見"的。也就是說,線程B此時可能無法觀測到status已被修改為true。

所謂可見性,是指當一條線程修改了共享變量的值,新值對於其他線程來說是可以立即得知的。很顯然,上述的例子中是沒有辦法做到內存可見性的。

對於普通的共享變量來講,比如我們上文中的status,線程A將其修改為true這個動作發生在線程A的本地內存中,此時還未同步到主內存中去;而線程B緩存了status的初始值false,此時可能沒有觀測到status的值被修改了,所以就導致了上述的問題。那么這種共享變量在多線程模型中的不可見性如何解決呢?比較粗暴的方式自然就是加鎖,但是此處使用synchronized或者Lock這些方式太重量級了,有點炮打蚊子的意思。比較合理的方式其實就是volatile。

volatile具備兩種特性,第一就是保證共享變量對所有線程的可見性。將一個共享變量聲明為volatile后,會有以下效應:

  • 當寫一個volatile變量時,JMM會把該線程對應的本地內存中的變量強制刷新到主內存中去;
  • 這個寫會操作會導致其他線程中的緩存無效。

上面的例子只需將status聲明為volatile,即可保證在線程A將其修改為true時,線程B可以立刻得知

 volatile boolean status = false;

2.2 緩存一致性

為什么volatile修飾后,當主線程中某個值被更改,其它線程能馬上知曉呢?其實這里是用到了總線嗅探技術

在說嗅探技術之前,首先談談緩存一致性的問題,就是當多個處理器運算任務都涉及到同一塊主內存區域的時候,將可能導致各自的緩存數據不一。

為了解決緩存一致性的問題,需要各個處理器訪問緩存時都遵循一些協議,在讀寫時要根據協議進行操作,這類協議主要有MSI、MESI等等。

2.3 MESI

當CPU寫數據時,如果發現操作的變量是共享變量,即在其它CPU中也存在該變量的副本,會發出信號通知其它CPU將該內存變量的緩存行設置為無效,因此當其它CPU讀取這個變量的時,發現自己緩存該變量的緩存行是無效的,那么它就會從內存中重新讀取。

2.4 總線嗅探

那么是如何發現數據是否失效呢?

這里是用到了總線嗅探技術,就是每個處理器通過嗅探在總線上傳播的數據來檢查自己緩存值是否過期了,當處理器發現自己的緩存行對應的內存地址被修改,就會將當前處理器的緩存行設置為無效狀態,當處理器對這個數據進行修改操作的時候,會重新從內存中把數據讀取到處理器緩存中。

2.5 總線風暴

總線嗅探技術有哪些缺點?

由於Volatile的MESI緩存一致性協議,需要不斷的從主內存嗅探和CAS循環,無效的交互會導致總線帶寬達到峰值。因此不要大量使用volatile關鍵字,至於什么時候使用volatile、什么時候用鎖以及syschonized都是需要根據實際場景的。

3. volatile不保證原子性

3.1 不保證原子性分析

原子性即不可分割,完整性,也就是說某個線程正在做某個具體業務時,中間不可以被加塞或者被分割,需要具體完成,要么同時成功,要么同時失敗。

為了測試volatile是否保證原子性,我們創建了20個線程,然后每個線程分別循環1000次,來調用number++的方法;最后通過 Thread.activeCount(),來感知20個線程是否執行完畢,這里判斷線程數是否大於2,為什么是2?因為默認是有兩個線程的,一個main線程,一個gc線程;最后在線程執行完畢后,我們查看number的值,假設volatile保證原子性的話,那么最后輸出的值應該是:

20 * 1000 = 20000

完整代碼如下所示:

import java.util.concurrent.TimeUnit;

/**
 * 假設是主物理內存
 */
class MyData {
    /**
     * volatile 修飾的關鍵字,是為了增加 主線程和線程之間的可見性,只要有一個線程修改了內存中的值,其它線程也能馬上感知
     */
    volatile int number = 0;

    public void addTo60() {
        this.number = 60;
    }

    /**
     * 注意,此時number 前面是加了volatile修飾
     */
    public void addPlusPlus() {
        number ++;
    }
}

/**
 * 驗證volatile的可見性
 * 1、 假設int number = 0, number變量之前沒有添加volatile關鍵字修飾
 * 2、添加了volatile,可以解決可見性問題
 *
 * 驗證volatile不保證原子性
 * 1、原子性指的是什么意思?
 */
public class VolatileDemo {

    public static void main(String args []) {

        MyData myData = new MyData();

        // 創建10個線程,線程里面進行1000次循環
        for (int i = 0; i < 20; i++) {
            new Thread(() -> {
                // 里面
                for (int j = 0; j < 1000; j++) {
                    myData.addPlusPlus();
                }
            }, String.valueOf(i)).start();
        }

        // 需要等待上面20個線程都計算完成后,在用main線程取得最終的結果值
        // 這里判斷線程數是否大於2,為什么是2?因為默認是有兩個線程的,一個main線程,一個gc線程
        while(Thread.activeCount() > 2) {
            // yield表示不執行
            Thread.yield();
        }

        // 查看最終的值
        // 假設volatile保證原子性,那么輸出的值應該為:  20 * 1000 = 20000
        System.out.println(Thread.currentThread().getName() + "\t finally number value: " + myData.number);

    }
}

最終結果我們會發現,number輸出的值並沒有20000,而且是每次運行的結果都不一致的,這說明了volatile修飾的變量不保證原子性:

為什么會出現數值丟失?我們通過對addPlusPlus()這個方法的字節碼文件進行分析:

//源代碼
public void addPlusPlus() {
        number ++;
}

//轉化后的字節碼
public void addPlusPlus();
    Code:
       0: aload_0
       1: dup
       2: getfield      #2    // Field n:I
       5: iconst_1
       6: iadd
       7: putfield      #2    // Field n:I
      10: return

我們能夠發現 n++這條命令,被拆分成了3個指令

  • 執行getfield 從主內存拿到原始n
  • 執行iadd 進行加1操作
  • 執行putfileld 把累加后的值寫回主內存

假設我們沒有加 synchronized那么第一步就可能存在着,三個線程同時通過getfield命令,拿到主存中的 n值,然后三個線程,各自在自己的工作內存中進行加1操作,但他們並發進行 iadd 命令的時候,因為只能一個進行寫,所以其它操作會被掛起,假設1線程,先進行了寫操作,在寫完后,volatile的可見性,應該需要告訴其它兩個線程,主內存的值已經被修改了,但是因為太快了,其它兩個線程,陸續執行 iadd命令,進行寫入操作,這就造成了其他線程沒有接受到主內存n的改變,從而覆蓋了原來的值,出現寫丟失,這樣也就讓最終的結果少於20000。

3.2 解決不保證原子性

因此這也說明,在多線程環境下 number ++ 在多線程環境下是非線程安全的,解決的方法有哪些呢?

1. 在方法上加入 synchronized

public synchronized void addPlusPlus() {
    number ++;
}

引入synchronized關鍵字后,保證了該方法每次只能夠一個線程進行訪問和操作,最終輸出的結果也就為20000。

2. 上面的方法引入synchronized,雖然能夠保證原子性,但是為了解決number++,而引入重量級的同步機制,不是最合理的方式。除了引用synchronized關鍵字外,還可以使用JUC下面的原子包裝類,即上面例子中 int類型的number,可以使用AtomicInteger來代替。

/**
  *  創建一個原子Integer包裝類,默認為0
  */
AtomicInteger atomicInteger = new AtomicInteger();
public void addAtomic() {
   // 相當於 atomicInter ++
   atomicInteger.getAndIncrement();
}

4. volatile禁止指令重排序

4.1 指令重排序

計算機在執行程序時,為了提高性能,編譯器和處理器常常會對指令重排,主要流程如下:

源代碼 -> 編譯器優化的重排 -> 指令並行的重排 -> 內存系統的重排 -> 最終執行指令

重排序也需要遵守一定規則:

  1.重排序操作不會對存在數據依賴關系的操作進行重排序。

    比如:a=1;b=a; 這個指令序列,由於第二個操作依賴於第一個操作,所以在編譯時和處理器運行時這兩個操作不會被重排序。

  2.重排序是為了優化性能,但是不管怎么重排序,單線程下程序的執行結果不能被改變

    比如:a=1;b=2;c=a+b這三個操作,第一步(a=1)和第二步(b=2)由於不存在數據依賴關系,所以可能會發生重排序,但是c=a+b這個操作是不會被重排序的,因為需要保證最終的結果一定是c=a+b=3。

多線程環境中線程交替執行,由於編譯器優化重排的存在,兩個線程中使用的變量能否保證一致性是無法確定的,結果無法預測。

4.2 volatile禁止指令重排

volatile實現禁止指令重排優化,從而避免了多線程環境下程序出現亂序執行的現象。

首先了解一個概念,內存屏障(Memory Barrier)又稱內存柵欄,是一個CPU指令,它的作用有兩個:

  • 保證特定操作的順序

  • 保證某些變量的內存可見性(利用該特性實現volatile的內存可見性)

由於編譯器和處理器都能執行指令重排的優化,如果在指令間插入一條Memory Barrier則會告訴編譯器和CPU,不管什么指令都不能和這條Memory Barrier指令重排序,也就是說 通過插入內存屏障禁止在內存屏障前后的指令執行重排序優化。 內存屏障另外一個作用是刷新出各種CPU的緩存數,因此任何CPU上的線程都能讀取到這些數據的最新版本。

其實現是在volatile修飾變量的寫 和 讀的時候,加入屏障,防止出現指令重排。

volatile內存語義的實現——JMM對volatile的內存屏障插入策略:
在每個volatile寫操作的前面插入一個StoreStore屏障。在每個volatile寫操作的后面插入一個StoreLoad屏障。
在每個volatile讀操作的后面插入一個LoadLoad屏障。在每個volatile讀操作的后面插入一個LoadStore屏障。

volatile禁止指令重排序也有一些規則,如下:

  • 當第二個操作是voaltile寫時,無論第一個操作是什么,都不能進行重排序
  • 當第一個操作是volatile讀時,不管第二個操作是什么,都不能進行重排序
  • 當第一個操作是volatile寫時,第二個操作是volatile讀時,不能進行重排序

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM