简单了解volatile关键字

了解过并发编程的同学对volatile都不陌生吧,volatile是java中的一个关键字,是一个轻量级的线程同步机制。 其特性有三点,分别为:保证可见性,保证有序性(也可以理解为禁止指令重排),不保证原子性。

如何保证可见性

什么是可见性?可见性是指在并发情况下,当一个线程修改了某个变量值后,其他线程可以立即获取该变量最新的值。举个简单的例子,定义一个变量count,用线程A,线程B两个线程分别去修改和监听count。

public static int count = 0;
public static void main(String[] args) throws InterruptedException {
    new Thread(()->{
        while(count==0){

        }
        System.out.println("========线程B获取count最新值========");
    },"线程B").start();

    Thread.sleep(1000L);

    new Thread(()->{
        while(count==0){
            count++;
        }
        System.out.println("=======线程A执行结束=======");
    },"线程A").start();
}
复制代码

运行代码后,结果最终只打印‘线程A执行结束’,而线程B一直没有监听到count被修改,所以会一直处于死循环中。

这个时候volatile就可以发挥作用了。使用起来很是简单,在count前使用volatile修饰一下就搞定了,线程B成功获取到被线程A修改的结果值啦,是不是要比synchronized简单很多。

image.png

那么现在问题来了,volatile是如何让变量count对线程B可见的呢。下面就来简单的分析一下。在Java内存模型(JMM)中,每个线程在运行期间都会有自己的一块工作内存,一般情况下,变量都是存储在主内存中的,线程在处理变量时,会将变量复制到自己的工作内存中并对副本变量进行处理,当处理结束后,在将变量的结果写到主内存。 需要注意的是这里的主内存并不是指JVM中的堆,而是JMM一个抽象的概念,是用来存储共享变量的。

image.png

线程在复制共享变量到自己的工作内存最终写回到主内存,需要做下面几个原子操作,分别是:

  • read:读取主内存中的共享变量
  • load:加载共享变量到主内存
  • use:工作引擎使用该变量副本
  • assign:将处理后的结果重新赋值到工作内存
  • store:将工作内存的副本数据存储到主内存
  • write:将结果写到主内存
  • lock:对共享变量加锁,表示独占
  • unlock:对共享变量解锁

按照这些原子操作并结合上面的例子可以简单梳理一下工作流程,首先线程B将共享变量count读取并加载到自己的工作内存,并由执行引擎加载count副本来执行while判断,但此时count副本的值为0,所以线程B会一直处于循环状态。然后,线程A将共享变量加载到自己的工作内存,并且执行while判断,此时count副本的值为0,所以执行count++操作,并且将结果重新赋值到工作内存,此时count副本值不在为0,那么线程A会将终止循环,并且将副本之重新写回到主内存。

image.png

现在问题又来了,既然线程A已经将最新的count值更新到主内存了,但是此时的线程B仍然还在执行while中,因为线程B并不知道count值被修改,所以count副本值仍然为0。那么,如何才能让线程B知道被修改的消息呢?目前可以通过BUS总线锁和MESI缓存一直性协议来保证线程B可以及时的获取信息。通过查看字节码后可以发现,volatile会生成一个lock前缀指令,而lock指令可以触发总线锁和MESI协议。

BUS总线锁

在计算机中CPU要和其他设备连接,需要通过一个叫总线的东西,比如内存和CPU之间的交互。在早期,使用总线锁的效果类似于常说的互斥,也就是说当对总线加锁后,除非当前持有锁的CPU主动释放锁,其他CPU才可以继续竞争锁。可想而知,多核CPU就失去了存在的意义。所以现在很少使用总线锁,毕竟效率太低。

image.png

MESI缓存一致性协议

上面提到了由于BUS总线锁,效率不高,所以现在广泛使用MESI缓存一致性协议。

MESI是Modified,Exclusive,Shared以及Invalid四个首字母的缩写。MESI有点类似于读写锁,对于内存地址写操作同一时间只能由一个处理器来执行。下面的表格是对MESI四种状态的描述。

状态 描述
M修改 缓存行有效,数据被修改,与内存中的数据不一致,数据只会存储在缓存行中
E互斥 缓存行有效,数据与内存中的数据一致且其他CPU中没有该数据副本
S共享 缓存行有效,数据与内存中的数据一致且其他CPU中存在该数据副本
I失效 缓存行数据失效

缓存行:CPU缓存中可分配的最小存储单位,大小为64字节。

结合上面的例子,可以简单的分析一下MESI到底如和保证可见性。首先,线程B将count变量复制到自己的工作内存,此时,仅有线程B中存在该变量,所以变量状态应该为"E"。

image.png

然后,线程A将共享变量count复制到自己的工作内存。但是,此时线程B通过嗅探总线,发现其他线程已经存在了变量副本,所以A,B两个工作内存中的副本变量需要分别变更状态为"S","E"->"S"。

image.png

其次,线程A修改副本变量,存储与缓存行并发起修改通知。此时线程A中,变量副本count状态为M。线程B通过嗅探总线发现count已被其他线程所修改时,将状态由S更改为I。当副本状态被标识为失效时,线程会将该脏数据丢弃,并且会重新从主内存中读取最新的数据。

image.png

正常状态下,基本的流程如上所述。但是在非正常状态下会有什么问题呢,如果A,B两个线程同时发起修改请求会怎么样?如果副本变量超过64字节结果又会怎么样呢?

image.png

第一个问题,如果A,B两个线程同时发起修,它们会各自写入缓存行,并对缓存行进行加锁。如果加锁速度快的一方会像BUS总线发送M通知。如果同时加锁完成,那么会有总线进行裁决,裁决出最终由哪一个线程来完成修改操作。具体怎么裁决,应该是采用高低电位的方式,这就涉及到硬件知识了。

第二个问题,如果副本的大小超过了缓存行的大小,就生存入下一缓存行,那么MESI将无法执行并且会升级到BUS总线锁。

如何保证有序性

我们意向中,代码的执行顺序是按照瀑布的方式自上而下进行运行的。其实并不然,在执行程序时,为了提高性能,编译器和处理器会对指令做出重排序。举个例子,下面有一段代码

private static int a;

private static int b;

private static int x;

private static int y;

public static void main(String[] args) throws InterruptedException {
    int i = 0;
    while (true) {
        a = 0;b = 0;
        x = 0;y = 0;
        i++;
        new Thread(() -> {
            a = 1;
            x = b;
        }, "线程C").start();

        new Thread(() -> {
            b = 2;
            y = a;
        }, "线程D").start();

        Thread.sleep(10);

        if (x == y && x == 0) {
            System.out.println("第" + i + "次,   x=" + x + ", y=" + y);
            break;
        } else {
            System.out.println("" + i + "次,   x=" + x + ", y=" + y);
        }

    }

}
复制代码

这段代码逻辑就是对a,b,x,y四个参数进行赋值,并且每次都会输出x,y的结果值。在不考虑重排序的情况下,那么就来梳理一下可能的情况。

  1. 线程C执行完成后,线程D在执行。此时x=0,y=1;
  2. 线程D执行完成后,线程C在执行。此时x=2,y=0;
  3. 线程C在执行到a=1后,线程D开始执行。此时x=2,y=1;

image.png

  1. 线程D在执行到b=2后,线程C开始执行。此时x=2,y=1;

image.png

梳理完成后,执行上述代码。但是运行结果有可能不在这四个结果里面。这就是因为处理器对指令进行的重排序。将对x,y的赋值指令重排到了a,b赋值指令的前面,导致结果为0。

image.png

指令重排

重排序分为3中类型,分别为:编译器优化的重排序,指令级并行重排序和内存系统的重排序。

编译器优化的重排序:编译器在不改变程序执行结果的前提下,会进行重排序。

指令级并行重排序:现在大多数CPU采用指令级并行技术来将多条指令重叠执行。在不存在数据依赖的情况下,CPU可以改变机器指令的执行顺序。

内存系统重排序:由于CPU使用缓存和读写缓冲区,只是看起来像在乱序执行而已,其为伪重排序。

所以,从Java源代码到最终实际执行的指令序列,会分别经历上述三种指令重排。那么现在问题来了,编译器或CPU是怎么重排序的呢?又是如何禁止重排序的呢?编译器或CPU在重排序时,并非事实随机的,而是需要满足happens-before原则与as-if-serial原则。volatile可以保证有序性,是因为程序在编译成指令时,会插入特定的内存屏障,通过内存屏障来保证禁止指令重排序,从而达到有序性。

内存屏障

内存屏障,又称内存栅栏,是一个CPU指令。其作用有两个,一个是保证语句的执行顺序,另一个就是保证某些变量的内存可见性。在JVM中,提供了四种类型内存屏障指令:

类型 指令 描述
LoadLoad Load1;LoadLoad;Load2 保证Load1读取操作在Load2读取操作之前执行
StoreStore Store1;StoreStore;Store2 保证store1的写操作在store2写操作之前执行,并保证store1写入完成
LoadStore Load1;LoadStore;Store2 保证load1的读取操作在store2写操作之前完成,并保证load1读取结束
StoreLoad Store1;StoreLoad;Load2 保证Store1的写操作在Load2读操作之前完成,并保证Store1写如完成

除了JVM提供的这四种内存屏障,我们也可以在程序中手动添加内存屏障。在JDK中,为我们提供了Unsafe类,如果对该类不熟悉的话,那么不建议使用该方法。

image.png

happens-before原则

从JDK5开始,java就提供了happens-before原则来辅助保证程序执行的原子性,可见性以及有序性。在JMM中,如果一个操作做的结果对另一个操作做结果可见,那么两个操作之间必须存在happens-before关系。这些操作可以在同一个线程内,也可以在不同的线程内。所以,A happens-before B并不是必须保证B在A后执行,只要能保证操作结果可见即可。happens-before的原则内容主要有如下几个

  • lock规则:解锁操作必须发生在同一个锁的加锁之前。也就是说,加锁操作之前必须先要解锁。
  • volatile规则:volatile变量的写,对后续读操作可见。这一条上面有说过。
  • 传递规则:如果A happens-before B ,并且B happens-before C。那么A happens-before C。
  • 程序顺序规则:一个线程内的每一个操作,必须保证语义串行性。

as-if-serial原则

as-if-serial的意思是:单线程情况下,不管怎么重排序,只要不影响程序的执行结果就可以。 为了遵守这一原则,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这样会影响到执行结果。但是,如果操作之间不存在依赖,那么这些操作就会被重排。比如下面两段代码:

/**
* 线程C中 a=1,和x=b两个操作并不存在依赖关系,那么编译器或处理器就可以进行重排。
* 重排后无论是a=1先执行,还是x=b先执行,最终的结果并不会受到影响。
*/
new Thread(() -> {
    a = 1;
   // Unsafe.getUnsafe().fullFence();
    x = b;
}, "线程C").start();
复制代码
/**
* 线程C中 a=1,和x=a两个操作存在依赖关系,那么编译器或处理器就不可以进行重排。
* 排序后可能会影响到执行结果
*/
new Thread(() -> {
    a = 1;
   // Unsafe.getUnsafe().fullFence();
    x = a;
}, "线程C").start();
复制代码

禁止指令重排的应用-DCL单例模式

单例模式的话可分为饿汉式,懒汉式和DCL。DCL模式是在懒汉模式的基础上做了线程同步处理,并且使用了volatile修饰,这种方式安全且在多线程情况下能保持高性能。

class LazySingleton {
    private LazySingleton() {
    }

    private static volatile LazySingleton lazySingleton;

    public static LazySingleton getInstance() {
        if (null == lazySingleton) {
            synchronized (LazySingleton.class) {
                if (null == lazySingleton) {
                    lazySingleton = new LazySingleton();
                }
            }
        }
        return lazySingleton;
    }
}
复制代码

那么,既然已经使用synchronized加锁保证的线程安全并且对象不会被重复创建,那么为什么还要使用voliatile呢?这是因为,对象在创建的过程并不是一步到位的,而是简单 分为下面几步,具体可以参考JVM对象创建的过程

  1. 判断类是否类加载,如果没被加载,在执行加载流程
  2. 在堆空间中申请内存空间并分配到对象
  3. 对象初始化,为成员变量赋初始值
  4. 设置对象头
  5. 执行init方法

知道了对象创建的过程,那么就来结合程序字节码来解释为什么要使用volatile。先看一下下面的对象创建字节码文件部分内容

10 monitorenter
11 getstatic #2 <com/gz/lazy/LazySingleton.lazySingleton>
14 ifnonnull #7 (+13)
17 new #3 <com/gz/lazy/LazySingleton>
18 invokespecial #4 <com/gz/lazy/LazySingleton.lazySingleton.<init>>  //调用对象构造方法
20 putstatic #2 <com/gz/lazy/LazySingleton.lazySingleton>             //变量赋值
27 aload 0
28 monitorexit
复制代码

在并发情况下如果线程1现在获取了锁并且正在执行对象的创建,下面两行发生了重排序, image.png

image.png

20 putstatic #2 <com/gz/lazy/LazySingleton.lazySingleton>             //变量赋值
18 invokespecial #4 <com/gz/lazy/LazySingleton.lazySingleton.<init>>  //调用对象构造方法
复制代码

那么执行到putstatic时,就已经将变量值写到堆内存了,但是对象并没有执行构造方法,此时对象是一个半初始化状态。这个时候另一个线程2在调用getInstance方法时,发现对象并不为空,那么就将获取到一个半初始化对象,程序将会发生问题。所以这就是问什么要加volatile的原因。

如何保证原子性

volatile是无法保证原子性的,如果需要,可以借助synchronized或者是ReentrantLock,这两者中方式后面在做介绍

总结

经过上面的介绍,可以简单了解在可见性方面,volatile是通过生成lock前缀指令结合MESI协议来保证可见性的,以及中间可能会出现的问题和解决办法。在有序性方面,volatile会采用插入内存屏障的方式来禁止指令重排序。

猜你喜欢

转载自juejin.im/post/7128622289259593736