volatile关键字作用及原理

2,110 阅读9分钟

当访问共享变量的多个线程运行在多核CPU上时,可能会出现可见性问题。synchronized关键字和lock可以解决这个问题,但是会阻塞线程,降低性能,所以java给出了更轻量级关键字volatile,不会阻塞线程。volatile有两个作用,一个是保证共享变量的可见性,另一个是防止指令重排序。

为理解volatile关键字的作用和原理,需要先了解一些计算机基础知识。

并发编程的三个特性

并发编程时,线程安全涉及三个特性:原子性、可见性、有序性。

原子性

要么全做,要么全部不做。 java中的原子操作包括:

  1. longdouble之外的基本类型的赋值操作
  2. 所有引用reference的赋值操作
  3. java.concurrent.Atomic.* 包中所有类的一切操作 longdouble,因为它们在32位操作系统上,会被分成两部分进行更新,所以不是原子操作。 自加操作(i++)不是原子操作,因为它是读取i的值赋值给局部变量tmptmp+1把结果赋值给i,3个原子操作的集合。

可见性

所有线程都能看到共享内存的最新状态。 多个线程访问同一个变量时,变量被一个线程修改后,能被其他线程看到。即多个线程访问同一变量时,看到的变量值是一致的。

有序性

为提高CPU流水线并行性能,编译器会对指令进行重排序,把没有被依赖的指令重新排序。

java 内存模型 (JMM)

程序存储在外存中,当进程被执行时,代码被调入主存。
但是主存的速度远远慢于CPU的速度,为提高性能,CPU中添加高速缓存cache,CPU不直接访问主存,而是通过cache和主存交互。当指令被执行时,指令所在块先从主存调入cache,CPU再访问cache获取指令。
CPU通常有多个内核,每个内核都有独立的cache。当多线程同时访问一块共享变量时,他们可能运行在不同的内核上,共享变量在每个内存的cache都有一个备份,线程各自操作自己所在内核cache中共享变量的备份,会造成缓存一致性问题。
Java虚拟机规范定义了一种java内存模型(JMM),来屏蔽掉各种硬件和操作系统的内存访问差异,让Java程序在各种平台上都能达到一致的内存访问效果。为了获得较好的执行性能,Java内存模型并没有限制执行引擎使用处理器的寄存器或者告诉缓存来提升指令执行速度,也没有限制编译器对指令进行重排序。简单的来说,在Java内存模型中,会存在缓存一致性和指令重排序的问题。

volatile 作用

volatile用于保证修饰变量的可见性、有序性,但是不能保证原子性。

共享变量可见性及实现原理

当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,会从内存中重新读取新值。
而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,一方面此时内存中可能还是原来的旧值,因此无法保证可见性,另一方面可能访问的是线程自己所在内核cache中的缓存数据,而不是主存数据,即使主存中数据已更新,也无法读取到最新值。

可见性实现原理

  1. 线程1修改volatile修饰的变量时,会立即写入主存,并把主存当前块在其它cache中缓存都置为无效。
  2. 其他线程所在cache探听总线,当发现自己块对应的主存已修改时,就把本cache中对应块置为无效状态。当处理器要对这个数据读写时,发现块已失效,会重新从主存中调入块到cache后,再读取,那么读取到的数据就是最新的。 所以,如果一个变量被volatile所修饰的话,在每次数据变化之后,其值都会被强制刷入主存。而其他处理器的缓存由于遵守了缓存一致性协议,也会把这个变量的值从主存加载到自己的缓存中。这就保证了一个volatile在并发编程中,其值在多个缓存中是可见的。

通过synchronizedLock也能够保证可见性,他们能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。但是这两种方式会阻塞其他线程执行,对性能损耗较大。

防止重排序及原理

代码在实际执行过程中,并不全是按照编写的顺序进行执行的,在保证单线程执行结果不变的情况下,编译器或者CPU可能会对指令进行重排序,以提高程序的执行效率。但是在多线程的情况下,指令重排序可能会造成一些问题,最常见的就是双重校验锁单例模式:
用懒加载方式实现单例模式时,通常使用双重检查加锁的方式(DCL),代码实现如下。

public class Singleton {
    public static volatile Singleton singleton;

    private Singleton() {};

    public static Singleton getInstance() {
        if (singleton == null) {
            synchronized (Singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

通常的实现中没有使用到volatile关键字,那这里为什么要加上volatile修饰呢?要理解这个问题,先要了解对象的创建过程,实例化一个对象分为三个步骤:

  1. 分配内存空间
  2. 初始化对象
  3. 将内存空间的地址赋值给引用 但由于编译器可以对指令进行重排序,所以上面的过程也可能变成如下过程:
  4. 分配内存空间
  5. 将内存空间的地址赋值给引用
  6. 初始化对象 如果是这个过程,那么多线程环境下就将一个未初始化对象的引用暴露出来,从而导致不可预料的结果。 因此,为了防止这个过程的重排序,需要将这个变量修改为volatile类型。

禁止重排序原理
volatile防止指令重排序是通过内存屏障来实现的。编译器在生成字节码文件时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。 内存屏障分为如下三种:

  • Store Barrier: Store屏障,是x86的”sfence“指令,强制所有在store屏障指令之前的store指令,都在该store屏障指令执行之前被执行。
  • Load Barrier: Load屏障,是x86上的”ifence“指令,强制所有在load屏障指令之后的load指令,都在该load屏障指令执行之后被执行
  • Full Barrier: Full屏障,是x86上的”mfence“指令,复合了load和save屏障的功能。 Java内存模型中volatile变量在写操作之后会插入一个store屏障,在读操作之前会插入一个load屏障,并且volatile修饰的变量的读写指令不能和其前后的任何指令重排序,其前后的指令可能会被重排序。一个类的final字段会在初始化后插入一个store屏障,来确保final字段在构造函数初始化完成并可被使用时可见。也正是JMM在volatile变量读写前后都插入了内存屏障指令,进而保证了指令的顺序执行。

不能完全保证原子性

volatile用于修饰简单类型变量时,如int、float、boolean,对它们的操作就会变成原子级别的。 但有一定的限制,如果volatile修改的简单变量与该变量以前的值相关,那么volatile不起作用,所以下方test()方法不是原子操作,如果要想使这种情况变成原子操作,需要使用synchronized关键字,如test2()方法。

class Test {
	volatile int n;

	private void test() {
		n++;
		n = n + 1;
	}

	private synchronized void test2() {
		n++;
		n = n + 1;
	}
}

所以使用volatile关键字时要慎重,并不是只要简单类型变量使用volatile修饰,对这个变量的所有操作都是原子操作,当变量的值由自身的上一个决定时,如n=n+1n++等,volatile关键字将失效,只有当变量的值和自身上一个值无关时对该变量的操作才是原子级别的,如n = m + 1,这个就是原子级别的。 另外,如果使用AtomicInteger.set(AtomicInteger.get() + 1),会和上述情况一样有并发问题,要使用AtomicInteger.getAndIncrement()才可以避免并发问题。

总结

  1. 正确的使用场景:一写多读,只由一个线程更新,其他线程都来读取。
  2. volatile是轻量级同步机制。在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,是一种比synchronized关键字更轻量级的同步机制。
  3. volatile只能保证内存可见性,不能保证原子性,所以不能替代synchronized和加锁机制。后两种机制既可以确保可见性又可以确保原子性。
  4. volatile不能修饰写入操作依赖当前值的变量。声明为volatile的简单变量如果当前值与该变量以前的值相关,那么volatile关键字不起作用,也就是说如下的表达式都不是原子操作:count++count = count+1
  5. 当要访问的变量已在synchronized代码块中,或者为常量时,没必要使用volatile
  6. volatile频繁从内存中读写,且屏蔽掉了JVM中必要的代码优化,和普通变量比较,效率上比较低,因此一定在必要时才使用此关键字。

参考

Java volatile的作用
volatile关键字及其作用
volatile的作用及正确的使用模式
[面试必备]深入理解Java的volatile关键字