多线程之volatile

140 阅读5分钟
  • 关于内存模型

    如果要想理解volatile那么我必须要理解Java的内存模型。再说Java的内存模型前我们先说下内存模型的由来。

    在计算机中我们的数据是存储到内存中的,但是当CPU高速运行的时候,内存的数据读写速度跟不上CPU的运行速度,这就造成了每当CPU需要一个数据的时候都要等待数据从物理内存读取出来然后再执行。这样就极大的浪费了时间,为了解决这个问题于是就在CPU和内存中间加入了一个接近CPU运行速度的高速缓存。每当CPU需要数据的时候都在高速缓存中读取,而高速缓存会和主内存的数据同步。但是当使用多核处理器的时候就会出现像多线程那样产生脏数据,这就是缓存一致性问题。所以就在缓存和物理内存中间加入一些协议,这些协议规定了高速缓存怎样存入物理内存。

  • Java内存模型

    Java的内存模型和物理硬件的内存模型相似,只不过每个处理器变成了Jvm中的每个线程,而高速缓存改名叫工作内存。

    Java的内存模型规定所有的变量都存储在主内存中(此处的变量是指实例字段,静态字段和构成数组对象的元素)线程对所有变量的操作都要在工作内存完成,而不能直接读写主内存中的数据,而对于每个线程来说工作内存是私有的,其他的线程不能访问。

  • volatile关键字

    volatile关键字在使用上存在一下特性

    • 可见性

      volatile的可见性是指假如存在两个线程A、B,且两个线程共享一个变量,一个线程改变这个变量,另外一个线程马上就能看见。

      那么这个可见性是怎么实现的呢?

      我们之前说了Java的内存模型,每个线程都是在工作内存中存取数据,然后工作内存再和主内存通过save和load等操作进行数据的交互。

      但是如果在变量上面加上volatile关键字的话,那么这个线程就会绕过工作内存直接和主内存进行交互,这样每个线程在使用这个变量的时候都会去主内存中查看下这个变量是否已经更改,如果已经更改立马更新当前值,而 当使用完毕之后再将结果写入到主内存中。

    • 原子性

      有一点要注意的是在32位的系统中,long和double没有实现写操作的原子性,所以这个时候可以使用volatile实现原子性操作,而在64位系统中要看具体的实现了,比如在X86的架构中long和double的写操作是原子性的。

      当然这个其实都不是重点,最重要的是如果volatile修饰的变量进行前加加,后加加这种操作那么这个操作并不是原子性的,因为这种操作其实是在实现的时候是拆分成两个部分进行的所以在多线程中并不是原子的。

      public class demo {
          public static volatile int count =0;
          public static void main(String[] args){
              // 如果操作时原子的那么count为10000,但是结果却是小于10000
              for (int i=0;i<100;i++){
                  new Thread(()->{
                      for (int j=0;j<100;j++){
                          ++count;
                      }
                  }).start();
              }
              System.out.println("count = " + count);
          }
      }
      

      如果要保持这种操作是原子性的那么可以使用Atomic原子类或者使用锁

    • 禁止代码重排序

      代码重排序其实叫指令重排序更好一点

      因为指令重排序是指编译器/处理器对用户执行代码的一种优化手段,允许其将多条指令不按程序定义的顺序执行,而是可以以自定义的一些规则进行重排序,以达到更高的执行效率。它不仅仅是在代码编译时期会产生,处理器也会进行重排序处理。

      volatile就像一堵墙壁,在volatile修饰修的语句之前可以进行重排序,在之后也可以进行重排序,但是之前的代码不可以重排序到之后,之后的代码不可以重排序到之前

      volatile禁止重排序的应用最为有名的地方莫过于单例模式的双重检查了,这也是经常面试的地方。

      public class Mrg02 {
          // 此处使用volatile防止重排序
          private static volatile  Mrg02 MRG_02 ;
          private Mrg02(){}
      
          public  static Mrg02 getMrg02(){
              if (MRG_02==null){
                  synchronized(Mrg02.class){
                      if (MRG_02 == null){
                          MRG_02 = new Mrg02();
                      }
                  }
              }
              return MRG_02;
          }
          public static void main(String[] args){
              for (int i=0;i<100;i++){
                  new Thread(()->{
                      System.out.println(Mrg02.getMrg02());
                  }).start();
              }
          }
      }
      

      为什么在此处需要使用volatile来预防重排序呢?

      其实创建一个对象实例,可以分为三步:

      1. 分配对象内存
      2. 调用构造器方法,执行初始化
      3. 将对象引用赋值给变量。

      虚拟机实际运行时,以上指令可能发生重排序。以上代码 2,3 可能发生重排序,但是并不会重排序 1 的顺序。也就是说 1 这个指令都需要先执行,因为 2,3 指令需要依托 1 指令执行结果。

      如果产生了2,3重排序的时候那么很有可能产生的对象是一个空的对象,很容易产生npe异常。

  • volatile需要的注意的

    由于volatile保证的原子性并不是某块代码保持原子,所以在不符合一下两条规则的时候我们仍然需要通过加锁来保证原子性

    • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值
    • 变量不需要与其他的状态变量共同参与不变约束