乐趣区

初识volatile关键字

上次我们已经学习了 Synchronized 关键字,有兴趣的可以看看:死磕 Synchronized 实现原理,今天来研究一下 volatile 关键字,主要有以下知识点:

  • 并发编程中的三个概念
  • 深入剖析 volatile 关键字
  • volatile 的原理和实现机制
  • 使用 volatile 关键字的场景

并发编程中的三个概念

在并发编程中,我们通常会遇到以下三个问题:原子性问题,可见性问题,有序性问题

原子性

一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

一个很经典的例子就是银行账户转账问题:比如从账户 A 向账户 B 转 1000 元,那么必然包括 2 个操作:从账户 A 减去 1000 元,往账户 B 加上 1000 元。试想一下,如果这 2 个操作不具备原子性,会造成什么样的后果。假如从账户 A 减去 1000 元之后,操作突然中止。这样就会导致账户 A 虽然减去了 1000 元,但是账户 B 没有收到这个转过来的 1000 元。所以这 2 个操作必须要具备原子性才能保证不出现一些意外的问题。

可见性

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

举个简单的例子,看下面这段代码:

 // 线程 1 执行的代码
 int i = 0;
 i = 10;
 
 // 线程 2 执行的代码
 j = i;

假若执行线程 1 的是 CPU1,执行线程 2 的是 CPU2。当线程 1 执行 i = 10 这句时,会先把 i 的初始值加载到 CPU1 的高速缓存中,然后赋值为 10,那么在 CPU1 的高速缓存当中 i 的值变为 10 了,却没有立即写入到内存当中。此时线程 2 执行 j = i,它会先去内存读取 i 的值并加载到 CPU2 的缓存当中,注意此时内存当中 i 的值还是 0,那么就会使得 j 的值为 0,而不是 10。
这就是可见性问题,线程 1 对变量 i 修改了之后,线程 2 没有立即看到线程 1 修改的值。

有序性

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

首先解释一下什么是 指令重排序 ,一般来说, 处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。

虽然重排序不会影响单个线程内程序执行的结果,但是多线程呢?下面看一个例子:

// 线程 1:
context = loadContext();   // 语句 1
inited = true;             // 语句 2
 
// 线程 2:
while(!inited){sleep()
}
doSomethingwithconfig(context);

上面代码中,由于语句 1 和语句 2 没有数据依赖性,因此可能会被重排序。
假如发生了重排序,在线程 1 执行过程中先执行语句 2,而此时线程 2 会以为初始化工作已经完成,
那么就会跳出 while 循环,去执行 doSomethingwithconfig(context)方法,而此时 context 并没有被初始化,就会导致程序出错。

从上面可以看出,指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。
也就是说,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

深入剖析 volatile 关键字

性质

  • volatile 所修饰的变量是直接存在于主内存中的。
  • JMM 中的内存分为主内存和工作内存,其中主内存是所有线程共享的。而工作内存是每个线程独立分配的,各个线程的工作内存之间相互独立、互不可见。
  • 在线程启动的时候,JVM 为每个内存分配了一块工作内存,不仅包含了线程内部定义的局部变量,也包含了线程所需要的共享变量的副本,当然这是为了提高执行效率,读副本的比直接读主内存更快。

volatile 特性

内存可见性

即线程 A 对 volatile 变量的修改,其他线程获取的 volatile 变量都是最新的。
对于 volatile 修饰的变量 (共享变量) 来说,在工作内存发生了变化后,必须要马上写到主内存中,而线程读取到是 volatile 修饰的变量时,必须去主内存中去获取最新的值 ,而不是读工作内存中主内存的副本,这就有效的保证了线程之间变量的 可见性

可以禁止指令重排序

那么什么是指令重排序?
为了尽可能减少内存操作速度远慢于 CPU 运行速度所带来的 CPU 空置的影响,虚拟机会按照自己的一些规则将程序编写顺序打乱

volatile 能保证原子性吗?

volatile 可以保证线程的可见性和有序性,但没办法保证对变量的操作的原子性。

在 Java 中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。

请分析以下哪些操作是原子性操作:

  x = 10;        // 语句 1
  y = x;         // 语句 2
  x++;           // 语句 3
  x = x + 1;     // 语句 4 

只有语句 1 是原子性操作,其他三个语句都不是原子性操作。

语句 1 是直接将数值 10 赋值给 x,也就是说线程执行这个语句的会直接将数值 10 写入到工作内存中。
语句 2 实际上包含 2 个操作,它先要去读取 x 的值,再将 x 的值写入工作内存,虽然读取 x 的值以及将 x 的值写入工作内存这 2 个操作都是原子性操作,但是合起来就不是原子性操作了。
同样的,x++ 和 x = x+ 1 包括 3 个操作:读取 x 的值,进行加 1 操作,写入新的值。

下面看一个例子:

public class Test {
    public volatile int inc = 0;
     
    public void increase() {inc++;}
     
    public static void main(String[] args) {final Test test = new Test();
        for(int i=0;i<10;i++){new Thread(){public void run() {for(int j=0;j<1000;j++)
                        test.increase();};
            }.start();}
         
        while(Thread.activeCount()>1)  // 保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

事实上运行它会发现每次运行结果都不一致,都是一个小于 10000 的数字。上面的程序错在没能保证原子性。

volatile 的原理和实现机制

下面这段话摘自《深入理解 Java 虚拟机》:
观察加入 volatile 关键字和没有加入 volatile 关键字时所生成的汇编代码发现,加入 volatile 关键字时,会多出一个 lock 前缀指令。
lock 前缀指令实际上相当于一个 内存屏障,内存屏障会提供 3 个功能:

  • 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
  • 它会强制将对缓存的修改操作立即写入主存;
  • 如果是写操作,它会导致其他 CPU 中对应的缓存行无效。

使用 volatile 关键字的场景

synchronized 关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而 volatile 关键字在某些情况下性能要优于 synchronized,但是要注意volatile 关键字是无法替代 synchronized 关键字的,因为 volatile 关键字无法保证操作的原子性。

通常来说,使用 volatile 必须具备以下 2 个条件:

  • 对变量的写操作不依赖于当前值
  • 该变量没有包含在具有其他变量的不变式中

事实上,我的理解就是上面的 2 个条件需要保证操作是原子性操作,才能保证使用 volatile 关键字的程序在并发时能够正确执行。

参考

volatile 关键字

退出移动版