我所知道大廠高頻面試題之 volatile 的一連串轟炸問題

前言需求


咱們來看看不一樣大廠直接涉及到的一些有關volatile的面試題java

螞蟻花唄:請你談談volatile的工做原理程序員

今日頭條:Volatile的禁止指令重排序有什麼意義?synchronied怎麼用? 面試

螞蟻金服:volatile 的原子性問題?爲何i++ 這種不支持原子性?從計算機原理的設計來說下不能保證原子性的緣由編程

1、volatile 是什麼?

Java語言規範第三版中對volatile的定義以下:java編程語言容許線程訪問共享變量,爲了確保共享變量能被準確和一致的更新,線程應確保經過排他鎖單獨得到這個變量數組

Java語言提供了volatile,在某些狀況下比鎖更加方便。緩存

若是一個字段被聲明爲volatile,java線程內存模型確保全部線程看到這個變量的值是一致的安全

從上面的官方定義咱們可簡單一句話說明:volatile是輕量級的同步機制主要有三大特性:保證可見性、不保證原子性、禁止指令重排多線程

那麼僅接問題就來了:什麼是可見性?什麼是不保證原子性?指令重排請你說說?編程語言

2、volatile 的可見性特徵

在說可見性以前,咱們須要從JMM開始提及,否則怎麼講?ide

咱們知道JVM是Java虛擬機,JMM是什麼?答:Java內存模型

那麼JMM與volatile有什麼關係?

別急,咱們先來了解一下JMM是個什麼玩意先

JMM(Java內存模型Java Memory Model,簡稱JMM)自己是一種抽象的概念並不真實存在,它描述的是一組規則或規範

經過這組規範定義了程序中各個變量(包括實例字段,靜態字段和構成數組對象的元素)的訪問方式

簡單來講就像中國的十二生肖,其中有一龍,但你能在動物園裏牽一頭出來嗎?

這龍其實就是十二生肖之一,是一種規範,佔位,約定,有一個位置屬龍

JMM關於關於同步的規定

1.線程解鎖前,必須把共享變量的值刷新回主內存

2.線程加鎖前,必須讀取主內存的最新值到本身的工做內存

3.加鎖與解鎖要同一把鎖

什麼玩意?又多兩個知識點,什麼是主內存、工做內存?

對於咱們工做中的數據存儲大概是這樣:硬盤<內存<CPU

image.png

好比說當咱們的小明同窗存儲在主內存中

image.png

這時有三個線程須要修改小明的年齡,那麼會怎麼操做呢?

image.png

假如線程t1,將小明的年齡修改成:37,這時會怎麼樣呢?

image.png

咱們須要一種機制,能知道某線程操做完後寫回主內存及時通知其餘線程
image.png

簡單的來講:好比下一節咱們班的語文課修改成數學課,須要立刻通知給咱們班全部同窗,下節課改成數學課了

image.png

結論:只要有變更,當即收到最新消息

JMM的主內存與工做內存描述

因爲JVM運行程序的實體是線程,而每一個線程建立時JVM都會爲其建立一個工做內存(有些地方稱爲棧空間),工做內存是每一個線程的私有數據區域

Java內存模型中規定全部變量都存儲在主內存,主內存是共享內存區域全部線程均可以訪問

但線程對變量的操做(讀取賦值等)必須在工做內存中進行,首先要將變量從主內存拷貝的本身的工做內存空間,而後對變量進行操做

操做完成後再將變量寫回主內存不能直接操做主內存中的變量,各個線程中的工做內存中存儲着主內存中的變量副本拷貝

所以不一樣的線程間沒法訪問對方的工做內存,線程間的通訊(傳值)必須經過主內存來完成,其簡要訪問過程以下圖:

image.png

結論:當某線程修改完後並寫回主內存後,其餘線程第一時間就能看見,這種狀況稱:可見性

示例代碼來認識可見性

class TestData{

     int number = 0;

    //當方法調用的時候,number值改成60
    public void  addNum(){
        this.number = 60;
    }
}
public static void main(String[] args) {

        TestData  data = new TestData();

        new Thread(() -> {
            System.out.println(Thread.currentThread(). getName()+"\t come in");
            try {
                //暫停一會
                TimeUnit.SECONDS.sleep(3 );
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            //調用方法從新賦值
            data.addNum();
            System.out.println(Thread.currentThread(). getName()+"\t updated number value: " +data.number);
        },"AAA"). start();

        while(data.number == 0){

        }
        System.out.println(Thread.currentThread(). getName()+"\t getMessage number value: "+data.number);
    }
    
    
運行結果以下:
AAA     come in
AAA     updated number value: 60

可是有沒有發現,當將number 修改成60的時候,main主線程並不知道

因此咱們的main線程須要被通知,須要被第一時間看見修改的狀況

class TestData{

    volatile int number = 0;

    //當方法調用的時候,number值改成60
    public void  addNum(){
        this.number = 60;
    }
}
public static void main(String[] args) {

        TestData  data = new TestData();

        new Thread(() -> {
            System.out.println(Thread.currentThread(). getName()+"\t come in");
            try {
                //暫停一會
                TimeUnit.SECONDS.sleep(3 );
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            //調用方法從新賦值
            data.addNum();
            System.out.println(Thread.currentThread(). getName()+"\t updated number value: " +data.number);
        },"AAA"). start();

        while(data.number == 0){

        }
        System.out.println(Thread.currentThread(). getName()+"\t getMessage number value: "+data.number);
    }
    
    
運行結果以下:
AAA     come in
AAA     updated number value: 60
main    getMessage number value: 60

這時咱們的main 接收到最新狀況,並無進入while循環,沒有像剛剛那樣一直傻傻的等待,因此直接getMessage輸出最新的值

3、volatile的原子性特徵

首先咱們來看看什麼是原子性?

指的是:不可分割,完整性,也即某個線程正在作某個具體業務時,中間不能夠被加塞或者被分割。 須要總體完整要麼同時成功,要麼同時失敗

好比說:來上課同窗在黑板上籤到本身的名字,是不能被打斷或者修改的

image.png

那麼咱們上面根據官方的定義總結一句話說明,提到過不保證原子性

那麼爲何會出現不保證原子性呢?

咱們前面說到使用volatile 可讓其餘線程第一時間看到最新狀況

可是這也是很差的地方,咱們用案例來講說這種狀況是怎麼回事

class TestData{

    volatile int number = 0;

    //當方法調用的時候,number值++
    public void addNumPlus(){
        number ++;
    }
}

咱們採用for循環來模擬二十個線程,每一個線程作1000次的調用方式

public static void main(String[] args) {
    TestData data = new TestData();
    for (int i = 1; i<= 20; i++){
        new Thread(() -> {
            for (int j= 1; j<= 1000; j++){
                //調用方法從新賦值
                data.addNumPlus();
            }
        },String.valueOf(i)). start();
    }

    //須要等待上面20個線程都所有計算完成後,再main線程取得最終的結果值看是多少?
    while(Thread . activeCount() > 2){
        Thread.yield();
    }

    System.out.println(Thread.currentThread().getName()+"\t finally number  value"+ data.number);

運行結果以下:
main    finally number  value19853

咱們使用volatile 來保證可見性,按理來講20個線程每一個作1000次

咱們的到的結果應該是20000纔對,爲何是19853呢??why!

圖解爲何不保證原子性

還記得咱們的JMM規定全部變量都存儲在主內存,而線程對變量的操做(讀取賦值等)必須在工做內存中進行嗎?

首先要將變量從主內存拷貝的本身的工做內存空間,而後對變量進行操做

image.png

因此咱們當前的t一、t二、t3 初始值爲0

image.png

當咱們的線程調用方法進行++的時候,拷貝副本到本身的內存空間

image.png

好比說t一、t二、t3線程各自在本身的空間++完後,將變量寫回主內存

image.png

這時由於線程之間交錯,在某一時間段內出現了一些問題

image.png

致使被t2 線程寫入主內存,刷新數據寫回主內存

image.png

咱們volatile保證了可見性,這時應該是第一時間通知其餘線程

image.png

這也就是爲何不與咱們想的同樣,是20000,反而是19853

圖解解讀字節碼++操做作了哪些事情

image.png

這裏使用新的類T1,抽取出來但同等代碼是同樣的

image.png

咱們根據add 方法的事情,先看看它作了哪些事情

image.png

噢,看了分析圖,是否瞭解了實際n++ 分了三步驟

當咱們的線程t一、t二、t3執行第一步拷貝副本到本身空間

image.png

當咱們的線程t一、t二、t3執行第二步在本身空間操做變量

image.png

當咱們的線程t一、t二、t3執行第三步將值寫回給主內存時

image.png

volatile怎麼解決原子性問題

1.添加synchronized的方式
2.使用AtomicInteger

class TestData{

    volatile int number = 0;

    //當方法調用的時候,number值改成60
    public void  addNum(){
        this.number = 60;
    }
    public void addNumPlus(){
        number ++;
    }

    AtomicInteger atomicInteger = new AtomicInteger();
    public void addAtomic(){
        atomicInteger.getAndIncrement();
    }

}
public static void main(String[] args) {

    TestData data = new TestData();
    for (int i = 1; i<= 20; i++){
        new Thread(() -> {
            for (int j= 1; j<= 1000; j++){
                //調用方法從新賦值
                data.addNumPlus();
                data.addAtomic();
            }
        },String.valueOf(i)). start();
    }

    //須要等待上面20個線程都所有計算完成後,再main線程取得最終的結果值看是多少?
    while(Thread . activeCount() > 2){
        Thread.yield();
    }

    System.out.println(Thread.currentThread().getName()+"\t int finally number  value:"+ data.number);

    System.out.println(Thread.currentThread().getName()+"\t AtomicInteger finally number  value:"+ data.atomicInteger);
}


運行結果以下:
main     int finally number  value:19966
main     AtomicInteger finally number  value:20000

爲何使用AtomicInteger能夠解決這個問題呢?(小編水平不夠,後面再補)

4、volatile的指令重排

那麼咱們來聊聊什麼是指令重排,什麼是指令重排?

其實就是有序性,簡單的來講程序員通常寫的代碼長這樣

image.png

可是在咱們的電腦機器眼裏,咱們的代碼長這樣

image.png

換句話說,什麼是指令重排的呢?

image.png

爲了保證快、準、穩,會作一些指令重排提升性能

image.png

單線程環境裏面確保程序最終執行結果和代碼順序執行的結果一致。

處理器在進行重排序時必需要考慮指令之間的數據依賴性

多線程環境中載程交替執行,因爲編澤器優化重排的存在,兩個線程中使用的變量可否保證一致性是沒法肯定的結果沒法預測

示例一:班上同窗答題

image.png

咱們有五道題

當只有一個同窗的時候,咱們能夠隨便搶,都是一題一題有執行順序

當有多個同窗的時候,咱們沒法控制順序,搶到哪一題就是哪一題

示例二:代碼塊執行順序

public void mySort()
{
    int x=11;   //語句1
    int y=12;   //語句2
    x= x + 5;   //語句3
    y= x * x;   //語句4
}

當咱們單線程的時候,他的順序是1234

當咱們多線程的時候,他有可能順序就是:213四、1234了

那麼請問:執行順序能夠是413二、4123呢?

答案是不能夠的,由於必需要考慮指令之間的數據依賴性

示例三:代碼執行順序

image.png

請問x y 是多少?答:x = 0 y = 0

若是編譯器對這段代碼進行從新優化後,可能會出現如下狀況

image.png

請問x y 是多少?答:x = 2 y = 1

示例四:代碼塊執行順序

public class ReSortSeqDemo{

    int    a=0;
    boolean flag = false;

    public void method01(){
        a =1;        //語句1
        flag = true;//語句2
    }
    public void method02(){

        if(flag){
            a=a+5;            //語句3
        }
        System.out.println("*****retValue: "+a);
    }
}

假如示例代碼出現指令重排的狀況,語句1,語句2 的順序便從1-2,變成2-1,這個時候flag = true

當兩個線程有一個線程搶到flag = true 就會執行下面的if判斷

這時就會有兩個結果:a = 6 、a =5

volatile 禁止實現指令重排優化

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

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

1.保證特定操做的執行順序

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

因爲編譯器和處理器都能執行指令重排優化。若是在指令間插入一條MemoryBarrier則會告訴編譯器和CPU,無論什麼指令都不能讓這條Memory Barrier指令重排序

也就是說經過插入內存屏障禁止在內存屏障先後的指令執行重排序優化

內存屏障另一個做用是強制刷出各類CPU的緩存數據,所以任何CPU上的線程都能讀取到這些數據的最新版本

image.png

5、單例模式下的volatile

咱們都知道單例模式下懶漢有非線程安全的狀況發生,常見的方式下采用DCL(Double Check Lock)

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

那麼多線程狀況下會指令重排致使讀取的對象是半初始化狀態狀況

其實DCL機制也不必定是線程安全的狀況,緣由是有指令重排

緣由在於某一個線程執行第一次檢測的時候有如下狀況
1.讀取到instance !=null
2.instance 的引用對象沒有完成初始化

簡單來講:分座位,有一個叫張三的人一個小時候纔來坐這個位置

理論上座位分配出去了,但實際上人並無到,有名無實

咱們來分析一下instance = new Singleton(); 這一步代碼
1.memory = allocate(); //1. 分配對象內存空間
2.instance(memory);//2.初始化對象
3.instance = memory; //3. 設置instance指向剛分配的內存地址,此時instance! =null

簡單來講對應的步驟是
1.有個張三的須要分配座位,我爲給他留一個位置
2.給張三的位置分配好網線,電腦,擦乾淨桌子
3.一個小時後張三到了把位置給他坐下,進行上課

雖說這是理論上來講是這樣的,可是很抱歉步驟2和步驟3不存在數據依賴關係,並且不管重排前仍是重排後程序的執行結果在單線程中並無改變,所以這種重排優化是容許的。

指令重後變成了如下的執行順序
1.memory = allocate(); //1. 分配對象內存空間
2.instance = memory; //3. 設置instance指向剛分配的內存地址,此時instance! =null
3.instance(memory);//2.初始化對象

可是指令重排只會保證串行語義的執行的一致性(單線程),但並不會關心多線程間的語義一致性。

因此當一條線程訪問instance不爲nul時,因爲instance實例未必已初始化完成,也就形成了線程安全問題。

即示例未初始化完成,保留的是默認值,這樣也是出問題

因此使用volatile禁止指令重排,老老實實按順序來

參考資料


尚硅谷:Java大廠面試題全集(周陽主講):volatile

相關文章
相關標籤/搜索