Java併發編程--深入理解volatile關鍵字

轉自:https://blog.csdn.net/u013309870/article/details/73088852

前言

一個月以前就準備寫篇關於volatile關鍵字的博客,一直沒有動筆,期間看了大量的文章,發現一個小小volatile關鍵字竟然涉及JMM(Java memory model),JVM(Java virtual machine),Java多線程同步與安全各個方面的知識,寫起了非常的困難,後面附帶的參考文獻僅僅是我看過文獻的一部分。


Java memory model(Java內存模型)

在講volatile關鍵字之前必須先了解一下Java內存模型,如果不瞭解Java內存模型volatile關鍵字無從講起。先看看下面的圖。

這裏寫圖片描述

由上圖可以看出來,在JMM中內存分爲兩部分一個是Main Memory(主內存)另一個是Working Memory(工作內存)

Main Memory(主內存):主要存放Variable,此處的變量(Variable)與Java編程中所說的變量有所區別,它包括了實例字段靜態字段構成數組對象的元素,但不包括局部變量與方法參數,因爲後者是線程私有的,不會被共享,自然就不會存在競爭問題。 Java內存模型規定了所有的變量都存儲在主內存(Main Memory)中(此處的主內存與介紹物理硬件時的主內存名字一樣,兩者也可以互相類比,但此處僅是虛擬機內存的一部分)。

Working Memory(工作內存):每條線程還有自己的工作內存(Working Memory,可與處理器高速緩存類比),線程的工作內存中保存了被該線程使用到的變量的主內存副本拷貝,線程對變量的所有操作(讀取、 賦值等)都必須在工作內存中進行,而不能直接讀寫主內存中的變量。不同的線程之間也無法直接訪問對方工作內存中的變量,線程間變量值的傳遞均需要通過主內存來完成。線程、 主內存、 工作內存三者的交互關係如下圖所示。

這裏寫圖片描述
也就是說Java每條線程所擁有的工作內存通過,sava和load指令與主內存進行交互。另外還需要了解的一點是:Java的一切指令操作都是基於棧的(stack),因此工作內存中又包含以下兩個部分:

這裏寫圖片描述

①操作數棧(Operand Stack)對應上圖左邊的stack,也常稱爲操作棧,它是一個後入先出棧。 當一個方法剛剛開始執行的時候,這個方法的操作數棧是空的,在方法的執行過程中,會有各種字節碼指令往操作數棧中寫入和提取內容,也就是出棧/入棧操作。 例如,在做算術運算的時候是通過操作數棧來進行的,又或者在調用其他方法的時候是通過操作數棧來進行參數傳遞的。

舉個例子,整數加法的字節碼指令iadd在運行的時候操作數棧中最接近棧頂的兩個元素已經存入了兩個int型的數值,當執行這個指令時,會將這兩個int值出棧並相加,然後將相加的結果入棧。

②局部變量表(Local Variable Table)對應上圖右邊的variable是一組變量值存儲空間,用於存放方法參數和方法內部定義的局部變量。 在Java程序編譯爲Class文件時,就在方法的Code屬性的max_locals數據項中確定了該方法所需要分配的局部變量表的最大容量。
關於JMM(Java內存模型)部分,只需要瞭解這麼多。

內存間交互操作

關於主內存與工作內存之間具體的交互協議,即一個變量如何從主內存拷貝到工作內存、 如何從工作內存同步回主內存之類的實現細節,Java內存模型中定義了以下8種操作來完成,虛擬機實現時必須保證下面提及的每一種操作都是原子的、 不可再分的。

① lock(鎖定):作用於主內存的變量,它把一個變量標識爲一條線程獨佔的狀態。

②unlock(解鎖):作用於主內存的變量,它把一個處於鎖定狀態的變量釋放出來,釋放後的變量纔可以被其他線程鎖定。

③read(讀取):作用於主內存的變量,它把一個變量的值從主內存傳輸到線程的工作內存中,以便隨後的load動作使用。

④load(載入):作用於工作內存的變量,它把read操作從主內存中得到的變量值放入工作內存的變量副本中。

⑤use(使用):作用於工作內存的變量,它把工作內存中一個變量的值傳遞給執行引擎,每當虛擬機遇到一個需要使用到變量的值的字節碼指令時將會執行這個操作。

⑥assign(賦值):作用於工作內存的變量,它把一個從執行引擎接收到的值賦給工作內存的變量,每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作。

⑦store(存儲):作用於工作內存的變量,它把工作內存中一個變量的值傳送到主內存中,以便隨後的write操作使用。

⑧write(寫入):作用於主內存的變量,它把store操作從工作內存中得到的變量的值放入主內存的變量中。

如果要把一個變量從主內存複製到工作內存,那就要順序地執行read和load操作,如果要把變量從工作內存同步回主內存,就要順序地執行store和write操作。 注意,Java內存模型只要求上述兩個操作必須按順序執行,而沒有保證是連續執行。 也就是說,read與load之間、 store與write之間是可插入其他指令的,如對主內存中的變量a、 b進行訪問時,一種可能出現順序是read a、read b、load b、load a 關於內存間交互操作的更多細節請參考深入理解Java虛擬機第二版(393頁)。

併發原則

①原子性(Atomicity):原子性:即一個操作或者多個操作 要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行。由Java內存模型來直接保證的原子性變量操作包括read、 load、assign、 use、 store和write,我們大致可以認爲基本數據類型的訪問讀寫是具備原子性的。

②可見性(Visibility):可見性是指當一個線程修改了共享變量的值,其他線程能夠立即得知這個修改。

③有序性(Ordering):Java程序中天然的有序性可以總結爲一句話:如果在本線程內觀察,所有的操作都是有序的;如果在一個線程中觀察另一個線程,所有的操作都是無序的。 前半句是指“線程內表現爲串行的語義”(Within-Thread As-If-Serial Semantics),後半句是指“指令重排序”現象和“工作內存與主內存同步延遲”現象。

Java Volatile Keyword

先看一段英文解釋:

The Java volatile keyword is used to mark a Java variable as “being stored in main memory”. More precisely that means, that every read of a volatile variable will be read from the computer’s main memory, and not from the CPU cache(working memory), and that every write to a volatile variable will be written to main memory, and not just to the CPU cache(working memory).

簡單對上面的這段話做兩點說明:
①線程讀取一個被volatile修飾的變量時直接從main memory(主內存)中讀取,而不是從working memory中讀取。
②線程寫對一個volatile變量進行寫操作時,直接寫入到main memory(主內存)中,而不僅僅是寫到working memory中。

volatile確保可見性

先看下面的一個實例:

private  static boolean stop = false;

    public static void main(String[] args) {
        //線程1
        new Thread(){
            @Override
            public void run() {         
                    while(!stop)                    
                        System.out.println("is not stop!");                             
            }

        }.start();
        //線程2
        new Thread(){
            @Override
            public void run() {
                stop=true;
            }

        }.start();
    }

這段代碼是很典型的一段代碼,很多人在中斷線程時可能都會採用這種標記辦法。但是事實上,這段代碼會完全運行正確麼?即一定會將線程中斷麼?不一定,也許在大多數時候,這個代碼能夠把線程中斷,但是也有可能會導致無法中斷線程(雖然這個可能性很小,但是隻要一旦發生這種情況就會造成死循環了)。

  下面解釋一下這段代碼爲何有可能導致無法中斷線程。在前面已經解釋過,每個線程在運行過程中都有自己的工作內存,那麼線程1在運行的時候,會將stop變量的值拷貝一份放在自己的工作內存當中。

  那麼當線程2更改了stop變量的值之後,但是還沒來得及寫入主存當中,線程2轉去做其他事情了,那麼線程1由於不知道線程2對stop變量的更改,因此還會一直循環下去。

  但是用volatile修飾之後就變得不一樣了:

  第一:使用volatile關鍵字會強制將修改的值立即寫入主存;

  第二:使用volatile關鍵字的話,當線程2進行修改時,會導致線程1的工作內存中緩存變量stop的緩存行無效(反映到硬件層的話,就是CPU的L1或者L2緩存中對應的緩存行無效);

  第三:由於線程1的工作內存中緩存變量stop的緩存行無效,所以線程1再次讀取變量stop的值時會去主存讀取。

  那麼在線程2修改stop值時(當然這裏包括2個操作,修改線程2工作內存中的值,然後將修改後的值寫入內存),會使得線程1的工作內存中緩存變量stop的緩存行無效,然後線程1讀取時,發現自己的緩存行無效,它會等待緩存行對應的主存地址被更新之後,然後去對應的主存讀取最新的值。那麼線程1讀取到的就是最新的正確的值。

volatile確保有序性

在前面提到volatile關鍵字能禁止指令重排序,所以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

由於flag變量爲volatile變量,那麼在進行指令重排序的過程的時候,不會將語句3放到語句1、語句2前面,也不會講語句3放到語句4、語句5後面。但是要注意語句1和語句2的順序、語句4和語句5的順序是不作任何保證的。

  並且volatile關鍵字能保證,執行到語句3時,語句1和語句2必定是執行完畢了的,且語句1和語句2的執行結果對語句3、語句4、語句5是可見的。

volatile不能保證原子性

爲了說明volatile不保證原子性先舉一個經典的例子,這個例子來自於深入理解Java虛擬機第二版(395頁)


public class VolatileTest{

    public static volatile int race=0;

    public static void increase(){
        race++;
    }

    private static final int THREADS_COUNT=20;

    public static void main(String[]args){

        Thread[]threads=new Thread[THREADS_COUNT];

        for(int i=0;i<THREADS_COUNT;i++){

            threads[i]=new Thread(new Runnable(){

            @Override
            public void run(){
                for(int i=0;i<10000;i++){
                        increase();
                }
        }
        );
        threads[i].start();
    }
    //等待所有累加線程都結束
    while(Thread.activeCount()>1)
        Thread.yield();
        System.out.println(race);
    }
}

這段代碼發起了20個線程,每個線程對race變量進行10000次自增操作,如果這段代碼能夠正確併發的話,最後輸出的結果應該是200000。 讀者運行完這段代碼之後,並不會獲得期望的結果,而且會發現每次運行程序,輸出的結果都不一樣,都是一個小於200000的數字。
先來看看上面代碼中increase()方法的字節碼,然後對照着JMM來分析一下爲什麼會出現上面程序運行的結果。

public static void increase();
Code:
Stack=2,Locals=0,Args_size=0
0:getstatic#13;//從主存中直接獲取race的值放入到操作棧中。
3:iconst_1  //將常數1放入操作棧中,
4:iadd  //獲取操作棧中的兩個值相加
5:putstatic#13;//將操作棧中的race的值直接放入到主存中。
8:return
LineNumberTable:
line 14:0
line 15:8

對於上面的字節碼,給出一個運行圖:

這裏寫圖片描述

將Race=1放入到stack中,將常量1放入到stack中:
這裏寫圖片描述

將stack中的兩個數相加,並將結果放回到Main Memory中:
這裏寫圖片描述

有上面的過程圖可知,當執行將Race=1放入到stack中,將常量1放入到stack中時,其他線程可能搶佔jvm此時還未執行後面的加法操作,比如說兩個線程同時執行了前面的兩步,將race=1放到了stack中,然後又執行加操作,此時雖然執行了兩次加操作,race的值卻是2,所以綜合可知volatile並不能保證原子性。

參考文獻

深入理解Java虛擬機第二版
http://www.cnblogs.com/dolphin0520/p/3920373.html
http://jeremymanson.blogspot.sg/2008/11/what-volatile-means-in-java.html
https://stackoverflow.com/questions/4885570/what-does-volatile-mean-in-java
http://tutorials.jenkov.com/java-concurrency/volatile.html

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