谈谈volatile

2020-10-26 17:50:37 浏览数 (1)

Java语言为了解决并发编程中存在的原子性、可见性和有序性问题,提供了一系列和并发处理相关的关键字,比如synchronized、volatile、final、concurrent包等。

volatile的用法

volatile通常被比喻成“轻量级的synchronized”,也是Java并发编程中比较重要的一个关键字。和synchronized不同,volatile是一个变量修饰符,只能用来修饰变量,无法修饰方法或代码块等。

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

代码语言:txt复制
/**
 * 双重校验锁实现单例模式
 */
public class Singleton {
    private volatile static Singleton singleton;
    private Singleton(){ }
    public static Singleton getSingleton(){
        if (singleton==null){
            synchronized (Singleton.class){
                if (singleton==null){
                    singleton=new Singleton();
                }
            }
        }
        return singleton;
    }
}

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

volatile的原理

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

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

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

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

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

内存语义

当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新到主内存中。

当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量

volatile与可见性

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

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

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

其实,volatile对于可见性的实现,内存屏障也起着至关重要的作用。因为内存屏障相当于一个数据同步点,他要保证在这个同步点之后的读写操作必须在这个点之前的读写操作都执行完之后才可以执行。并且在遇到内存屏障的时候,缓存数据会和主存进行同步,或者把缓存数据写入主存、或者从主存把数据读取到缓存。

Java内存模型解决了缓存一致性问题。内存一致性模型的实现可以通过缓存一致性协议来实现。

那么既然已经有了缓存一致性协议,为什么还需要volatile?

这个问题可以从多方面来回答:

并不是所有的硬件架构都提供了相同的一致性协议,Java作为一门跨平台语言,JVM需要提供一个统一的语义。

操作系统中的缓存和JVM中线程的本地内存并不是一回事,通常我们可以认为:MESI可以解决缓存层面的可见性问题。使用volatile关键字,可以解决JVM层面的可见性问题。

可见性问题的延伸:由于传统的MESI协议的执行成本比较大,所以CPU通过Store Buffer和Invalidate Queue组件来解决,但是由于这两个组件的引入,也导致缓存和主存之间的通信并不是实时的。也就是说,缓存一致性模型只能保证缓存变更时其他缓存也跟着改变,但不能保证立刻马上执行。

其实,在计算机内存模型中,也是使用内存屏障来解决缓存的可见性问题的(再次强调:缓存可见性和并发编程中的可见性可以互相类比,但是他们并不是一回事儿)。

写内存屏障(Store Memory Barrier)可以促使处理器将当前store buffer(存储缓存)的值写回主存。读内存屏障(Load Memory Barrier)可以促使处理器处理invalidate queue(失效队列)。进而避免由于Store Buffer和Invalidate Queue的非实时性带来的问题。

所以,内存屏障也是保证可见性的重要手段,操作系统通过内存屏障保证缓存间的可见性,JVM通过给volatile变量加入内存屏障保证线程之间的可见性。

volatile与有序性

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

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

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

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

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

那么volatile是如何禁止指令重排的呢?答案是:volatile通过内存屏障来禁止指令重排。

内存屏障(Memory Barrier)是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作,下表描述了和volatile有关的指令重排序禁止行为:

重排序重排序

从表中可以看出:

  • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
  • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
  • 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

具体实现方式是在编译期生成字节码时,会在指令序列中增加内存屏障来保证,下面是基于保守策略的JMM内存屏障插入策略:

在每个volatile写操作的前边插入一个StoreStore屏障 在每个volatile写操作的后面插入一个StoreLoad屏障 在每个volatile读操作的后面插入一个LoadLoad屏障 在每个volatile读操作的后面插入一个LoadStore屏障

volatile写volatile写
volatile读volatile读

**StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作都已经刷新到主内存中。

StoreLoad屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序。

LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。

LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。**

所以,volatile通过在volatile变量的操作前后插入内存屏障的方式,来禁止指令重排,进而保证多线程情况下对共享变量的有序性。

Example

以如下一段代码来看:

代码语言:txt复制
public class VolatileBarrierExample {
    int a = 0;
    volatile int v1 = 1;
    volatile int v2 = 2;

    void readAndWrite(){
        int i = v1;     //volatile读
        int j = v2;     //volatile读
        a = i   j;      //普通读
        v1 = i   1;     //volatile写
        v2 = j * 2;     //volatile写
    }
}

优化后的内存屏障图例:

内存屏障内存屏障

注意,最后的StoreLoad屏障不能省略。因为第二个volatile写之后,方法立即return。此时编译器可能无法准确判定后面是否会有volatile读或者写,为了安全起见,编译器通常会在这里插入一个StoreLoad屏障。

volatile与原子性

原子性是指一个操作是不可中断的,要全部执行完成,要不就都不执行。

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

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

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

在以下两个场景中可以使用volatile来代替synchronized:

运算结果并不依赖变量的当前值,或者能够确保只有单一的线程会修改变量的值 变量不需要与其他状态变量共同参与不变约束

除以上场景外,都需要使用其他方式来保证原子性,如synchronized或者concurrent包。

个人理解

网上有很多文章,拿i 的例子说明volatile不能保证原子性,然后进行各种分析,有的说由于引入内存屏障导致无法保证原子性,有的说一段i 代码,在编译后字节码有四个步骤。

这些分析只是说明了i 本身不是一个原子操作,即使使用volatile修饰i,也无法保证他是一个原子操作。并不能解释为什么volatile不能保证原子性。

在我看来,由于CPU是按照时间片来进行线程调度的,只要是包含多个步骤的操作的执行,天然就是无法保证原子性的。因为这种线程执行,又不像数据库一样可以回滚。如果一个线程要执行的步骤有5步,执行完3步就失去了CPU了,失去后就可能再也不会被调度,这怎么可能保证原子性呢。

为什么synchronized可以保证原子性 ,因为被synchronized修饰的代码片段,在进入之前加了锁,只要他没执行完,其他线程是无法获得锁执行这段代码片段的,就可以保证他内部的代码可以全部被执行。进而保证原子性。

但是synchronized对原子性保证也不绝对,如果真要较真的话,一旦代码运行异常,也没办法回滚。所以呢,在并发编程中,原子性的定义不应该和事务中的原子性一样。他应该定义为:一段代码,或者一个变量的操作,在没有执行完之前,不能被其他线程执行。

那么,为什么volatile不能保证原子性呢?因为他不是锁,他没做任何可以保证原子性的处理。当然就不能保证原子性了。

0 人点赞