1. 程式人生 > >Java Volatile(包含對volatile陣列和物件的理解)

Java Volatile(包含對volatile陣列和物件的理解)

1.多執行緒中重要概念

1.1 可見性
也就說假設一個物件中有一個變數i,那麼i是儲存在main memory中的,當某一個執行緒要操作i的時候,首先需要從main memory中將i 載入到這個執行緒的working memory中,這個時候working memory中就有了一個i的拷貝,這個時候此執行緒對i的修改都在其working memory中,直到其將i從working memory寫回到main memory中,新的i的值才能被其他執行緒所讀取。從某個意義上說,可見性保證了各個執行緒的working memory的資料的一致性。
可見性遵循下面一些規則:

當一個執行緒執行結束的時候,所有寫的變數都會被flush回main memory中。
當一個執行緒第一次讀取某個變數的時候,會從main memory中讀取最新的。
volatile的變數會被立刻寫到main memory中的,在jsr133中,對volatile的語義進行增強,後面會提到
當一個執行緒釋放鎖後,所有的變數的變化都會flush到main memory中,然後一個使用了這個相同的同步鎖的程序,將會重新載入所有的使用到的變數,這樣就保證了可見性。
1.2 原子性

還拿上面的例子來說,原子性就是當某一個執行緒修改i的值的時候,從取出i到將新的i的值寫給i之間不能有其他執行緒對i進行任何操作。也就是說保證某個執行緒對i的操作是原子性的,這樣就可以避免資料髒讀。
通過鎖機制或者CAS(Compare And Set 需要硬體CPU的支援)操作可以保證操作的原子性。

1.3 有序性
假設在main memory中存在兩個變數i和j,初始值都為0,在某個執行緒A的程式碼中依次對i和j進行自增操作(i,j的操作不相互依賴),


i++;
j++;
由於,所以i,j修改操作的順序可能會被重新排序。那麼修改後的ij寫到main memory中的時候,順序可能就不是按照i,j的順序了,這就是所謂的reordering,在單執行緒的情況下,當執行緒A執行結束的後i,j的值都加1了,線上程自己看來就好像是執行緒按照程式碼的順序進行了執行(這些操作都是基於as-if-serial語義的),即使在實際執行過程中,i,j的自增可能被重新排序了,當然計算機也不能幫你亂排序,存在上下邏輯關聯的執行順序肯定還是不會變的。但是在多執行緒環境下,問題就不一樣了,比如另一個執行緒B的程式碼如下


if(j==1) {
    System.out.println(i);
}
按照我們的思維方式,當j為1的時候那麼i肯定也是1,因為程式碼中i在j之前就自增了,但實際的情況有可能當j為1的時候i還是為0。這就是reordering產生的不好的後果,所以我們在某些時候為了避免這樣的問題需要一些必要的策略,以保證多個執行緒一起工作的時候也存在一定的次序。JMM提供了happens-before 的排序策略。

2. JMM(java記憶體模型)

    Java 記憶體模型的抽象 

在java中,所有例項域、靜態域和陣列元素儲存在堆記憶體中,堆記憶體線上程之間共享(本文使用“共享變數”這個術語代指例項域,靜態域和陣列元素)。區域性變數(Local variables),方法定義引數(java語言規範稱之為formal method parameters)和異常處理器引數(exception handler parameters)不會線上程之間共享,它們不會有記憶體可見性問題,也不受記憶體模型的影響。

Java執行緒之間的通訊由Java記憶體模型(本文簡稱為JMM)控制,JMM決定一個執行緒對共享變數的寫入何時對另一個執行緒可見。從抽象的角度來看,JMM定義了執行緒和主記憶體之間的抽象關係:執行緒之間的共享變數儲存在主記憶體(main memory)中,每個執行緒都有一個私有的本地記憶體(local memory),本地記憶體中儲存了該執行緒以讀/寫共享變數的副本。本地記憶體是JMM的一個抽象概念,並不真實存在。它涵蓋了快取,寫緩衝區,暫存器以及其他的硬體和編譯器優化。Java記憶體模型的抽象示意圖如下:

從上圖來看,執行緒A與執行緒B之間如要通訊的話,必須要經歷下面2個步驟:

  1. 首先,執行緒A把本地記憶體A中更新過的共享變數重新整理到主記憶體中去。
  2. 然後,執行緒B到主記憶體中去讀取執行緒A之前已更新過的共享變數。

下面通過示意圖來說明這兩個步驟:

如上圖所示,本地記憶體A和B有主記憶體中共享變數x的副本。假設初始時,這三個記憶體中的x值都為0。執行緒A在執行時,把更新後的x值(假設值為1)臨時存放在自己的本地記憶體A中。當執行緒A和執行緒B需要通訊時,執行緒A首先會把自己本地記憶體中修改後的x值重新整理到主記憶體中,此時主記憶體中的x值變為了1。隨後,執行緒B到主記憶體中去讀取執行緒A更新後的x值,此時執行緒B的本地記憶體的x值也變為了1。

從整體來看,這兩個步驟實質上是執行緒A在向執行緒B傳送訊息,而且這個通訊過程必須要經過主記憶體。JMM通過控制主記憶體與每個執行緒的本地記憶體之間的互動,來為java程式設計師提供記憶體可見性保證。

3. Volatile

對一個共享變數使用Volatile關鍵字保證了執行緒間對該資料的可見性,即不會讀到髒資料。

注:1. 可見性:對一個volatile變數的讀,總是能看到(任意執行緒)對這個volatile變數最後的寫入

        2. 原子性:對任意單個volatile變數的讀/寫具有原子性(long,double這2個8位元組的除外),但類似於volatile++這種複合操作不具有原子性。

        3. volatile修飾的變數如果是物件或陣列之類的,其含義是物件獲陣列的地址具有可見性,但是陣列或物件內部的成員改變不具備可見性

            eg:以下程式碼要以-server模式執行,強制虛擬機器開啟優化

package com.xj;

public class VolatileObjectTest implements Runnable {
    private ObjectA a; // <span style="color:#ff0000;"><strong>加上volatile 就可以正常結束While迴圈了  </strong></span> 
    public VolatileObjectTest(ObjectA a) {
        this.a = a;
    }
 
    public ObjectA getA() {
        return a;
    }
 
    public void setA(ObjectA a) {
        this.a = a;
    }
 
    @Override
    public void run() {
        long i = 0;
        while (a.isFlag()) {
            i++;
//            System.out.println("------------------");
        }
        System.out.println("stop My Thread " + i);
    }
 
    public void stop() {
        a.setFlag(false);
    }
 
    public static void main(String[] args) throws InterruptedException {
         // 如果啟動的時候加上-server 引數則會 輸出 Java HotSpot(TM) Server VM
        System.out.println(System.getProperty("java.vm.name"));
         
        VolatileObjectTest test = new VolatileObjectTest(new ObjectA());
        new Thread(test).start();
 
        Thread.sleep(1000);
        test.stop();
        Thread.sleep(1000);
        System.out.println("Main Thread " + test.getA().isFlag());
    }
 
    static class ObjectA {
        private boolean flag = true;
 
        public boolean isFlag() {
            return flag;
        }
 
        public void setFlag(boolean flag) {
            this.flag = flag;
        }
 
    }
}

       以上程式碼如果是紅色標記那一行加volatile關鍵字,子執行緒是可以退出迴圈的,不加的話,子執行緒沒法退出迴圈,如此說來,volatile變數修飾物件或者陣列,當我們改變物件或者陣列的成員的時候,豈非不同執行緒之間具有可見性?

      在看如下程式碼:

package com.xj;

public class VolatileTestAgain implements Runnable {
    private <span style="color:#ff0000;"><strong>volatile</strong></span> ObjectA a; // <span style="color:#ff0000;"><strong>加上volatile也無法結束While迴圈了</strong>
</span> 
    public VolatileTestAgain(ObjectA a) {
        this.a = a;
    }
 
    public ObjectA getA() {
        return a;
    }
 
    public void setA(ObjectA a) {
        this.a = a;
    }
 
    @Override
    public void run() {
        long i = 0;
        ObjectASub sub = a.getSub();
        while (!sub.isFlag()) {
            i++;        }
        System.out.println("stop My Thread " + i);
    }
  
    public static void main(String[] args) throws InterruptedException {
         // 如果啟動的時候加上-server 引數則會 輸出 Java HotSpot(TM) Server VM
        System.out.println(System.getProperty("java.vm.name"));
        ObjectASub <span style="color:#ff0000;">sub</span> = new ObjectASub();
        ObjectA sa = new ObjectA();
        sa.setSub(sub);
        VolatileTestAgain test = new VolatileTestAgain(sa);
        new Thread(test).start();
 
        Thread.sleep(1000);
        sub.setFlag(true);
        Thread.sleep(1000);
        System.out.println("Main Thread " + sub.isFlag());
    }
 
    static class ObjectA {
        private ObjectASub sub;

		public ObjectASub getSub() {
			return sub;
		}

		public void setSub(ObjectASub sub) {
			this.sub = sub;
		}
    }
    
    static class ObjectASub{
    	private boolean flag;

		public boolean isFlag() {
			return flag;
		}

		public void setFlag(boolean flag) {
			this.flag = flag;
		}
    	
    	
    }
}

      如上程式碼即使新增volatile關鍵字也無法讓子執行緒結束迴圈,讀者可以仔細對比一下2段程式碼,下面是我的解釋。

     1)程式碼1中當主執行緒更改flag欄位時候,是呼叫stop()方法裡面的“a.setFlag(false); ”,注意這一句其實包含多步操作,含義豐富:首先是對volatile變數a的讀,既然是volatile變數,當然讀到的是主存(而不是執行緒私有的)中的地址,然後再setFlag來更新這個標誌位實際上是更新的主存中引用指向的堆記憶體;然後是子執行緒讀

a.isFlag(),同樣的包含多步:首先是對volatile變數的讀,當然讀的是主存中的引用地址,然後根據這個引用找堆記憶體中flag值,當然找到的就是之前主執行緒寫進去的值,所以能夠立即生效,子執行緒退出。

    2)程式碼1中雖然主執行緒和子執行緒都是讀volatile值,然後一個是改,一個是讀,按照java記憶體模型中的happen-before,2個執行緒對volatile變數的讀是不具有happen-before特性的,但是這裡要注意的是,因為都是以volatile變數為根,層層引用,最後找到的都是同一塊堆記憶體,然後一個修改,一個檢視,所以實際上相當於同一個執行緒在寫和修改(因為寫和修改的是同一塊記憶體);所以可以利用happen-before中第一條規則——程式順序規則,從而有主執行緒的寫happen-before子執行緒的讀

    3)程式碼2中加了volatile關鍵字仍然子執行緒無法退出,這是因為主執行緒的對flag標誌位的改,已經不是通過volatile根物件先定位到主存中的地址,然後逐級索引去找到堆記憶體,然後改地址,而是直接線上程中儲存了一個sub物件,這樣改掉的,實際上不是主存中的volatile根物件引用的ObjectASub物件再引用的flag標誌位的值了,他改變的是本地執行緒中快取的值;同理子執行緒中取的也是每次都取的本地執行緒中快取的值;主執行緒的寫沒有及時重新整理到主存中,子執行緒也沒用從主存中去讀,導致了資料的不一致性。

    總結:1)用volatile修飾陣列和物件不是不可以,要注意一點:修改操作要從volatile變數逐級引用,去找到要修改的變數,保證修改是重新整理到主存中的值對應的變數;讀取操作,也要以volatile變數為根,逐級去定位,這樣保證修改即使重新整理到主存中volatile變數指向的堆記憶體,讀取能夠每次從主存的volatile變數指向的堆記憶體去讀,保證資料的一致性。

                2)在保證了總結1)的前提下,因為大家讀取修改的都是同一塊記憶體,所以變相的符合happen-before規則中的程式順序規則,具有happen-before性。

      3. volatile寫-讀建立的happens before關係

              對程式設計師來說,volatile對執行緒的記憶體可見性的影響比volatile自身的特性更為重要,也更需要我們去關注.

             happen-before規則

             程式順序規則:一個執行緒中的每個操作,happens- before 於該執行緒中的任意後續操作。
             監視器鎖規則:對一個監視器鎖的解鎖,happens- before 於隨後對這個監視器鎖的加鎖。 
             volatile變數規則:對一個volatile域的寫,happens- before 於任意後續對這個volatile域的讀。
            傳遞性:如果A happens- before B,且B happens- before C,那麼A happens- before C。
             Thread.start()的呼叫會happens-before於啟動執行緒裡面的動作。
             Thread中的所有動作都happens-before於其他執行緒從Thread.join中成功返回。

             進一步關注JMM如何實現volatile寫/讀的記憶體語義

            前文我們提到過重排序分為編譯器重排序和處理器重排序。為了實現volatile記憶體語義,JMM會分別限制這兩種型別的重排序型別。下面是JMM針對編譯器制定的volatile

            重排序規則表:

是否能重排序 第二個操作
第一個操作 普通讀/寫 volatile讀 volatile寫
普通讀/寫 NO
volatile讀 NO NO NO
volatile寫 NO NO

舉例來說,第三行最後一個單元格的意思是:在程式順序中,當第一個操作為普通變數的讀或寫時,如果第二個操作為volatile寫,則編譯器不能重排序這兩個操作。


從上表我們可以看出:
當第二個操作是volatile寫時,不管第一個操作是什麼,都不能重排序。這個規則確保volatile寫之前的操作不會被編譯器重排序到volatile寫之後。
當第一個操作是volatile讀時,不管第二個操作是什麼,都不能重排序。這個規則確保volatile讀之後的操作不會被編譯器重排序到volatile讀之前。
當第一個操作是volatile寫,第二個操作是volatile讀時,不能重排序。

當第一個操作是volatile寫,第二個操作是volatile讀時,不能重排序。

     eg:(對以上volatile的happen-before特性的利用)

java併發庫ConcurrentHashMap中get操作的無鎖弱一致性實現

V get(Object key, int hash) {   
    <span style="color:#ff0000;"><strong>if (count != 0) { // read-volatile</strong>   
</span>        HashEntry<K,V> e = getFirst(hash);   
        while (e != null) {   
            if (e.hash == hash && key.equals(e.key)) {   
                V v = e.value;   
                if (v != null)   
                    return v;   
                return readValueUnderLock(e); // recheck   
            }   
            e = e.next;   
        }   
    }   
    return null;   
}  
 V put(K key, int hash, V value, boolean onlyIfAbsent) {
            lock();
            try {
                int c = count;
                if (c++ > threshold) // ensure capacity
                    rehash();
                HashEntry<K,V>[] tab = table;
                int index = hash & (tab.length - 1);
                HashEntry<K,V> first = tab[index];
                HashEntry<K,V> e = first;
                while (e != null && (e.hash != hash || !key.equals(e.key)))
                    e = e.next;

                V oldValue;
                if (e != null) {
                    oldValue = e.value;
                    if (!onlyIfAbsent)
                        e.value = value;
                }
                else {
                    oldValue = null;
                    ++modCount;
                    tab[index] = new HashEntry<K,V>(key, hash, first, value);
                    <span style="color:#ff0000;">count = c; // write-volatile
</span>                }
                return oldValue;
            } finally {
                unlock();
            }
        }

get操作不需要鎖。第一步是訪問count變數,這是一個volatile變數,由於所有的修改操作在進行結構修改時都會在最後一步寫count變數,通過這種機制保證get操作能夠得到幾乎最新的結構更新。對於非結構更新,也就是結點值的改變,由於HashEntry的value變數是volatile的,也能保證讀取到最新的值。接下來就是對hash鏈進行遍歷找到要獲取的結點,如果沒有找到,直接訪回null。對hash鏈進行遍歷不需要加鎖的原因在於鏈指標next是final的。但是頭指標卻不是final的,這是通過getFirst(hash)方法返回,也就是存在table陣列中的值。這使得getFirst(hash)可能返回過時的頭結點,例如,當執行get方法時,剛執行完getFirst(hash)之後,另一個執行緒執行了刪除操作並更新頭結點,這就導致get方法中返回的頭結點不是最新的。這是可以允許,通過對count變數的協調機制,get能讀取到幾乎最新的資料,雖然可能不是最新的。要得到最新的資料,只有採用完全的同步。