【Java线程】深入理解Volatile关键字和使用

2020-12-22 14:58:53 浏览数 (1)

背景

理解volatile底层原理之前,首先介绍关于缓存一致性协议的知识。 背景:计算机在执行程序时,每条指令都是由CPU调度执行的。CPU执行计算指令时,产生与内存(物理内存)通讯的过程(即数据的读取和写入),由于CPU执行速度很快,而从内存读取数据和内存写入数据的过程跟CPU执行指令的速度比起来要慢的多,因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。因此在CPU里面就有了高速缓存(Cache)

示例:

代码语言:javascript复制
public void main() {
int i = 2;
i = i   1;
}

当线程执行这个i = i 1语句时: 1.先从主存当中读取i的值,然后复制一份到CPU高速缓存当中; 2.然后CPU执行指令对i进行加1操作,然后将数据写入高速缓存; 3.最后将高速缓存中i最新的值刷新到主存当中。 如图:

对于单CPU处理器,执行以上代码似乎不会存在问题,毕竟对于单核CPU来说只不过是以线程调度的形式来分别执行的。 但是对于多CPU环境中,大家考虑一下是否存在问题呢???

代码语言:javascript复制
1.两个线程A,B分别从主内存读取i=2的值存入各自所在的CPU的高速缓存当中;
2.然后线程A进行加1操作,然后把i的最新值3写入到内存(主存);
3.但是线程B的高速缓存当中i的值还是2,进行加1操作之后,i的值为3,然后线程B把i的值写入主内存中;
4.最终结果i的值是3,而不是我们预料的值4。这就是著名的缓存一致性问题。
通常称这种被多个线程访问的变量i为共享变量。 

针对以上问题,存在两个解决方案,但均属于硬件层面。

1)通过在总线加LOCK#锁的方式 2)通过缓存一致性协议MSEI

  • 方案一:总线加锁

在早期的CPU当中,是通过在总线上加LOCK#锁的形式来解决缓存不一致的问题。 因为CPU和其他部件进行通信都是通过总线来进行的,如果对总线加LOCK#锁的话,也就是说阻塞了其他CPU对其他部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。 比如上面例子中 如果一个线程在执行 i = i 1,如果在执行这段代码的过程中,在总线上发出了LCOK#锁的信号,那么只有等待这段代码完全执行完毕之后,其他CPU才能从变量i所在的内存读取变量,然后进行相应的操作。这样就解决了缓存不一致的问题。

缺点:由于在锁住总线期间,其他CPU无法访问内存,导致性能低下。

  • 方案二:MSEI协议

Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。 它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量(S),即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态(I)。 因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

volatile原理

熟悉了上面缓存一致性协议相关概念后,现在开始阐述volatile原理:

1)Lock前缀指令会引起处理器缓存回写到内存; 2)一个处理器的缓存回写到内存会导致其他处理器的缓存无效;

对于volatile变量,当对volatile变量进行写操作的时候,JVM会向处理器发送一条lock前缀的指令,将这个缓存中的变量强制回写到系统主存中。 而其他处理器的缓存由于遵守了缓存一致性协议,故其缓存变量的值还是旧的,再执行计算操作就会有问题,所以在多处理器下会把这个变量的值再次从主存加载到自己的缓存中,因此保证了一个volatile在并发编程中,其值在多个缓存中是可见的。

volatile特性

可见性

可见性:多个线程共同访问共享变量时,某个线程修改了此变量,其他线程能立即看到修改后的值。 理解了上面缓存一致性协议相关知识,再理解volatile可见性应该会轻松一点。

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

对volatile变量的写操作与普通变量的主要区别有两点: (1)修改volatile变量时会强制将修改后的值刷新的主内存中。 (2)修改volatile变量后写入主内存操作,会导致其他线程工作内存中对应的变量值失效。因此,再读取该变量值的时候就需要重新从读取主内存中的值。

有序性

有序性:程序执行的顺序按照代码的先后顺序执行。由于JMM模型中允许编译器和处理器为了效率,进行指令重排序的优化。指令重排序在单线程内表现为串行语义,在多线程中会表现为无序。那么多线程并发编程中,就要考虑如何在多线程环境下可以允许部分指令重排,又要保证有序性。 为了理解有序性,那么又要先引入一个原则:happen-before规则

  • JSR 133中对Happen-before的定义:

Two actions can be ordered by a happens-before relationship.If one action happens before another, then the first is visible to and ordered before the second.

简言之:如果a happen-before b(a先于B执行),则a所做的任何操作对b是可见的。

  • JSR 133中定义happen-before的规则:

• Each action in a thread happens before every subsequent action in that thread. • An unlock on a monitor happens before every subsequent lock on that monitor. • A write to a volatile field happens before every subsequent read of that volatile. • A call to start() on a thread happens before any actions in the started thread. • All actions in a thread happen before any other thread successfully returns from a join() on that thread. • If an action a happens before an action b, and b happens before an action c, then a happens before c. 翻译过来为: 1.同一个线程中的,前面的操作 happen-before 后续的操作。(即单线程内按代码顺序执行。但是,在不影响在单线程环境执行结果的前提下,编译器和处理器可以进行重排序,这是合法的。换句话说,这一是规则无法保证编译重排和指令重排)。 2.监视器上的解锁操作 happen-before 其后续的加锁操作。(Synchronized 规则) 3.对volatile变量的写操作 happen-before 后续的读操作。(volatile 规则) 4.线程的start() 方法 happen-before 该线程所有的后续操作。(线程启动规则) 5.线程所有的操作 happen-before 其他线程在该线程上调用 join 返回成功后的操作。 6.如果 a happen-before b,b happen-before c,则a happen-before c(传递性)

了解以上规则后,主要介绍第三点:volatile变量的保证有序性的规则—内存屏障。

  • 计算机层面理解一下内存屏障: 内存屏障,又称内存栅栏,是一个CPU指令。 一是保证特定操作的执行顺序; 二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。 Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据;因此任何CPU上的线程都能读取到这些数据的最新版本。总之,volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。
  • JVM层面

从上图可以看出: 1)当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保 volatile写之前的操作不会被编译器重排序到volatile写之后。 2)当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保 volatile读之后的操作不会被编译器重排序到volatile读之前。 3)当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来 禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数 几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略。

1.在每个volatile写操作的前面插入一个StoreStore屏障。 2.在每个volatile写操作的后面插入一个StoreLoad屏障。 3.在每个volatile读操作的后面插入一个LoadLoad屏障。 4.在每个volatile读操作的后面插入一个LoadStore屏障。

示例:

代码语言:javascript复制
public class VolatileBarrierExample {
    int a;
    volatile int m1 = 1;
    volatile int m2 = 2;

    void readAndWrite() {
        int i = m1;   // 第一个volatile读
        int j = m2;   // 第二个volatile读

        a = i   j;    // 普通写

        m1 = i   1;   // 第一个volatile写
        m2 = j * 2;   // 第二个 volatile写
    }

}

原子性

原子性:一个操作或者多个操作,要么全部执行成功,要么全部执行失败。满足原子性的操作,中途不可被中断。

代码语言:javascript复制
public class VolatileAtomicDemo {

    private static volatile int counter = 0;

    public static void main(String[] args) {
        for (int i = 0; i < 10; i  ) {
            Thread thread = new Thread(()->{
                for (int j = 0; j < 1000; j  ) {
                    counter  ; //不是一个原子操作,第一轮循环结果是没有刷入主存,这一轮循环已经无效
                    //1 load counter 到工作内存
                    //2 add counter 执行自加
                    }
            });
            thread.start();
        }

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println(counter);
    }

}

每次运行的结果不相同。不过应该能看出,volatile是无法保证原子性的。原因也很简单,i 其实是一个复合操作,包括三步骤: (1)读取counter的值。 (2)对counter加1。 (3)将counter的值写回内存。 volatile是无法保证这三个操作是具有原子性的,我们可以通过AtomicInteger或者Synchronized来保证 1操作的原子性。

使用场景

经典单例模式应用,高并发环境中创建单例对象:

代码语言:javascript复制
public class Singleton {
    public static volatile Singleton singleton;

    /**
     * 构造函数私有,禁止外部实例化
     */
    private Singleton() {};

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

解析:为什么要在变量singleton之间加上volatile关键字?

singleton之间加上volatile关键字。要理解这个问题,先要了解对象的构造过程,实例化一个对象其实可以分为三个步骤: (1)分配内存空间。 (2)初始化对象。 (3)将内存空间的地址赋值给对应的引用。

但是由于操作系统可以对指令进行重排序,所以上面的过程也可能会变成如下过程: (1)分配内存空间。 (2)将内存空间的地址赋值给对应的引用。 (3)初始化对象 如果创建对象是以上流程,那么多线程下就可能将一个未初始化的对象引用暴露出来,后果嘛~NPE。因此,为了防止这个过程的重排序,我们需要将变量设置为volatile类型的变量。

参考资料: 1.x86系统cache locking的原理 2.volatile底层原理详解 3.深入理解Volatile关键字及其实现原理

0 人点赞