See More

# 深入理解 volatile 关键字 * [深入理解 volatile 关键字](#深入理解-volatile-关键字) * [先从内存模型谈起](#先从内存模型谈起) * [JVM 内存模型](#jvm-内存模型) * [并发编程中的三个主要问题](#并发编程中的三个主要问题) * [可见性问题](#可见性问题) * [原子性问题](#原子性问题) * [有序性问题](#有序性问题) * [volatile 的实现原理](#volatile-的实现原理) * [可见性](#可见性) * [原子性](#原子性) * [关键概念](#关键概念) * [如何正确的使用 volatile 变量](#如何正确的使用-volatile-变量) * [状态标志](#状态标志) * [双重检查锁](#双重检查锁) * [读-写锁](#读-写锁) volatile 这个关键字大家都不陌生,这个关键字一般通常用于并发编程中,是 Java 虚拟机提供的轻量化同步机制,你可能知道 volatile 是干啥的,但是你未必能够清晰明了的知道 volatile 的实现机制,以及 volatile 解决了什么问题,这篇文章我就来带大家解析一波。 volatile 能够保证共享变量之间的 `可见性`,共享变量是存在堆区的,而堆区又与内存模型有关,所以我们要聊 volatile ,就需要首先了解一下 JVM 内存模型,而 JVM 又是和内存进行交互的,所以在聊 JVM 内存模型前,我们还需要了解一下操作系统层面中内存模型的相关概念。 ## 先从内存模型谈起 计算机在执行程序时,会从内存中读取数据,然后加载到 CPU 中运行。由于 CPU 执行指令的速度要比从内存中读取和写入的速度快的多,所以如果每条指令都要和内存交互的话,会大大降低 CPU 的运行速度,造成昂贵的 CPU 性能损耗,为了解决这种问题,设计了 **CPU 高速缓存**。有了 CPU 高速缓存后,CPU 就不再需要频繁的和内存交互了,有高速缓存就行了,而 CPU 高速缓存,就是我们经常说的 L1 、L2、L3 cache。 当程序在运行过程中,会将运算需要的数据从主存复制一份到 CPU 的高速缓存中,在 CPU 进行计算时就可以直接从它的高速缓存读写数据,当运算结束之后,再将高速缓存中的数据刷新到主存中。 就拿我们常说的 ```java 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 内存模型的问题了,图示如下 ![](https://z3.ax1x.com/2021/05/20/gToSaT.png) - `虚拟机栈` : 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 才能够继续访问内存中的变量,再继续执行后面的代码,这样就解决了缓存不一致问题。 ![](https://z3.ax1x.com/2021/05/20/gTIzZV.png) 但是上面的方式会有一个问题,由于在锁住总线期间,其他 CPU 无法访问内存,导致效率低下。 在 JDK 1.6 之后,优化了 synchronized 声言 LOCK# 的方式,不再对总线进行锁定,转而采取了对 CPU 缓存行进行锁定,因为本篇文章不是介绍 synchronized 实现细节的文章,所以不再对这种方式进行详细介绍,读者只需要知道在优化之后,synchronized 的性能不再成为并发问题的瓶颈了。 MESI 协议就是缓存一致性协议,即 **Modified(被修改)Exclusive(独占的) Shared(共享的) Or Invalid(无效的)**。MESI 的基本思想就是如果发现 CPU 操作的是共享变量,其他 CPU 中也会出现这个共享变量的副本,在 CPU 执行代码期间,会发出信号通知其他 CPU 自己正在修改共享变量,其他 CPU 收到通知后就会把自己的共享变量置为无效状态。 ![](https://z3.ax1x.com/2021/05/20/gTIvq0.png) ## 并发编程中的三个主要问题 ### 可见性问题 在单核 CPU 时代,所有的线程共用一个 CPU,CPU 缓存和内存的一致性问题容易解决,我们还拿上面的 i = 1 + 1 来举例,CPU 和 内存之间如果用图来表示的话我想会是下面这样。 ![](https://z3.ax1x.com/2021/05/20/gTIq2j.png) 在多核时代,每个核都能够独立的运行一个线程,每个 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的 CPU 上执行时,这些线程使用的是不同的 CPU 缓存。 ![](https://z3.ax1x.com/2021/05/20/gTILxs.png) 因为 i 没有经过任何线程安全措施的保护,多个线程会并发修改 i 的值,所以我们认为 i 不是线程安全的,导致这种结果的出现是由于 aThread 和 bThread 中读取的 i 值彼此不可见,所以这是由于 `可见性` 导致的线程安全问题。 ### 原子性问题 当两个线程开始运行后,每个线程都会把 i 的值读入到 CPU 缓存中,再执行 + 1 操作,然后把 + 1 之后的值写入内存。因为线程间都有各自的虚拟机栈和程序计数器,他们彼此之间没有数据交换,所以当 aThread 执行 + 1 操作后,会把数据写入到内存,同时 bThread 执行 + 1 操作后,也会把数据写入到内存,因为 CPU 时间片的执行周期是不确定的,所以会出现当 aThread 还没有把数据写入内存时,bThread 就会读取内存中的数据,然后执行 + 1操作,再写回内存,从而覆盖 i 的值。 ![](https://z3.ax1x.com/2021/05/20/gTIbGQ.png) ### 有序性问题 在并发编程中还有带来让人非常头疼的 `有序性` 问题,有序性顾名思义就是顺序性,在计算机中指的就是指令的先后执行顺序。一个非常显而易见的例子就是 JVM 中的`类加载`。 ![](https://z3.ax1x.com/2021/05/20/gTIHPg.png) 这是一个 JVM 加载类的过程图,也称为类的生命周期,类从加载到 JVM 到卸载一共会经历五个阶段 **加载、连接、初始化、使用、卸载**。这五个过程的执行顺序是一定的,但是在连接阶段,也会分为三个过程,即 **验证、准备、解析** 阶段,这三个阶段的执行顺序不是确定的,通常交叉进行,在一个阶段的执行过程中会激活另一个阶段。 在执行程序的过程中,为了提高性能,编译器和处理器通常会对指令进行重排序。重排序主要分为三类 * **编译器优化的重排序**:编译器在不改变单线程语义的情况下,会对执行语句进行重新排序。 * **指令集重排序**:现代操作系统中的处理器都是并行的,如果执行语句之间不存在数据依赖性,处理器可以改变语句的执行顺序 * **内存重排序**:由于处理器会使用读/写缓冲区,出于性能的原因,内存会对读/写进行重排序 也就是说,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。 ## volatile 的实现原理 上面聊了这么多,你可能都要忘了这篇文章的故事主角了吧?主角永远存在于我们心中 ...... 其实上面聊的这些,都是在为 volatile 做铺垫。 在并发编程中,最需要处理的就是线程之间的`通信`和线程间的`同步`问题,上面的可见性、原子性、有序性也是这两个问题带来的。 ### 可见性 而 volatile 就是为了解决这些问题而存在的。Java 语言规范对 volatile 下了定义:Java 语言为了确保能够安全的访问共享变量,提供了 volatile 这个关键字,volatile 是一种**轻量级同步**机制,它并不会对共享变量进行加锁,但在某些情况下要比加锁**更加方便**,如果一个字段被声明为 volatile,Java 线程内存模型能够确保所有线程访问这个变量的值都是一致的。 一旦共享变量被 volatile 修饰后,就具有了下面两种含义 1. 保证了这个字段的可见性,也就是说所有线程都能够"看到"这个变量的值,如果某个 CPU 修改了这个变量的值之后,其他 CPU 也能够获得通知。 2. 能够禁止指令的重排序 下面我们来看一段代码,这也是我们编写并发代码中经常会使用到的 ```java 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。 ![](https://z3.ax1x.com/2021/05/20/gTITIS.png) >那么 volatile 不能保证原子性,那么该如何保证原子性呢? 在 JDK 5 的 java.util.concurrent.atomic 包下提供了一些原子操作类,例如 AtomicInteger、AtomicLong、AtomicBoolean,这些操作是原子性操作。它们是利用 CAS 来实现原子性操作的(Compare And Swap),CAS实际上是利用处理器提供的 `CMPXCHG` 指令实现的,而处理器执行 CMPXCHG 指令是一个原子性操作。 详情可以参考笔者的这篇文章 [一场 Atomic XXX 的魔幻之旅](https://mp.weixin.qq.com/s?__biz=MzkwMDE1MzkwNQ==&mid=2247495805&idx=1&sn=74cb9ee585edf13b3af7431bbb0f4e0e&chksm=c04ae723f73d6e35e0212472778b997ebeae428d6562e88bf15b311c87241a32331af7f42101&token=530741505&lang=zh_CN#rd)。 >那么 volatile 能不能保证有序性呢? 这里就需要和你聊一聊 volatile 对有序性的影响了 ###有序性 上面提到过,重排序分为编译器重排序、处理器重排序和内存重排序。我们说的 volatile 会禁用指令重排序,实际上 volatile 禁用的是编译器重排序和处理器重排序。 下面是 volatile 禁用重排序的规则 ![](https://z3.ax1x.com/2021/05/20/gTIoa8.png) 从这个表中可以看出来,读写操作有四种,即不加任何修饰的普通读写和使用 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 使用的内存屏障如下 ![](https://z3.ax1x.com/2021/05/20/gTIRxA.png) 下面是一个使用内存屏障的示例 ```java 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://z3.ax1x.com/2021/05/20/gTIhrt.png) 地址在 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://z3.ax1x.com/2021/05/20/gTI4qP.png) 地址在 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 并发编程艺术》里面也提到了这个关键点。 ![](https://z3.ax1x.com/2021/05/20/gTIIVf.png) 从上面的分析可知,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 作为状态标志来使用。 ```java volatile boolean shutdownRequested; public void shutdown() { shutdownRequested = true; } public void doWork() { while (!shutdownRequested) { // do stuff } } ``` 为了能够正确的调用 shutdown() 方法,你需要确保 shutdownRequested 的可见性。这种状态标志的一种特性就是通常只有一种状态转换:shutdownRequested 的标志从 false 转为 true,然后程序停止。这种模式可以相互来回转换。 ### 双重检查锁 使用 volatile 和 synchronized 可以满足双重检查锁的单例模式。 ```java 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 一起使用的示例,用于实现开销比较低的读-写锁。 ```java public class ReadWriteLockTest { private volatile int value; public int getValue() { return value; } public synchronized int increment() { return value++; } } ``` 如果只使用 volatile 是不能安全实现计数器的,但是你能够在读操作中使用 volatile 保证可见性。如果你想要实现一种读写锁的话,必须进行外部加锁。 ![image-20210716163352584](https://tva1.sinaimg.cn/large/008i3skNly1gsivkbczxoj31l20t8al5.jpg) ![image-20210716163433337](https://tva1.sinaimg.cn/large/008i3skNly1gsivl4khz9j31d60h8mze.jpg)