0%

Java并发-volatile

volatile综述

volatile主要保证两点

  1. 变量的值在多线程之间是可见的
  2. 阻止指令重排序,间接的保证变量值的可见性

但不保证原子性,所以开销比synchronized加锁要小

可见性的解释涉及到java内存模型,每个线程都有自己的私有内存,对应堆中的虚拟机栈中,当多个线程访问主存上同一个变量时,会拷贝变量到各自私有线程中,线程对变量做出修改都是在私有内存中的修改,线程并不知道各自的修改值,也就是所谓的不可见。

用volatile修饰变量后,对变量的修改会立刻刷新到主存,读volatile变量也会从主存去读取,保证可见性,但这让缓存失效,牺牲了一定的访问速度。

指令重排序有好几个层面的,有CPU层面和编译器层面的。

为什么要进行指令重排序呢?是为了提高CPU利用率。

CPU层面的指令重排序是因为防止CPU指令流水线的停顿,指令流水线是做什么的呢?一条指令可以分为多个执行步骤,每个执行步骤可以由多个功能单元的硬件来执行,这样就不用串行的执行指令了,可以让多个功能单元并行的执行指令的不同的步骤,多个指令的执行速度一下提升好几倍。但是如果指令之间存在数据依赖,流水线就只能停顿等待,但是如果把没有依赖的指令重排序一下,就不用停顿等待了,这样CPU利用率就提高了。

编译器的重排序要达到的目的也是提高CPU利用率。

检查编译器有没有做重排序,可以开两个线程做验证,给两个变量a1、a2赋初始值0,线程1执行两条语句,先给a1赋值1,然后打印a2,线程2也执行两条语句,先给a2赋值1,然后打印a1,正常执行结果是打印1和1,执行很多次过后会发现有一次打印出来的两个结果是10、01甚至00,打印语句被重排序到前面去先执行了。

既然指令重排序这么好,为什么还要阻止呢?

因为指令重排序虽然不会影响单线程的代码语义,但是在多线程下会影响程序运行的正确性,有时候需要手动阻止。这个可以写代码验证的。

volatile阻止指令重排序具体是通过内存屏障来实现的。

内存屏障分为读屏障和写屏障,读屏障将内存数据拷贝到缓存,写屏障将缓存数据刷新到内存。

volatile写操作之前插入StoreStore屏障,之后插入StoreLoad屏障

volatile写操作volatile读操作之后插入LoadLoad屏障和LoadStore屏障

内存屏障有两个作用:

  1. 阻止内存屏障两侧的指令重排序
  2. 强制把缓存数据回写到主内存,对所有线程可见

但也不能总是用volatile来保证有序性,那样太麻烦了。所有Java内存模型规定了一个happens-before原则,保证前一个操作的结果对后续操作是可见的,来帮助开发者辅助判断代码是否有线程安全的问题。

比如

  1. 一个线程内必须保持语义的串行,按照代码顺序执行
  2. 解锁操作要发生在对这个锁的加锁操作之前
  3. 线程start()方法先于线程的每个动作

在多线程中需要修改和读取的变量,不需要保证原子性的,但要保证可见性的,就要用volatile修饰

一个典型的使用场景是双重检查锁定的单例模式,对单例对象加volatile修饰,以防止new对象的时候发生指令重排序,进而导致得到了一个对象的引用但是对象还没有初始化。

volatile解决了什么问题?

  1. 保证变量的可见性,多线程可读取到变量最新修改值
  2. 保证变量的有序性,阻止指令重排序

可见性是什么意思?

首先要介绍Java内存模型,Java的数据都存储在主存中,每个线程读写主存的数据时,都会把数据拷贝到线程自己的工作内存中再进行读写,工作内存可以理解为线程自己的缓存,多个线程对同一个数据修改时,修改的都是线程自己工作内存的数据拷贝,多个线程的工作内存之间是非共享的,对数据做出修改后互相不可见。

volatile是如何保证可见性的?

多线程读写主存中存储的某个volatile变量时,会把这个变量所在的内存块都复制到线程的工作内存,当某个线程修改了工作内存中的volatile变量,会立即将新数据写入共享内存,其他线程的工作内存中这一缓存块会被标记失效,被要求重新从共享内存读取数据,保证读取到的是最新的值。

可以参考缓存一致性协议来理解。

为什么要有指令重排序,指令重排序有什么好处?

减少CPU空等,提高CPU整体的吞吐量。

编译器层面有编译器对指令顺序的优化。

CPU层面有指令流水线对指令执行的优化。

虚拟机是如何为volatile实现可见性和有序性?如何防止指令重排序?

通过内存屏障来实现。

内存屏障之前所有的写操作都要回写到主存,内存屏障之后的所有读操作都能获取内存屏障之前的所有写操作的最新结果,这样就保证了可见性。

因此也就禁止把内存屏障之后的指令重排序到内存屏障之前。

先简单了解两个指令:

  • Store:将处理器缓存的数据刷新到内存中。
  • Load:将内存存储的数据拷贝到处理器的缓存中。
屏障类型 指令示例 说明
LoadLoad Barriers Load1;LoadLoad;Load2 该屏障确保Load1数据的装载先于Load2及其后所有装载指令的的操作
StoreStore Barriers Store1;StoreStore;Store2 该屏障确保Store1立刻刷新数据到内存(使其对其他处理器可见)的操作先于Store2及其后所有存储指令的操作
LoadStore Barriers Load1;LoadStore;Store2 确保Load1的数据装载先于Store2及其后所有的存储指令刷新数据到内存的操作
StoreLoad Barriers Store1;StoreLoad;Load2 该屏障确保Store1立刻刷新数据到内存的操作先于Load2及其后所有装载装载指令的操作。它会使该屏障之前的所有内存访问指令(存储指令和访问指令)完成之后,才执行该屏障之后的内存访问指令

volatile为什么不能保证原子性?

因为一个volatile变量的操作,比如i++,本身多个字节码指令,并且每条指令也不一定是原子化的,所以volatile变量的操作也不可能是原子化的。

这个可以用程序验证,volatile变量初始值为0,两个线程分别对其自增一万次,最后变量的值是小于两万的。

可以从字节码指令看出来

如下的代码:

1
2
3
4
5
6
7
public class VolatileDemo {
private volatile int value = 200;

public void increament() {
value++;
}
}

编译后value++的字节码指令如下:

1
2
3
4
GETFIELD VolatileDemo.value : I
ICONST_1
IADD
PUTFIELD VolatileDemo.value : I

GETFIELD指令是获取对象的字段值,将值压入栈顶
ICONST_1指令是int型常量1入栈,因为要加1,所以就入栈1
IADD指令是弹出栈顶的两个数字进行相加,相加的和再压入栈
PUTFIELD指令是给对象的变量赋值栈顶的值

volatile可以保证GETFIELD时可以读取到value的最新值,但是iconst_1和add时,其他线程可能已经把value值也改变了,最后PUTFIELD是过期的值,所以多线程调用increament时,最终的值会变小。

一个volatile变量自增操作有4个指令,一条字节码指令也不一定是原子的,可能还会转换为若干条本地机器码指令,所以volatile不能保证原子性。

需要通过synchronized或java.util.concurrent包下的锁或原子类来保证原子性。

volatile使用案例

  1. volatile可以使得long和double的赋值是原子的
  2. 多线程需要实时读取的值,例如:
    a. AtomicInteger里的value都是volatile的
    b. AQS里的state变量也是volatile
  3. CopyOnWriteArrayList里的数组也是volatile的,保证数组拷贝过后,对其他读线程立即可见
  4. 阻止if或while中条件变量的重排序,防止出现不符合预期的结果。if或while语句内部的变量,跟条件变量的赋值,如果在同一个方法中执行,因为没有依赖关系,可能会发生重排序。比如方法a里会读取先通过if判断flag变量是否为true,为true就打印变量c的值,然后在方法b中,先对c赋值,再对flag赋值true,b方法中两个语句可能会重排序的,在多线程执行的时候,就会导致flag先为true,但是c还没赋值,另外一个线程已经执行方法a了,
  5. 双重检查锁定的单例