一.案例分析
案例1:
public 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关键字
*/
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:
public 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能保证可见性吗,怎么结果确不尽人意呢。
我们接着测试。
public 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关键字原理分析
1.硬件层面
计算机最重要的组成部分是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(全屏障)确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障之后的读写操作。
通过写屏障和读屏障来强制刷新内存保证程序的正常执行。
2.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方法。
- 监视器规则:对于一个锁的解锁,一定在加锁之后。