之前讀CHM的源碼(JDK8),其中有一段印象比較深,它內部有一個Node數組,volatile修飾, transient volatile Node<K,V>[] table; 。而Node對象本身,存儲數據的val變量,也是用volatile修飾的。這兩個一個是保證擴容時,變更table引用時的可見性,一個是保證value修改后的可見性。
1. 非volatile數組的可見性問題
實驗一:
1 public class Test { 2 static int[] a = new int[]{1}; 3 4 public static void main(String[] args) { 5 new Thread(() -> { 6 System.out.println("線程1開始休眠:" + LocalDateTime.now().toString()); 7 try { 8 Thread.sleep(1000); 9 } catch (InterruptedException e) { 10 e.printStackTrace(); 11 } 12 System.out.println("線程1休眠結束:" + LocalDateTime.now().toString()); 13 a[0] = 0; 14 }).start(); 15 16 while (a[0] != 0) { 17 } 18 System.out.println("主線程退出循環:" + LocalDateTime.now().toString()); 19 } 20 }
上述代碼測試時,主線程無法退出循環,這說明了主線程使用的一直是工作內存中的數組數據,沒有從主存刷新數據。
多線程下,修改普通數組,是不可見的。
實驗二:
1 while (a[0] != 0) { 2 System.out.println(""); 3 } 4 System.out.print("主線程退出循環:" + LocalDateTime.now().toString());
修改實驗一的部分代碼,神奇的事情發生了
竟然可以了? System.out.println(""); 有這么大魔力嗎?我們看下方法實現
看到synchronized有木有,synchronized保證了原子性、可見性和防止指令重排序。對於可見性,JMM規定,線程獲取Lock,需要將清空工作內存中共享變量的值,從主存中重新獲取。而釋放鎖前,需要將自身變量值同步回主存。請見:第十二章 Java內存模型與線程。實驗二改動的代碼部分,加入了獲取鎖,所以會不停刷新變量的值。並且,所有的 System.out.println 方法,鎖住的都是同一個鎖對象,即 public final static PrintStream out; 。提到這一點是,網上有些資料說,必須保證是同一個鎖的加鎖解鎖,才能保證可見性。
那么我們再試一下,鎖住不同對象,還能正常刷新嗎?
1 while (a[0] != 0) { 2 synchronized ("") { 3 } 4 }
將實驗二修改成如上代碼,再次實驗:
可見並不需要是同一個鎖,只要獲取鎖就會去主存刷新緩存。
實驗三:
1 public class Test { 2 static int[] a = new int[]{1}; 3 static volatile boolean b = false; 4 5 public static void main(String[] args) { 6 new Thread(() -> { 7 System.out.println("線程1開始休眠:" + LocalDateTime.now().toString()); 8 try { 9 Thread.sleep(1000); 10 } catch (InterruptedException e) { 11 e.printStackTrace(); 12 } 13 System.out.println("線程1休眠結束:" + LocalDateTime.now().toString()); 14 a[0] = 0; 15 }).start(); 16 17 while (a[0] != 0) { 18 b = false; 19 } 20 System.out.println("主線程退出循環:" + LocalDateTime.now().toString()); 21 } 22 }
參考資料1中提到,當線程讀取一個volatile修飾的變量時,會將這個線程中所有的變量都從主存中刷新一下。所以這里主線程訪問變量b時,也會同時刷新數組。
2. volatile數組的可見性問題
實驗三:
1 public class Test { 2 static volatile int[] a = new int[]{1}; 3 4 public static void main(String[] args) { 5 new Thread(() -> { 6 System.out.println("線程1開始休眠:" + LocalDateTime.now().toString()); 7 try { 8 Thread.sleep(1000); 9 } catch (InterruptedException e) { 10 e.printStackTrace(); 11 } 12 System.out.println("線程1休眠結束:" + LocalDateTime.now().toString()); 13 a[0] = 0; 14 }).start(); 15 16 while (a[0] != 0) { 17 } 18 System.out.println("主線程退出循環:" + LocalDateTime.now().toString()); 19 } 20 }
主線程正常退出,那么問題來了,volatile到底只保證引用的可見性,還是包含了引用指向對象的可見性?
3. volatile修飾數組的作用
在網上查閱資料,說這里需要區分一下基礎類型數組和對象類型數組。上面的實驗都是基於整數數組,那我們繼續實驗一下對象數組
實驗四:
1 public class Test { 2 static volatile A[] a = new A[]{new A(1)}; 3 4 public static void main(String[] args) { 5 new Thread(() -> { 6 System.out.println("線程1開始休眠:" + LocalDateTime.now().toString()); 7 try { 8 Thread.sleep(1000); 9 } catch (InterruptedException e) { 10 e.printStackTrace(); 11 } 12 System.out.println("線程1休眠結束:" + LocalDateTime.now().toString()); 13 a[0] = new A(0); 14 }).start(); 15 16 while (a[0].val != 0) { 17 } 18 System.out.println("主線程退出循環:" + LocalDateTime.now().toString()); 19 } 20 21 22 static class A { 23 public int val; 24 25 A(int val) { 26 this.val = val; 27 } 28 } 29 }
很遺憾,跟實驗三的結果是一樣的。
那么為什么CHM需要再使用volatile保證Node對象value屬性的可見性呢?而網上說的volatile只能保證引用的可見性是否正確呢?
JUC下的另一個並發工具類CopyOnWriteArrayList,這個也定義了一個對象數組 private transient volatile Object[] array; ,但是在訪問元素時,並沒有特殊的手段保證可見性,在設置元素時,先獲取鎖,將原數組拷貝一份,修改新數組后,修改array指向新數組。
4. 引申
其實這個問題,是之前寫一個小功能遇到的,原問題是:線程1需要在線程2和線程3執行完成之后執行,實現方式有很多,比如柵欄、Jdk8的CompletableFuture、同步機制等,還想到一個數組形式,比如一個長度為2的數組,每個線程執行完畢之后,修改對應位置標志,這樣避免了同步的問題。我們拋開上面的問題不談,假設使用volatile修飾數組,實現這個功能,是否沒有其他問題呢?
其實還有一個緩存行偽共享的問題。見參考資料2,其實就是說不同線程修改同一個緩存行的問題,每個線程讀取一個緩存行,修改之后,同步到主存,會導致其他線程中相同的緩存行失效,這將帶來性能上的問題。
5. 更新
關於volatile的可見性,參考文獻3及文獻5中說明了,從JDK5開始,volatile保證可見性不僅局限於其修飾的變量,還包括了線程中使用的其他變量。具體是
1. 讀取volatile變量時,在該變量之后的變量也將從主存中重新讀取(在volatile變量讀操作發生之后的變量,因為禁止了指令重排序,所以是可見的)
2. 寫入volatile變量時,在該變量之前的變量產生的修改也將寫入到主存中(在volatile變量寫操作發生之前的變量,因為禁止了指令重排序,所以是可見的)
volatile防止指令重排序(參考4):
對實驗三和實驗四的結果做出解釋,即我們先讀取了volatile修飾的數組,這個操作將導致之后所有用到的值都會從主存中刷新一下,意味着數組內部的元素的值也被刷新了,如此我們才能訪問到最新的數據。
參考:
3. volatile修飾List能否保證內部元素的可見性?