溫馨提示×

溫馨提示×

您好,登錄后才能下訂單哦!

密碼登錄×
登錄注冊×
其他方式登錄
點擊 登錄注冊 即表示同意《億速云用戶服務(wù)條款》

如何理解Java volatile內(nèi)存屏障底層原理語義

發(fā)布時間:2021-09-24 16:53:49 來源:億速云 閱讀:108 作者:柒染 欄目:開發(fā)技術(shù)

本篇文章為大家展示了如何理解Java volatile內(nèi)存屏障底層原理語義,內(nèi)容簡明扼要并且容易理解,絕對能使你眼前一亮,通過這篇文章的詳細介紹希望你能有所收獲。

    一、volatile關(guān)鍵字介紹及底層原理

    1.volatile的特性(內(nèi)存語義)

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

    使用volatile變量的第二個語義是禁止指令重排序優(yōu)化,普通的變量僅會保證在該方法的執(zhí)行過程中所有依賴賦值結(jié)果的地方都能獲取到正確的結(jié)果,而不能保證變量賦值操作的順序與程序代碼中的執(zhí)行順序一致。因為在同一個線程的方法執(zhí)行過程中無法感知到這點,這就是Java內(nèi)存模型中描述的所謂“線程內(nèi)表現(xiàn)為串行的語義”(Within-Thread As-If-Serial Semantics)。

    2.volatile底層原理

    volatile關(guān)鍵字修飾的變量可以保證可見性與有序性,無法保證原子性。那么volatile關(guān)鍵字的底層原理是什么呢?我們可以通過查看Java代碼的匯編指令去看一下volatile的底層原理:查詢Java代碼的匯編指令需要設(shè)置JVM允許參數(shù):-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -Xcomp;如果你的jdk版本小于等于8還要在jdk里面添加Hsdis插件,將該插件目錄里面的兩個文件(hsdis-amd64.dll,hsdis-i386.dll)復(fù)制到 %JAVA_HOME%\jre\bin\server 下,然后運行你的Java程序,就可以看到控制臺里面一堆的匯編指令代碼輸出了。

    public class Singleton {
        private volatile static Singleton myinstance;
     
        public static Singleton getInstance() {
            if (myinstance == null) {
                synchronized (Singleton.class) {
                    if (myinstance == null) {
                        myinstance = new Singleton();//對象創(chuàng)建過程,本質(zhì)可以分文三步
                    }
                }
            }
            return myinstance;
        }
     
        public static void main(String[] args) {
            Singleton.getInstance();
        }
    }

    上面所示是一段標準的雙鎖檢測(Double Check Lock,DCL)單例代碼,可以觀察加入volatile和未加入volatile關(guān)鍵字時所生成的匯編代碼的差別。不加volatile關(guān)鍵字時在控制臺輸出指令搜索myinstance可以看到如下兩行

    0x00000000038064dd: mov %r10d,0x68(%rsi)
    0x00000000038064e1: shr $0x9,%rsi
    0x00000000038064e5: movabs $0xf1d8000,%rax
    0x00000000038064ef: movb $0x0,(%rsi,%rax,1) ;*putstatic myinstance
    ; - com.it.edu.jmm.Singleton::getInstance@24 (line 22)

    加了volatile關(guān)鍵字后,變成下面這樣了:

    0x0000000003cd6edd: mov %r10d,0x68(%rsi)
    0x0000000003cd6ee1: shr $0x9,%rsi
    0x0000000003cd6ee5: movabs $0xf698000,%rax
    0x0000000003cd6eef: movb $0x0,(%rsi,%rax,1)
    0x0000000003cd6ef3: lock addl $0x0,(%rsp) ;*putstatic myinstance
    ; - com.it.edu.jmm.Singleton::getInstance@24 (line 22)

    通過對比發(fā)現(xiàn),關(guān)鍵變化在于有volatile修飾的變量,賦值后(前面movb $0x0,(%rsi,%rax,1)這句便是賦值操作)多執(zhí)行了一個“l(fā)ock addl $0x0,(%rsp)”操作,這個操作的作用相當于一個內(nèi)存屏障(Memory Barrier或Memory Fence,指重排序時不能把后面的指令重排序到內(nèi)存屏障之前的位置,只有一個處理器訪問內(nèi)存時,并不需要內(nèi)存屏障;但如果有兩個或更多處理器訪問同一塊內(nèi)存,且其中有一個在觀測另一個,就需要內(nèi)存屏障來保證一致性了。

    這里的關(guān)鍵在于lock前綴,它的作用是將本處理器的緩存寫入了內(nèi)存,該寫入動作也會引起別的處理器或者別的內(nèi)核無效化(Invalidate,MESI協(xié)議的I狀態(tài))其緩存,這種操作相當于對緩存中的變量做了一次前面介紹Java內(nèi)存模式中所說的“store和write”操作。所以通過這樣一個操作,可讓前面volatile變量的修改對其他處理器立即可見。lock指令的更底層實現(xiàn):如果支持緩存行會加緩存鎖(MESI);如果不支持緩存鎖,會加總線鎖。

    二、volatile——可見性

    volatile修飾變量之后,可以保證可見性,下面通過一個程序示例演示一下:

    public class VolatileVisibilitySample {
        private volatile boolean initFlag = false;
        static Object object = new Object();
     
        public void refresh(){
            this.initFlag = true;
            System.out.println("線程:"+Thread.currentThread().getName()+":修改共享變量initFlag");
        }
     
        public void load(){
            int i = 0;
            while (!initFlag){
    //            synchronized (object){
    //                i++;
    //            }
            }
            System.out.println("線程:"+Thread.currentThread().getName()+"當前線程嗅探到initFlag的狀態(tài)的改變"+i);
        }
     
        public static void main(String[] args) throws InterruptedException {
            VolatileVisibilitySample sample = new VolatileVisibilitySample();
            Thread threadA = new Thread(()->{
                sample.refresh();
            },"threadA");
     
            Thread threadB = new Thread(()->{
                sample.load();
            },"threadB");
     
            threadB.start();
            Thread.sleep(2000);
            threadA.start();
        }
    }

    可以看到共享變量被volatile修飾之前,線程B中調(diào)用的方法中 “當前線程嗅探到initFlag的狀態(tài)的改變” 這句輸出是打印不出來的,也就意味著線程A中將initFlag改為true,但是線程B并沒有獲取到最新值,程序一直在循環(huán)空跑。此時JMM操作如下圖:雖然線程A中將initFlag改為了true并且最終會同步回主內(nèi)存,但是線程B中循環(huán)讀取的initFlag一直都是從工作內(nèi)存讀取的,所以會一直進行死循環(huán)無法退出。

    如何理解Java volatile內(nèi)存屏障底層原理語義

    添加了volatile修飾之后,“當前線程嗅探到initFlag的狀態(tài)的改變” 這句話就會被打印出來,因為添加volatile關(guān)鍵字后,就會有l(wèi)ock指令,使用緩存一致性協(xié)議,線程B中會一直嗅探initFlag是否被改變,線程A修改initFlag后會立即同步回主內(nèi)存,這時候會通知線程B將緩存行狀態(tài)改為I(無效狀態(tài)),需要重新從主內(nèi)存讀取。如下圖所示:

    如何理解Java volatile內(nèi)存屏障底層原理語義

    我們將上面的代碼的load()方法進行修改——去掉volatile關(guān)鍵字,添加synchronized同步塊,即修改為下面這樣的情況,會達到跟添加volatile關(guān)鍵字相同的效果,這是因為添加了鎖同步塊,CPU會分配時間片,線程進行鎖競爭導(dǎo)致線程上下文切換,重新讀取主存的變量。

    public void load(){
            int i = 0;
            while (!initFlag){
                synchronized (object){
                    i++;
                }
            }
            System.out.println("線程:"+Thread.currentThread().getName()+"當前線程嗅探到initFlag的狀態(tài)的改變"+i);
        }

    三、volatile——無法保證原子性

    由于volatile變量只能保證可見性,在不符合以下兩條規(guī)則的運算場景中,我們?nèi)匀灰ㄟ^加鎖(使用synchronized、java.util.concurrent中的鎖或原子類)來保證原子性:

    1. 運算結(jié)果并不依賴變量的當前值,或者能夠確保只有單一的線程修改變量的值。

    2. 變量不需要與其他的狀態(tài)變量共同參與不變約束

    下面通過一個示例演示一下:10個線程,每個線程加1000次(counter++不是一個原子性的操作,可以通過javap命令查看底層指令,可以看到有加載變量數(shù)據(jù)、將變量放到操作數(shù)棧頂、執(zhí)行加法運算等操作)。運行幾次發(fā)現(xiàn),有時運行結(jié)果是小于10000的。下面分析一下:

    • 1.首先counter不加volatile修飾時:因為10個線程同時對變量進行自加1運算,每個運算一次后去寫會主內(nèi)存,會覆蓋其他線程的運算結(jié)果,所以運行結(jié)果可能會小于10000。

    • 2.counter添加volatile修飾時:添加volatile修飾之后,變量被修改后會立即同步回主存,一直嗅探其他線程是否對變量進行過修改,修改后重新從主存讀取變量。但是正因為添加了volatile關(guān)鍵字時MESI緩存一致性協(xié)議生效了,當一個變量執(zhí)行加1操作后,需要同步回主存,這是會鎖緩存行,通知其他線程變量已經(jīng)被修改過了,將本地緩存行改為I無效狀態(tài),這樣被改為無效狀態(tài)的線程本地加1操作的結(jié)果被丟棄了,沒有寫回主內(nèi)存,也就是白加了一次,所以運行結(jié)果也可能會小于10000。

    想要實現(xiàn)原子性操作,可以通過synchronized,ReentrantLock加鎖,或者使用AtomicInteger進行原子性運算。

    public class VolatileAtomicSample {
        private static volatile int counter = 0;
     
        public static void main(String[] args) throws InterruptedException {
            for (int i = 0; i < 10; i++) {
                Thread thread = new Thread(()->{
                    for (int j = 0; j < 1000; j++) {
                        counter++;
                    }
                });
                thread.start();
            }
            Thread.sleep(1000);
            System.out.println(counter);
        }
    }

    四、volatile——禁止指令重排

    1.指令重排

    重排序是指編譯器和處理器為了優(yōu)化程序性能而對指令序列進行重新排序的一種手段。java語言規(guī)范規(guī)定JVM線程內(nèi)部維持順序化語義。即只要程序的最終結(jié)果與
    它順序化情況的結(jié)果相等,那么指令的執(zhí)行順序可以與代碼順序不一致,此過程叫指令的重排序。指令重排序的意義是什么?JVM能根據(jù)處理器特性(CPU多級緩存系統(tǒng)、多核處理器等)適當?shù)膶C器指令進行重排序,使機器指令能更符合CPU的執(zhí)行特性,最大限度的發(fā)揮機器性能。

    下圖為從源碼到最終執(zhí)行的指令序列示意圖

    如何理解Java volatile內(nèi)存屏障底層原理語義

    指令重排主要有兩個階段:

    1.編譯器編譯階段:編譯器加載class文件編譯為機器碼時進行指令重排

    2.CPU執(zhí)行階段: CPU執(zhí)行匯編指令時,可能會對指令進行重排序

    2.as-if-serial語義

    as-if-serial語義的意思是:不管怎么重排序(編譯器和處理器為了提高并行度),(單線程)程序的執(zhí)行結(jié)果不能被改變。編譯器、runtime和處理器都必須遵守as-if-serial語義。為了遵守as-if-serial語義,編譯器和處理器不會對存在數(shù)據(jù)依賴關(guān)系的操作做重排序,因為這種重排序會改變執(zhí)行結(jié)果。但是,如果操作之間不存在數(shù)據(jù)依賴關(guān)系,這些操作就可能被編譯器和處理器重排序。

    通過一個程序代碼,演示一下指令重排的效果:只有x=0并且y=0的情況下才會跳出循環(huán)

    public class VolatileReOrderSample {
        private static int x = 0, y = 0;
        private static int a = 0, b =0;
        static Object object = new Object();
     
        public static void main(String[] args) throws InterruptedException {
            int i = 0;
     
            for (;;){
                i++;
                x = 0; y = 0;
                a = 0; b = 0;
                Thread t1 = new Thread(new Runnable() {
                    @Override
                    public void run() {
                        a = 1; 
                        x = b;
                    }
                });
                Thread t2 = new Thread(new Runnable() {
                    @Override
                    public void run() {
                        b = 1;
                        y = a;
                    }
                });
                t1.start();
                t2.start();
                t1.join();
                t2.join();
     
                String result = "第" + i + "次 (" + x + "," + y + ")";
                if(x == 0 && y == 0) {
                    System.err.println(result);
                    break;
                } else {
                    System.out.println(result);
                }
            }
        }
    }

    通過分析,會有三種可能的輸出:[0,1],[1,0],[1,1]。

    • 輸出可能1——[0,1]:線程1先執(zhí)行完,線程2再執(zhí)行,則會出現(xiàn)x=0,y=1

    • 輸出可能1——[1,0]:線程2先執(zhí)行完,線程1再執(zhí)行,則會出現(xiàn)x=1,y=0

    • 輸出可能1——[1,1]:線程1、線程2交替執(zhí)行,a=1,b=1,然后執(zhí)行x=1,y=1,則會出現(xiàn)x=1,y=1

    當運行之后會發(fā)現(xiàn)上面分析的三種情況確實出現(xiàn)了,但是程序最終跳出了循環(huán),也就是出現(xiàn)了x=0并且y=0的情況,這說明出現(xiàn)了指令重排的情況,即線程1中a=1 x=b的指令出現(xiàn)了順序調(diào)整或線程2中b=1 y=a的指令出現(xiàn)了順序調(diào)整。

    當我們給變量a和b添加volatile關(guān)鍵字修飾后(private volatile static int a = 0, b =0;),再次運行發(fā)現(xiàn)程序一直在循環(huán)輸出,沒有出現(xiàn)x=y=0的情況從而退出循環(huán)。

    volatile可以禁止指令重排的原因是因為添加了lock指令,會添加內(nèi)存屏障。

    五、volatile與內(nèi)存屏障(Memory Barrier)

    1.內(nèi)存屏障(Memory Barrier)

    內(nèi)存屏障(Memory Barrier)又稱內(nèi)存柵欄,是一個CPU指令,它的作用有兩個,一是保證特定操作的執(zhí)行順序,二是保證某些變量的內(nèi)存可見性(利用該特性實現(xiàn)volatile的內(nèi)存可見性)。由于編譯器和處理器都能執(zhí)行指令重排優(yōu)化。如果在指令間插入一條Memory Barrier則會告訴編譯器和CPU,不管什么指令都不能和這條Memory Barrier指令重排序,也就是說通過插入內(nèi)存屏障禁止在內(nèi)存屏障前后的指令執(zhí)行重排序優(yōu)化。Memory Barrier的另外一個作用是強制刷出各種CPU的緩存數(shù)據(jù),因此任何CPU上的線程都能讀取到這些數(shù)據(jù)的最新版本??傊?,volatile變量正是通過內(nèi)存屏障(lock指令)實現(xiàn)其在內(nèi)存中的語義,即可見性和禁止重排優(yōu)化。

    上面的程序示例:synchronized+volatile實現(xiàn)的DCL模式的單例模式,就是利用了volatile禁止指令重排的特性。因為myinstance = new Singleton();這句代碼本質(zhì)上是有三步:1.為對象分配內(nèi)存空間;2.實例化對象數(shù)據(jù);3.將引用指向?qū)ο髮嵗膬?nèi)存空間。如果第一個線程執(zhí)行創(chuàng)建對象時出現(xiàn)了指令重排,比如3排到了2之前,那么線程2在最外層代碼判斷myinstance!=null為true返回對象引用,但是實際上這時候?qū)ο笊形闯跏蓟瓿?,這樣是有問題的,需要通過添加volatile關(guān)鍵字去禁止指令重排。

    2.volatile的內(nèi)存語義實現(xiàn)

    前面提到過重排序分為編譯器重排序和處理器重排序。為了實現(xiàn)volatile內(nèi)存語義,JMM會分別限制這兩種類型的重排序類型。下圖是JMM針對編譯器制定的volatile重排序規(guī)則表。

    如何理解Java volatile內(nèi)存屏障底層原理語義

    舉例來說,第三行最后一個單元格的意思是:在程序中,當?shù)谝粋€操作為普通變量的讀或?qū)憰r,如果第二個操作為volatile寫,則編譯器不能重排序這兩個操作。
    從上圖我們可以看出:

    • 當?shù)诙€操作是volatile寫時,不管第一個操作是什么,都不能重排序。這個規(guī)則確保volatile寫之前的操作不會被編譯器重排序到volatile寫之后。

    • 當?shù)谝粋€操作是volatile讀時,不管第二個操作是什么,都不能重排序。這個規(guī)則確保volatile讀之后的操作不會被編譯器重排序到volatile讀之前。

    • 當?shù)谝粋€操作是volatile寫,第二個操作是volatile讀時,不能重排序。

    為了實現(xiàn)volatile的內(nèi)存語義,編譯器在生成字節(jié)碼時,會在指令序列中插入內(nèi)存屏障來禁止特定類型的處理器重排序。對于編譯器來說,發(fā)現(xiàn)一個最優(yōu)布置來最小化插入屏障的總數(shù)幾乎不可能。為此,JMM采取保守策略。下面是基于保守策略的JMM內(nèi)存屏障插入策略。

    • 在每個volatile寫操作的前面插入一個StoreStore屏障。

    • 在每個volatile寫操作的后面插入一個StoreLoad屏障。

    • 在每個volatile讀操作的后面插入一個LoadLoad屏障。

    • 在每個volatile讀操作的后面插入一個LoadStore屏障。

    上述內(nèi)存屏障插入策略非常保守,但它可以保證在任意處理器平臺,任意的程序中都能得到正確的volatile內(nèi)存語義。

    下面是保守策略下,volatile寫插入內(nèi)存屏障后生成的指令序列示意圖,如圖所示。

    如何理解Java volatile內(nèi)存屏障底層原理語義

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

    而volatile寫后面的StoreLoad屏障,作用是避免volatile寫與后面可能有的volatile讀/寫操作重排序

    下圖是在保守策略下,volatile讀插入內(nèi)存屏障后生成的指令序列示意圖

    如何理解Java volatile內(nèi)存屏障底層原理語義

    上圖中LoadLoad屏障用來禁止處理器把上面的volatile讀與下面的普通讀重排序。LoadStore屏障用來禁止處理器把上面的volatile讀與下面的普通寫重排序。

    上述volatile寫和volatile讀的內(nèi)存屏障插入策略非常保守。在實際執(zhí)行時,只要不改變 volatile寫-讀的內(nèi)存語義,編譯器可以根據(jù)具體情況省略不必要的屏障。

    六、JMM對volatile的特殊規(guī)則定義

    最后我們再Java內(nèi)存模型中對volatile變量定義的特殊規(guī)則的定義。假定T表示一個線程,V和W分別表示兩個volatile型變量,那么在進行read、load、use、assign、store和write操作時需要滿足如下規(guī)則:

    只有當線程T對變量V執(zhí)行的前一個動作是load的時候,線程T才能對變量V執(zhí)行use動作;并且,只有當線程T對變量V執(zhí)行的后一個動作是use的時候,線程T才能對變量V執(zhí)行l(wèi)oad動作。線程T對變量V的use動作可以認為是和線程T對變量V的load、read動作相關(guān)聯(lián)的,必須連續(xù)且一起出現(xiàn)。

    這條規(guī)則要求在工作內(nèi)存中,每次使用V前都必須先從主內(nèi)存刷新最新的值,用于保證能看見其他線程對變量V所做的修改。

    只有當線程T對變量V執(zhí)行的前一個動作是assign的時候,線程T才能對變量V執(zhí)行store動作;并且,只有當線程T對變量V執(zhí)行的后一個動作是store的時候,線程T才能對變量V執(zhí)行assign動作。線程T對變量V的assign動作可以認為是和線程T對變量V的store、write動作相關(guān)聯(lián)的,必須連續(xù)且一起出現(xiàn)。

    這條規(guī)則要求在工作內(nèi)存中,每次修改V后都必須立刻同步回主內(nèi)存中,用于保證其他線程可以看到自己對變量V所做的修改。

    假定動作A是線程T對變量V實施的use或assign動作,假定動作F是和動作A相關(guān)聯(lián)的load或store動作,假定動作P是和動作F相應(yīng)的對變量V的read或write動作;與此類似,假定動作B是線程T對變量W實施的use或assign動作,假定動作G是和動作B相關(guān)聯(lián)的load或store動作,假定動作Q是和動作G相應(yīng)的對變量W的read或write動作。如果A先于B,那么P先于Q。

    這條規(guī)則要求volatile修飾的變量不會被指令重排序優(yōu)化,從而保證代碼的執(zhí)行順序與程序的順序相同。

    上述內(nèi)容就是如何理解Java volatile內(nèi)存屏障底層原理語義,你們學(xué)到知識或技能了嗎?如果還想學(xué)到更多技能或者豐富自己的知識儲備,歡迎關(guān)注億速云行業(yè)資訊頻道。

    向AI問一下細節(jié)

    免責聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點不代表本網(wǎng)站立場,如果涉及侵權(quán)請聯(lián)系站長郵箱:is@yisu.com進行舉報,并提供相關(guān)證據(jù),一經(jīng)查實,將立刻刪除涉嫌侵權(quán)內(nèi)容。

    AI