volatile 的作用
volatile 可以在多处理器环境下保证共享变量的可见性。
如何保证可见性
使用 hsdis 工具,可以查看代码的汇编指令。
在使用volatile的代码的输出的结果中,查找下 lock 指令会发现,在修改带有 volatile 修饰的成员变量时,会多一个 lock 指令。
lock是一种控制指令,在多处理器环境下,lock 汇编指令可以基于总线锁或者缓存锁的机制来达到可见性的一个效果。
从硬件层面了解可见性的本质
为了最大化的利用 CPU 提升性能,现代计算机系统从硬件、操作系统、编译器等方面都做出了很多的优化。
- CPU 增加了高速缓存;
- 操作系统增加了进程、线程。通过 CPU 的时间片切换最大化的提升 CPU 的使用率;
- 编译器的指令优化,更合理的去利用好 CPU 的高速缓存。
每一种优化,都会带来相应的问题,而这些问题也是导致线程安全性问题的根源。
CPU 高速缓存
现代计算机系统都会增加一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存和处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步到内存之中。
通过高速缓存的存储交互很好的解决了处理器与内存的速度矛盾,但是也为计算机系统带来了更高的复杂度,因为它引入了一个新的问题,缓存一致性。
为了解决缓存不一致的问题,在 CPU 层面做了很多事情,主要提供了两种解决办法:
- 总线锁
简单来说就是,在多 cpu 下,当其中一个处理器要对共享内存进行操作的时候,在总线上发出一个 LOCK#信号,这个信号使得其他处理器无法通过总线来访问到共享内存中的数据,总线锁定把 CPU 和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据。 - 缓存锁
总线锁定的开销比较大,这种机制显然是不合适的。所以引入了缓存锁,它核心机制是基于缓存一致性协议来实现的。
缓存一致性协议
为了达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来操作,常见的协议有MSI,MESI,MOSI 等。最常见的就是 MESI 协议。
MESI 表示缓存行的四种状态,分别是:
- M(Modify) 表示共享数据只缓存在当前 CPU 缓存中,并且是被修改状态,也就是缓存的数据和主内存中的数据不一致;
- E(Exclusive) 表示缓存的独占状态,数据只缓存在当前CPU 缓存中,并且没有被修改;
- S(Shared) 表示数据可能被多个 CPU 缓存,并且各个缓存中的数据和主内存数据一致;
- I(Invalid) 表示缓存已经失效。
在 MESI 协议中,每个缓存的缓存控制器不仅知道自己的读写操作,而且也监听(snoop)其它 Cache 的读写操作。
对于 MESI 协议,从 CPU 读写角度来说会遵循以下原则:
- CPU 读请求:缓存处于 M、E、S 状态都可以被读取,I 状态 CPU 只能从主存中读取数据。
- CPU 写请求:缓存处于 M、E 状态才可以被写。对于 S 状态的写,需要将其他 CPU 中缓存行置为无效才可写。
MESI 优化带来的可见性问题
MESI 协议虽然可以实现缓存的一致性,但是也会存在一些问题。
就是各个 CPU 缓存行的状态是通过消息传递来进行的。如果 CPU0 要对一个在缓存中共享的变量进行写入,首先需要发送一个失效的消息给到其他缓存了该数据的 CPU。并且要等到他们的确认回执。CPU0 在这段时间内都会处于阻塞状态。
为了避免阻塞带来的资源浪费。在 cpu 中引入了 Store Bufferes。
这种优化会存在两个问题
- 数据什么时候提交是不确定的,因为需要等待其他 cpu给回复才会进行数据同步。这里其实是一个异步操作。
- 引入了 storebufferes 后,处理器会先尝试从 storebuffer中读取值,如果 storebuffer 中有数据,则直接从storebuffer 中读取,否则就再从缓存行中读取。
可行的解决方案
在 CPU 层面提供了 memory barrier(内存屏障)的指令,从硬件层面来看这个 memroy barrier 就是 CPU flush store bufferes 中的指令。软件层面可以决定在适当的地方来插入内存屏障。
内存屏障
内存屏障就是将 store bufferes 中的指令写入到内存,从而使得其他访问同一共享内存的线程的可见性。
X86 的 memory barrier 指令包括 lfence(读屏障) sfence(写屏障) mfence(全屏障)。
Store Memory Barrier(写屏障) 告诉处理器在写屏障之前的所有已经存储在存储缓存(store bufferes)中的数据同步到主内存,简单来说就是使得写屏障之前的指令的结果对屏障之后的读或者写是可见的。
Load Memory Barrier(读屏障) 处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的。
Full Memory Barrier(全屏障) 确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障后的读写操作。
总的来说,内存屏障的作用可以通过防止 CPU 对内存的乱序访问来保证共享数据在多线程并行执行下的可见性。
回到最开始的 volatile 关键字的代码,这个关键字会生成一个 Lock 的汇编指令,这个指令其实就相当于实现了一种内存屏障。
JMM
JMM 全称是 Java Memory Model。
通过前面的分析发现,导致可见性问题的根本原因是缓存以及重排序。 而 JMM 实际上就是提供了合理的禁用缓存以及禁止重排序的方法。所以它最核心的价值在于解决可见性和有序性。
JMM 属于语言级别的抽象内存模型,可以简单理解为对硬件模型的抽象,它定义了共享内存中多线程程序读写操作的行为规范:在虚拟机中把共享变量存储到内存以及从内存中取出共享变量的底层实现细节。
通过这些规则来规范对内存的读写操作从而保证指令的正确性,它解决了 CPU 多级缓存、处理器优化、指令重排序导致的内存访问问题,保证了并发场景下的可见性。
需要注意的是,JMM 并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序,也就是说在 JMM 中,也会存在缓存一致性问题和指令重排序问题。只是 JMM 把底层的问题抽象到 JVM 层面,再基于 CPU 层面提供的内存屏障指令,以及限制编译器的重排序来解决并发问题。
JMM 抽象模型分为主内存、工作内存。
- 主内存是所有线程共享的,一般是实例对象、静态字段、数组对象等存储在堆内存中的变量。
- 工作内存是每个线程独占的,线程对变量的所有操作都必须在工作内存中进行,不能直接读写主
内存中的变量,线程之间的共享变量值的传递都是基于主内存来完成。
Java 内存模型底层实现可以简单的认为:通过内存屏障(memory barrier)禁止重排序,即时编译器根据具体的底层体系架构,将这些内存屏障替换成具体的 CPU 指令。
对于编译器而言,内存屏障将限制它所能做的重排序优化。
对于处理器而言,内存屏障将会导致缓存的刷新操作。比如,对于 volatile,编译器将在 volatile 字段的读写操作前后各插入一些内存屏障。
JMM 是如何解决可见性有序性问题
简单来说,JMM 提供了一些禁用缓存以及进制重排序的方法,来解决可见性和有序性问题。这些方法包括:volatile、synchronized、final
JMM 如何解决顺序一致性问题
- 重排序问题
为了提高程序的执行性能,编译器和处理器都会对指令做重排序,所谓的重排序其实就是指执行的指令顺序。
处理器的重排序是由于 CPU 的乱序执行。
编译器的重排序指的是程序编写的指令在编译之后,指令可能会产生重排序来优化程序的执行性能。
从源代码到最终执行的指令,可能会经过三种重排序:
1.编译器优化重排序;
2.指令级并行重排序;
3.内存系统重排序。
其中后两者属于处理器重排序。
这些重排序可能会导致可见性问题。
编译器的重排序,JMM 提供了禁止特定类型的编译器重排序。
处理器重排序,JMM 会要求编译器生成指令时,会插入内存屏障来禁止处理器重排序
数据依赖性原则
不是所有的程序都会出现重排序问题,编译器的重排序和 CPU 的重排序的原则一样,会遵守数据
依赖性原则,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。
这种规则也成为 as-if-serial。
JMM 层面的内存屏障
为了保证内存可见性,Java 编译器在生成指令序列的适当位置会插入内存屏障来禁止特定类型的处理器的重排序,在 JMM 中把内存屏障分为四类:
- LoadLoad Barriers
load1; LoadLoad;load2
确保load1数据的装载优先于load2及所有后续装载指令的装载。 - StoreStore Barriers
store1;storestore;store2
确保store1数据对其他处理器可见优先于store2及所有后续存储指令的存储。 - LoadStore Barriers
load1;loadstore;store2
确保load1数据装载优先于store2以及后续的存储指令刷新到内存。 - StoreLoad Barriers
store1;storeload;load2
确保store1数据对其他处理器变得可见,优先于load2及所有后续装载指令的装载:这条内存屏障指令是一个全能型的屏障。
HappenBefore
它的意思是前一个操作的结果对于后续操作是可见的,所以它是一种表达多个线程之间对于内存的可见性。
可以认为在 JMM 中,如果一个操作执行的结果需要对另一个操作课件,那么这两个操作必须要存在happens-before 关系。这两个操作可以是同一个线程,也可以是不同的线程。
JMM 中有哪些方法建立 happen-before 规则
1.程序顺序规则: 一个线程中的每个操作,happens-before 于该线程中的任意后续操作; 可以简单认为是 as-if-serial。单个线程中的代码顺序不管怎么变,对于结果来说是不变的。
- volatile 变量规则,对于 volatile 修饰的变量的写的操作,一定 happen-before 后续对于 volatile 变量的读操作。
- 传递性规则,如果 1 happens-before 2; 3happens-before 4; 那么传递性规则表示: 1 happens-before 4;
- start 规则,如果线程 A 执行操作 ThreadB.start() ,那么线程 A 的 ThreadB.start() 操作 happens-before 线程 B 中的任意操作。
- join 规则,如果线程 A 执行操作 ThreadB.join() 并成功返回,那么线程 B 中的任意操作 happens-before 于线程 A 从 ThreadB.join()操作成功返回。
- 监视器锁的规则,对一个锁的解锁,happens-before 于随后对这个锁的加锁。