1.初识volatile
下面这段代码,演示了一个使用 volatile 以及没使用 volatile这个关键字,对于变量更新的影响 ,使用volatile来修改变量stop会保证主线程修改stop对t1线程的可见,不使用volatile的线程t1对主线程对stop变量的修改不可见。
/**
* @Project: ThreadExample
* @description: volatile 例子,保证线程的可见性
* @author: sunkang
* @create: 2020-06-27 13:05
* @ModificationHistory who when What
**/
public class VolatileDemo {
public static volatile boolean stop=false;
public static void main( String[] args ) throws InterruptedException {
Thread t1=new Thread(()->{
int i=0;
while(!stop){ //condition 不满足
i++;
System.out.println(i);
}
});
t1.start();
Thread.sleep(10);
stop=true; //true
}
}
2.volatile 的作用
可见性
:可见性是指多线程情况下线程能够自动发现volatile变量的最新值。如果对Java内存模型比较了解的话会知道,每个线程都会被分配一个线程栈,如果对象是多线程间的共享资源时,当线程访问某一个对象时候值的时候,首先通过对象的引用找到对应在堆内存的变量的值,然后把堆内存变量的值load到线程栈中,建立一个变量副本,之后线程操作的都是副本变量,当修改完副本变量之后,会写回值到主内存。但由于线程栈是线程间相互隔离的,即多线程间不可见,如果有其他线程修改了这个变量,但还未写回主内存或者更新主内存后,其他线程读取的仍是自己线程栈的副本时,就会出现问题。而volidate则是用来保证可见性。即一个线程对共享变量的修改,能够及时被其他线程看到。
指令重排序
:在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。虽然代码顺序是有先后顺序,但真正执行时却不一定按照代码顺序执行。这样在多线程下就可能存在问题。注意:只是可能出现问题,另外指令重排序在实际下发生情况比较少,由于Java、CPU和内存之间都有一套严格的指令重排序规则,具体可参照JSR和JVM相关资料。重排序分三种类型:
编译器优化的重排序
:编译器在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
指令级并行的重排序
:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
内存系统的重排序
:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
不保证原子性
:原子性是指这个操作是不可中断,要么全部执行成功要么全部执行失败,就算在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程所干扰。volatile并不能保证原子操作,例如i++操作时,分为Load、Increment、Store、Memory Barriers四个步骤,即装载、新增、存储和内存屏障四个步骤,第四步则是保证jvm让最新的变量值在所有线程可见,但从Load、Increment、到Store是不安全的,中间如果其他的CPU线程修改值将会存在问题。
3.volatile 关键字是如何保证可见性的
我们可以使用【hsdis】这个工具,来查看前面演示的这段 代码的汇编指令,具体的使用请查看使用说明文档 在运行的代码中,设置jvm参数如下 【-server -Xcomp -XX:+UnlockDiagnosticVMOptions XX:+PrintAssembly XX:CompileCommand=compileonly,App.(替换成实际 运行的代码) 】
然后在输出的结果中,查找下lock指令,会发现,在修改带有volatile修饰的成员变量时,会多一个lock指令。lock 是一种控制指令,在多处理器环境下,lock 汇编指令可以 基于总线锁或者缓存锁的机制来达到可见性的一个效果。
为了让大家更好的理解可见性的本质,我们需要从硬件层 面进行梳理
4.从硬件层面了解可见性的本质
一台计算机中最核心的组件是CPU、内存、以及I/O设备。 在整个计算机的发展历程中,除了CPU、内存以及I/O设 备不断迭代升级来提升计算机处理性能之外,还有一个非 常核心的矛盾点,就是这三者在处理速度的差异。CPU的 计算速度是非常快的,内存次之、最后是IO设备比如磁盘。 而在绝大部分的程序中,一定会存在内存访问,有些可能 还会存在I/O设备的访问
为了提升计算性能,CPU从单核升级到了多核甚至用到了 超线程技术最大化提高 CPU 的处理性能,但是仅仅提升 CPU性能还不够,如果后面两者的处理性能没有跟上,意 味着整体的计算效率取决于最慢的设备。为了平衡三者的速度差异,最大化的利用CPU提升性能,从硬件、操作系 统、编译器等方面都做出了很多的优化
- CPU增加了高速缓存
- 操作系统增加了进程、线程。通过CPU的时间片切换最 大化的提升CPU的使用率
- 编译器的指令优化,更合理的去利用好CPU的高速缓存 然后每一种优化,都会带来相应的问题,而这些问题也是 导致线程安全性问题的根源。为了了解前面提到的可见性 问题的本质,我们有必要去了解这些优化的过程
CPU 高速缓存
线程是CPU调度的最小单元,线程设计的目的最终仍然是 更充分的利用计算机处理的效能,但是绝大部分的运算任 务不能只依靠处理器“计算”就能完成,处理器还需要与内 存交互,比如读取运算数据、存储运算结果,这个 I/O 操 作是很难消除的。而由于计算机的存储设备与处理器的运 算速度差距非常大,所以现代计算机系统都会增加一层读 写速度尽可能接近处理器运算速度的高速缓存来作为内存 和处理器之间的缓冲:将运算需要使用的数据复制到缓存 中,让运算能快速进行,当运算结束后再从缓存同步到内 存之中。
通过高速缓存的存储交互很好的解决了处理器与内存的速 度矛盾,但是也为计算机系统带来了更高的复杂度,因为 它引入了一个新的问题,缓存一致性。
什么叫缓存一致性呢
首先,有了高速缓存的存在以后,每个CPU的处理过程是, 先将计算需要用到的数据缓存在CPU高速缓存中,在CPU 进行计算时,直接从高速缓存中读取数据并且在计算完成 之后写入到缓存中。在整个运算过程完成后,再把缓存中 的数据同步到主内存。
由于在多CPU种,每个线程可能会运行在不同的CPU内, 并且每个线程拥有自己的高速缓存。同一份数据可能会被 缓存到多个 CPU 中,如果在不同 CPU 中运行的不同线程
看到同一份内存的缓存值不一样就会存在缓存不一致的问 题
为了解决缓存不一致的问题,在 CPU 层面做了很多事情, 主要提供了两种解决办法
- 总线锁
- 缓存锁
总线锁和缓存锁
总线锁
,简单来说就是,在多cpu下,当其中一个处理器 要对共享内存进行操作的时候,在总线上发出一个LOCK# 信号,这个信号使得其他处理器无法通过总线来访问到共 享内存中的数据,总线锁定把CPU和内存之间的通信锁住 了,这使得锁定期间,其他处理器不能操作其他内存地址 的数据,所以总线锁定的开销比较大,这种机制显然是不 合适的
如何优化呢?最好的方法就是控制锁的保护粒度,我们只 需要保证对于被多个 CPU 缓存的同一份数据是一致的就 行。所以引入了缓存锁,它核心机制是基于缓存一致性协 议来实现的。
缓存一致性协议
为了达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来操作,常见的协议有 MSI,MESI,MOSI等。最常见的就是MESI协议。接下来 给大家简单讲解一下MESI MESI表示缓存行的四种状态,分别是
- M(Modify) 表示共享数据只缓存在当前 CPU 缓存中, 并且是被修改状态,也就是缓存的数据和主内存中的数据不一致
- E(Exclusive) 表示缓存的独占状态,数据只缓存在当前 CPU缓存中,并且没有被修改
- S(Shared) 表示数据可能被多个CPU缓存,并且各个缓 存中的数据和主内存数据一致
- I(Invalid) 表示缓存已经失效
在 MESI 协议中,每个缓存的缓存控制器不仅知道自己的 读写操作,而且也监听(snoop)其它Cache的读写操作
E状态示例如下:
只有Core 0访问变量x,它的Cache line状态为E(Exclusive)。
S状态示例如下:
3个Core都访问变量x,它们对应的Cache line为S(Shared)状态。
M状态和I状态示例如下:
Core 0修改了x的值之后,这个Cache line变成了M(Modified)状态,其他Core对应的Cache line变成了I(Invalid)状态。
对于 MESI 协议,从 CPU 读写角度来说会遵循以下原则:
CPU读请求:缓存处于M、E、S状态都可以被读取,I状态CPU只能从主存中读取数据
CPU写请求:缓存处于M、E状态才可以被写。对于S状 态的写,需要将其他CPU中缓
存行置为无效才可写
使用总线锁和缓存锁机制之后,CPU对于内存的操作大概 可以抽象成下面这样的结构。从而达到缓存一致性效果
总结可见性的本质
由于CPU 高速缓存的出现使得 如果多个cpu同时缓存了 相同的共享数据时,可能存在可见性问题。也就是CPU0修 改了自己本地缓存的值对于 CPU1 不可见。不可见导致的 后果是 CPU1 后续在对该数据进行写入操作时,是使用的脏数据。使得数据最终的结果不可预测。
了解到这里,大家应该会有一个疑问,刚刚不是说基于缓存一致性协议或者总线锁能够达到缓存一致性的要求吗? 为什么还需要加 volatile 关键字?或者说为什么还会存在 可见性问题呢?
MESI 优化带来的可见性问题
MESI协议虽然可以实现缓存的一致性,但是也会存在一些 问题。
就是各个CPU缓存行的状态是通过消息传递来进行的。如 果 CPU0 要对一个在缓存中共享的变量进行写入,首先需 要发送一个失效的消息给到其他缓存了该数据的CPU。并 且要等到他们的确认回执。CPU0 在这段时间内都会处于阻塞状态。为了避免阻塞带来的资源浪费。在cpu中引入 了Store Bufferes。
CPU0只需要在写入共享数据时,直接把数据写入到store bufferes中,同时发送invalidate消息,然后继续去处理其 他指令。
当收到其他所有CPU发送了invalidate acknowledge消息 时,再将 store bufferes 中的数据数据存储至 cache line 中。最后再从缓存行同步到主内存。
但是这种优化存在两个问题
- 数据什么时候提交是不确定的,因为需要等待其他 cpu 给回复才会进行数据同步。这里其实是一个异步操作
- 引入了storebufferes后,处理器会先尝试从storebuffer 中读取值,如果 storebuffer 中有数据,则直接从 storebuffer中读取,否则就再从缓存行中读取
我们来看一个例子
exeToCPU0和exeToCPU1分别在两个独立的CPU上执行。 假如 CPU0 的缓存行中缓存了 isFinish 这个共享变量,并 且状态为(E)、而Value可能是(S)状态。
那么这个时候,CPU0在执行的时候,会先把value=10的 指令写入到storebuffer中。并且通知给其他缓存了该value 变量的CPU。在等待其他CPU通知结果的时候,CPU0会 继续执行isFinish=true这个指令。 而因为当前CPU0缓存了isFinish并且是Exclusive状态,所以可以直接修改 isFinish=true。
这个时候 CPU1 发起 read 操作去读取isFinish的值可能为true,但是value的值不等于10。 这种情况我们可以认为是CPU的乱序执行,也可以认为是一种重排序,而这种重排序会带来可见性的问题
这下硬件工程师也抓狂了,我们也能理解,从硬件层面很 难去知道软件层面上的这种前后依赖关系,所以没有办法 通过某种手段自动去解决。
所以在 CPU 层面提供了 memory barrier(内存屏障)的指 令,从硬件层面来看这个memroy barrier就是CPU flush store bufferes中的指令。软件层面可以决定在适当的地方 来插入内存屏障。
CPU 层面的内存屏障
什么是内存屏障?从前面的内容基本能有一个初步的猜想, 内存屏障就是将 store bufferes 中的指令写入到内存,从 而使得其他访问同一共享内存的线程的可见性。
X86的memory barrier指令包括lfence(读屏障) sfence(写 屏障) mfence(全屏障)
Store Memory Barrier(写屏障) 告诉处理器在写屏障之前 的所有已经存储在存储缓存(store bufferes)中的数据同步 到主内存,简单来说就是使得写屏障之前的指令的结果对 屏障之后的读或者写是可见的
Load Memory Barrier(读屏障) 处理器在读屏障之后的读 操作,都在读屏障之后执行。配合写屏障,使得写屏障之前 的内存更新对于读屏障之后的读操作是可见的
Full Memory Barrier(全屏障) 确保屏障前的内存读写操作 的结果提交到内存之后,再执行屏障后的读写操作
有了内存屏障以后,对于上面这个例子,我们可以这么来 改,从而避免出现可见性问题
总的来说,内存屏障的作用可以通过防止CPU对内存的乱序访问来保证共享数据在多线程并行执行下的可见性
但是这个屏障怎么来加呢?回到最开始我们讲 volatile 关 键字的代码,这个关键字会生成一个Lock的汇编指令,这 个指令其实就相当于实现了一种内存屏障
这个时候问题又来了,内存屏障、重排序这些东西好像是 和平台以及硬件架构有关系的。作为Java语言的特性,一 次编写多处运行。我们不应该考虑平台相关的问题,并且 这些所谓的内存屏障也不应该让程序员来关心。
5.JMM
什么是 JMM
JMM全称是Java Memory Model. 什么是JMM呢? 通过前面的分析发现,导致可见性问题的根本原因是缓存 以及重排序。 而JMM实际上就是提供了合理的禁用缓存 以及禁止重排序的方法。所以它最核心的价值在于解决可 见性和有序性。 JMM属于语言级别的抽象内存模型,可以简单理解为对硬 件模型的抽象,它定义了共享内存中多线程程序读写操作 的行为规范:在虚拟机中把共享变量存储到内存以及从内 存中取出共享变量的底层实现细节
通过这些规则来规范对内存的读写操作从而保证指令的正 确性,它解决了CPU多级缓存、处理器优化、指令重排序 导致的内存访问问题,保证了并发场景下的可见性。
需要注意的是,JMM并没有限制执行引擎使用处理器的寄 存器或者高速缓存来提升指令执行速度,也没有限制编译 器对指令进行重排序,也就是说在JMM中,也会存在缓存 一致性问题和指令重排序问题。只是JMM把底层的问题抽 象到 JVM 层面,再基于 CPU 层面提供的内存屏障指令, 以及限制编译器的重排序来解决并发问题
JMM抽象模型分为主内存、工作内存;主内存是所有线程 共享的,一般是实例对象、静态字段、数组对象等存储在 堆内存中的变量。工作内存是每个线程独占的,线程对变 量的所有操作都必须在工作内存中进行,不能直接读写主 内存中的变量,线程之间的共享变量值的传递都是基于主 内存来完成
Java 内存模型底层实现可以简单的认为:通过内存屏障 (memory barrier)禁止重排序,即时编译器根据具体的底层 体系架构,将这些内存屏障替换成具体的 CPU 指令。对 于编译器而言,内存屏障将限制它所能做的重排序优化。 而对于处理器而言,内存屏障将会导致缓存的刷新操作。 比如,对于 volatile,编译器将在 volatile 字段的读写操作前后各插入一些内存屏障。
JMM 是如何解决可见性有序性问题的
简单来说,JMM提供了一些禁用缓存以及进制重排序的方 法,来解决可见性和有序性问题。这些方法大家都很熟悉: volatile、synchronized、final;
JMM 如何解决顺序一致性问题
重排序问题
为了提高程序的执行性能,编译器和处理器都会对指令做重排序,其中处理器的重排序在前面已经分析过了。所谓的重排序其实就是指执行的指令顺序
编译器的重排序指的是程序编写的指令在编译之后,指令可能会产生重排序来优化程序的执行性能。 从源代码到最终执行的指令,可能会经过三种重排序。
2 和 3 属于处理器重排序。这些重排序可能会导致可见性问题。
编译器的重排序,JMM提供了禁止特定类型的编译器重排序。
处理器重排序,JMM会要求编译器生成指令时,会插入内存屏障来禁止处理器重排序
当然并不是所有的程序都会出现重排序问题 编译器的重排序和CPU的重排序的原则一样,会遵守数据 依赖性原则,编译器和处理器不会改变存在数据依赖关系 的两个操作的执行顺序,比如下面的代码,
a=1; b=a;
a=1;a=2;
a=b;b=1;
这三种情况在单线程里面如果改变代码的执行顺序,都会 导致结果不一致,所以重排序不会对这类的指令做优化。 这种规则也成为 as-if-serial。
不管怎么重排序,对于单个线程来说执行结果不能改变。比如
int a=2; //1
int b=3; //2
int rs=a*b; //3
1 和 3、2 和 3 存在数据依赖,所以在最终执行的指令中, 3不能重排序到1和2之前,否则程序会报错。由于1和2 不存在数据依赖,所以可以重新排列1和2的顺序
JMM 层面的内存屏障
为了保证内存可见性,Java 编译器在生成指令序列的适当位置会插入内存屏障来禁止特定类型的处理器的重排序, 在JMM中把内存屏障分为四类
6.HappenBefore
它的意思表示的是前一个操作的结果对于后续操作是可见 的,所以它是一种表达多个线程之间对于内存的可见性。 所以我们可以认为在JMM中,如果一个操作执行的结果需 要对另一个操作可见,那么这两个操作必须要存在 happens-before关系。这两个操作可以是同一个线程,也 可以是不同的线程
JMM 中有哪些方法建立 happen-before 规则
程序顺序规则
- 一个
线程中
的每个操作,happens-before于该线程中的任意后续操作; 可以简单认为是as-if-serial。 单个线程中的代码顺序不管怎么变,对于结果来说是不变的
顺序规则表示 1 happenns-before 2; 3 happensbefore 4
public class VolatileDemo {
int a=0;
volatile boolean flag=false;
public void writer(){ //线程A
a=1; //1
flag=true; //2
}
public void reader(){
if(flag){ //3
int x=a; //4
}
}
}
-
volatile变量规则
,对于volatile修饰的变量的写的操作, 一定happen-before后续对于volatile变量的读操作;
根据volatile规则,2 happens before 3 -
传递性规则
,如果 1 happens-before 2; 3happensbefore 4; 那么传递性规则表示: 1 happens-before 4;
public class VolatileDemo {
int a=0;
volatile boolean flag=false;
public void writer(){ //线程A
a=1; //1
flag=true; //2
}
public void reader(){
if(flag){ //3
int x=a; //4
}
}
}
-
start规则
,如果线程A执行操作ThreadB.start(),那么线 程 A 的 ThreadB.start()操作 happens-before 线程 B 中
的任意操作
public class StartRule {
static int x=0;
public static void main(String[] args) {
Thread t1=new Thread(()->{
//use x=10 //t1线程可以看到变量x被修改为10
});
x=10;
t1.start();
}
}
-
join规则
,如果线程A执行操作ThreadB.join()并成功返 回,那么线程 B 中的任意操作 happens-before 于线程 A从ThreadB.join()操作成功返回
public class JoinRule {
static int x=0;
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(()->{
x=100;
});
t1.start();
t1.join();
System.out.println(x); //输出100
}
}
-
监视器锁的规则
,对一个锁的解锁,happens-before于 随后对这个锁的加锁
public class SyncDemo {
int x=10;
public void setValue() {
synchronized (this) {//ThreadA / ThreadB
if(this.x <12){
this.x=12;
System.out.println(x);//只会输出一次12
}
}//此处自动解锁
}
public static void main(String[] args) {
SyncDemo sync = new SyncDemo();
new Thread(()->sync.setValue()).start();
new Thread(()->sync.setValue()).start();
}
}
假设 x 的初始值是 10,线程 A 执行完代码块后 x 的 值会变成 12(执行完自动释放锁),线程 B 进入代码块 时,能够看到线程 A 对 x 的写操作,也就是线程 B 能 够看到 x==12。