文章目录
系列文章
Java并发编程第五篇:可见性,java内存模型
1.可见性
在多线程环境下,
读
和写
发生在不同的线程中的时候,可能会出现读线程
不能及时的读取到其他线程
写入的最新的值。这就是所谓的可见性.
测试代码:
/**
* 可见性测试
*/
public class NoVisbility {
private static boolean ready;
private static int number;
private static class ReaderThread extends Thread {
@Override
public void run() {
while (!ready) {
Thread.yield();
}
System.out.println(number);
}
}
public static void main(String[] args) throws InterruptedException {
new ReaderThread().start();
Thread.sleep(1000);
ready = true;
number = 43;
}
}
执行的结果可能是43
,可能会一直执行
,也可能是0
;因为代码中没有使用足够的同步机制,无法保证主线程中的ready
变量和number
变量的值对ReaderThread
线程是可见的。
2.计算机硬件层面可见性
一台计算机计算的核心组件
- CPU
- 内存
- I/O设备(硬盘)
几个组件的计算速度是不一样的,CPU>内存>I/O设备,根据木桶原则,短板决定了桶的容量。
所以计算机从硬件、操作系统、编译器等方面做了优化,降低不同组件的计算速度造成的影响。
- CPU增加了高速缓存
- 操作系统增加了进程、线程、通过CPU的时间片切换提高CPU的使用率
- 编译器的指令优化(指令的重排序)
2.1CPU高速缓存
由于计算机的存储设备与处理器的运算速度差距非常大,所以现代计算机系统都会增加一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存和处理器之间的缓冲;将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步到内存之中。
计算速度 L1>L2>L3
但是这种方式会带来缓存一致性的问题。
什么是缓存一致性?
使用了高速缓存之后,cpu处理的过程发生了变化
- 先将主内存中需要计算的值同步到各个cpu自己的高速缓存中
- cpu计算时直接从高速缓存中读取进行计算
- 计算完成之后,再把缓存中的值同步到主内存
多个cpu核心,同一个值可能在不同的cpu高速缓存中,当一个cpu缓存中的值发生变化,而其他cpu的值不变就造成了缓存不一致
。
一般有两种解决方法
- 总线锁
- 当一个cpu对共享内存进行处理时,在总线上放置了一把锁,其他的cpu访问共享内存时会被阻塞,当第一个cpu处理完,将值同步到主内存,释放锁,其他cpu再开始进行处理
- 总线锁的性能开销比较大,显然不太适合
- 缓存锁
- 缓存锁是基于缓存一致性协议来实现的
2.2缓存一致性
什么是缓存一致性协议?`
为了达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来操作,常见的协议有MSI,MESI,MOSI 等
MESI 表示缓存行的四种状态,分别是
- M(Modify) 表示共享数据只缓存在当前 CPU 缓存中,并且是被修改状态,也就是缓存的数据和主内存中的数据不一致
- E(Exclusive) 表示缓存的独占状态,数据只缓存在当前CPU 缓存中,并且没有被修改
- S(Shared) 表示数据可能被多个 CPU 缓存,并且各个缓存中的数据和主内存数据一致
- I(Invalid) 表示缓存已经失效
在 MESI 协议中,每个缓存的缓存控制器不仅知道自己的读写操作,而且也监听(snoop)其它 Cache 的读写操作
2.2.1状态变化过程
-
cpu0中
i = 0
;cpu1还没有变量i
的值此时,cpu0中的缓存为
E
状态
-
cpu0和cpu1中变量
i
的值都和主内存一致等于0此时,cpu0和cpu1缓存状态为
S
-
cpu0中的
i
修改为 1cpu0缓存状态修改为
S
,并且通知cpu1中的缓存状态修改为I
对应MESI协议,CPU读写会遵循以下原则:
- CPU 读请求:缓存处于 M、E、S 状态都可以被读取,I 状态 CPU 只能从主存中读取数据
- CPU 写请求:缓存处于 M、E 状态才可以被写。对于 S 状 态的写,需要将其他 CPU 中缓存行置为无效才可写
2.2.2 cpu操作内存抽象图
使用总线锁和缓存锁机制之后,CPU 对于内存的操作大概可以抽象成下面这样的结构;从而达到缓存一致性效果。
2.3可见性的本质
由于CPU高速缓存的出现,如果多个cpu同时缓存了相同的共享数据,可能存在可见性问题。就是
cpu0
修改了自己本地缓存的值对于其他cpu
是不可见的。导致其他cpu
对该数据进行操作时,使用的是脏数据;从而导致数据结果的不可预测。
2.4MESI带来的可见性问题
MESI协议虽然可以实现缓存的一致性,但是同时也带来了一些问题。
各个CPU缓存行的状态是通过消息传递来进行的,如果cpu0要对缓存中的变量i
进行修改,首先要通知各个cpu,使cache
中该变量i
的值为失效状态,并且等到他们的确认回执;cpu0在这段时间内都是阻塞状态,为了避免阻塞资源浪费,cpu中又引入了Store Buffers
。
cpu0
修改共享变量i
时,直接将数据写入到store buffer
中;同时发送invalidate
消息,然后继续去处理其他指令。- 当
cpu1
收到invalidate
之后,并发送Acknowledgement
消息到cpu0
。 cpu0
再将store buffer
中的数据数据存储至缓存行
中。- 最后再从
缓存行
同步到主内存。
2.4.1这种优化存在两个问题
- 数据什么时候提交是不确定的,因为需要等待其他 cpu 给回复才会进行数据同步,这里其实是一个异步操作
- 引入了
store buffers
后,处理器会先尝试从store buffer
中读取值,如果store buffer
中有数据,则直接从store buffer
中读取,否则就再从缓存行中读取
例子:
//伪代码
value = 3;
isFinish = false;
void exeToCPU0(){
value = 10;
isFinish = true;
}
void exeToCPU1(){
if(isFinish){
assert value == 10;
}
}
假设方法exeToCPU0和exeToCPU1分别在两个不同的cpu中执行。
-
cpu0
执行方法exeToCPU0,变量value
和isFinish
都是E状态
-
cpu1
同时执行方法exeToCPU1,共享变量value=3
,假设此时isFinish
还未共享 -
cpu0
执行value
修改为10
,状态S
->状态M
,将value=10
的指令写入到store buffer
中(异步过程) -
cpu1
同时执行isFinish
修改为true
,状态E
->状态M
,如果当value=10
修改还未完成的时候,isFinish
修改完成(因为isFinish目前还是E状态,所以可以直接修改(MESI协议),那么cpu1
执行exeToCPU1的结果可能如下void exeToCPU1(){ if(isFinish){ //true assert value == 10;//value=3,结果输出false } }
这种情况我们可以认为是 CPU 的乱序执行,也可以认为是一种重排序,而这种重排序会带来可见性的问题
2.4.2cpu层面的解决方案
CPU 层面提供了 memory barrier(内存屏障)的指令,从硬件层面来看这个 memory barrier 就是 CPU flush store buffers 中的指令。软件层面可以决定在适当的地方来插入内存屏障。
什么是内存屏障?
从前面的内容基本能有一个初步的猜想, 内存屏障就是将 store buffers 中的指令写入到内存,从而使得其他访问同一共享内存的线程的可见性。
- Store Memory Barrier(写屏障) 告诉处理器在写屏障之前的所有已经存储在存储缓存(store buffers)中的数据同步到主内存,简单来说就是使得写屏障之前的指令的结果对屏障之后的读或者写是可见的
- Load Memory Barrier(读屏障) 处理器在读屏障之后的读 操作,都在读屏障之后执行。配合写屏障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的
- Full Memory Barrier(全屏障) 确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障后的读写操作有了内存屏障以后,对于上面这个例子,我们可以这么来改,从而避免出现可见性问题
修改伪代码
//伪代码
value = 3;
isFinish = false;
void exeToCPU0(){
value = 10;
storeMemoryBarrier();//插入一个写屏障,使的value的值强制写入主内存
isFinish = true;
}
void exeToCPU1(){
if(isFinish){
loadMemoryBarrier();//插入一个读屏障,是的cpu1从主内存中获取最新的值
assert value == 10;
}
}
内存屏障的作用可以通过防止 CPU 对内存的乱序访问来保证共享数据在多线程并行执行下的可见性
3.JMM(java内存模型)
终于到了本章的重点,JMM 全称是 Java Memory Model(java内存模型)
- Java虚拟机规范中试图定义一种Java内存模型(JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。
- Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。
- JMM 并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序这类的优化措施,也就是说在 JMM 中,也会存在缓存一致性问题和指令重排序问题
3.1 主内存和工作内存
Java内存模型规定了所有的变量都存储在主内存中(此处的主内存与上述讲到计算机硬件的主内存名字一致,两者可以互相类比,但此处只是虚拟机内存的一部分)
此处的变量(Variables)与Java编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题。
每条线程还有自己的工作内存(可与前面讲的处理器高速缓存类比),线程的工作内存保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读主内存中的变量。
不同的线程也无法访问对方工作内存中的变量,线程间的变量传递都需要通过主内存来完成,线程、主内存、工作内存的关系如下图:
3.2 重排序
JMM最核心的价值是解决了可见性、有序性
JMM 提供了一些禁用缓存以及禁止重排序的方法,来解决可见性和有序性问题,比如以下方法
volatile
/synchronized
/final
/(happens-before
规则)
从源代码到最终执行的指令,可能会经过三种重排序
重排序是一种优化方式,通过合理的安排指令执行的顺序,优化程序的执行性能,提高cpu的利用率
重排序例子:
可以重排序
//这两个指令就可以重排序
int a = 1;
int b = 2;
不能重排序(因为重排序会影响线程执行的结果)
int a = 1;
int b = a;
//或者
int a = 1;
int a = 2;
3.3 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及所有后续装载指令的装载;这条内存屏障指令时一个全能型的屏障 |
3.4 volatile
当变量加上volatile
关键字之后,jdk源码中会对变量做判断,如果变量带有volatile
关键字,在最后会加上一个storeload
的全屏障,保证变量的store
写操作,对其他线程load
读操作时可见的
3.5 happens-before
A操作 happens-before B操作 —> A操作的结果一定对B可见
哪些操作会建立happens-before原则?
happens-before八大原则
-
程序的顺序规则
一个线程中的每个操作,happens-before于该线程中的任意后续操作
public class Demo { int a = 0; volatile boolean flag = false; public void writer() {//线程A a = 1; //1 flag = true; //2 // 1 happens-before 2 } public void reader() { if (flag) { //3 a = 3; //4 } // 3 happens-before 4 } }
-
volatile规则
对一个volatile的写操作,happens-before于任意后续对这个volatile的读操作
- 第一个例子
2 happens-before 3
- 第一个例子
-
传递性
如果A happens-before B,且B happens-before C,那么A happens-before C
- 第一个例子
1 happens-before 4
- 第一个例子
-
start规则
如果线程 A 执行操作 ThreadB.start(),那么线程 A 的 ThreadB.start()操作 happens-before 线程 B 中的任意操作
public class StartRuleDemo { static int x = 0; public static void main(String[] args) { Thread t1 = new Thread(() -> { System.out.println(x); }); x = 10; t1.start(); } }
-
join规则
如果线程 A 执行操作 ThreadB.join()并成功返回,那么线程 B 中的任意操作 happens-before 于线程 A 从 ThreadB.join()操作成功返回
public class JoinRuleDemo { static int x = 0; public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { System.out.println(x); x = 100; }); x = 10; t1.start(); t1.join(); System.out.println(x);//执行结果为100 } }
-
监视器锁规则
对一个锁的解锁,happens-before 于随后对这个锁的加锁
public class SyncRuleDemo { int x = 0; public void demo() { synchronized (this) { //线程A和现场B访问锁 if (this.x < 12) {//线程A修改x=12 释放锁之后,线程B拿到锁,此时获取x的值为12 this.x = 12; } } } } // 此处自动解锁
-
线程中断规则
对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
public class InterruptionRule { private static int i; public static void main(String[] args) throws InterruptedException { Thread thread = new Thread(() -> { while (!Thread.currentThread().isInterrupted()) { i++; } System.out.println("i:" + i); }); thread.start(); TimeUnit.SECONDS.sleep(1); thread.interrupt(); } }
-
对象终结规则
一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
public class FinalizerRule { public FinalizerRule(){ System.out.println("构造方式"); } @Override protected void finalize() throws Throwable { System.out.println("finalize..."); } public static void main(String[] args) { FinalizerRule finalizerRule = new FinalizerRule(); finalizerRule = null; System.gc(); } }
执行结果:
4.参考
- 咕泡学院-mic老师-并发基础课程
- 《Java并发编程实战》-第三章 对象的共享-可见性
- 《深入理解java虚拟机》-第五部分 java内存模型与线程
- 外国一篇论文 Memory Barriers: a Hardware View for Software Hackers