天天看點

一篇文章讀懂volatile

前提

計算機在執行程式代碼的時候,實際上執行的是一條條指令,而這些指令,肯定會涉及到資料的讀取和寫入操作。

在我們的程式中,所定義的變量等臨時資料,計算機會放在記憶體中,也稱為主存。

那麼問題來了,CPU執行指令的速度是很快的,但是從記憶體中讀取資料和寫入資料的過程,相比CPU執行指令的速度來說是比較慢的。如果每個程式都是直接從記憶體中讀取資料,那麼由于CPU執行指令的速度和資料的讀取寫入操作的速度不一緻,那麼肯定會大大降低了執行的效率,是以在CPU裡面引入了高速緩存。

當程式在運作過程中,會将運算需要的資料從主存複制一份到CPU的高速緩存當中,那麼CPU進行計算時就可以直接從它的高速緩存讀取資料和向其中寫入資料,當運算結束之後,再将高速緩存中的資料重新整理到主存當中。

Java記憶體模型

内容

Java程式的所有變量都存儲在主記憶體中。我們知道,Java的每個線程在運作的時候,都會有自己的工作記憶體,線程所用的變量和資料都是用工作記憶體中的,工作記憶體的資料都是從主存中擷取的。由于每個線程都是獨立的,是以不同線程不可以互相通路工作記憶體的變量,隻有通過主存還傳遞變量,當線程對資料進行操作之後,會把工作記憶體的資料重新整理到主存,但是這個重新整理的時間是不确定的。

一篇文章讀懂volatile

多線程帶來的髒讀問題

int i = 10
i = i + 1
           
  1. 單線程,i的值存放在記憶體中,當隻有一個線程進行+1操作的時候,先從記憶體中讀取I的值到線程自己的工作記憶體中,然後進行自增操作,然後寫入到自己的工作記憶體,然後再重新整理到主存中。
  2. 多線程,如果同時有兩個線程執行+1的操作,我們預期的效果i的值是12。可是在多核CPU中,兩個線程可能會同時從記憶體讀取i的值讀取到工作記憶體,此時工作記憶體之間都是獨立存在的。是以當一個線程對i的值進行+1,寫到自己的工作記憶體,然後重新整理到主存,此時主存I的值為11,另外一個線程由于是同時讀取i的值,也就是讀的時候是10,那麼操作完成之後也是把主存值變為11,畢竟兩個線程對i的操作都是一樣嘛。這就不符合我們當初的預期了

多線程引出的問題就是緩存一緻性的問題了,被多個線程通路的變量i也被成為共享變量。

那麼問題來了,如何才能讓多線程執行才能符合我們預期呢?

先了解并發程式設計的 原子性,可見性,有序性 吧!!!

并發程式設計特性

原子性

内容

定義

一個操作或者多個操作要麼全部執行并且執行的過程不會被任何因素打斷,要麼就都不執行。

執行個體

賬戶A給賬戶B進行銀行轉賬1000元,包括兩個操作 1. 賬戶A扣去1000元,2. 賬戶B加上1000元。

實際中,這兩個操作必須符合原子性,就是說,操作1和操作2要麼一起執行,要麼全部不執行。

如果不符合原則性,那麼會帶來問題。當賬戶A扣去1000元的時候,操作由于某些原因突然中止,那麼A賬戶已經扣去1000元了,可是操作2并沒有執行,也就是賬戶B沒有加上1000元,那麼使用者就白白損失了1000元了。

Java的原子性

定義

在Java記憶體模型中,隻對***變量的讀取***和***用常量指派給變量的操作***是具有原子性。

變量給變量之間的互相指派這個過程不具有原子性。

其他的地方,如果要實作更大範圍的原子性,可用關鍵字Synchronized和Lock實作。

執行個體

x = 10;        //語句1
y = x;         //語句2
x++;           //語句3
x = x + 1;     //語句4
           

由定義可知,隻對***變量的讀取***和***用常量指派給變量的操作***是具有原子性

語句1,滿足常量指派給變量的要求,整個操作符合原子性。

語句2,包含兩個操作,1 讀取X變量的值,2 将讀取到的值指派給y變量。這兩個操作隻是各自符合原子性,但是合起來就不符合原子性。

語句3 和語句4是一樣的, 包含三個操作, 1 讀取x變量的值 , 2 對變量x進行+1 ,3 将步驟2所得的值指派給變量x。和步驟2一行,獨自的操作符合原子性,合在一起就不符合了。

綜上,除了語句1,其他語句如果在多線程的情況下執行,很有可能會出現和我們預想不到的結果。

有序性

内容

程式執行的順序按照代碼的先後順序執行。

指令重排序

處理器為了提高程式運作效率,可能會對輸入代碼進行優化,也就是對執行指令進行重排序。它不保證程式中各個語句的執行先後順序同代碼中的順序一緻,但是它會保證程式最終執行結果和代碼順序執行的結果是一緻的。

如何保證指令順序不一樣但是執行的最終結果和代碼順序執行時一樣的?

處理器在進行重排序時是會考慮指令之間的資料依賴性,如果一個指令Instruction 2必須用到Instruction 1的結果,那麼處理器會保證Instruction 1會在Instruction 2之前執行。

TIP:指令重排序不會影響單個線程的執行結果,但是多線程則不一定。

執行個體

單線程
int i = 0;              

boolean flag = false;

i = 1;                //語句1  
flag = true;          //語句2
           

語句1和語句2所代表的指令,互相之間并沒有什麼依賴關系,是以這兩語句執行的順序怎樣都不會影響結果。也就是說

可能是 1—>2,也有可能是2—>1,但不影響最終結果

int a = 10;    //語句1
int r = 2;    //語句2
a = a + 3;    //語句3
r = a*a;     //語句4
           

我們看到,語句3依賴于語句1,語句4依賴于語句3,是以這個順序肯定是不可以變的,正是因為這樣,是以才可以保證指令重排序但是執行的結果依然不變。

執行順序可能是

1->2->3->4

2->1->3->4

多線程
boolean inited = false;
//線程1:
context = loadContext();   //語句1
inited = true;             //語句2

 //線程2:
while(!inited ){
   sleep()
}
doSomethingwithconfig(context);
           

語句1和語句2并沒有依賴關系,是以指令重排序之後,線程1可能會先執行語句2然後再執行語句1。可能會發生這種情況,線程1執行完語句2,由于某種原因發生了阻塞,線程2此時跳出死循環,然後執行到doSomethingwithconfig(context)

可是context并沒有被加載出來,那麼很有可能會出現故障。

綜上:指令重排序不會影響單個線程的執行,但是會影響到線程并發執行的正确性。

Java的有序性

Java記憶體模型本身就有一些有序性,也就是說不需要通過任何手段就能夠得到保證的有序性。

稱為 happens-before原則。如果兩個操作的執行次序無法從happens-before原則推導出來,那麼它們就不能保證它們的有序性,虛拟機可以随意地對它們進行重排序。

  1. 程式次序規則:一個線程内,按照代碼順序,書寫在前面的操作先行發生于書寫在後面的操作
  2. 鎖定規則:一個unLock操作先行發生于後面對同一個鎖的lock操作
  3. volatile變量規則:對一個變量的寫操作先行發生于後面對這個變量的讀操作
  4. 傳遞規則:如果操作A先行發生于操作B,而操作B又先行發生于操作C,則可以得出操作A先行發生于操作C

1 2 4 都比較好了解,就不多多說,說下3

volatile變量規則

如果一個線程先去寫一個變量,然後一個線程去進行讀取,那麼寫入操作肯定會先行發生于讀操作。(記住這句,後面分析的時候會用到)

了解volatile關鍵字

保證可見性

共享變量被volatile修飾之後,就說明有以下的特性

  1. 不會緩存到自己的本地記憶體,直接從主記憶體中讀取。
  2. 一個線程對其進行修改馬上對其他線程可見。

執行個體

//線程1
boolean stop = false;
while(!stop){
    doSomething();
}

//線程2
stop = true;
           

前提:我們期望線程2修改stop的值進而讓線程1停止循環doSomething

不用volatile關鍵字的問題

線程2的語句有兩個步驟,1、将true指派給工作記憶體中的stop臨時變量。 2、将工作記憶體中的臨時變量更新到主存中,但是不是到什麼時候更新。可是在執行步驟1之後,線程2可能去做其他事情了。進而導緻并沒有讓主存的stop的值得到更新,那麼線程1之前一直是讀取工作記憶體中的值,那麼如果某一時刻讀取主存中的值的時候,那麼stop的值還是沒有改變,是以就會一直循環,不符合我們的預期。

加了volatile關鍵字

線程2還是有兩個步驟,和上面的一樣,差別在于步驟2,步驟2會馬上将線程2的工作記憶體中的值設定到主存中,

  1. 如果線程1還沒有讀取stop值,那麼讀取工作記憶體的時候會發現讀取無效,則會到記憶體中讀取。
  2. 如果線程1在工作記憶體無效之前已經讀取過一次,那麼下一次循環的時候(也就是線程2将主存的值更新并且設定線程1的工作記憶體無效)就會從記憶體中讀取最新的值了!

是以加了關鍵字,這段代碼可以不會發生我們的預期之外,你看神奇吧?!

原理

在多處理器環境下,為了保證各個處理器緩存一緻,每個處理會通過嗅探在總線上傳播的資料來檢查 自己的緩存是否過期,當處理器發現自己緩存行對應的記憶體位址被修改了,就會将目前處理器的緩存行設定成無效狀态,當處理器要對這個資料進行修改操作時,會強制重新從系統記憶體把資料讀到處理器緩存裡。 這一步確定了其他線程獲得的聲明了volatile變量都是從主記憶體中擷取最新的。

引用自别人的部落格:https://blog.csdn.net/it_dx/article/details/70045286

不保證原子性

package suanfa;

public class VolatileTest {
    public static volatile int count = 0;

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    for(int j=0;j<100;j++){
                        count++;//每個線程執行加100
                    }
                    System.out.print(count+" ");
                }
            }).start();
        }
    }
//    運作結果
//    200 400 500 300 200 600 700 800 900 1000
//    100 200 384 500 400 600 700 800 900 1000
//    200 200 300 400 578 578 678 778 878 978 
}

           

分析

之前曾經說過,count++這個操作不符合原子性,就是說會有三個步驟 1. 讀取count變量的值 、2.對值進行+1 、 3. 指派給工作記憶體,然後重新整理到記憶體中。

這裡說下可見性的本質(個人了解):

  1. 線程準備讀取自己工作記憶體的變量的時候,如果其他線程讓主存發生了重新整理,那麼讀取工作空間的變量會失效。
  2. 如果線程1在自己的工作記憶體沒有失效之前已經讀取了,線程2讓記憶體的值發生了變化,線程1隻用自己成功讀取到的值!!

在某一時刻count的值是10,線程1和線程2同時去讀取count的值,存放在自己的工作記憶體,由可見性的本質的第2點可知,假如線程2完成更新操作,讓記憶體的值完成了更新變為了11,可是線程1因為早就讀取了值,不會受到影響,是以自己就是操作10,最後更新到記憶體值還是11,兩次++,但是值是11。

說明了volatile并不保證原子性!

解決辦法

  1. 通過Synchronized和Lock加鎖,實作原子性。
  2. CAS操作,可以去了解AtomInterger的源碼就知道CAS是怎麼操作的了

參考連結

保證有序性

前面說過volatile不會讓指令重排序,是以volatile能在一定程度上保證有序性。

兩點去了解有序性

  1. 當程式執行到volatile變量的讀操作或者寫操作時,在其前面的操作的更改肯定全部已經進行,且結果已經對後面的操作可見;在其後面的操作肯定還沒有進行;
  2. 在進行指令優化時,不能将在對volatile變量的讀操作或者寫操作的語句放其前面語句的後面執行,也不能把volatile變量後面的語句放到其前面執行。

通過代碼執行個體來看

//x、y為非volatile變量
//flag為volatile變量

x = 2;        //語句1
y = 0;        //語句2
flag = true;  //語句3
x = 4;         //語句4
y = -1;       //語句5
           

如果執行語句3,那麼語句1和2肯定已經執行了,但是由于指令重排序(非volatile關鍵字),1和2誰先執行并不知道。

而且語句3執行之後的效果,對後面的語句4和5是可見的,4和5的順序也是不一定的,雖然這裡沒什麼可見的。

語句4和5,語句1和2各自也沒什麼資料依賴上的關系,但是由于flag是volatile,是以重排序的時候4和5不能在flag前執行,1和2也不能在flag後執行。

前面的例子

//線程1:
context = loadContext();   //語句1
inited = true;             //語句2

//線程2:
while(!inited ){
  sleep()
}
doSomethingwithconfig(context);
           

如果inited加了volatile關鍵字那麼就可以保證不會出錯了。

由于有序性,那麼inited = true如果執行了,那麼前面的context肯定已經初始化了,是以線程2執行就不會出現context沒有初始化的情況了!

原理

Lock字首指令實際上相當于一個記憶體屏障(也成記憶體栅欄),它確定指令重排序時不會把其後面的指令排到記憶體屏障之前的位置,也不會把前面的指令排到記憶體屏障的後面;即在執行到記憶體屏障這句指令時,在它前面的操作已經全部完成。

引用自别人的部落格:https://blog.csdn.net/it_dx/article/details/70045286

應用

Synchronizedvolatile關鍵字在某些情況下性能要優于synchronized,但是要注意volatile關鍵字是無法替代synchronized關鍵字的,因為volatile關鍵字無法保證操作的原子性。通常來說,使用volatile必須具備以下2個條件

  1. 對變量的寫操作不依賴于目前值
  2. 該變量沒有包含在具有其他變量的不變式中
class Singleton{
    private volatile static Singleton instance = null;

    private Singleton() {

    }

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

語句1包括以下3個操作,并不符合原子性(沒有加Synchronized的情況下)

  1. 給 instance 配置設定記憶體
  2. 調用 Singleton 的構造函數來初始化成員變量
  3. 将instance對象指向配置設定的記憶體空間(執行完這步 instance 就為非 null )

(以下說的是不加Synchronized的情況)

如果不給instance加volatile關鍵字,那麼由于指令重排序的優化,步驟3可能先于步驟2執行,是以當目前線程執行步驟3的時候,其他線程也用了instance資源,這時由于instance不為空,那麼直接傳回instance,那麼就出錯了。

如果添加了Synchronized和volatile,也就是源碼那樣,就可以很好避免上面說的問題了!

總結

  1. 對Java的記憶體模型有了深刻的印象
  2. 加深了volatile和Synchronized和CAS的印象,以及其中的差別

參考

  1. https://blog.csdn.net/it_dx/article/details/70045286
  2. https://blog.csdn.net/strivenoend/article/details/80440884

繼續閱讀