Java进阶之并发编程(二)volatile关键字
一、什么是线程安全的可见性与有序性
先看如下案例,在该样例中,线程外将stop改为true之后,理论上线程会跳出while循环进而终止,但是实际运行会发现线程依然继续运行,为什么会出现这种情况呢?
1 | public class VolatileDemo { |
这是因为现代JVM实现(如HotSpot JVM)中的即时编译器会对程序进行代码优化,从而导致对stop变量的读取在循环中只被执行一次,即初始状态下读取到的true,而外部对stop的修改并不能被线程内部的stop所获取到,这就是所谓的可见性问题
想解决上述代码可见性问题,只需要增加一个volatile关键字即可,该关键字可以保证代码的可见性
那什么是有序性问题呢?看如下案例
1 | public class ReorderingDemo { |
在上述代码中,线程1调用了writer方法,而线程2调用了reader方法。由于没有使用同步机制或者volatile关键字,线程2可能会看到变量flag为true,但是变量x的值还没有被写入,从而导致输出的x值为0
这是因为在java当中,如果一段代码没有强依赖关系,那么在多线程条件下这些代码可能会被重排序,从而导致并发顺序问题;此外,x = 42并不是一个原子性操作,而是由初始化x为0和将42赋值给x两步组成的,因此多线程条件下可能会先拿到为0的x值,这就是线程的有序性问题
二、为什么会出现可见性问题
1.为了性能处理所做的优化
在整个计算机的发展历程中,除了CPU、内存以及I/O设备不断迭代升级来提升计算机处理性能之外, 还有一个非常核心的矛盾点,就是这三者在处理速度的差异。CPU的计算速度是非常快的,其次是内存、最后是IO设备(比如磁盘),也就是CPU的计算速度远远高于内存以及磁盘设备的I/O速度。
为了平衡这三者之间的速度差异,最大化的利用CPU。所以在硬件层面、操作系统层面、编译器层面做出了很多的优化
- CPU增加了高速缓存
- 操作系统增加了进程、线程。通过CPU的时间片切换最大化的提升CPU的使用率
- 编译器的指令优化,更合理的去利用好CPU的高速缓存
每一种优化,都会带来相应的问题,而这些问题是导致线程安全性问题的根源,那接下来我们逐步去了解这些优化的本质和带来的问题
2.CPU层面的缓存
CPU在做计算时,和内存的IO操作是无法避免的,而这个IO过程相对于CPU的计算速度来说是非常耗时,基于这样一个问题,所以在CPU层面设计了高速缓存,这个缓存行可以缓存存储在内存中的数据,CPU每次会先从缓存行中读取需要运算的数据,如果缓存行中不存在该数据,才会从内存中加载,通过这样一个机制可以减少CPU和内存的交互开销从而提升CPU的利用率。
对于主流的x86平台,cpu的缓存行(cache)分为L1、L2、L3总共3级。

1)缓存一致性问题
CPU高速缓存的出现,虽然提升了CPU的利用率,但是同时也带来了另外一个问题—缓存一致性问题, 这个一致性问题体现在。
在多线程环境中,当多个线程并行执行加载同一块内存数据时,由于每个CPU都有自己独立的L1、L2缓存,所以每个CPU的这部分缓存空间都会缓存到相同的数据,并且每个CPU执行相关指令时,彼此之间不可见,就会导致缓存的一致性问题。
2)缓存一致性协议
为了达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来操作,常 见的协议有MSI,MESI,MOSI等。最常见的就是MESI协议。接下来简单讲解一下MESI。
MESI表示缓存行的四种状态,分别是
- M(Modify) 表示共享数据只缓存在当前CPU缓存中,并且是被修改状态,也就是缓存的 数据和主内存中的数据不一致
- E(Exclusive) 表示缓存的独占状态,数据只缓存在当前CPU缓存中,并且没有被修改
- S(Shared) 表示数据可能被多个CPU缓存,并且各个缓存中的数据和主内存数据一致
- I(Invalid) 表示缓存已经失效
在CPU的缓存行中,每一个Cache一定会处于以下三种状态之一
- Shared状态:表示该缓存块正在被多个处理器共享。在该状态下,缓存块的副本位于多个处理器的缓存中,这些副本之间的数据应该是相同的,因此任何一个处理器的缓存更改都必须与其他处理器的缓存同步。
- Exclusive状态:表示该缓存块只存在于当前处理器的缓存中。在这种情况下,当前处理器是唯一可以访问该缓存块的处理器,并且其他处理器无法访问该缓存块。如果其他处理器需要访问该缓存块,则必须先使其从Exclusive状态转换为Shared状态。
- Invalid状态:表示该缓存块无效或者已经过期,不能被任何处理器使用。当某个处理器修改了一个缓存块的数据时,该缓存块的状态会变为Invalid状态,此时其他处理器需要刷新其缓存中的数据,以确保其缓存中的数据与内存中的数据一致。
即共享,独占和无效
但是,缓存一致性协议情况下依然可能会存在指令重排序问题,一般采用内存屏障来处理,保证指令有序性与可见性
也可以使用缓存锁/总线锁等机制解决
3.指令重排序问题
CPU层面的指令重排序问题是指在现代处理器中,为了提高指令执行效率,处理器内部可能会对指令进行重排序,从而可能导致指令的执行顺序发生变化。这种重排序通常不会影响程序的语义,但是在多线程环境下,如果不加以控制,就可能会导致线程之间的数据竞争和有序性问题。
比如存在a和b两个普通共享变量,a变量被一个线程修改后,有缓存还没有更新a值,这时候b变量用到了a变量的值,但是读取到了修改前的值,从而导致线程安全问题
Java层面的指令重排序问题是指在Java程序中,由于JVM的优化机制和编译器的优化策略,可能会导致程序中的指令执行顺序发生变化。这种重排序通常不会影响单线程程序的语义,但是在多线程环境下,如果不加以控制,就可能会导致线程之间的数据竞争和有序性问题。
java层面,最常见解决可见性与有序性问题的手段是使用volatile关键字或者加锁
本质是采用了内存屏障的技术
4.内存屏障
内存屏障(Memory Barrier),也叫内存栅栏,是一种CPU硬件提供的指令,用于限制处理器和内存的乱序执行和重排序。内存屏障可以被用来保证指令的执行顺序、控制CPU和内存之间的同步、保证内存可见性等。内存屏障通常分为以下几种:
- Load Barrier(读屏障):它保证在读取某个变量的值之前,其它变量的值已经被加载到处理器的缓存中。读屏障确保了程序的有序性和可见性,避免了程序中出现未初始化的变量或无效的数据。
- Store Barrier(写屏障):它保证在修改某个变量的值之后,其它变量的值被刷新到内存中。写屏障确保了程序的有序性和可见性,避免了出现脏数据或不一致的数据。
- Full Barrier(全屏障):它同时包括了读屏障和写屏障的功能,保证了程序的所有操作都是有序的。全屏障一般比较耗费资源,只在特定情况下使用。
在Java中,volatile关键字使用的内存屏障主要包括store-store屏障、load-load屏障和store-load屏障,它们合起来构成了一种全屏障的机制。
store-store屏障会保证在该屏障之前的所有store指令都已经完成,确保该屏障之前的所有修改对其他线程可见;load-load屏障会保证在该屏障之后的所有load指令都能够读取到最新的值,确保该屏障之后的所有读操作都是有效的;store-load屏障会保证在该屏障之前的所有store指令都已经完成,确保该屏障之前的所有修改对其他线程可见,同时会强制所有之后的load指令重新从内存中读取数据,而不是使用之前的缓存数据,确保该屏障之后的所有读操作都能够读取到最新的值。
三、JMM模型
简单来说,Java内存模型(JMM)定义了多线程程序中,线程之间通过内存进行通信的规则和限制。
具体的,Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了这个线程中用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。
不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行,流程图如下:
目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题
本地内存是JMM的一个抽象概念,并不真实存在。
它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。
上面的可见性与有序性问题主要是通过volatile关键字来解决的,那有哪些情况是,不需要通过增加volatile关键字,也能保证在多线程环境下的可见性和有序性的呢?
从JDK1.5开始,引入了一个happens-before的概念来阐述多个线程操作共享变量的可见性问题。所以我们可以认为在JMM中,如果一个操作执行的结果需要对另一个操作课件,那么这两个操作必须要存在 happens-before关系。这两个操作可以是同一个线程,也可以是不同的线程。
1.happens-before 规则
“happens-before“ 是 Java 内存模型(JMM)中的一个概念,用来描述多线程程序中各个线程之间操作的先后顺序。
具体来说,如果操作 A happens-before 操作 B,那么 A 一定在 B 之前执行,且 B 可以看到 A 执行的结果。happens-before 规则定义了一组顺序关系,用来保证多线程程序中的操作顺序,从而确保程序的正确性和可移植性。
happens-before 规则包括以下几个方面:
- 传递性(Transitivity)规则:如果事件 A 在事件 B 之前发生,事件 B 在事件 C 之前发生,那么事件 A 必须在事件 C 之前发生。
- volatile 变量规则:如果一个线程先写一个 volatile 变量,然后另一个线程读取该变量,那么这个写操作将 happen-before 于这个读操作。
- 监视器锁规则:如果一个线程获得了一个监视器锁并释放了它,那么这个获得操作将 happen-before 于这个释放操作。
- start 规则:如果线程 A 启动线程 B,那么线程 A 的 start 操作将 happen-before 于线程 B 的任意操作。
- join 规则:如果线程 A 等待线程 B 结束,那么线程 B 的任意操作都将 happen-before 于线程 A 的 join 操作返回。
2.as-if-serial
“as-if-serial” 是指,虚拟机可以对指令进行重排序,只要重排序后的执行结果与原来的执行结果一致,就可以保证程序的正确性。换句话说,虚拟机可以将指令序列中无关联的操作进行并行执行,只要保证最终的结果与串行执行的结果一致即可。









