面试必问的 volatile 关键字,一篇给你安排了!!!

Java建设者

共 12330字,需浏览 25分钟

 ·

2021-06-01 10:31


volatile 这个关键字大家都不陌生,这个关键字一般通常用于并发编程中,是 Java 虚拟机提供的轻量化同步机制,你可能知道 volatile 是干啥的,但是你未必能够清晰明了的知道 volatile 的实现机制,以及 volatile 解决了什么问题,这篇文章我就来带大家解析一波。

volatile 能够保证共享变量之间的 可见性,共享变量是存在堆区的,而堆区又与内存模型有关,所以我们要聊 volatile ,就需要首先了解一下  内存模型。Java 中的内存模型是 JVM 提供的,而 JVM 又是和内存进行交互的,所以在聊  内存模型前,我们还需要了解一下操作系统层面中内存模型的相关概念。

先从内存模型谈起

计算机在执行程序时,会从内存中读取数据,然后加载到 CPU 中运行。由于 CPU 执行指令的速度要比从内存中读取和写入的速度快的多,所以如果每条指令都要和内存交互的话,会大大降低 CPU 的运行速度,造成昂贵的 CPU 性能损耗,为了解决这种问题,设计了 CPU 高速缓存。有了 CPU 高速缓存后,CPU 就不再需要频繁的和内存交互了,有高速缓存就行了,而 CPU 高速缓存,就是我们经常说的 L1 、L2、L3 cache。

当程序在运行过程中,会将运算需要的数据从主存复制一份到 CPU 的高速缓存中,在 CPU 进行计算时就可以直接从它的高速缓存读写数据,当运算结束之后,再将高速缓存中的数据刷新到主存中。

就拿我们常说的

i = i + 1 

来举例子

当 CPU 执行这条语句时,会先从内存中读取 i 的值,复制一份到高速缓存当中,然后 CPU 执行指令对 i 进行加 1 操作,再将数据写入高速缓存,最后将高速缓存中 i 最新的值刷新到主存当中。

这个代码在单线程中运行是没有任何问题的,但是在多线程中运行就会有问题了,因为每个 CPU 都可以运行一条线程,线程就是程序的顺序执行流,因此每个线程运行时有自己的高速缓存(对单核 CPU 来说,其实也会出现这种问题,只不过是以线程调度的形式来分别执行的)。本文我们以多核 CPU 为例来讲解说明。

比如同时有 2 个线程执行这段代码,假如初始时 i 的值为 0,那么我们希望两个线程执行完之后 i 的值变为 2,但是事实会是这样吗?

可能存在下面一种情况:初始时,两个线程分别读取 i 的值存入各自所在的 CPU 高速缓存中,然后线程 1 执行加 1 操作,把 i 的最新值 1 写入到内存。此时线程 2 的高速缓存当中 i 的值还是 0,进行加 1 操作之后,i 的值为 1,然后线程 2 把 i 的值写入内存。

最终结果 i 的值是 1,而不是 2。这就是著名的缓存一致性问题。通常称这种被多个线程访问的变量为共享变量。

也就是说,如果一个变量在多个 CPU 中都存在缓存(一般在多线程编程时才会出现),就很可能存在缓存不一致的问题。

JVM 内存模型

我们上面说到,共享变量会存在缓存不一致的问题,缓存不一致问题换种说法就是线程安全问题,那么共享变量在 Java 中是如何存在的呢?JVM 有没有提供线程安全的变量或者数据呢?

这就要聊聊 JVM 内存模型的问题了,图示如下

  • 虚拟机栈 : Java 虚拟机栈是线程私有的数据区,Java 虚拟机栈的生命周期与线程相同,虚拟机栈也是局部变量的存储位置。方法在执行过程中,会在虚拟机栈创建一个 栈帧(stack frame)

  • 本地方法栈: 本地方法栈也是线程私有的数据区,本地方法栈存储的区域主要是 Java 中使用 native 关键字修饰的方法所存储的区域。

  • 程序计数器:程序计数器也是线程私有的数据区,这部分区域用于存储线程的指令地址,用于判断线程的分支、循环、跳转、异常、线程切换和恢复等功能,这些都通过程序计数器来完成。

  • 方法区:方法区是各个线程共享的内存区域,它用于存储虚拟机加载的 类信息、常量、静态变量、即时编译器编译后的代码等数据。

  • :堆是线程共享的数据区,堆是 JVM 中最大的一块存储区域,所有的对象实例都会分配在堆上

  • 运行时常量池:运行时常量池又被称为 Runtime Constant Pool,这块区域是方法区的一部分,它的名字非常有意思,它并不要求常量一定只有在编译期才能产生,也就是并非编译期间将常量放在常量池中,运行期间也可以将新的常量放入常量池中,String 的 intern 方法就是一个典型的例子。

根据上面的描述可以看到,会产生缓存不一致问题(线程安全问题)的有堆区和方法区。而虚拟机栈、本地方法栈、程序计数器是线程私有,由线程封闭的原因,它们不存在线程安全问题。

针对线程安全问题,有没有解决办法呢?

一般情况下,Java 中解决缓存不一致的方法有两种,第一种就是 synchronized 使用的总线锁方式,也就是在总线上声言 LOCK# 信号;第二种就是著名的 MESI 协议。这两种都是硬件层面提供的解决方式。

我们先来说一下第一种总线锁的方式。通过在总线上声言 LOCK# 信号,能够有效的阻塞其他 CPU 对于总线的访问,从而使得只能有一个 CPU 访问变量所在的内存。在上面的 i = i + 1 代码示例中,在代码执行的过程中,声言了 LOCK# 信号后,那么只有等待 i = i + 1 的结果执行完毕并应用到内存后,总线锁才会解开,其他 CPU 才能够继续访问内存中的变量,再继续执行后面的代码,这样就解决了缓存不一致问题。

但是上面的方式会有一个问题,由于在锁住总线期间,其他 CPU 无法访问内存,导致效率低下。

在 JDK 1.6 之后,优化了 synchronized 声言 LOCK# 的方式,不再对总线进行锁定,转而采取了对 CPU 缓存行进行锁定,因为本篇文章不是介绍 synchronized 实现细节的文章,所以不再对这种方式进行详细介绍,读者只需要知道在优化之后,synchronized 的性能不再成为并发问题的瓶颈了。

MESI 协议就是缓存一致性协议,即 Modified(被修改)Exclusive(独占的) Shared(共享的) Or Invalid(无效的)。MESI 的基本思想就是如果发现 CPU 操作的是共享变量,其他 CPU 中也会出现这个共享变量的副本,在 CPU 执行代码期间,会发出信号通知其他 CPU 自己正在修改共享变量,其他 CPU 收到通知后就会把自己的共享变量置为无效状态。

并发编程中的三个主要问题

可见性问题

在单核 CPU 时代,所有的线程共用一个 CPU,CPU 缓存和内存的一致性问题容易解决,我们还拿上面的 i = 1 + 1 来举例,CPU 和 内存之间如果用图来表示的话我想会是下面这样。

在多核时代,每个核都能够独立的运行一个线程,每个 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的 CPU 上执行时,这些线程使用的是不同的 CPU 缓存。

因为 i 没有经过任何线程安全措施的保护,多个线程会并发修改 i 的值,所以我们认为 i 不是线程安全的,导致这种结果的出现是由于 aThread 和 bThread 中读取的 i 值彼此不可见,所以这是由于 可见性 导致的线程安全问题。

原子性问题

当两个线程开始运行后,每个线程都会把 i 的值读入到 CPU 缓存中,再执行 + 1 操作,然后把 + 1 之后的值写入内存。因为线程间都有各自的虚拟机栈和程序计数器,他们彼此之间没有数据交换,所以当 aThread 执行 + 1 操作后,会把数据写入到内存,同时 bThread 执行 + 1 操作后,也会把数据写入到内存,因为 CPU 时间片的执行周期是不确定的,所以会出现当 aThread 还没有把数据写入内存时,bThread 就会读取内存中的数据,然后执行 + 1操作,再写回内存,从而覆盖 i 的值。

有序性问题

在并发编程中还有带来让人非常头疼的 有序性 问题,有序性顾名思义就是顺序性,在计算机中指的就是指令的先后执行顺序。一个非常显而易见的例子就是 JVM 中的类加载

这是一个 JVM 加载类的过程图,也称为类的生命周期,类从加载到 JVM 到卸载一共会经历五个阶段 加载、连接、初始化、使用、卸载。这五个过程的执行顺序是一定的,但是在连接阶段,也会分为三个过程,即 验证、准备、解析 阶段,这三个阶段的执行顺序不是确定的,通常交叉进行,在一个阶段的执行过程中会激活另一个阶段。

在执行程序的过程中,为了提高性能,编译器和处理器通常会对指令进行重排序。重排序主要分为三类

  • 编译器优化的重排序:编译器在不改变单线程语义的情况下,会对执行语句进行重新排序。

  • 指令集重排序:现代操作系统中的处理器都是并行的,如果执行语句之间不存在数据依赖性,处理器可以改变语句的执行顺序

  • 内存重排序:由于处理器会使用读/写缓冲区,出于性能的原因,内存会对读/写进行重排序

也就是说,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

volatile 的实现原理

上面聊了这么多,你可能都要忘了这篇文章的故事主角了吧?主角永远存在于我们心中 ……

其实上面聊的这些,都是在为 volatile 做铺垫。

在并发编程中,最需要处理的就是线程之间的通信和线程间的同步问题,上面的可见性、原子性、有序性也是这两个问题带来的。

可见性

而 volatile 就是为了解决这些问题而存在的。Java 语言规范对 volatile 下了定义:Java 语言为了确保能够安全的访问共享变量,提供了 volatile 这个关键字,volatile 是一种轻量级同步机制,它并不会对共享变量进行加锁,但在某些情况下要比加锁更加方便,如果一个字段被声明为 volatile,Java 线程内存模型能够确保所有线程访问这个变量的值都是一致的。

一旦共享变量被 volatile 修饰后,就具有了下面两种含义

  1. 保证了这个字段的可见性,也就是说所有线程都能够"看到"这个变量的值,如果某个 CPU 修改了这个变量的值之后,其他 CPU 也能够获得通知。

  2. 能够禁止指令的重排序

下面我们来看一段代码,这也是我们编写并发代码中经常会使用到的

boolean isStop = false;
while(!isStop){
    ...
}

isStop = true;

在这段代码中,如果线程一正在执行 while 循环,而线程二把 isStop 改为 true 之后,转而去做其他事情,因为线程一并不知道线程二把 isStop 改为 true ,所以线程一就会一直运行下去。

如果 isStop 用 volatile 修饰之后,那么事情就会变的不一样了。

使用 volatile 修饰了 isStop 之后,在线程二把 isStop 改为 true 之后,会强制将其写入内存,并且会把线程一中 isStop 的值置为无效(这个值实际上是在缓存在 CPU 中的缓存行里),当线程一继续执行代码的时候,会从内存中重新读取 isStop 的值,此时 isStop 的值就是正确的内存地址的值。

volatile 有下面两条实现原则,其实这两条原则我们在上面介绍的时候已经提过了,一种是总线锁的方式,我们后面说总线锁的方式开销比较大,所以后面设计人员做了优化,采用了锁缓存的方式。另外一种是 MESI 协议的方式。

  • 在 IA-32 架构软件开发者的手册中,有一种 Lock 前缀指令,这种指令能够声言 LOCK# 信号,在最近的处理器中,LOCK# 信号用于锁缓存,等到指令执行完毕后,会把缓存的内容写回内存,这种操作一般又被称为缓存锁定

  • 当缓存写回内存后,IA-32 和 IA-64 处理器会使用 MESI 协议控制内部缓存和其他处理器一致。IA-32 和 IA-64 处理器能够嗅探其他处理器访问系统内部缓存,当内存值修改后,处理器会从内存中重新读取内存值进行新的缓存行填充。

由此可见,volatile 能够保证线程的可见性。

那么 volatile 能够保证原子性吗?

原子性

我们还是以 i = i + 1 这个例子来说明一下,i = i + 1 分为三个操作

  • 读取 i 的值

  • 自增 i 的值

  • 把 i 的值写内存

我们知道,volatile 能够保证修改 i 的值对其他线程可见,所以我们此时假设线程一执行 i 的读取操作,此时发生了线程切换,线程二读取到最新 i 的值是 0 ,然后线程再次发生切换,线程一把 i 的值改为 1,线程再次切换,因为此时 i 的值还没有应用到内存,所以线程 i 同样把 i 的值改为 1 后,线程再次发生切换,线程一把 i 的值写入内存后,再次发生切换,线程二再次把 i 的值写会内存,所以此时,虽然内存值改了两次,但是最后的结果却不是 2。

那么 volatile 不能保证原子性,那么该如何保证原子性呢?

在 JDK 5 的 java.util.concurrent.atomic 包下提供了一些原子操作类,例如 AtomicInteger、AtomicLong、AtomicBoolean,这些操作是原子性操作。它们是利用 CAS 来实现原子性操作的(Compare And Swap),CAS实际上是利用处理器提供的 CMPXCHG 指令实现的,而处理器执行 CMPXCHG 指令是一个原子性操作。

详情可以参考笔者的这篇文章 一场 Atomic XXX 的魔幻之旅

那么 volatile 能不能保证有序性呢?

这里就需要和你聊一聊 volatile 对有序性的影响了

有序性

上面提到过,重排序分为编译器重排序、处理器重排序和内存重排序。我们说的 volatile 会禁用指令重排序,实际上 volatile 禁用的是编译器重排序和处理器重排序。

下面是 volatile 禁用重排序的规则

从这个表中可以看出来,读写操作有四种,即不加任何修饰的普通读写和使用 volatile 修饰的读写。

从这个表中,我们可以得出下面这些结论

  • 只要第二个操作(这个操作就指的是代码执行指令)是 volatile 修饰的写操作,那么无论第一个操作是什么,都不能被重排序。

  • 当第一个操作是 volatile 读时,不管第二个操作是什么,都不能进行重排序。

  • 当第一个操作是 volatile 写之后,第二个操作是 volatile 读/写都不能重排序。

为了实现这种有序性,编译器会在生成字节码中,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

这里我们先来了解一下内存屏障的概念。

内存屏障也叫做栅栏,它是一种底层原语。它使得 CPU 或编译器在对内存进行操作的时候, 要严格按照一定的顺序来执行, 也就是说在 memory barrier 之前的指令和 memory barrier 之后的指令不会由于系统优化等原因而导致乱序。

内存屏障提供了两个功能。首先,它们通过确保从另一个 CPU 来看屏障的两边的所有指令都是正确的程序顺序;其次它们可以实现内存数据可见性,确保内存数据会同步到 CPU 缓存子系统。

不同计算机体系结构下面的内存屏障也不一样,通常需要认真研读硬件手册来确定,所以我们的主要研究对象是基于 x86 的内存屏障,通常情况下,硬件为我们提供了四种类型的内存屏障。

  • LoadLoad 屏障

它的执行顺序是 Load1 ;LoadLoad ;Load2 ,其中的 Load1 和 Load2 都是加载指令。LoadLoad 指令能够确保执行顺序是在 Load1 之后,Load2 之前,LoadLoad 指令是一个比较有效的防止看到旧数据的指令。

  • StoreStore 屏障

它的执行顺序是 Store1 ;StoreStore ;Store2 ,和上面的 LoadLoad 屏障的执行顺序相似,它也能够确保执行顺序是在 Store1 之后,Store2 之前。

  • LoadStore 屏障

它的执行顺序是 Load1 ;StoreLoad ;Store2 ,保证 Load1 的数据被加载在与这数据相关的 Store2 和之后的 store 指令之前。

  • StoreLoad 屏障

它的执行顺序是 Store1 ;StoreLoad ;Load2 ,保证 Store1 的数据被其他 CPU 看到,在数据被 Load2 和之后的 load 指令加载之前。也就是说,它有效的防止所有 barrier 之前的 stores 与所有 barrier 之后的 load 乱序。

JMM 采取了保守策略来实现内存屏障,JMM 使用的内存屏障如下

下面是一个使用内存屏障的示例

class MemoryBarrierTest {
  int a, b;
  volatile int v, u;
  void f() {
    int i, j;

    i = a;
    j = b;
    i = v;

    j = u;

    a = i;
    b = j;

    v = i;

    u = j;

    i = u;

    j = b;
    a = i;
  }
}

这段代码虽然比较简单,但是使用了不少变量,看起来有些乱,我们反编译一下来分析一下内存屏障对这段代码的影响。

从反编译的代码我们是看不到内存屏障的,因为内存屏障是一种硬件层面的指令,单凭字节码是肯定无法看到的。虽然无法看到内存屏障的硬件指令,但是 JSR-133 为我们说明了哪些字节码会出现内存屏障。

  • 普通的读类似 getfield 、getstatic 、 不加 volatile 修饰的数组 load 。

  • 普通的写类似 putfield 、 putstatic 、 不加 volatile 修饰的数组 store 。

  • volatile 读是可以被多个线程访问修饰的 getfield、 getstatic 字段。

  • volatile 写是可以被当个线程访问修饰的 putfield、 putstatic 字段。

这也就是说,只要是普通的读写加上了 volatile 关键字之后,就是 volatile 读写(呃呃呃,我好像说了一句废话),并没有其他特殊的 volatile 独有的指令。

根据这段描述,我们来继续分析一下上面的字节码。

a、b 是全局变量,也就是实例变量,不加 volatile 修饰,u、v 是 volatile 修饰的全局变量;i、j 是局部变量。

首先 i = a、j = b 只是把全局变量的值赋给了局部变量,由于是获取对象引用的操作,所以是字节码指令是 getfield 。

从官方手册就可以知晓原因了。

地址在 https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-6.html

由内存屏障的表格可知,第一个操作是普通读写的情况下,只有第二个操作是 volatile 写才会设置内存屏障。

继续向下分析,遇到了 i = v,这个是把 volatile 变量赋值给局部变量,是一种 volatile 读,同样的 j = u 也是一种 volatile 读,所以这两个操作之间会设置 LoadLoad 屏障。

下面遇到了 a = i ,这是为全局变量赋值操作,所以其对应的字节码是 putfield

地址在 https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-6.html

所以在 j = u 和 a = i 之间会增加 LoadStore 屏障。然后 a = i 和 b = j 是两个普通写,所以这两个操作之间不需要有内存屏障。

继续往下面分析,第一个操作是 b = j ,第二个操作是 v = i 也就是 volatile 写,所以需要有 StoreStore 屏障;同样的,v = i 和 u = j 之间也需要有 StoreStore 屏障。

第一个操作是 u = j 和 第二个操作 i = u volatile 读之间需要 StoreLoad 屏障。

最后一点需要注意下,因为最后两个操作是普通读和普通写,所以最后需要插入两个内存屏障,防止 volatile 读和普通读/写重排序。

《Java 并发编程艺术》里面也提到了这个关键点。

从上面的分析可知,volatile 实现有序性是通过内存屏障来实现的。

关键概念

在 volatile 实现可见性和有序性的过程中,有一些关键概念,cxuan 这里重新给读者朋友们唠叨下。

  • 缓冲行:英文概念是 cache line,它是缓存中可以分配的最小存储单位。因为数据在内存中不是以独立的项进行存储的,而是以临近 64 字节的方式进行存储。

  • 缓存行填充:cache line fill,当 CPU 把内存的数据载入缓存时,会把临近的共 64 字节的数据一同放入同一个 Cache line,因为局部性原理:临近的数据在将来被访问的可能性大。

  • 缓存命中:cache hit,当 CPU 从内存地址中提取数据进行缓存行填充时,发现提取的位置仍然是上次访问的位置,此时 CPU 会选择从缓存中读取操作数,而不是从内存中取。

  • 写命中:write hit ,当处理器打算将操作数写回到内存时,首先会检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器会将这个操作数写回到缓存,而不是写回到内存,这种方式被称为写命中。

  • 内存屏障:memory barriers,是一组硬件指令,是 volatile 实现有序性的基础。

  • 原子操作:atomic operations,是一组不可中断的一个或者一组操作。

如何正确的使用 volatile 变量

上面我们聊了这么多 volatile 的原理,下面我们就来谈一谈 volatile 的使用问题。

volatile 通常用来和 synchronized 锁进行比较,虽然它和锁都具有可见性,但是 volatile 不具有原子性,它不是真正意义上具有线程安全性的一种工具。

从程序代码简易性和可伸缩性角度来看,你可能更倾向于使用 volatile 而不是锁,因为 volatile 写起来更方便,并且 volatile 不会像锁那样造成线程阻塞,而且如果程序中的读操作的使用远远大于写操作的话,volatile 相对于锁还更加具有性能优势。

很多并发专家都推荐远离 volatile 变量,因为它们相对于锁更加容易出错,但是如果你谨慎的遵从一些模式,就能够安全的使用 volatile 变量,这里有一个 volatile 使用原则

只有在状态真正独立于程序内其他内容时才能使用 volatile

下面我们通过几段代码来感受一下这条规则的力量。

状态标志

一种最简单使用 volatile 的方式就是将 volatile 作为状态标志来使用。

volatile boolean shutdownRequested;

public void shutdown() { shutdownRequested = true; }

public void doWork() 
    while (!shutdownRequested) { 
        // do stuff
    }
}

为了能够正确的调用 shutdown() 方法,你需要确保 shutdownRequested 的可见性。这种状态标志的一种特性就是通常只有一种状态转换:shutdownRequested 的标志从 false 转为 true,然后程序停止。这种模式可以相互来回转换。

双重检查锁

使用 volatile 和 synchronized 可以满足双重检查锁的单例模式。

class Singleton{

    private volatile static Singleton instance = null;
    private Singleton() {}

    public static Singleton getInstance() {
        if(instance == null) {
            synchronized (Singleton.class) {
                if(instance == null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
}

这里说下为什么要用两次检查,假如有两个线程,线程一在进入到 synchronized 同步代码块之后,在还没有生成 Singleton 对象前发生线程切换,此时线程二判断 instance == null 为 true,会发生线程切换,切换到线程一,然后退出同步代码块,线程切换,线程二进入同步代码块后,会再判断一下 instance 的值,这就是双重检查锁的必要所在。

读-写锁

这也是 volatile 和 synchronized 一起使用的示例,用于实现开销比较低的读-写锁。

public class ReadWriteLockTest {
        private volatile int value;

    public int getValue() return value; }

    public synchronized int increment() {
        return value++;
    }
}

如果只使用 volatile 是不能安全实现计数器的,但是你能够在读操作中使用 volatile 保证可见性。如果你想要实现一种读写锁的话,必须进行外部加锁。

另外,cxuan 肝了六本 PDF,公号回复 cxuan ,领取作者全部 PDF 。



浏览 31
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报