Java并发编程 之 volatile 关键字

141 阅读6分钟

简介

volatile是java提供的一种轻量级的同步机制。Java语言包含两种内在的同步机制:同步块(或方法)和volatile变量,相比于synchronized(synchronized通常称为重量级锁),volatile更轻量级,因为它不会引起线程上下文的切换和调度。

volatile 的特性

保证可见性,不保证原子性

可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。当写一个volatile变量时,JMM会把该线程本地内存中的变量强制刷新到主内存中去;这个写操作会导致其他线程中的缓存无效。

原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性(基于这点,我们通常会认为volatile不具备原子性)。volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥性执行的特性可以确保整个临界区代码的执行具有原子性。

禁止指令重排

有序性:java对于volatile修饰的变量的读写操作前后加上各种特定的内存屏障来禁止指令重排序以保障有序性。(关于指令重排可见指令重排)

volatile 写-读 的内存语义

  • 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
  • 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量。

volatile 可见性实现原理

JMM 内存层面实现原理

在JVM底层volatile是采用“内存屏障”来保证可见性的。volatile修饰的变量的read、load、use操作和assign、store、write必须是连续的,即修改后必须立即同步回主内存,使用时必须从主内存刷新,由此保证volatile变量操作对多线程的可见性。

硬件层面实现原理

通过lock前缀指令,会锁定变量缓存行区域并写回主内存,这个操作称为“缓存锁定”,缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据。一个处理器的缓存回写到内存会导致其他处理器的缓存无效。

volatile 无法保证线程安全

如下代码,运行后输出结果并不是期待的1000,由此可以证明volatile不一定能够保证线程安全:


import java.util.Vector;

public class VolatileDemo {

   public volatile  int count = 0;
   public void load(){
      for(int i=0; i<10; i++){
         try {
            Thread.sleep(500);
         } catch (InterruptedException e) {
            e.printStackTrace();
         }
         count++;
      }
   }

   public static void main(String[] args) throws InterruptedException {
      VolatileDemo volatileDemo = new VolatileDemo();
      Vector<Thread> threads = new Vector<>();
      for (int i=0; i<100; i++){
         Thread thread1 = new Thread(volatileDemo::load, "Thread" + i);
         thread1.start();
         threads.add(thread1);
      }
      for (Thread thread: threads){
         thread.join();
      }
      // 输出结果,正确结果应该是1000,实际却小于1000
      System.out.println(""+ volatileDemo.count);
   }
}

volatile不能保证线程安全是因为:volatile保证了可见性,但却不能保证操作的原子性,前面提过count++不是原子性操作,会当做三步,先读取count值,然后+1,最后赋值回给count变量。要保证线程安全的话,则需要用synchronized关键字或者lock锁,给count++ 这段代码加锁:

public synchronized void add(){
   count++;
}

指令重排

Java语言规范规定JVM线程内部维持顺序化语义。即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。

指令重排序的意义

JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。

在编译器与CPU处理器中都能执行指令重排优化操作 image.png

指令重排序在单线程是没有问题的,不会影响执行结果,而且还提高了性能。但是在多线程的环境下就不能保证一定不会影响执行结果了。如下代码changeData中a=2和flag=true不存在数据依赖关系,有可能会被重排序,即先执行flag=true,再执行a=2。如果线程B此时顺利执行到run()中的int b=a+1,而线程A此时执行到changeData中flag=true,a=2这个操作还未被执行,这时b=a+1的结果就可能等于2。 所以在多线程环境下,就需要禁止指令重排序

public class VolatileDemo01 {

   int a =1;
   boolean flag = false;

   public void changeData(){
      a = 2;
      flag = true; //此处a=2和flag=true不存在数据依赖关系,有可能会被重排序
   }

   public void run(){

      if (flag){
         int b = a + 1;
         System.out.println("输出b的结果:" + b);
      }
   }
}

重排序规则

重排序需要遵守一定规则:

  • 重排序操作不会对存在数据依赖关系的操作进行重排序。
    比如:a=1;b=a; 这个指令序列,由于第二个操作依赖于第一个操作,所以在编译时和处理器运行时这两个操作不会被重排序。
  • 重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变
    比如:a=1;b=2;c=a+b这三个操作,第一步(a=1)和第二步(b=2)由于不存在数据依赖关系, 所以可能会发生重排序,但是c=a+b这个操作是不会被重排序的,因为需要保证最终的结果一定是c=a+b=3。

volatile重排序规则

image.png volatile禁止重排场景:

  • 第二个操作是volatile写,不管第一个操作是什么都不会重排序
  • 第一个操作是volatile读,不管第二个操作是什么都不会重排序
  • 第一个操作是volatile写,第二个操作是volatile读,也不会发生重排序

JMM 内存屏障插入策略

image.png

  • 在每个volatile写操作的前面插入一个StoreStore屏障
  • 在每个volatile写操作的后面插入一个StoreLoad屏障
  • 在每个volatile读操作的后面插入一个LoadLoad屏障
  • 在每个volatile读操作的后面插入一个LoadStore屏障

附: JVM层面的内存屏障:

  • LoadLoad屏障:(指令Load1; LoadLoad; Load2),在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  • LoadStore屏障:(指令Load1; LoadStore; Store2),在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
  • StoreStore屏障:(指令Store1; StoreStore; Store2),在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
  • StoreLoad屏障:(指令Store1; StoreLoad; Load2),在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能