写点什么

Java 中不可或缺的关键字「volatile」

作者:小小怪下士
  • 2023-01-06
    湖南
  • 本文字数:5794 字

    阅读完需:约 19 分钟

什么是 volatile 关键字

volatile 是 Java 中用于修饰变量的关键字,其可以保证该变量的可见性以及顺序性,但是无法保证原子性。更准确地说是 volatile 关键字只能保证单操作的原子性, 比如 x=1 ,但是无法保证复合操作的原子性,比如 x++


其为 Java 提供了一种轻量级的同步机制:保证被 volatile 修饰的共享变量对所有线程总是可见的,也就是当一个线程修改了一个被 volatile 修饰共享变量的值,新值总是可以被其他线程立即得知。相比于 synchronized 关键字(synchronized 通常称为重量级锁),volatile 更轻量级,开销低,因为它不会引起线程上下文的切换和调度。

保证可见性

可见性:是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看到修改的值。我们一起来看一个例子:


public class VisibilityTest {    private boolean flag = true;    public void change() {        flag = false;        System.out.println(Thread.currentThread().getName() + ",已修改flag=false");    }    public void load() {        System.out.println(Thread.currentThread().getName() + ",开始执行.....");        int i = 0;        while (flag) {            i++;        }        System.out.println(Thread.currentThread().getName() + ",结束循环");    }    public static void main(String[] args) throws InterruptedException {        VisibilityTest test = new VisibilityTest();        // 线程threadA模拟数据加载场景        Thread threadA = new Thread(() -> test.load(), "threadA");        threadA.start();        // 让threadA执行一会儿        Thread.sleep(1000);        // 线程threadB 修改 共享变量flag        Thread threadB = new Thread(() -> test.change(), "threadB");        threadB.start();    }}
复制代码


其中:threadA 负责循环,threadB 负责修改 共享变量 flag,如果 flag=false 时,threadA 会结束循环,但是上面的例子会死循环! 原因是 threadA 无法立即读取到共享变量 flag 修改后的值。 我们只需 private volatile boolean flag = true;,加上 volatile 关键字 threadA 就可以立即退出循环了。


其中 Java 中的 volatile 关键字提供了一个功能:那就是被 volatile 修饰的变量 P 被修改后,JMM 会把该线程本地内存中的这个变量 P,立即强制刷新到主内存中去,导致其他线程中的 volatile 变量 P 缓存无效,也就是说其他线程使用 volatile 变量 P 在时,都是从主内存刷新的最新数据。而普通变量的值在线程间传递的时候一般是通过主内存以共享内存的方式实现的;


因此,可以使用 volatile 来保证多线程操作时变量的可见性。除了 volatile,Java 中的 synchronized 和 final 两个关键字 以及各种 Lock 也可以实现可见性。加锁的话, 当一个线程进入 synchronized 代码块后,线程获取到锁,会清空本地内存,然后从主内存中拷贝共享变量的最新值到本地内存作为副本,执行代码,又将修改后的副本值刷新到主内存中,最后线程释放锁。

保证有序性

有序性,顾名思义即程序执行的顺序按照代码的先后顺序执行。但现代的计算机中 CPU 中为了能够让指令的执行尽可能地同时运行起来,提示计算机性能,采用了指令流水线。一个 CPU 指令的执行过程可以分成 4 个阶段:取指、译码、执行、写回。这 4 个阶段分别由 4 个独立物理执行单元来完成。


理想的情况是:指令之间无依赖,可以使流水线的并行度最大化 但是如果两条指令的前后存在依赖关系,比如数据依赖,控制依赖等,此时后一条语句就必需等到前一条指令完成后,才能开始。所以 CPU 为了提高流水线的运行效率,对无依赖的前后指令做适当的乱序和调度,即现代的计算机中 CPU 是乱序执行指令的


另一方面,只要不会改变程序的运行结果,Java 编译器是可以通过指令重排来优化性能。然而,重排可能会影响本地处理器缓存与主内存交互的方式,可能导致在多线程的情况下发生"细微"的 BUG。


指令重排一般可以分为如下三种类型:


  • 编译器优化重排序,编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

  • 指令级并行重排序,现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

  • 内存系统重排序,由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。这并不是显式的将指令进行重排序,只是因为缓存的原因,让指令的执行看起来像乱序。


从 Java 源代码到最终执行的指令序列,一般会经历下面三种重排序:


编译器优化重排序 - 指令级并行重排序 - 内存系统重排序 - 最终执行的指令排序

变量初始化赋值

我们一起来看一个例子,让大家体悟 volatile 关键字的禁止指令重排的作用:


int i = 0;int j = 0;int k = 0;i = 10; j = 1; 
复制代码


对于上面的代码我们正常的执行流程是:


初始化 i 初始化 j 初始化 k i 赋值 j 赋值


但由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序。语句可能的执行顺序如下:


初始化 i i 赋值 初始化 j j 赋值 初始化 k


指令重排对于非原子性的操作,在不影响最终结果的情况下,其拆分成的原子操作可能会被重新排列执行顺序,提升性能。指令重排不会影响单线程的执行结果,但是会影响多线程并发执行的结果正确性。


但当我们用 volatile 修饰变量 k 时:


int i = 0;int j = 0;volatile int k = 0;i = 10; j = 1; 
复制代码


这样会保证上面代码执行顺序:变量 i 和 j 的初始化,在 volatile int k = 0 之前,变量 i 和 j 的赋值操作在 volatile int k = 0 后面

懒汉式单例 -- 双重校验锁 volatile 版

我们可以使用 volatile 关键字去阻止重排 volatile 变量周围的读写指令,这种操作通常称为 memory barrier (内存屏障)

隐藏特性

volatile 关键字除了禁止指令重排的作用,还有一个特性: 当线程向一个 volatile 变量写入时,在线程写入之前的其他所有变量(包括非 volatile 变量)也会刷新到主内存。当线程读取一个 volatile 变量时,它也会读取其他所有变量(包括非 volatile 变量)与 volatile 变量一起刷新到主内存。 尽管这是一个重要的特性,但是我们不应该过于依赖这个特性,来"自动"使周围的变量变得 volatile,若是我们想让一个变量是 volatile 的,我们编写程序的时候需要非常明确地用 volatile 关键字来修饰。

无法保证原子性

volatile 关键字无法保证原子性 ,更准确地说是 volatile 关键字只能保证单操作的原子性, 比如 x=1 ,但是无法保证复合操作的原子性,比如 x++


所谓原子性:即一个或者多个操作作为一个整体,要么全部执行,要么都不执行,并且操作在执行过程中不会被线程调度机制打断;而且这种操作一旦开始,就一直运行到结束,中间不会有任何上下文切换(context switch)


int  = 0;   //语句1,单操作,原子性的操作i++;         //语句2,复合操作,非原子性的操作
复制代码


其中:语句 2i++ 其实在 Java 中执行过程,可以分为 3 步:


1.i 被从局部变量表(内存)取出,2.压入操作栈(寄存器),操作栈中自增 3.使用栈顶值更新局部变量表(寄存器更新写入内存)


执行上述 3 个步骤的时候是可以进行线程切换的,或者说是可以被另其他线程的 这 3 步打断的,因此语句 2 不是一个原子性操作

volatile 版

我们再来看一个例子:


public class Test1 {    public static volatile int val;    public static void add() {        for (int i = 0; i < 1000; i++) {            val++;        }    }    public static void main(String[] args) throws InterruptedException {        Thread t1 = new Thread(Test1::add);        Thread t2 = new Thread(Test1::add);        t1.start();        t2.start();        t1.join();//等待该线程终止        t2.join();        System.out.println(val);    }}
复制代码


2 个线程各循环 2000 次,每次+1,如果 volatile 关键字能够保证原子性,预期的结果是 2000,但实际结果却是:1127,而且多次执行的结果都不一样,可以发现 volatile 关键字无法保证原子性。

synchronized 版

我们可以利用 synchronized 关键字来解决上面的问题:


public class SynchronizedTest {    public static int val;    public synchronized static void add() {        for (int i = 0; i < 1000; i++) {            val++;        }    }    public static void main(String[] args) throws InterruptedException {        Thread t1 = new Thread(SynchronizedTest::add);        Thread t2 = new Thread(SynchronizedTest::add);        t1.start();        t2.start();        t1.join();//等待该线程终止        t2.join();        System.out.println(val);    }}
复制代码


运行结果:2000

Lock 版

我们还可以通过加锁来解决上述问题:


public class LockTest {    public static int val;    static Lock lock = new ReentrantLock();    public static void add() {        for (int i = 0; i < 1000; i++) {            lock.lock();//上锁            try {                val++;            }catch(Exception e) {                e.printStackTrace();            }finally {                lock.unlock();//解锁            }        }    }    public static void main(String[] args) throws InterruptedException {        Thread t1 = new Thread(LockTest::add);        Thread t2 = new Thread(LockTest::add);        t1.start();        t2.start();        t1.join();//等待该线程终止        t2.join();        System.out.println(val);    }}
复制代码


运行结果:2000

Atomic 版 i++

Java 从 JDK 1.5 开始提供了 java.util.concurrent.atomic 包(以下简称 Atomic 包),这个包中的原子操作类, 靠 CAS 循环的方式来保证其原子性,是一种用法简单、性能高效、线程安全地更新一个变量的方式。


这些类可以保证多线程环境下,当某个线程在执行 atomic 的方法时,不会被其他线程打断,而别的线程就像自旋锁一样,一直等到该方法执行完成,才由 JVM 从等待队列中选择一个线程执行。


我们来用 atomic 包来解决 volatile 原子性的问题:


public class AtomicTest {    public static AtomicInteger val = new AtomicInteger();    public static void add() {        for (int i = 0; i < 1000; i++) {            val.getAndIncrement();        }    }    public static void main(String[] args) throws InterruptedException {        Thread t1 = new Thread(AtomicTest::add);        Thread t2 = new Thread(AtomicTest::add);        t1.start();        t2.start();        t1.join();//等待该线程终止        t2.join();        System.out.println(val);    }}
复制代码


运行结果:2000, 如果我们维护现有的项目,如果遇到 volatile 变量最好将其替换为 Atomic 变量,除非你真的特别了解 volatile。Atomic 就不展开说了,先挖个坑,以后补上

volatile 原理

当大家仔细读完上文的懒汉式单例 -- 双重校验锁 volatile 版,会发现 volatile 关键字修饰变量后,我们反汇编后会发现 多出了 lock 前缀指令,lock 前缀指令在汇编中 LOCK 指令前缀功能如下:


被修饰的汇编指令成为"原子的"


与被修饰的汇编指令一起提供"内存屏障"效果(lock 指令可不是内存屏障)


内存屏障主要分类:


1.一类是可以强制读取主内存,强制刷新主内存的内存屏障,叫做 Load 屏障和 Store 屏障


2.另一类是禁止指令重排序的内存屏障,主要有四个分别叫做 LoadLoad 屏障、StoreStore 屏障、LoadStore 屏障、StoreLoad 屏障


这 4 个屏障具体作用:


  • LoadLoad 屏障:(指令 Load1; LoadLoad; Load2),在 Load2 及后续读取操作要读取的数据被访问前,保证 Load1 要读取的数据被读取完毕。

  • LoadStore 屏障:(指令 Load1; LoadStore; Store2),在 Store2 及后续写入操作被刷出前,保证 Load1 要读取的数据被读取完毕。

  • StoreStore 屏障:(指令 Store1; StoreStore; Store2),在 Store2 及后续写入操作执行前,保证 Store1 的写入操作对其它处理器可见。

  • StoreLoad 屏障:(指令 Store1; StoreLoad; Load2),在 Load2 及后续所有读取操作执行前,保证 Store1 的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能


对于 volatile 操作而言,其操作步骤如下:


  • 每个 volatile 写入之前,插入一个 StoreStore,写入以后插入一个 StoreLoad

  • 每个 volatile 读取之前,插入一个 LoadLoad,读取之后插入一个 LoadStore


我们再总结以下,用 volatile 关键字修饰变量后,主要发生的变化有哪些?:


1.当一个线程修改了 volatile 修饰的变量,当修改后的变量写回主内存时,其他线程能立即看到最新值。即 volatile 关键字保证了并发的可见性


使用 volatile 关键字修饰共享变量后,每个线程要操作该变量时会从主内存中将变量拷贝到本地内存作为副本,但当线程操作完变量副本,会强制将修改的值立即写入主内存中。 然后通过 CPU 总线嗅探机制告知其他线程中该变量副本全部失效,(在 CPU 层,一个处理器的缓存回写到内存会导致其他处理器的缓存行无效),若其他线程需要该变量,必须重新从主内存中读取。


2.在 x86 的架构中,volatile 关键字 底层 含有 lock 前缀的指令,与被修饰的汇编指令一起提供"内存屏障"效果,禁止了指令重排序,保证了并发的有序性


确保一些特定操作执行的顺序,让 cpu 必须按照顺序执行指令,即当指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;


3.volatile 关键字无法保证原子性 ,更准确地说是 volatile 关键字只能保证单操作的原子性, 比如 x=1 ,但是无法保证复合操作的原子性,比如 x++。


有人可能问赋值操作是原子操作,本来就是原子性的,用 volatile 修饰有什么意义? 在 Java 数据类型足够大的情况下(在 Java 中 long 和 double 类型都是 64 位),写入变量的过程分两步进行,就会发生 Word tearing (字分裂) 情况。 JVM 被允许将 64 位数量的读写作为两个单独的 32 位操作执行,这增加了在读写过程中发生上下文切换的可能性,多线程的情况下可能会出现值会被破坏的情况


在缺乏任何其他保护的情况下,用 volatile 修饰符定义一个 long 或 double 变量,可阻止字分裂情况

用户头像

还未添加个人签名 2022-09-04 加入

热衷于分享java技术,一起交流学习,探讨技术。 需要Java相关资料的可以+v:xiaoyanya_1

评论

发布
暂无评论
Java中不可或缺的关键字「volatile」_Java_小小怪下士_InfoQ写作社区