硬件的效率与一致性
多级缓存的作用:由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以为了提高处理器的利用率,现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。
缓存虽然解决了速度差,但是也引入了新问题——缓存与主内存的数据一致性(Cache Coherence)

本文中「内存模型」一词,可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。
除了增加高速缓存之外,为了使得处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行(Out-Of-Order Execution)优化(即 指令重排序),处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致。
Java 语言规范要求 JVM 在线程中维护一种类似串行的语义:只要程序的最终结果与在严格串行环境中执行的结果相同,那么上述操作(指令重排序、增加高速缓存)都是允许的。
Java 内存模型
主内存与工作内存
以下所谈的变量(Variables)与 Java 编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题。
- ~注~:如果局部变量是一个 reference 类型,它引用的对象在 Java 堆中可被各个线程共享,但是 reference 本身在 Java 栈的局部变量表中,它是线程私有的。
- Java 内存模型规定了所有的变量都存储在主内存(Main Memory)中(此处的主内存与介绍物理硬件时的主内存名字一样,两者也可以互相类比,但此处仅是虚拟机内存的一部分)。
- 每条线程还有自己的工作内存(Working Memory,可与前面讲的处理器高速缓存类比),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝 [4]
- 线程对变量的所有操作(读取、赋值等)都必须在==工作==内存中进行,而不能直接读写主内存中的变量。
- 干活要在操作线程中干。
- 这个对象的引用、对象中某个在线程访问到的字段是有可能存在拷贝的,但不会有虚拟机实现成把整个对象拷贝一次。
- 不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

内存间交互操作
Java 内存模型中定义了以下 8 种操作来完成,虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的(对于 double 和 long 类型的变量来说,load、store、read 和 write 操作在某些平台上允许有例外。
- lock、unlock
- read 从主存读取
- load 加载
- use 使用
- assign 分配
- store 存储
- write 写回主存
- 如果要把一个变量从主内存复制到工作内存,那就要顺序地执行 read 和 load 操作,
- 如果要把变量从工作内存同步回主内存,就要顺序地执行 store 和 write 操作。
- 注意,Java 内存模型只要求上述两个操作必须按顺序执行,而没有保证是==连续==执行。
Java 内存模型还规定了在执行上述 8 种基本操作时必须满足如下规则:
- 不允许 read 和 load、store 和 write 操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起回写了但主内存不接受的情况出现。
- read 和 load、store 和 write 。工作内存与主内存之间有求必应。
- 不允许一个线程丢弃它的最近的 assign 操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
- 改了一定要让主内存知道。
- 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中。
- 没改就别瞎折腾。
- 一个新的变量只能在主内存中「诞生」,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量,换句话说,就是对一个变量实施 use、store 操作之前,必须先执行过了 assign 和 load 操作。
- 一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。
- 锁了多少次,开多少次。
- 如果对一个变量执行 lock 操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量的值。
- 如果一个变量事先没有被 lock 操作锁定,那就不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定住的变量。
- 没有锁不要乱开锁
- 对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行 store、write 操作)。
- 做好手尾,再开锁。
这 8 种内存访问操作以及上述规则限定,再加上稍后介绍的对 volatile 的一些特殊规定,就已经完全确定了 Java 程序中哪些内存访问操作在并发下是安全的。
对于 volatile 型变量的特殊规则
当一个变量定义为 volatile 之后,它将具备两种特性,
- 第一是保证此变量对所有线程的可见性,这里的「可见性」是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。
- 而普通变量不能做到这一点,普通变量的值在线程间传递均需要通过主内存来完成,例如,线程 A 修改一个普通变量的值,然后向主内存进行回写,另外一条线程 B 在线程 A 回写完成了之后再从主内存进行读取操作,新变量值才会对线程 B 可见。
- 第二个语义是禁止指令重排序。
可见性 != 线程安全
volatile 变量在各个线程的工作内存中不存在一致性问题(在各个线程的工作内存中,volatile 变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题),但是 Java 里面的==运算并非原子操作==,导致 volatile 变量的运算在并发下一样是不安全的,
- 非原子操作会导致读取数据后,多个线程并行所进行操作的都是旧值。那么加载到主内存的值也就不是最新的了。
- 非原子操作 执行过程中是可以暂停的,暂停的那一段时间里其他线程可能修改了这个变量的值。
由于 volatile 变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(使用 synchronized 或 java.util.concurrent 中的原子类)来保证原子性。
- 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
- 变量不需要与其他的状态变量共同参与不变约束
volatile 性能
某些情况下,volatile 的同步机制的性能确实要优于锁(使用 synchronized 关键字或 java.util.concurrent 包里面的锁),但是由于虚拟机对锁实行的许多消除和优化,使得我们很难量化地认为 volatile 就会比 synchronized 快多少。
- volatile 变量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作则可能会慢一些,因为它需要在本地代码中插入许
多内存屏障指令来保证处理器不发生乱序执行。
大多数场景下 volatile 的总开销仍然要比锁低,我们在 volatile 与锁之中选择的唯一依据仅仅是 volatile 的语义能否满足使用场景的需求。
内存屏障
内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种 CPU 指令,用于控制特定条件下的重排序和内存可见性问题。Java 编译器也会根据内存屏障的规则禁止重排序。
内存屏障可以被分为以下 4 种类型
- LoadLoad 屏障:对于这样的语句 Load1; LoadLoad 屏障; Load2,在 Load2 及后续读取操作要读取的数据被访问前,保证 Load1 要读取的数据被读取完毕。
- StoreStore 屏障:对于这样的语句 Store1; StoreStore 屏障; Store2,在 Store2 及后续写入操作执行前,保证 Store1 的写入操作对其它处理器可见。
- LoadStore 屏障:对于这样的语句 Load1; LoadStore 屏障; Store2,在 Store2 及后续写入操作被刷出前,保证 Load1 要读取的数据被读取完毕。
- StoreLoad 屏障:对于这样的语句 Store1; StoreLoad 屏障; Load2,在 Load2 及后续所有读取操作执行前,保证 Store1 的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。
有的处理器的重排序规则较严,无需内存屏障也能很好的工作,Java 编译器会在这种情况下不放置内存屏障。
为了实现 JSR-133 的规定,Java 编译器会这样使用内存屏障:

对于 long 和 double 型变量的特殊规则
允许虚拟机实现选择可以不保证 64 位数据类型的 load、store、read 和 write 这 4 个操作的原子性,这点就是所谓的 long 和 double 的非原子性协定(Nonatomic Treatment ofdouble and long Variables)。
Java 内存模型「强烈建议」虚拟机把这些操作实现为具有原子性的操作。目前各种商用虚拟机也都是将它们实现为原子操作,因此我们在编写代码时==一般不需要==把用到的 long 和 double 变量专门声明为 volatile。
原子性、可见性与有序性
原子性
原子性是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其它线程干扰。
由 Java 内存模型直接保证的原子性变量操作包括 read、load、assign、use、store 和 write,我们大致可以认为基本数据类型的访问读写是具备原子性的(例外就是 long 和 double 的非原子性协定)。
尽管虚拟机未把 lock 和 unlock 操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter 和 monitorexit 来隐式地使用这两个操作,这两个字节码指令反映到 Java 代码中就是同步块——synchronized 关键字,因此在 synchronized 块之间的操作也具备原子性。
可见性
可见性(Visibility):可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。
除了 volatile 以外,Java 还有两个关键字能实现可见性,即 synchronized 和 final。
- 同步块的可见性是由「对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行 store、write 操作)」这条规则获得的,
- final 关键字的可见性是指:保证一个对象的构建方法结束前,所有 final 成员变量都必须完成初始化(前提是没有 this 引用溢出)。在构造器中一旦初始化完成,并且构造器没有把「this」的引用传递出去,那在其他线程中就能看见 final 字段的值。
- this 引用逃逸 是指在构造函数返回之前其他线程就持有该对象的引用。调用尚未构造完全的对象的方法可能引发令人疑惑的错误, 因此应该避免 this 逃逸的发生。
- 例子:在构造函数中启动线程 / 注册监听器/ 创建匿名内部类。
- this 引用逃逸 是指在构造函数返回之前其他线程就持有该对象的引用。调用尚未构造完全的对象的方法可能引发令人疑惑的错误, 因此应该避免 this 逃逸的发生。
有序性
如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指「线程内表现为串行的语义」(Within-Thread As-If-Serial Semantics),后半句是指「指令重排序」现象和「工作内存与主内存同步延迟」现象。
先行发生原则
先行发生原则是判断数据是否存在竞争、线程是否安全的主要依据。
先行发生是 Java 内存模型中定义的两项操作之间的偏序关系。所谓偏序关系可以这样理解:对于两个操作A和B,这两个操作可以在不同的线程中执行。如果A Happens-Before B,那么可以保证,当A操作执行完后,A操作的执行结果对B操作是可见的。
|
|
假设 A B C 之间满足 Happens-Before 条件,且 A Happens-Before B Happens-Before C,那么执行结果就是 j = 1; i = 2;
这些先行发生关系无须任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序。
- 程序次序规则(Program Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
- 管程锁定规则(Monitor Lock Rule):一个 unlock 操作先行发生于后面对同一个锁的 lock操作。这里必须强调的是同一个锁,而「后面」是指时间上的先后顺序。
- volatile 变量规则(Volatile Variable Rule):对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作,这里的「后面」同样是指时间上的先后顺序。
- 线程启动规则(Thread Start Rule):Thread 对象的 start()方法先行发生于此线程的每一个动作。
- 线程终止规则(Thread Termination Rule):线程中的所有操作必须在其他线程检测到该线程已经结束之前完成,我们可以通过 Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
- 线程中断规则(Thread Interruption Rule):对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupted()方法检测到是否有中断发生。
- 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize()方法的开始。
- 传递性(Transitivity):如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那就可以得出操作 A 先行发生于操作 C 的结论。
Java 无需任何同步手段就能成立的先行发生规则就以上 8 点。看上去有些抽象,想要进一步了解的同学可以参考下这篇文章 从Java多线程可见性谈Happens-Before原则
==时间先后顺序与先行发生原则之间基本没有太大的关系==,所以我们衡量并发安全问题的时候==不要受到时间顺序的干扰,一切必须以先行发生原则为准==。
这句话应该如何理解呢?
考虑这样一个场景:
|
|
但是线程 A 的 getValue 获取的结果是什么呢?可能是 0 ,也可能是 2。也就是说,没有进行同步处理的情况下,不同线程对在时间上先后对调用对某个共享变量的操作,并不能保证它们的结果是根据它们在时间上调用顺序确定的。换句话说,在不同线程之间,「时间上的先调用」并不代表「先行发生」。要解决这个问题,可以给 getValue 和 setValue 方法都定义为 synchronized 的,因为这样就可以套用「管程锁定原则」了。或者将 value 定义了 volatile 变量,因为 setValue 方法对 value 的修改不依赖 value 的原值,满足 volatile 关键字使用场景,这样就可以套用 volatile 变量规则来实现先行发生规则了。
通过上面的讨论,我们可以得出结论:一个操作「时间上的先发生」不代表这个操作会会是「先行发生」。那么一个操作「先行发生」是否能推导出这个操作必定是「时间上先发生」呢?这个推论也是不成立的,一个典型的栗子就是「指令重排序」
|
|
按照程序次序规则,int i = 2; 的操作先行发生与 int j = 1;,但是 int j = 1; 完全可能先执行,这并不影响先行发生原则的正确性,因为在同一条线程中无法感知到这点。
先行发生原则的真正意义
该段内容参考自从Java多线程可见性谈Happens-Before原则
从上面的分析中我们可以看出,Happens-Before原则致力于解决变量间可见性问题。但是它是如何解决的呢?
导致多线程间变量间可见性问题的根源在于 CPU 缓存以及指令重排序。那么,要解决这个可见性问题,一个最简单粗暴的方法就是禁止所有的重排序和 CPU 缓存。即关闭所有的编译器、操作系统和处理器的优化,这样所有的指令顺序全部按照程序代码书写的顺序执行。去掉 CPU 高速缓存,让 CPU 每次读写操作都直接与主存交互。
但是,如此粗暴的解决方案是不可取的,因为这会极大影响处理器的计算性能,并且对于那些非多线程共享的 变量是极不公平的。我们可以采用一种折中的方案来解决 CPU 高速缓存与指令重排带来的数据一致性问题。使用分割线把整个程序划分为几个程序块,在每个程序块内部的指令是可重排序的,但是分割线上的指令与程序块其他指令之间是不可以重排序的。在一个程序块内部,CPU 不用每次都与主内存进行交互,只需要在 CPU 缓存中执行读写操作即可,但是当程序执行到分割线出,CPU 必须将执行结果同步到主内存或者从主内存读取最新的变量值,那么 Happen-before 规则就是定义了这些程序块的分割线。

如图所示,这里的unlock M和lock M就是划分程序的分割线。在这里,红色区域和绿色区域的代码内部是可以进行重排序的,但是 unlock 和 lock 操作是不能与它们进行重排序的。即第一个图中的红色部分必须要在unlock M指令之前全部执行完,第二个图中的绿色部分必须全部在lock M指令之后执行。并且在第一个图中的unlock M指令处,红色部分的执行结果要全部刷新到主存中,在第二个图中的lock M指令处,绿色部分用到的变量都要从主存中重新读取。
在程序中加入分割线将其划分成多个程序块,虽然在程序块内部代码仍然可能被重排序,但是保证了程序代码在宏观上是有序的。并且可以确保在分割线处,CPU一定会和主内存进行交互。Happens-Before原则就是定义了程序中什么样的代码可以作为分割线。并且无论是哪条Happens-Before原则,它们所产生分割线的作用都是相同的。
Java 与线程
线程的实现
(广义)
实现线程主要有 3 种方式:使用内核线程实现、使用用户线程实现和使用用户线程加轻量级进程混合实现。
Thread 类与大部分的 Java API 有显著的差别,它的所有关键方法都是声明为 Native 的。在 Java API 中,一个 Native 方法往往意味着这个方法没有使用或无法使用平台无关的手段来实现(当然也可能是为了执行效率而使用 Native 方法,不过,通常最高效率的手段也就是平台相关的手段)。
1.内核线程实现
内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核(Kernel,下称内核)支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。
程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口——轻量级进程(Light Weight Process,LWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。
系统调用的代价相对较高,需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换。其次,每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持轻量级进程的数量是有限的。
1:1
2.使用用户线程实现
从广义上来讲,一个线程只要不是内核线程,就可以认为是用户线程(User Thread,UT),因此,从这个定义上来讲,轻量级进程也属于用户线程,但轻量级进程的实现始终是建立在内核之上的,许多操作都要进行系统调用,效率会受到限制。
狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知用户线程存在的实现。
现在使用用户线程的程序越来越少了,Java、Ruby 等语言都曾经使用过用户线程,最终又都放弃使用它。
3.使用用户线程加轻量级进程混合实现
线程除了依赖内核线程实现和完全由用户程序自己实现之外,还有一种将内核线程与用户线程一起使用的实现方式。在这种混合实现下,既存在用户线程,也存在轻量级进程。用户线程还是完全建立在用户空间中在这种混合模式中,用户线程与轻量级进程的数量比是不定的,即为 N:M 的关系
线程调度
主要调度方式有两种,分别是协同式线程调度(Cooperative Threads-Scheduling)和抢占式线程调度(Preemptive Threads-Scheduling)。
如果使用协同式调度的多线程系统,线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上。
- 优点:实现简单,不用考虑同步问题。
- 缺点:不稳定,前面一个线程执行出现问题,那么后面的线程都会受到影响。
如果使用抢占式调度的多线程系统,那么每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定。
- 优点:相对于协同式调度而言,较为稳定。
- 缺点:可控性不是很高,因为线程的优先级不是很靠谱;虽然 Thread.yield()可以让出执行时间,但是要获取执行时间的话,线程本身是没有什么办法。
目前 Java 使用的线程调度方式就是抢占式调度。JDK 后续版本中有可能会提供协程(Coroutines)方式来进行多任务处理
为什么说线程优先级并不是太靠谱?因为 Java 的线程是通过映射到系统的原生线程上来实现的,所以线程调度最终还是取决于操作系统,虽然现在很多操作系统都提供线程优先级的概念,但是并不见得能与 Java 线程的优先级一一对应。
线程的状态转换
图片参考自这篇文章

- 创建 (New)
- 运行,两种子状态
- Running 正在执行
- Ready 等待着 CPU 给它分配执行时间。
- 等待 (Waiting)
- 无限期等待
- 没有设置 Timeout 参数的 Object.wait()方法。
- 没有设置 Timeout 参数的 Thread.join()方法。
- LockSupport.park()方法。
- 有限期等待 (Timed Waiting)
- Thread.sleep()方法。
- 设置了 Timeout 参数的 Object.wait()方法。
- 设置了 Timeout 参数的 Thread.join()方法。
- LockSupport.parkNanos()方法。
- LockSupport.parkUntil()方法。
- 无限期等待
- 阻塞 (Blocked)
- 「阻塞状态」与「等待状态」的区别是:「阻塞状态」在等待着获取到一个互斥锁(排它锁),这个事件将在另外一个线程放弃这个锁的时候发生;而「等待状态」则是在等待一段时间,或者唤醒动作的发生。在程序等待进入同步区域的时候,线程将进入这种状态。
- 结束 (Terminated)
相关方法介绍
调用 sleep、join 时,不会释放所占用的资源,会进入阻塞状态;
调用 wait 时,会释放所占用的资源,会进入等待队列。
sleep 方法
线程睡眠到期自动苏醒,并返回到可运行状态,不是运行状态 。sleep() 中指定的时间是线程不会运行的最短时间。因此,sleep() 方法不能保证该线程睡眠到期后就开始执行。
sleep()是静态方法,只能控制当前正在运行的线程。
wait 方法
因为 wait() 方法会释放锁,所以必须要在 synchronized 块中调用(也就是要先拿到锁,不然就没有锁可以是释放了)。
join 方法
join() 导致线程栈发生了变化,当然这些变化都是瞬时的。
join() 方法有带超时限制的重载版本。 例如 t.join(5000);则让线程等待 5000 毫秒,如果超过这个时间,则停止等待,变为可运行状态。
yield 方法
yield() 方法会停止当前线程(使之进入可运行状态),让同等优先权的线程运行。如果没有同等优先权的线程,那么 yield() 方法将不会起作用。 实际中无法保证 yield() 达到让步目的,因为让步的线程(回到可执行状态)还有可能被线程调度程序再次选中。
简记:
硬件效率与一致性
因为处理器需要读取和返回数据,所以需要存储器。而存储器的速度与处理器的运算速度相差几个数量级。所以引入了缓存,引入缓存的同时也引入了主存与缓存中的数据一致性问题。
为了充分利用处理器的运算能力,除了引入缓存以外,处理器可能还会对输入的代码进行来乱序执行(不影响结果)。JVM 的即时编译器中也有类似于指令重排的优化。
工作内存与主内存
每条线程有自己的工作内存,工作内存是线程私有的,如果要交换必须通过主内存。
若要把二者与 Java 中的内存区域对应起来,则工作内存像对应虚拟机栈中的部分区域,主内存对应于堆中的对象实例数据部分。
虚拟机可能会让工作内存优先存储在寄存器或者高速缓存中,因为程序运行时主要访问读写的是工作内存 。
内存间的交互操作
(lock)read load use assign store write(unlock)
- 一个变量在同一时刻只能被某一个线程 lock。但是该线程可以对这个变量 lock 多次。
- 某些操作必须成对出现,比如 read 与 load 、store 与 write。
- 对某个变量执行了 assign 操作,必须把它同步回主内存。
volatile 关键字
保证可见性、禁止指令重排。
- 可见性。将 read load use 三个原子操作变为一个原子操作。将 assign store write 三个原子操作变为一个原子操作。
无法保证一致性。以多线程修改变量自增为例,当线程 A 读取了该变量(此时变量的值是正确的)但是还未进行 +1 操作,此变量的值被线程 B 执行了 +1 操作(更新后会通知所有线程,但是由于 线程 A 已经读取过该值了但是由还没有进行赋值操作,所以不会得到最新的值),但是之后线程 A 会对旧值进行自增操作,导致结果错误。
- 注:i++ 由 4 条字节码指令构成。从字节码层面解释:当 getStatic 指令把 race 的值(进行自增操作的变量)读取到操作栈顶时,volatile 保证 race 的值在此时是正确的,但是在执行 iconst_1、add 这些指令时,其他线程可能已经把 race 的值加大了,而在操作栈顶的值就变成了过期的数据,所以 putStatic 指令执行后就可能把较小的 race 值同步回主内存中。
在不符合以下两条规则的运算场景中,仍然要通过加锁来保证原子性。
- 运算结果不依赖变量当前的值 or 只要单一的线程修改变量的值
- 变量不需要与其他的状态变量共同参与不变约束。
普通变量只保证「结果正确」,volitale 变量还保证「程序正确」
- 「程序正确」指的是按照顺序执行
参考资料与学习资源推荐
- 《深入理解 Java 虚拟机》
- Java 内存访问重排序的研究
- 线程状态的转换
- 从Java多线程可见性谈Happens-Before原则
- 正确使用 Volatile 变量
若本文中有不正确的结论、说法,请大家指出,共同探讨,共同进步,谢谢!