面試打怪升升級-被問爛的volatile關鍵字,這次我要搞懂它(深入到操作系統層面理解,超多圖片示意圖)

一、volatile簡介

Java語言規範第3版中對volatile的定義如下:Java編程語言允許線程訪問共享變量,爲了確保共享變量能被準確和一致地更新,線程應該確保通過排他鎖單獨獲得這個變量。Java語言提供了volatile,在某些情況下比鎖要更加方便。如果一個字段被聲明成volatile,Java線程內存模型確保所有線程看到這個變量的值是一致的。

二、多線程下的安全問題

1. visibility(可見性引起的問題)

(1)、代碼

在多線程併發執行下,多個線程修改共享的成員變量,會出現一個線程修改了共享變量值後,兩一個線程不能立即看到該線程修改後的變量的新值

public class VisibilityDemo extends Thread {
    private static boolean isRunning = true;

    public static void main(String[] args) throws InterruptedException {
    
        new VisibilityDemo().start();
        Thread.sleep(100);
        isRunning = false;
        System.out.println("我已經修改了標誌變量,isRunning = " + isRunning);

    }

    @Override
    public void run() {
        while (isRunning) ;
        System.out.println("線程被停止了");
    }
}

這個例子很簡單,在子線程中判斷flag的值,如果爲true一直運行;如果爲false則停止運行。我們在main線程中啓動了子線程,在sleep(100)後設置flag值爲false(保證子線程先運行),發現線程無法停止。

(2)、測試結果

在這裏插入圖片描述

2. order(有序性引起的問題)

(1)、代碼

定義兩組變量x,y和a,b。然後再分別在兩個線程t1,t2中修改變量值。

public class OrderDemo {
    private static int x = 0, y = 0;
    private static int a = 0, b = 0;


    public static void main(String[] args) throws InterruptedException {
        int count = 0;
        while (count++ >= 0) {
            //A
            A t1 = new Thread(() -> {
                x = 1;// A1
                a = y;// A2
            });

            //B
            B t2 = new Thread(() -> {
                y = 1;// B1
                b = x;// B2
            });


            t1.start();
            t2.start();

            //等待t1,t2運行完畢,查看變量值
            t1.join();
            t2.join();


            System.out.println("第"+count+"次,a = "+a+",b = "+b);
            //這種情況概率比較少,所以在這裏判斷它作爲中止條件
            if(a==0 && b==0){
                break;
            }

            //initialize
            x = y = a = b = 0;
        }
    }
}

(2)、測試結果

在這裏插入圖片描述
指令1、2、3、4的混序執行導致出現了不一樣的運行結果。其中a,b分別爲四組值:(1,0)、(0,1)、(1,1)、(0,0)

三、volatile的作用與原理

1. 可見性測試代碼解釋,如何保證可見性

(1)、現象解釋

在測試用例1中,可以看到我們在main線程中修改了共享變量值,但是在子線程中卻沒有讀取到改變後的值,導致程序無法正常停止。說到原因我們要先從cpu說起,現代cpu的處理速度遠遠高於硬盤的io讀取速度,因此在這種情況下就出現了緩存策略,緩存是在內存中分配的空間,大大提高了io速度。並由最開始的一級緩存進化到現在3級緩存
在這裏插入圖片描述
緩存的意義:

  • 時間局部性:如果某個數據被訪問,name在不久的將來他可能能再次被訪問。
  • 空間局部性:如果某個數據被訪問,那麼他與相鄰的數據很快也可能被訪問。
    在這裏插入圖片描述
    爲了方便理解我們可以得出一個更爲簡潔的等效模型
    在這裏插入圖片描述
    由上圖可以看出線程在運行時將主內存中的變量緩存到本地工作空間中(複製一份副本)。cpu的處理策略一般是會==修改緩存中變量值,而不是直接修改主內存中的值。==在合適的時候(調用os同步指令,或累計夠了一定值)寫入主內存中。在我們的測試中就是因爲main線程修改的是它的工作空間的變量值,而子線程讀因爲一直在執行while沒有空閒去從主內存中同步變量isRunning,子線程中isRunning值一直是緩存的false,所以不會停止運行。

(2)、使用volatile保證可見性

使用volatile修飾isRunning變量,發現線程可以正常停止,達到我們的預期效果。
在這裏插入圖片描述
這是因爲什麼呢,我們查看代碼的彙編指令,下載hsdis-amd64插件(lbbc),將他解壓到jre/bin目錄下。然後在idea中設置啓動參數(eclipse中同理)如下:
在這裏插入圖片描述

VM options:-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*VisibilityDemo.main(最後替換爲類名.方法名即可)

我們仔細觀察添加volatile後的代碼和沒有添加之前的區別,如圖:
在這裏插入圖片描述
可以看到在賦值的時候多了一個lock指令,這個lock指令是什麼呢,我們查看Intel® 64 and IA-32 Architectures Software Developer’s Manual文檔可以發現這個指令
在這裏插入圖片描述

對於Intel486和Pentium處理器,在lock操作時,總是在總線上聲言LOCK#信號。(這會給總線枷鎖,效率較低)
但是對於P6和更新的處理器系列,訪問的內存區域如果已經緩存在處理器內部,則不會聲言LOCK#信號。相反他會鎖定這塊內存區域的緩存並回寫到內存,並使用緩存一致性機制來確保修改的原子性,此操作被成 “緩存鎖定“緩存一致性機制會防止緩存了相同內存區域的兩個或多個進程同時修改該區域中的數據。在Pentium和P6 family處理器中,如果通過嗅探一個處理器來檢測其他處理器打算寫內存地址,而這個地址當前處於共享狀態,那麼正在嗅探的處理器將使它的緩存行無效,在下次訪問相同內存地址時,強制執行緩存行填充一個處理器的緩存回寫到內存會導致其他處理器的緩存無效。

緩存一致性協議:一致性緩存:所有緩存副本中的值都相同,多個CPU處理器共享緩存並且更改共享數據時,更改必須同步到所有緩存副本。在處理器中,嗅探是一致性緩存的常見的機制,各個核能夠時刻監控自己和其他核的狀態,從而統一管理協調。窺探的思想是:CPU的各個緩存是獨立的,但是內存卻是共享的,所有緩存的數據最終都通過總線寫入同一個內存,因此CPU各個核都能“看見”總線,即各個緩存不僅在進行內存數據交換的時候訪問總線,還可以時刻“窺探”總線,監控其他緩存在幹什麼。因此當一個緩存在往內存中寫數據時,其他緩存也都能“窺探”到,從而按照一致性協議保證緩存間的同步。關於更多的細節可以查看這篇博客:《大話處理器》Cache一致性協議之MESI

狀態 說明
失效(Invalid)緩存段 要麼已經不在緩存中,要麼它的內容已經過時。爲了達到緩存的目的,這種狀態的段將會被忽略。一旦緩存段被標記爲失效,那效果就等同於它從來沒被加載到緩存中。
共享(Shared)緩存段 它是和主內存內容保持一致的一份拷貝,在這種狀態下的緩存段只能被讀取,不能被寫入。多組緩存可以同時擁有針對同一內存地址的共享緩存段,這就是名稱的由來。
獨佔(Exclusive)緩存段 和 S 狀態一樣,也是和主內存內容保持一致的一份拷貝。區別在於,如果一個處理器持有了某個 E 狀態的緩存段,那其他處理器就不能同時持有它,所以叫“獨佔”。這意味着,如果其他處理器原本也持有同一緩存段,那麼它會馬上變成“失效”狀態。
已修改(Modified)緩存段,屬於髒段 它們已經被所屬的處理器修改了。如果一個段處於已修改狀態,那麼它在其他處理器緩存中的拷貝馬上會變成失效狀態,這個規律和 E 狀態一樣。此外,已修改緩存段如果被丟棄或標記爲失效,那麼先要把它的內容回寫到內存中——這和回寫模式下常規的髒段處理方式一樣。

只有當緩存段處於 E 或 M 狀態時,處理器才能去寫它,也就是說只有這兩種狀態下,處理器是獨佔這個緩存段的。當處理器想寫某個緩存段時,如果它沒有獨佔權,它必須先發送一條“我要獨佔權”的請求給總線,這會通知其他處理器,把它們擁有的同一緩存段的拷貝失效(如果它們有的話)。只有在獲得獨佔權後,處理器才能開始修改數據——並且此時,這個處理器知道,這個緩存段只有一份拷貝,在我自己的緩存裏,所以不會有任何衝突。反之,如果有其他處理器想讀取這個緩存段(我們馬上能知道,因爲我們一直在窺探總線),獨佔或已修改的緩存段必須先回到“共享”狀態。如果是已修改的緩存段,那麼還要先把內容回寫到內存中
總的來說就是在添加lock操作後,緩存一致協議會保證我們對volatile修飾變量的寫操作是原子的,並且會同步到主內存中,對volatile讀會保證它永遠是最新的值。那麼這樣就解決了我們之前用例中的可見性問題。改變後的通信模型如下:
在這裏插入圖片描述
在這種情況下,在main線程修改isRunning變量後,會寫入到主內存中,而子線程在讀取isRunning變量時也會是最新的值。

2.有序性測試代碼解釋, 如何保證有序性

(1)、現象解釋

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

  1. 編譯器優化的重排序。編譯器在不改變單線程程序語義的前提下,可以重新安排語句
    的執行順序。
  2. 指令級並行的重排序。現代處理器採用了指令級並行技術(Instruction-LevelParallelism,ILP)來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應 機器指令的執行順序。
  3. 內存系統的重排序。由於處理器使用緩存和讀/寫緩衝區,這使得加載和存儲操作看上去可能是在亂序執行。

從Java源代碼到最終實際執行的指令序列,會分別經歷下面3種重排序:
在這裏插入圖片描述

指令1、2、3、4的混序執行導致出現了不一樣的運行結果。其中a,b分別爲三組值:(1,0)、(0,1)、(1,1)、(0,0)。前兩種可以畫處示意圖如下
在這裏插入圖片描述
(1,1)同理,可能是A1->B1->B2->A2等。(0,0)這種情況稍微複雜一點,可能是A1和A2指令重排序後變爲先執行A2(因爲A2不依賴於A1的運行結果),那麼有可能出現A2->B1->B2->A2,也有可能因爲內存系統重排序導致這種情況,如下圖所示:
在這裏插入圖片描述
這裏處理器A和處理器B可以同時把共享變量寫入自己的寫緩衝區(A1,B1),然後從內存中讀取另一個共享變量(A2,B2),最後才把自己寫緩存區中保存的髒數據刷新到內存中(A3, B3)。當以這種時序執行時,程序就可以得到x=y=0的結果。

(2)、使用volatile保證有序性

上面的例子中如果將變量都用volatile修飾就可以保證不會有(0,0)的結果出現,即A1和A2,B1和B2無法指令重排序,但是他們之間仍然可以交替執行,要解決這一問題,只能添加synchronized關鍵字修飾方法,來解決原子性問題。下面解釋volatile關鍵字的內存語言。

JSR133規範

在解釋volatile保證有序性之前先說一下,JSR133規範中給出的解決模型(就好像iOS網絡7層協議,只是給出了指導規範,具體實踐中採用的其實是四層模型)。

  • happens-before

    從JDK 5開始,Java使用新的JSR-133內存模型JSR-133使用happens-before的概念來闡述操作之間的內存可見性。在JMM中,如果一 個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須要存在happens-before關 系。這裏提到的兩個操作既可以是在一個線程之內,也可以是在不同線程之間。具體規定的規則如下:

    規則 解釋
    程序順序規則 一個線程中的每個操作,happens-before於該線程中的任意後續操作
    監視器鎖規則 對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖
    volatile變量規則 對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀
    傳遞性 如果A happens-before B,且B happens-before C,那麼A happens-before C
    start()規則 如果線程A執行操作ThreadB.start()(啓動線程B),那麼A線程的ThreadB.start()操作happens-before於線程B中的任意操作
    join()規則 如果線程A執行操作ThreadB.join()併成功返回,那麼線程B中的任意操作happens-before於線程A從ThreadB.join()操作成功返回
  • as-if-serial
    as-if-serial語義的意思是:不管怎麼重排序(編譯器和處理器爲了提高並行度),(單線程)程序的執行結果不能被改變。編譯器、runtime和處理器都必須遵守as-if-serial語義。爲了遵守as-if-serial語義,編譯器和處理器不會對存在數據依賴關係的操作做重排序,因爲這種重排序會改變執行結果。但是,如果操作之間不存在數據依賴關係,這些操作就可能被 編譯器和處理器重排序。如下示例:

double pi = 3.14; // A
double r = 1.0; // B 
double area = pi * r * r;// C

上面的程序依賴關係如下:
在這裏插入圖片描述
那麼只要保證A、B在C之前執行即可,這中不改變單線程的執行結果的重排序,JMM實現時是允許的。
在這裏插入圖片描述

虛擬機具體實現

java內存模型中定義了8種操作來完成,虛擬機保證了每種操作都是原子的。

nbsp;操作名稱 解釋
lock(鎖定) 作用於主存的變量,把一個變量標識爲一條線程獨佔狀態。
unlock(解鎖) 作用於主存變量,把一個處於鎖定狀態的變量釋放出來,釋放後的變量纔可以被其他線程鎖定。
read(讀取) 作用於主存變量,把一個變量的值從主存傳輸到工作內存。
load(載入) 作用於工作內存變量,把 read 來的值放入工作內存的變量副本中。
use(使用) 作用於工作內存變量,把工作內存中一個變量的值傳遞給執行引擎,每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作。
store(存儲) 作用於工作內存變量,把工作內存中一個變量的值傳送到主存。
write(寫入) 作用於主存變量,把 store 操作從工作內存中得到的變量的值放入主存的變量中。

如果要把一個變量從主存複製到工作內存:順序執行 read 和 load 操作。
如果要把變量從工作內存同步會主存:順序執行 store 和 write 操作。

注意:JMM 只是規定了必須順序執行,而沒有保證是連續執行,其間可以插入其他指令。

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

  • 處理器重排序規則如下
    在這裏插入圖片描述
    通過上表可以發現,常見的處理器都允許Store-Load重排序;常見的處理器都不允許對存在數據依賴的操作做重排序。

爲了保證內存可見性,Java編譯器在生成指令序列的適當位置會插入內存屏障指令來禁止特定類型的處理器重排序。JMM把內存屏障指令分爲4類,如表所示。
在這裏插入圖片描述
StoreLoad Barriers是一個“全能型”的屏障,它同時具有其他3個屏障的效
果。

前文提到過重排序分爲編譯器重排序和處理器重排序。爲了實現volatile內存語義,JMM會分別限制這兩種類型的重排序類型。下表是JMM針對編譯器制定的volatile重排序規則表。
在這裏插入圖片描述
從表中得出:

  • volatile寫是第二個操作時,第一個操作不論是什麼都不準重排序到volatile寫後面
  • 當第一個是volatile讀時,不管第二個操作是什麼,都不能重排序
  • 當第一個是volatile寫時不能排到volatile讀後面

爲了實現volatile的內存語義,編譯器會在生成字節碼時插入內存屏障,針對不同情況減少不必要的屏障。
在這裏插入圖片描述
這裏我們總結一下
volatile變量在讀和寫時會添加lock指令保證緩存同步,volatile讀會從內存中去同步所有的共享變量,並且保證不會有任何指令越過volatile讀重排序到他的前面即volatile讀後面的操作,都是使用的內存中最新的值。volatile寫保證在寫入時會將工作空間中的所有變量都同步到主內存中,而且所有指令都無法越過volatile寫重排序到他的後面,因此他保證了,volatile寫之前的所有操作最終都會同步到主內存中。volatile寫happens before volatile讀。

synchronized鎖的獲取和volatile讀有同樣的內存語義,鎖的釋放和volatile寫有同樣的語音。實現大致相同,除去原子性,排他性。

四、常見關於volatile的面試題

1. volatile可以保證原子性對嗎?

public class Test {
    volatile static AtomicInteger x = new AtomicInteger(0);
    static int y = 0;
	
	//普通加
    static void add() {
        y++;
    }
	//原子加
    static void addAtomic() {
        x.getAndIncrement();
    }

    public static void main(String[] args) throws InterruptedException {

        for (int i = 0; i < 1000; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    add();
                }
            }).start();

        }
        Thread.sleep(5000);
        System.out.println(y);
    }
}

測試結果
在這裏插入圖片描述
我們去執行addAtomic()方法,輸出x的值永遠都是1000000
在這裏插入圖片描述
究其原因,我們查看javap輸出的信息
在這裏插入圖片描述
可以得到i++並非是一個原子操作,而是三,get,add,put。因此在多線程情況下可能出現多個線程同時get取到相同值,然後add,放入內存,這樣最後就會總數小於1000000。

2. 單例模式的應用

public class Singleton {
    static Singleton instance;
 
    public Object o;
 
    public Singleton() {
        this.o = new Object();
    }
 
    static Singleton getInstance(){
        if (instance == null) {
            synchronized(Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

現在我們分析一下爲什麼要在變量singleton之間加上volatile關鍵字。要理解這個問題,先要了解對象的構造過程,實例化一個對象其實可以分爲三個步驟:

(1)分配內存空間。

(2)初始化對象。

(3)將內存空間的地址賦值給對應的引用。

但是由於操作系統可以對指令進行重排序,所以上面的過程也可能會變成如下過程:

(1)分配內存空間。

(2)將內存空間的地址賦值給對應的引用。

(3)初始化對象

如果是這個流程,多線程環境下就可能將一個未初始化的對象引用暴露出來,從而導致不可預料的結果。因此,爲了防止這個過程的重排序,我們需要將變量設置爲volatile類型的變量,volatile可以禁止指令重排序,這樣我們的單例模式就安全了。

參考
http://ifeve.com/wp-content/uploads/2014/03/JSR133中文版.pdf
Intel® 64 and IA-32 Architectures Software Developer’s Manual
深入理解java虛擬機
java併發編程的藝術
Java併發編程實戰

寫在最後,這篇文章寫了挺久,知識點挺瑣碎,複習了以前看過的一些書,想寫這個內容不是很難,但是難在如何用最短的篇幅講清楚更多的內容。昨天晚上在腦子裏構思了很久,一有想法就會馬上去驗證,就這樣睡着都半夜了吧。bb這麼多,創作不易,還請多多支持。如果有問題和建議請在下方留言。

更多優質文章
Synchronized關鍵字深析(小白慎入,深入jvm源碼,兩萬字長文)

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