深入Volatile的实现原理

819 阅读8分钟

前言

volatile是jvm提供的最轻量级的同步机制(相比于synchronized,其要轻量很多),和synchronized不同,volatile是一个变量修饰符,只能用来修饰变量。无法修饰方法及代码块等。

当一个变量定义为volatile后,其具备两种特性:

  • 此变量对所有线程的可见性 可见性:当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。
  • 禁止指令重排序优化 指令重排序:JVM为了进行优化,会对变量赋值等操作进行一系列的优化,其只保证了所有依赖赋值结果的地方都能获取到正确的结果,但不能保证该变量赋值操作的顺序与程序代码中的执行顺序一致。

注意:重排序优化是机器级的优化操作,不是Java源代码层面进行的。

volatile的用法

volatile的用法比较简单,只需要在声明一个可能被多线程同时访问的变量时,使用volatile修饰就可以了。

如以下代码,是一个比较典型的使用双重锁校验的形式实现单例的,其中使用volatile关键字修饰可能被多个线程同时访问到的singleton。

class Singleton{  
   private volatile static Singleton instance = null;  
     
   private Singleton() {  
         
  }  
     
   public static Singleton getInstance() {  
       if(instance==null) {                // step 1
           synchronized (Singleton.class) {  
               if(instance==null)          // step 2
                   instance = new Singleton();  //step 3
          }  
      }  
       return instance;  
  }  
}

volatile的原理

为了提高处理器的执行速度,在处理器和内存之间增加了多级缓存来提升。但是由于引入了多级缓存,就存在缓存数据不一致问题。

但是,对于volatile变量,当对volatile变量进行写操作的时候,JVM会向处理器发送一条lock前缀的指令,将这个缓存中的变量回写到系统主存中。

但是就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议

缓存一致性协议(MESI协议):每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。

所以,如果一个变量被volatile所修饰的话,在每次数据变化之后,其值都会被强制刷入主存。而其他处理器的缓存由于遵守了缓存一致性协议,也会把这个变量的值从主存加载到自己的缓存中。这就保证了一个volatile在并发编程中,其值在多个缓存中是可见的。

volatile与可见性

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。所以,就可能出现线程1改了某个变量的值,但是线程2不可见的情况。

656873-20170823225844824-617863995.png

前面的关于volatile的原理中介绍过了,Java中的volatile关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新。因此,可以使用volatile来保证多线程操作时变量的可见性。

volatile与有序性

有序性即程序执行的顺序按照代码的先后顺序执行。

除了引入了时间片以外,由于处理器优化和指令重排等,CPU还可能对输入代码进行乱序执行,比如load->add->save 有可能被优化成load->save->add 。这就是可能存在有序性问题。

而volatile除了可以保证数据的可见性之外,还有一个强大的功能,那就是他可以禁止指令重排优化等。

普通的变量仅仅会保证在该方法的执行过程中所依赖的赋值结果的地方都能获得正确的结果,而不能保证变量的赋值操作的顺序与程序代码中的执行顺序一致。

volatile可以禁止指令重排,这就保证了代码的程序会严格按照代码的先后顺序执行。这就保证了有序性。被volatile修饰的变量的操作,会严格按照代码顺序执行,load->add->save 的执行顺序就是:load、add、save。

class Singleton{  
   private volatile(去掉) static Singleton instance = null;  
     
   private Singleton() {  
         
  }  
     
   public static Singleton getInstance() {  
       if(instance==null) {                // step 1
           synchronized (Singleton.class) {  
               if(instance==null)          // step 2
                   instance = new Singleton();  //step 3
          }  
      }  
       return instance;  
  }  
}

如果 instance 不用 volatile 修饰,可能产生什么结果呢?

假设有两个线程在调用 getInstance() 方法,线程 1 执行步骤 step1 ,发现 instance 为 null ,然后同步锁住 Singleton 类,接着再次判断 instance 是否为 null ,发现仍然是 null,然后执行 step 3 ,开始实例化 Singleton 。而在实例化的过程中,线程 2 走到 step 1,有可能发现 instance 不为空,但是此时 instance 有可能还没有完全初始化。

  • 对象在初始化的时候分三个步骤,用下面的伪代码表示:
memory = allocate();  //1. 分配对象的内存空间
ctorInstance(memory); //2. 初始化对象
instance = memory;    //3. 设置 instance 指向对象的内存空间

因为步骤 2 和步骤 3 需要依赖步骤 1,而步骤 2 和 步骤 3 并没有依赖关系,所以这两条语句有可能会发生指令重排,也就是或有可能步骤 3 在步骤 2 的之前执行。在这种情况下,步骤 3 执行了,但是步骤 2 还没有执行,也就是说 instance 实例还没有初始化完毕,正好,在此刻,线程 2 判断 instance 不为 null,所以就直接返回了 instance 实例,但是,这个时候 instance 其实是一个不完全的对象,所以,在使用的时候就会出现问题。

volatile与原子性

原子性即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

线程是CPU调度的基本单位。CPU有时间片的概念,会根据不同的调度算法进行线程调度。当一个线程获得时间片之后开始执行,在时间片耗尽之后,就会失去CPU使用权。所以在多线程场景下,由于时间片在线程间轮换,就会发生原子性问题。

为了保证原子性,需要通过字节码指令monitorenter和monitorexit,但是volatile和这两个指令之间是没有任何关系的。

所以,volatile是不能保证原子性的。

示例:

public class Part implements Runnable {
    private volatile static int a = 0;
    @Override
    public void run() {
        for (int i = 0; i < 200000; i++) {
        // 数字大一些,不然看不到效果
        // 这里在a++前后打印变量值
            System.out.println(Thread.currentThread().getId()+" before a = " + a);
            a++;
            System.out.println(Thread.currentThread().getId()+" after a = " + a);
        }
    }
}


主函数

public class Test {
    public static void main(String[] args) throws Exception {
        Part a = new Part();
      
        Thread t1 = new Thread(a);
        Thread t2 = new Thread(a);
        t1.start();
        t2.start();
    }
}

有两个线程,a最后的结果预期是400000。但是运行完的结果会小于400000,并且每次都不一样,这里就是原子性的问题。

a++这个操作有三个步骤,读取值,自增,再写入。 假设发生下面这种情况:线程a读取了a=1,此时还没自增,线程b也读取了a=1。他们再执行自增操作,最后a写入内存是2,b写入内存还是2。没有保证原子性。

解决方案: 可以通过synchronized或lock,进行加锁,来保证操作的原子性。也可以通过使用AtomicInteger。

我用AutomicInteger修改之后的:

public class Part implements Runnable {
    private volatile static AtomicInteger a = new AtomicInteger(0);
    @Override
    public void run() {
        for (int i = 0; i < 200000; i++) {
        //这个方法先自增,再返回自增之后的值
            System.out.println("a = " + a.incrementAndGet() );
        }
    }
}

synchronized

public class Part implements Runnable {
    private volatile static int a = 0;

    @Override
    public void run() {
        for (int i = 0; i < 200000; i++) {
            synchronized (Part.class) {
                // 数字大一些,不然看不到效果
                // 这里在a++前后打印变量值
                System.out.println(Thread.currentThread().getId() + " before a = " + a);
                a++;
                System.out.println(Thread.currentThread().getId() + " after a = " + a);
            }
        }
    }
}

ReentrantLock

public class Part implements Runnable {
    private volatile static int a = 0;
    private Lock lock = new ReentrantLock();

    @Override
    public void run() {
        lock.lock();

        for (int i = 0; i < 200000; i++) {
            // 数字大一些,不然看不到效果
            // 这里在a++前后打印变量值
            System.out.println(Thread.currentThread().getId() + " before a = " + a);
            a++;
            System.out.println(Thread.currentThread().getId() + " after a = " + a);
        }

        lock.unlock();
    }
}