volatile

时间:2024-11-18 07:52:55

1、简介

 

volatile 是 Java 提供的一种轻量级的同步机制。Java 语言包含两种内在的同步机制:同步块(或方法)和 volatile 变量,相比于synchronized(synchronized通常称为重量级锁),volatile更轻量级,因为它不会引起线程上下文的切换和调度。但是volatile 变量的同步性较差(有时它更简单并且开销更低),而且其使用也更容易出错。

2、volatile变量的特性

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

(1)当写一个volatile变量时,JMM会把该线程本地内存中的变量强制刷新到主内存中去;

(2)这个写会操作会导致其他线程中的 volatile 变量缓存无效。

案例:

对于 i++ 这种复合操作,即使使用 volatile 关键字修饰也不能保证操作的原子性,可能会引发数据不一致问题,即不能保证多线程环境下的指令交错问题。

 两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?

static volatile int counter = 0;
 public static void main(String[] args) throws InterruptedException {
     Thread t1 = new Thread(() -> {
         for (int i = 0; i < 5000; i++) {
             counter++;
         }
     }, "t1");
     Thread t2 = new Thread(() -> {
         for (int i = 0; i < 5000; i++) {
             counter--;
         }
     }, "t2");
     t1.start();
     t2.start();
     t1.join();
     t2.join();
     log.debug("{}",counter);
 }

以上的结果可能是正数、负数、零。为什么呢?因为 Java 中对静态变量的自增,自减并不是原子操作,要彻底理解,必须从字节码来进行分析。

例如对于i++而言(i 为静态变量),实际会产生如下的 JVM 字节码指令:

getstatic     i  // 获取静态变量i的值
 iconst_1         // 准备常量1
 iadd             // 自增
 putstatic     i  // 将修改后的值存入静态变量i

而 Java 的内存模型如下,完成静态变量的自增,自减需要在主存和工作内存中进行数据交换:

volatile_重排序

单线程是顺序执行没有问题,但多线程下指令可能交错运行: 

volatile_重排序_02

2. 禁止指令重排

    重排序是指编译器和处理器为了优化程序性能而对指令序列进行排序的一种手段。重排序需要遵守一定规则:

 (1)重排序操作不会对存在数据依赖关系的操作进行重排序。

  比如:a=1;b=a; 这个指令序列,由于第二个操作依赖于第一个操作,所以在编译时和处理器运行时这两个操作不会被重排序。

 (2)重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变

  比如:a=1;b=2;c=a+b这三个操作,第一步(a=1)和第二步(b=2)由于不存在数据依赖关系, 所以可能会发生重排序,但是c=a+b这个操作是不会被重排序的,因为需要保证最终的结果一定是c=a+b=3。

    重排序在单线程下一定能保证结果的正确性,但是在多线程环境下,可能发生重排序,影响结果,下例中的1和2由于不存在数据依赖关系,则有可能会被重排序,先执行status=true再执行a=2。而此时线程B会顺利到达4处,而线程A中a=2这个操作还未被执行,所以b=a+1的结果也有可能依然等于2。

public class TestVolatile{
     int a = 1;
     boolean status = false;//状态切换为true
     public void changeStatus{
         a = 2;   //1
         status = true;  //2
     }
     //若状态为true,则为running
     public void run(){
         if(status){   //3
             int b = a + 1;  //4
             System.out.println(b);
         }
     }
 }

使用volatile关键字修饰共享变量便可以禁止这种重排序。若用volatile修饰共享变量,在编译时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序,volatile禁止指令重排序也有一些规则:

     a.当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;

     b.在进行指令优化时,不能将对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。

     即执行到volatile变量时,其前面的所有语句都执行完,后面所有语句都未执行。且前面语句的结果对volatile变量及其后面语句可见。

3、实现原理

在 JVM 底层 volatile 是采用“内存屏障”来实现的。观察加入 volatile 关键字和没有加入 volatile 关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障(也成内存栅栏).

是不是感觉有点像 Java 的 synchronized 锁。但 volatile 底层使用多核处理器实现的 LOCK# 指令,更底层,消耗代价更小。因此有人将 Java 的 synchronized 看作重量级的锁,而 volatile 看作轻量级的锁并不是全无道理。

内存屏障会提供3个功能:

(1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;

(2)它会强制将对缓存的修改操作立即写入主存;

(3)如果是写操作,它会导致其他CPU中对应的缓存行无效。

4、double-checked locking 问题

double-checked locking 问题说明

public final class Singleton {
     private Singleton() { 
         
     }
     private static Singleton INSTANCE = null;
     
     public static Singleton getInstance() {
         if(INSTANCE == null) { // t2
             synchronized(Singleton.class) {
                 if (INSTANCE == null) { // t1 
                     INSTANCE = new Singleton();
                 }
             }
         }
         return INSTANCE;
     }
 }

以上的实现特点是:

  • 懒惰实例化
  • 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁 
  • 有隐含的,但很关键的一点:第一个 if 使用了 INSTANCE 变量,是在同步块之外

但在多线程环境下,上面的代码是有问题的,getInstance 方法对应的字节码为:

0 getstatic #2 <p5_1/Singleton.INSTANCE>
  3 ifnonnull 37 (+34)
  6 ldc #3 <p5_1/Singleton>
  8 dup
  9 astore_0
 10 monitorenter
 11 getstatic #2 <p5_1/Singleton.INSTANCE>
 14 ifnonnull 27 (+13)
 17 new #3 <p5_1/Singleton>
 20 dup
 21 invokespecial #4 <p5_1/Singleton.<init>>
 24 putstatic #2 <p5_1/Singleton.INSTANCE>
 27 aload_0
 28 monitorexit
 29 goto 37 (+8)
 32 astore_1
 33 aload_0
 34 monitorexit
 35 aload_1
 36 athrow
 37 getstatic #2 <p5_1/Singleton.INSTANCE>
 40 areturn
  • 17行new,新建一个Singleton对象,并拿到它的引用压入操作数栈中
  • 20行dup,复制对象的引用压入操作数栈中
  • 21行invokespecial,从操作数栈用弹出一个引用,用于调用类的构造方法进行初始化
  • 24行putstatic,从操作数栈用弹出一个引用用于给成员变量赋值

其中21,24行存在指令重排序的可能

volatile_静态变量_03

关键在于 0: getstatic 这行代码在 monitor 控制之外,它就像不守规则的人,可以越过 monitor 读取 INSTANCE 变量的值。这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初始化完毕的单例。

对 INSTANCE 使用 volatile 修饰即可,可以禁用指令重排,但要注意在 JDK 5 以上的版本的 volatile 才会真正有效

这里的INSTANC并没有完全受到synchronized管理synchronized并不能阻止指令重排序,如果共享变量完全被synchronized管理,不会有问题,,所以出现问题。

double-checked locking 解决

private static volatile Singleton INSTANCE = null;

字节码上看不出来 volatile 指令的效果

// -------------------------------------> 加入对 INSTANCE 变量的读屏障
  0 getstatic #2 <p5_1/Singleton.INSTANCE>
  3 ifnonnull 37 (+34)
  6 ldc #3 <p5_1/Singleton>
  8 dup
  9 astore_0
 10 monitorenter  -----------------------> 保证原子性、可见性
 11 getstatic #2 <p5_1/Singleton.INSTANCE>
 14 ifnonnull 27 (+13)
 17 new #3 <p5_1/Singleton>
 20 dup
 21 invokespecial #4 <p5_1/Singleton.<init>>
 24 putstatic #2 <p5_1/Singleton.INSTANCE>
 // -------------------------------------> 加入对 INSTANCE 变量的写屏障
 27 aload_0
 28 monitorexit  ------------------------> 保证原子性、可见性
 29 goto 37 (+8)
 32 astore_1
 33 aload_0
 34 monitorexit
 35 aload_1
 36 athrow
 37 getstatic #2 <p5_1/Singleton.INSTANCE>
 40 areturn

5、volatile 和 synchronized 的区别

  • volatile 本质是在告诉 JVM 当前变量在工作内存中的值是不确定的,需要从主存中读取; synchronized 则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住;
  • volatile 仅能使用在变量级别;synchronized 则可以使用在变量、方法、和类级别的;
  • volatile 能实现变量的可见性和有序性,不能保证原子性;而 synchronized 则可以保证变量的可见性、有序性和原子性,这里的有序性并不是代表能禁止指令重排序,它的本质是让多个线程在调用synchronized修饰的方法时,由并行(并发)变成串行调用,谁获得锁谁执行,但并不能保证内部指令重排序问题
  • volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞;
  • volatile 标记的变量不会被编译器优化;synchronized 标记的变量可以被编译器优化。