volatile有序性和可見性底層原理

聲明:本文爲作者原創,如若轉發,請指明轉發地址

1、緩存一致性

1、首先,編譯之後Java代碼會被編譯成字節碼.class文件,在運行時會被加載到JVM中,JVM會將.class轉換爲具體的CPU執行指令,CPU加載這些指令逐條執行。
在這裏插入圖片描述

2、由於計算機的主存和CPU的運算速度相差很大,讀寫主存中的數據沒有CPU中執行指令的速度快,所以會在處理器和主存之間加入一層或多層的高速緩存:將運算需要使用的數據複製到緩存中,在進行運算時CPU不再和主存打交道,而是直接從高速緩存中讀寫數據,只有當運行結束後再從緩存同步到主存之中,這樣處理器就無須等待緩慢的內存讀寫了。

3、但是加入緩存引入了緩存一致性問題。在多路處理器系統中,每個處理器都有自己的高速緩存,而它們又共享同一主內存,當多個處理器的運算任務都涉及同一塊主內存區域時,將可能導致各自的緩存數據不一致。如果真的發生這種情況,那同步回到主內存時該以誰的緩存數據爲準呢?爲了解決一致性的問題,需要各個處理器訪問緩存時都遵循一些協議,在讀寫時要根據協議來進行操作。緩存一致性協議(MESI協議)它確保每個緩存中使用的共享變量的副本是一致的。

在這裏插入圖片描述

2、JMM

JAVA內存模型是JAVA虛擬機用來屏蔽硬件和操作系統內存讀取差異,以達到各個平臺下都能達到一致的內存訪問效果。Java內存模型的主要目的是定義程序中各種變量的訪問規則,即關注在虛擬機中把變量值存儲到內存和從內存中取出變量值這樣的底層細節

1、java內存模型分爲主內存和線程工作內存兩種

主內存:所有線程都共享的內存。如下圖所示,方法區和堆屬於主內存區域。

線程工作內存:每個線程獨享的內存。如下圖所示,虛擬機棧、本地方法棧、程序計數器屬於線程獨享的工作內存。

在這裏插入圖片描述

**2、java內存模型規定了所有的變量都必須存儲在主存中,每條線程還有自己的工作內存,線程的工作內存中保存了被該線程使用的變量的主存副本,線程對變量的所有操作(讀寫、賦值)都必須在工作內存中進行,而不能直接讀寫主存中的數據。**不同的線程之間也無法訪問其他工作內存中的變量,線程中變量值的傳遞均通過主存來完成。

線程、主內存、工作內存的關係如圖:

在這裏插入圖片描述

3、舉例:

先來看一個現象,main 線程對 run 變量的修改對於 t 線程不可見,導致了 t 線程無法停止:

static boolean run = true;
public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread(()->{
        while(run){
        // ....
        }
    });
    t.start();
    sleep(1);
    run = false; // 線程t不會如預想的停下來
}
  1. 初始狀態, t 線程剛開始從主內存讀取了 run 的值到工作內存。

在這裏插入圖片描述

  1. 因爲 t 線程要頻繁從主內存中讀取 run 的值,JIT 編譯器會將 run 的值緩存至自己工作內存中的高速緩存中,
    減少對主存中 run 的訪問,提高效率
    在這裏插入圖片描述

  2. 1 秒之後,main 線程修改了 run 的值,並同步至主存,而 t 是從自己工作內存中的高速緩存中讀取這個變量
    的值,結果永遠是舊值

在這裏插入圖片描述

  1. 解決方法:在變量run前加入volatile修飾

    它可以用來修飾成員變量和靜態成員變量,他可以避免線程從自己的工作緩存中查找變量的值,必須到主存中獲取它的值,線程操作 volatile 變量都是直接操作主存

3、volatile可見性原理

當一個變量被定義成volatile之後,它將具備兩項特性:第一項是保證此變量對所有線程的可見性,這裏的“可見性”是指當一條線程修改了這個變量的值,新值對於其他線程來說是可以立即得知的。而普通變量並不能做到這一點,普通變量的值在線程間傳遞時均需要通過主內存來完成。比如,線程A修改一個普通變量的值,然後向主內存進行回寫,另外一條線程B在線程A回寫完成了之後再對主內存進行讀取操作,新變量值纔會對線程B可見。

1、lock前綴指令角度

緩存行(cache line):CPU高速緩存中可以分配的最小存儲單位。處理器填寫緩存行時會加載整個緩存行。

觀察加入volatile關鍵字和沒有加入volatile關鍵字時所生成的彙編代碼發現,加入volatile關鍵字時,會多出一個lock前綴指令,lock前綴指令實際上相當於一個內存屏障。

lock指令在多核處理器下會引發下面的事件:

將當前處理器的緩存行的數據寫回到系統內存,同時使其他CPU裏緩存了該內存地址的數據置爲無效。

爲了提高處理速度,處理器一般不直接和內存通信,而是先將系統內存的數據讀到內部緩存後再進行操作,但操作完成後並不知道處理器何時將緩存數據寫回到內存。

但如果對加了volatile修飾的變量進行寫操作,JVM就會向處理器發送一條lock前綴的指令,將這個變量在緩存行的數據寫回到主存。這時只是寫回到主存,但其他處理器的緩存行中的數據還是舊的,要使其他處理器緩存行的數據也是新寫回到主存的數據,就需要實現緩存一致性協議。

即在一個處理器將自己緩存行的數據寫回到系統內存後,其他的每個處理器就會通過嗅探在總線上傳播的數據來檢查自己緩存的數據是否已過期,當處理器發現自己緩存行對應的內存地址的數據被修改後,就會將自己緩存行緩存的數據設置爲無效,當處理器要對這個數據進行修改操作的時候,會重新從系統內存中把數據讀取到自己的緩存行,重新緩存。

總結下:volatile可見性的實現就是藉助了CPU的lock指令,通過在寫volatile的機器指令前加上lock前綴,使寫volatile具有以下兩個原則:

(1) 寫volatile時處理器會將緩存寫回到主內存。

(2) 一個處理器的緩存寫回到內存會導致其他處理器的緩存失效。

2、內存屏障角度

內存屏障(memory barriers):一組處理器指令,用於實現對內存操作的順序限制。

java數據原子操作(內存間交互操作):

read(讀取):從主存中讀取數據

load(載入):將讀取到的數據寫入到工作內存中

use(使用):從工作內部中讀取數據並計算

assign(賦值):將計算好的值重新賦值到工作內存中

store(存儲):將工作內存中的數據寫入到主存

write(寫入):將store過去的變量值賦值給主存中的變量
在這裏插入圖片描述

如果把一個變量從主存中拷貝到工作內存中,就要按順序執行read和load操作,同理如果把變量從工作內存同步到主存中,就要按順序執行store和write指令,java內存模型要求上面的兩個操作必須按順序執行,但是並不要求連續執行。

爲了保證可見性:

load、use的執行順序不被打亂 (保證使用變量前一定進行了load操作,從主存拿最新值來)

assign、wirte的執行順序不被打亂(保證賦值後馬上就是把值寫到主存)。

**所以需要使用讀屏障和寫屏障:**一組處理器指令,用於實現對內存操作的順序限制。

讀操作時在讀指令use插入讀屏障,重新從主存加載最新值進來,讓工作內存中的數據失效,強制從新從主內存加載數據。(讀屏障保證在該屏障之後,對共享變量的讀取,加載的是主存中最新數據 )

寫操作時在寫指令assign插入寫屏障,能讓寫入工作內存中的最新數據更新寫入主內存,讓其他線程可見。(寫屏障保證在該屏障之前的,對共享變量的改動,都同步到主存當中,其他線程就可以讀到最新的結果了 )

public void actor2(I_Result r) {
    num = 2;
    ready = true; // ready 是 volatile 賦值帶寫屏障
    // 寫屏障
}
public void actor1(I_Result r) {
    // 讀屏障
    // ready 是 volatile 讀取值帶讀屏障
    if(ready) {
    	r.r1 = num + num;
    } else {
    	r.r1 = 1;
    }
}

在這裏插入圖片描述

4、volatile有序性原理

1、指令重排序

重排序是指編譯器和處理器爲了優化程序性能而對指令序列進行重新排序的一種手段。(好處)

在執行程序時,爲了提高性能,編譯器和處理器常常會對指令做重排序。重排序分3種類型。

1)編譯器優化的重排序。編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執行順序。

2)指令級並行的重排序。現代處理器採用了指令級並行技術來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序。

3)內存系統的重排序。由於處理器使用緩存和讀/寫緩衝區,這使得加載和存儲操作看上去可能是在亂序執行。

從Java源代碼到最終實際執行的指令序列,會分別經歷下面3種重排序:

在這裏插入圖片描述

上述的1屬於編譯器重排序,2和3屬於處理器重排序。這些重排序可能會導致多線程程序出現內存可見性問題。

對於編譯器,JMM的編譯器重排序規則會禁止特定類型的編譯器重排序(不是所有的編譯器重排序都要禁止)。對於處理器重排序,JMM的處理器重排序規則會要求Java編譯器在生成指令序列時,插入特定類型的內存屏障指令,通過內存屏障指令來禁止特定類型的處理器重排序。JMM屬於語言級的內存模型,它確保在不同的編譯器和不同的處理器平臺之上,通過禁止特定類型的編譯器重排序和處理器重排序,爲程序員提供一致的內存可見性保證。

2、內存屏障角度

寫屏障會確保指令重排序時,不會將寫屏障之前的代碼排在寫屏障之後

讀屏障會確保指令重排序時,不會將讀屏障之後的代碼排在讀屏障之前

在這裏插入圖片描述

還是那句話,不能解決指令交錯:
寫屏障僅僅是保證之後的讀能夠讀到最新的結果,但不能保證讀跑到它前面去
而有序性的保證也只是保證了本線程內相關代碼不被重排序

在這裏插入圖片描述

5、happens-before規則

JSR-133使用happens- before的概念來闡述操作之間的內存可見性。在JMM中,如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須要存在happens-before關係。這裏提到的兩個操作既可以是在一個線程之內,也可以是在不同線程之間。

對於Java程序員來說,happens-before規則簡單易懂,它避免Java程序員爲了理解JMM提供的內存可見性保證而去學習複雜的重排序規則以及這些規則的具體實現方法。

happens-before 規定了對共享變量的寫操作對其它線程的讀操作可見,它是可見性與有序性的一套規則總結,拋開以下 happens-before 規則,JMM 並不能保證一個線程對共享變量的寫,對於其它線程對該共享變量的讀可見

程序次序規則:在一個線程內,按照控制流順序,書寫在前面的操作先行發生於書寫在後面的操作。注意,這裏說的是控制流順序而不是程序代碼順序,因爲要考慮分支、循環等結構。

管程鎖定規則 :一個unlock操作先行發生於後面對同一個鎖的lock操作。這裏必須強調的是“同一個鎖”,而“後面”是指時間上的先後。

volatile變量規則 :對一個volatile變量的寫操作先行發生於後面對這個變量的讀操作,這裏的“後面”同樣是指時間上的先後。

線程啓動規則 :Thread對象的start()方法先行發生於此線程的每一個動作。

線程終止規則 :線程中的所有操作都先行發生於對此線程的終止檢測,我們可以通過Thread::join()方法是否結束、Thread::isAlive()的返回值等手段檢測線程是否已經終止執行。

線程中斷規則 :對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生,可以通過Thread::interrupted()方法檢測到是否有中斷髮生。

對象終結規則 :一個對象的初始化完成(構造函數執行結束)先行發生於它的finalize()方法的開始。

傳遞性 :如果操作A先行發生於操作B,操作B先行發生於操作C,那就可以得出操作A先行發生於操作C的結論。

1、線程解鎖 m 之前對變量的寫,對於接下來對 m 加鎖的其它線程對該變量的讀可見:

//共享變量
static int x;
//對象鎖
static Object m = new Object();
new Thread(()->{
        synchronized(m) {
   //加入synchronized能夠保證有序性、原子性、可見性,第一個線程對該變量的寫對其他線程對該變量的讀可見。
        x = 10;
    }
},"t1").start();
new Thread(()->{
    synchronized(m) {
        //因爲加了synchronized,因此對於x變量的寫操作已經寫入到主存中去了,因此對其他線程的讀可見。
    	System.out.println(x);
    }
},"t2").start();

2、線程對 volatile 變量的寫,對接下來其它線程對該變量的讀可見 :

//讀共享變量x加了voilate關鍵字,那麼就能夠有序性和可見性
volatile static int x;
new Thread(()->{
    //能夠保證對當前變量的寫操作對其他線程的讀操作可見
	x = 10;
},"t1").start();
new Thread(()->{
	System.out.println(x);
},"t2").start();

3、線程 start 前對變量的寫,對該線程開始後對該變量的讀可見 :

static int x;
//線程開始前對該變量的寫
x = 10;
new Thread(()->{
    //對該線程開始後對該變量的讀可見
	System.out.println(x);
},"t2").start();

4、線程結束前對變量的寫,對其它線程得知它結束後的讀可見(比如其它線程調用 t1.isAlive() 或 t1.join()等待
它結束)

static int x;
Thread t1 = new Thread(()->{
    //線程結束前對變量的寫
	x = 10;
},"t1");
t1.start();

//主線程等待t1線程執行結束
t1.join();
//對其它線程得知它結束後的讀可見
System.out.println(x);

5、線程 t1 打斷 t2(interrupt)前對變量的寫,對於其他線程得知 t2 被打斷後對變量的讀可見(通過
t2.interrupted 或 t2.isInterrupted)

static int x;
public static void main(String[] args) {
    Thread t2 = new Thread(()->{
        while(true) {
        if(Thread.currentThread().isInterrupted()) {
            System.out.println(x);
            break;
        }
    }
},"t2");
t2.start();
    
new Thread(()->{
    sleep(1);
    x = 10;
    //線程 t1 打斷 t2(interrupt)前對變量的寫
    t2.interrupt();
},"t1").start();
    
while(!t2.isInterrupted()) {
    Thread.yield();
    }
    //對於其他線程得知 t2 被打斷後對變量的讀可見
    System.out.println(x);
    }
}

6、對變量默認值(0,false,null)的寫,對其它線程對該變量的讀可見
7、具有傳遞性,如果x hb-> y並且 y hb-> z那麼有x hb-> z ,配合 volatile 的防指令重排

volatile static int x;
static int y;
new Thread(()->{
    y = 10;
    x = 20;
},"t1").start();
new Thread(()->{
    // x=20 對 t2 可見, 同時 y=10 也對 t2 可見
    System.out.println(x);
},"t2").start();

6、as-if-serial語義

1、數據依賴性

如果兩個操作訪問同一個變量,且這兩個操作中有一個爲寫操作,此時這兩個操作之間就存在數據依賴性。

在這裏插入圖片描述

上面的三種情況,只要重排序代碼的順序,結果就會改變。編譯器和處理器可能會對操作做重排序。編譯器和處理器在重排序時,會遵守數據依賴性,編譯器和處理器不會改變存在數據依賴關係的兩個操作的執行順序。

這裏所說的數據依賴性僅針對單個處理器中執行的指令序列和單個線程中執行的操作,不同處理器之間和不同線程之間的數據依賴性不被編譯器和處理器考慮。

2、as-if-serial語義

as-if-serial語義的意思是:不管怎麼重排序(編譯器和處理器爲了提高並行度),(單線程)程序的執行結果不能被改變。編譯器、runtime和處理器都必須遵守as-if-serial語義。

int a=2;  	//A
int b=3;  	//B
int c=a+b;	//C

A和C之間存在數據依賴關係,同時B和C之間也存在數據依賴關係。因此在最終執行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程序的結果將會被改變)。但A和B之間沒有數據依賴關係,編譯器和處理器可以重排序A和B之間的執行順序。

在這裏插入圖片描述

as-if-serial語義把單線程程序保護了起來,遵守as-if-serial語義的編譯器、runtime和處理器共同爲編寫單線程程序的程序員創建了一個幻覺:單線程程序是按程序的順序來執行的。as-if-serial語義使單線程程序員無需擔心重排序會干擾他們,也無需擔心內存可見性問題。

7、指令重排序面試題

面試題:什麼是指令重排序?既然重排序有這麼好處,爲什麼還要禁止指令重排序?重排序有什麼後果?

class ReorderExample {
    int a = 0;
    boolean flag = false;
    //寫操作
    public void writer() {
            a = 1;          //1
            flag = true;    //2
    }
    //讀操作
    Public void reader() {
        if (flag) {          //3
        int i = a * a;      //4
  		}
    }
}

flag變量是個標記,用來標識變量a是否已被寫入。這裏假設有兩個線程A和B,A首先執行writer()方法,隨後B線程接着執行reader()方法。線程B在執行操作4時,能否看到線程A在操作1對共享變量a的寫入呢?

答案是:不一定能看到。由於操作1和操作2沒有數據依賴關係,編譯器和處理器可以對這兩個操作重排序;同樣,操作3和操作4沒有數據依賴關係,編譯器和處理器也可以對這兩個操作重排序

在這裏插入圖片描述

操作1和操作2做了重排序。程序執行時,線程A首先寫標記變量flag,隨後線程B讀這個變量。由於條件判斷爲真,線程B將讀取變量a。此時,變量a還沒有被線程A寫入,在這裏多線程程序的語義被重排序破壞了!

在單線程程序中,對存在控制依賴的操作重排序,不會改變執行結果(這也是as-if-serial語義允許對存在控制依賴的操作做重排序的原因);但在多線程程序中,對存在控制依賴的操作重排序,可能會改變程序的執行結果。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章