volatile修饰数组
之前读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能否保证内部元素的可见性?