多线程(三) volatile原理分析
案例分析
案例 1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31public class VolatileDemo {
private static Boolean flag = false;
public static void main(String[] args) throws InterruptedException {
Thread threadA = new Thread(() -> {
while (true) {
if (flag) {
System.out.println("线程:" + Thread.currentThread().getName());
break;
}
}
}, "threadA");
threadA.start();
Thread.sleep(1000
);
Thread threadB = new Thread(() -> {
System.out.println("线程:" + Thread.currentThread().getName());
flag = true;
}, "ThreadB");
threadB.start();
threadA.join();
threadB.join();
}
}输出结果:
我们启动了 2 个线程,并保证线程 A 能够先执行,结果发现在线程 B 中修改了 flag 的值并没有体现在线程 A 中,说明线程 A 中还是拿的 flag 之前的值,这样是有问题的。我们修改代码,给 flag 变量加上 volatile 关键字
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32*/
public class VolatileDemo {
private volatile static Boolean flag = false;
public static void main(String[] args) throws InterruptedException {
Thread threadA = new Thread(() -> {
while (true) {
if (flag) {
System.out.println("线程:" + Thread.currentThread().getName());
break;
}
}
}, "threadA");
threadA.start();
Thread.sleep(1000
);
Thread threadB = new Thread(() -> {
System.out.println("线程:" + Thread.currentThread().getName());
flag = true;
}, "ThreadB");
threadB.start();
threadA.join();
threadB.join();
}
}输出结果:
结果能够正常执行了,加了关键字 votile 关键字保证了变量在多线程的情况的可见性,一个线程对线程的修改,强制写入到另一个线程中。
案例 2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20public class VolatileDemo {
private static int i = 0 ;
public static void increment(){
i++;
}
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
increment();
}
}).start();
}
Thread.sleep(2000);
System.out.println(i);
}
}输出结果:
上述代码中,我们启动了 10 个线程,每个线程进行 1000 次自增 1 操作,正常结果应该是输出 10000,但是实际情况输出结果并不是如此,那么我们加上 volatile 关键字测试下
输出结果:
我们发现加了 volatile 关键字之后,还是没有正常打印出结果,不是说 volatile 能保证可见性吗,怎么结果确不尽人意呢。
我们接着测试。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20public class VolatileDemo3 {
private volatile static AtomicInteger num = new AtomicInteger(0) ;
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
num.getAndIncrement();
}
//System.out.println("11111");
}).start();
}
Thread.sleep(2000);
System.out.println(num.get());
}
}输出结果
可以发现当我们把进行自增操作的变量改成了 Atomic 原子操作自增时,程序时能正常运行的。
结论:
volatile 关键字能够保证可见性,不能保证原子性。
volatile 关键字原理分析
硬件层面
计算机最重要的组成部分是 CPU、内存、IO 设备,而这 3 者处理数据的速度是很大差别的。3 者的速度排名为 CPU>内存>IO 设备。为了解决速度的差异,更好的利用 cpu 资源,从硬件、操作系统、编译器上面做了很多的优化
CPU 增加高速缓存
操作系统增加进程,线程,通过 CPU 的时间片切换最大化的提高 CPU 的使用率
编译器的指令优化,更合理的利用好 CPU 的高速缓存
这些优化也使得我们在使用多线程的时候会带来一些线程不安全的情况。cpu 高速缓存的模型如下图所示:
cpu 中有 3 个高速缓存,L1、L2、L3 这 3 个缓存随着和 cpu 核心的距离变大而速率变慢,L1>L2>L3。
当 cpu 在处理数据的时候,会把内存中的数据拷贝一份到高速缓存中,当同时有多个 cpu 处理同一份数据的时候就会出现缓存不一致问题。
为了解决缓存不一致问题,cpu 做了以下处理。
- 总线锁
- 缓存锁
总线锁:简单来说,就是在多 cpu 下,当一个处理器要对共享内存进行操作的时候,在总线上发出一个 LOCK 信号,这个信号使得其他处理器无法经过总线来访问到共享内存中的数据。总线锁定把 cpu 和内存之间的通信锁住了,这就使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁的粒度太大,开销太大,并不可取。
缓存锁:为了减少锁的粒度,我们只需要保证当多个 cpu 缓存同一份数据是一致的就行,所以增加了缓存锁,它的核心机制是基于缓存一致性协议来实现的。
缓存一致性协议:为了达到数据的一致性,需要在各个处理器里面在访问缓存的时候遵循一些协议,在读写的时候根据协议来进行操作,常见的协议有 MSI,MESI,MOSI 等,最常见的就是 MESI 协议。
MESI 协议
MESI 表示缓存行的四种状态,分别是
- M(modify)表示共享内存只缓存在当前 cpu 中,并且是被修改状态,也就是缓存的数据和主内存之中的数据不一致。
- E(exclusive)表示缓存的独占状态,数据只缓存在当前 cpu 中,并且没有修改。
- S(Shred)表示数据可能被多个 cpu 缓存,并且各个缓存中的数据和主内存数据一致。
- I(invalid)表示缓存已经失效。
对于 MESI 协议,cpu 操作数据时遵循以下原则:
cpu 读请求:缓存处于 M、E、S 状态下都可以被读取,I 状态 cpu 只能从主存中获取数据。
cpu 写请求:缓存处于 M、E 状态下才可以被写,对于 S 状态下的写,需要其他 cpu 缓存设置成 I 才可以写。
使用总线锁或者存储锁的机制之后,cpu 对于内存的操作大致可以抽象成下面的结构:MESI 带来的可见性问题
MESI 协议虽然能够实现缓存的一致性,但是还是会存在一些问题的
各个 cpu 缓存的状态变更是通过消息传递来进行的,如果 cpu0 要对一个缓存中的共享变量进行写入,首先要发送一个失效的消息给其他缓存了该数据的额 cpu,并且要等到他们的确认回执。cpu0 在这段时间都会处于阻塞状态。为了避免阻塞带来的资源浪费,在 cpu 中引入了 StoreBuffer
cpu0 在写入共享数据时,直接把数据写入到 StoreBufferes 中,同时发送 invalidate 消息,然后继续去处理其他指令。当收到其他 cpu 发送的 ack 确认消息时,再将 StoreBufferes 中的数据存储到 cache line 中,最后再从缓存行同步到主内存。
但是这种优化又会带来存在下面 2 个问题
- 数据什么时候提交是不确定的,因为需要等待其他 cpu 给回复才会数据同步,这其实是一个异步操作。
- 引入了 storeBufferes 后,处理器会先尝试从 storebuffer 中读取数据,如果有就直接去,没有再到缓存行中读取。
而由于将变更的数据写入到 storeBuffer 中,cpu0 继续往下执行,那么会出现 cpu0 接下来的执行指令对其他 cpu 的程序执行产生错误的结果,所以硬件方面在 cpu 上提出了内存屏障来保证程序的程序的正常执行
cpu 层面的内存屏障
内存屏障由于编译器优化或者 cpu 乱序,导致内存的访问顺序和程序中的逻辑顺序不一致的问题,需要增加内存屏障来保证一些有前后顺序的代码正常运行。
- Store Memory Barrier(写屏障)告诉处理器在写屏障之前的所有已经存储在存储缓存(store bufferes)中的数据同步到主内存
- Load Memory Barrier(读屏障)告诉处理器在读屏障之后的读操作都在读屏障之后执行。可以让高速缓存中的缓存失效,强制从主内存中获取数据
- Full Memory Barrier(全屏障)确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障之后的读写操作。
通过写屏障和读屏障来强制刷新内存保证程序的正常执行。
JMM 层次
JMM:Java Memory Model,Java 内存模型
JMM 中的内存模型和 cpu 中的模型非常相似
主存:jmm 中的堆,创建的对象都保存在堆中。
栈:每一个线程对应一个栈,运行过程中会把堆中的内存拷贝一份数据到栈中。
通过前面的分析,导致可见性的根本问题是缓存和重排序,而 JMM 就是提供了合理的禁用缓存以及禁止重排序的方法。它最核心的价值是解决可见性和有序性。
JMM 属于语言级别的抽象内存模型,它让 java 开发者不用考虑各种操作系统的 cpu 指令差异,只要满足 JMM 定义的多线程程序读写操作的行为规范,就能写出跨平台差异的代码。
简单来说,就是 JMM 通过自己定义的内存屏障来禁止重排序,程序在运行的时候会将这些内存屏障转换成具体的 cpu 指令。
JMM 提供了一些禁用缓存和禁止重排序的方法,来解决可见性和有序性问题,例如:volatile,synchronized,final 等
对于上述的一些指令,程序在执行的时候会添加对应的内存屏障来保证程序正常运行。
JMM 中的内存屏障
- LoadLoad 屏障:指令实例 load1;loadload;load2;确保 load1 数据的装载优于 load2 及之后的所有指令
- StoreStore 屏障:指令实例 store1;StoreStore;Store;确认 store1 数据对其他处理器可见优先于 store2 及后续存储指令的存储
- LoadStore 屏障:指令实例 Load1;LoadStore;Store;确保 load1 数据的装载优于 Store2 以及后续的指令刷新到缓存
- StoreLoad 屏障:指令实例 Store;StoreLoad;Load;确认 store1 数据对其他处理器可见优先于 Load2 以及后续指令的装载
HappenBefore
它的意思是前一个操作对于后续操作是可见的,所以它是一种表达多线程之间对于内存的可见性。所以如果我们说一个操作对于另一个操作可见,那么这两个操作必须存在 HappenBefore 关系。
- 程序顺序规则:可以简单认为是 as-if-serial。单个线程中的代码顺序不管怎么变,对于结果来说是不变的。
- volatile 规则:对于 volatile 修饰的变量的写操作一定 Happen-before 后续对变量的读取操作
- 传递性规则:1 Happen-before 2 ,2 Happen-before 3,所以 1 Happen-before 3
- start 规则:调用线程 start 方法的线程执行 start 方法一定优先与 run 方法中实现的逻辑
- join 规则:ThradA 执行 ThradB 的 join 方法,那么 ThradB 中的任意操作都要优先与 ThradA 执行 ThreadB.join 方法。
- 监视器规则:对于一个锁的解锁,一定在加锁之后。