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修饰的数组,这个操作将导致之后所有用到的值都会从主存中刷新一下,意味着数组内部的元素的值也被刷新了,如此我们才能访问到最新的数据。

  

 

参考:

1. volatile修饰数组,那么数组元素可见吗?

2. 伪共享(False Sharing)

3. volatile修饰List能否保证内部元素的可见性?

4. 【并发】volatile是否能保证数组中元素的可见性?

5. Java Volatile Keyword

posted @ 2021-06-09 01:13  walker993  阅读(2704)  评论(0编辑  收藏  举报