volatile修飾陣列,那麼陣列元素可見嗎?

AndyandJennifer發表於2019-04-09

前言

最近一段時間,在看併發集合的原始碼,發現了一個非常有趣的現象。我們都知道併發集合,為了保持對其他執行緒的可見性,通常集合中的方法都會使用CAS、volatile、synchronized、Lock等方式。但是在CopyOnWriteArrayList與ConcurrentHashMap中,對其中的存放資料的陣列的操作卻截然不同。

CopyOnWriteArrayList

在CopyOnWiteArrayList中有一個用volatile修飾的陣列(實際存放資料的資料結構),而獲取CopyOnWiteArrayList中的資料直接是呼叫get(int index)方法。並沒有涉及到更多的保持可見性的操作。


  private transient volatile Object[] elements;

    final Object[] getArray() {
        return elements;
    }

    public E get(int index) {
        return get(getArray(), index);
    }

    private E get(Object[] a, int index) {
        return (E) a[index];
    }

複製程式碼

ConcurrentHashMap

而在ConcurrentHashMap同樣是獲取用volatile修飾的陣列中的元素,卻呼叫的getObjectVolatile來獲取陣列中的元素。

    transient volatile Node<K,V>[] table;
    static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
        return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
    }
複製程式碼

疑惑

我相信大家看到這裡,大家和我都有幾個疑惑。

  • 在CopyOnWriteArrayList中為什麼就直接使用角標獲取陣列中的元素,而ConcurrentHashMap卻呼叫的是getObjectVolatile方法呢?

  • 假如ConcurrentHashMap通過getObjectVolatile方法獲取陣列中的元素是正確的(也就是說volatile修飾的陣列的引用,陣列的元素對其他執行緒不可見,所以我們要使用getObjectVolatile,來保證資料的可見性),那麼CopyOnWriteArrayList簡簡單單的就直接獲取陣列中的元素,就能保證可見性了,這是什麼神奇的操作?

  • 假如CopyOnWriteArrayList的操作是正確的(也就是volatile修飾陣列的時候,其內部陣列的元素也是可見的),那麼對於ConcurrentHashMap中的getObjectVolatile方法是不是存在著多餘呢?

開始實驗

到了這裡問題的根源,其實就是volatile修飾的陣列時,陣列的元素是否對其他執行緒存在著可見性。為了解決這個疑惑,所以我就寫了幾個例子。

例子1

在例子1中我宣告瞭一個volatile陣列arr,然後通過一個執行緒1修改arr[19]=2,線上程2中判斷陣列中的arr[19]是否等於2,如果是則跳出迴圈,並列印。具體例子如下:

public class TestVolatile {
    public static volatile long[] arr = new long[20];
    public static void main(String[] args) throws Exception {
        //執行緒1
        new Thread(new Thread(){
            @Override
            public void run() {
                //Thread A
                try {
                    TimeUnit.MILLISECONDS.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                arr[19] = 2;
            }
        }).start();
        //執行緒2
        new Thread(new Thread(){
            @Override
            public void run() {
                //Thread B
                while (arr[19] != 2) {
                }
                System.out.println("Jump out of the loop!");
            }
        }).start();
    }
}
複製程式碼

執行結果:Jump out of the loop!

WTF!?程式居然跳出迴圈了!!!!也就是說volatile修飾的陣列確實可以保證陣列中的元素對其他執行緒的可見性?難度是Java開發人員不同,每個人的理解不同,所以寫出了不同的操作?

那ConcurrentHashMap中的getObjectVolatile方法到底是不是多餘的呢?難道是因為ConcurrentHashMap中儲存的是物件,所以要再次通過volatile判斷?為了驗證這個問題,我又寫了例子2。

例子2

在例子2中,我將陣列修改為Object型別,並建立了Person類,因為Person類(包含名字,年齡)是簡單的Java物件,我就沒有在例子中宣告瞭。具體例子如下:

public class TestVolatile {

    public static  volatile Object[] arr = new Object[20];
    
    public static void main(String[] args) throws Exception {
        new Thread(new Thread() {
            @Override
            public void run() {
                //執行緒1
                try {
                    TimeUnit.MILLISECONDS.sleep(1000);
                    arr[19] = new Person("xixi",12);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }).start();
        new Thread(new Thread() {
            @Override
            public void run() {
                //執行緒2
                while (true) {
                    Person p = (Person) arr[19];
                    if (p!=null&&p.getAge()==12) {
                        break;
                    }
                }
                System.out.println("Jump out of the loop!");
            }
        }).start();

    }
}

複製程式碼

執行結果:Jump out of the loop!

從結果來看,也就是說和陣列中儲存的內容是無關的,不管是基本資料型別,還是物件型別。只要是通過volatile修飾的陣列,陣列中的元素對其他執行緒都是可見的,這裡為什麼ConcurrentHashMap中的getObjectVolatile方法的原因也不得為知。如果大家知道原因的話,希望在部落格下方發表您的留言,讓大家都知道這個知識點。

例子3

雖然說發現了一個神奇的地方,但是好奇心,又驅使我做了另一個實驗。在例子3中,我並沒有用volatile變數修飾陣列,而是在程式中新增了一個用volatile修飾的變數vol。實際的邏輯和例子1差不多,但是線上程2中我讀取了volatile修飾的變數的值。具體例子如下:

public class TestVolatile {
    //注意沒用volatile修飾陣列
    public static long[] arr = new long[20]; 
    //這裡用volatile修飾另一個變數
    public static volatile int vol = 0; 

    public static void main(String[] args) throws Exception {
        //執行緒1
        new Thread(new Thread(){
            @Override
            public void run() {
                try {
                    TimeUnit.MILLISECONDS.sleep(1000);    
                    arr[19] = 2;
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }).start();
        //執行緒2
        new Thread(new Thread(){
            @Override
            public void run() {
                while (true) {
                    //讀取vol的值
                    int i = vol;
                    if (arr[19] == 2) {
                        break;
                    }
                }
                System.out.println("Jump out of the loop!");
            }
        }).start();
    }
}
複製程式碼

執行結果:Jump out of the loop!

WFT!?程式居然又跳出迴圈了,我並沒有用volatile修飾陣列,也就是說陣列中的元素對其他執行緒並不是可見的。那麼程式居然會跳出迴圈,什麼鬼????

為了解決這個問題,我查閱了相關資料,結果在stackoverflow 找到了我想要的答案。在原文的解答中是這樣解釋的:

If Thread A reads a volatile variable, then all all variables visible to Thread A when reading the volatile variable will also be re-read from main memory.
複製程式碼

簡單翻譯一下,就是當一個執行緒在讀取一個使用volatile修飾的變數的時候,會將該執行緒中所有使用的變數從主記憶體中重新讀取。

那麼也就解釋了在例子3中,執行緒2為什麼要跳出迴圈的原因了。

例子4

但是因為自己手賤,我又寫了另一個例子,這個例子,我真的無法解釋為什麼會跳出迴圈的原因,具體例子如下:

public class TestVolatile {

    public static long[] arr = new long[20];
    public static boolean flag = false;


    public static void main(String[] args) throws Exception {
        new Thread(new Thread() {
            @Override
            public void run() {
                //執行緒1
                try {
                    TimeUnit.MILLISECONDS.sleep(1000);
                    arr[19] = 2;
                    flag = true;
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }).start();
        new Thread(new Thread() {
            @Override
            public void run() {
                //執行緒2
                while (true) {
                    System.out.println("flag--->" + flag);
                    if (arr[19] == 2) {
                        System.out.println("flag in loop--->" + flag);
                        break;
                    }
                }
                System.out.println("Jump out of the loop!");
            }
        }).start();

    }
}

複製程式碼

輸出結果:

...
flag--->false
flag--->false
flag--->false
flag--->false
flag in loop--->true
Jump out of the loop!
複製程式碼

按照可見性原理,按照邏輯思維,程式是不可能跳出迴圈的。但是執行緒2居然迴圈了一定次數後,居然TM的跳出迴圈了。這到底發生了什麼事情。我再一次的懷疑我自己的人生,懷疑我的編譯器,懷疑我的cpu。

總結

寫這個部落格,完全自己一點點的好奇,但是一點一點的,我發現我把自己圈進去了,有可能這就是技術的魅力吧,因為我本身能力也非常有限,有些例子確實無法解釋,這裡把我的疑問丟擲來,希望和大家一起討論討論,希望得到大家的幫助。一起得到一個正確的答案。

相關文章