这里写自定义目录标题
可见性&&有序性
一、可见性
1、什么是可见性
CPU会从缓存中取值:
a.java内存模型规定所有的变量都是存在主存中,每个线程都有自己的工作内存。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。不同线程之间无法直接访问对方工作内存中的变量。线程间变量的值传递均需要通过主内存来完成。
b.**线程中的工作内存保存了该线程使用到的变量的主内存的副本拷贝。**线程对变量的所有操作(读取、赋值等)必须在该线程的工作内存中进行,之后再将修改后的值返回到主存中。
c.有可能一个线程在将共享变量修改后,还没有来得及将缓存中的变量返回给主存中,另外一个线程就对共享变量进行修改,那么这个线程拿到的值就是主存中未被修改的值 ,这就是可见性的问题。
可见性:一个线程对共享变量值的修改,能够及时地被其他线程看到。
共享变量:如果一个变量在多个线程的工作内存中都存在副本,那么这个变量就是这几个线程的共享变量。
private static class ShowVisibility implements Runnable{
private Boolean flag = false;//volatile
@Override
public void run() {
while (true){
if (flag){
System.out.println(Thread.currentThread().getName()+" " +flag);
}
}
}
}
public static void main(String[] args) throws InterruptedException {
ShowVisibility showVisibility = new ShowVisibility();
new Thread(showVisibility).start();//ShowVisibility线程
Thread.sleep(500);
showVisibility.flag = true;//修改flag为true,观察子线程是否有打印
System.out.println("flag is true,thread should print!");
Thread.sleep(1000);
System.out.println("end...");
}
上述代码分析:
CPU会从缓存中取得变量flag的值
1)ShowVisibility线程启动后,就进入while循环,一直进行判断,运算时把flag从主内存拿到自己工作内存中的缓存中,此后就会一直从缓存中读取flag
2)main线程更新了flag的值,但是ShowVisibility线程的缓存暂未更新,所以一直用之前的值进行判断,导致没有输出。
2、解决可见性问题
总线锁、缓存锁(了解)
CPU缓存分为:一级缓存(L1)、二级缓存(L2)、三级缓存(L3)
由下图可知 :L1、L2位于CPU核内部,L3是多核共享,主内存是多个CPU共享。
“按块取值”——缓存行(了解)
每个缓存里面都是由缓存行组成的,缓存系统中是以**缓存行(Cache Line)**为单位存储的。最常见的缓存行大小是64Byte,一个Java的long类型是8字节,因此在一个缓存行中可以存8个long类型的变量。
1)总线锁:(了解)
a.总线:是所有CPU与芯片组连接的主干道,负责CPU与外界所有部件的通信,其控制总线向各个部件发送控制信号、通过地址总线发送地址信号指定其要访问的部件等。
b.总线锁就是用来锁住总线的,我们可以通过上图来了解总线在这个场景中所处的位置。当一个CPU核执行一个线程去访问数据做操作的时候,它会向总线上发送一个LOCK信号,此时其他的线程想要去请求主内存的时候,就会被阻塞,这样该处理器核心就可以独享这个共享内存。可以理解为,总线锁通过把内存和CPU之间的通信锁住,把并行化的操作变成了串行,这其实会导致很严重的性能问题。
2)缓存锁(缓存一致性协议)(了解)
当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致的情况,如果真的发生这种情况,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI等
处理器上有一套完整的协议,来保证缓存的一致性,比较经典的应该就是MESI(标记高速缓存行的四种独占状态【修改、独占、共享、无效】)协议。
当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取每个Core的Cache控制器不仅知道自己的读写操作,也监听其它Cache的读写操作,就是嗅探(snooping)协议。
CPU缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。
嗅探总线:在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己的缓存值是不是过期了,如果处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据库读到处理器缓存中。
MESI失效场景:并非所有情况都会使用缓存一致性,如被操作的数据不能被缓存在CPU内部或操作数据跨越多个缓存行(状态无法标识),则处理器会调用总线锁定。
Q:既然CPU有了MESI协议可以保证cache的一致性,那么为什么还需要volatile这个关键词来保证可见性?或者是只有加了volatile的变量才会触发缓存一致性协议?
多核情况下,所有的cpu操作都会涉及缓存一致性的校验**,只不过该协议是弱一致性,不能保证一个线程修改变量后,其他线程立马可见**,也就是说虽然其他CPU状态已经置为无效,但是当前CPU可能将数据修改之后又去做其他事情,没有来得及将修改后的变量刷新回主存,而如果此时其他CPU需要使用该变量,则又会从主存中读取到旧的值。而volatile则可以保证可见性,即立即刷新回主存,修改操作和写回操作必须是一个原子操作。
volatile如何保证可见性?
使用volatile关键字时,会多出一个lock前缀指令,它有三个功能:
-
确保指令重排序时不会把其后面的指令重排到内存屏障之前的位置,也不会把前面的指令排到内存屏障后面,即在执行到内存屏障这句指令时,前面的操作已经全部完成;
-
将当前处理器缓存行的数据立即写回系统内存(由volatile先行发生原则保证);
-
这个写回内存的操作会引起在其他CPU里缓存了该内存地址的数据无效。写回操作时要经过总线传播数据,而每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态,当处理器要对这个值进行修改的时候,会强制重新从系统内存里把数据读到处理器缓存(也是由volatile先行发生原则保证);
解决可见性的方法(底层:JVM、CPU、OS合作完成)
要实现共享变量的可见性,必须保证两点:
- 线程修改后的共享变量值能够及时从工作内存刷新到主存中
- 其他线程能够及时把共享变量的最新值从主内存更新到自己的工作内存中
二、有序性
1、指令重排序
指令重排序:CPU为了提高运行效率,可能会对编译后的代码指令进行优化,即:代码书写顺序与实际执行的顺序不同,但执行结果一定与按照编写顺序执行的结果一致(符合指令间的依赖关系)。 – 有些代码翻译成机器指令后,进行重排序 ,重排序后的指令更加符合CPU执行特点,这样就可以最大限度的发挥CPU性能 ——as-if-serial语义:无论如何重排序,程序执行的结果应该与代码顺序执行的结果一致(Java编译器、运行时和处理器都会保证Java在单线程下遵循as-if-serial语义)
重排序条件:
- as-if-serial
- 不影响单线程的最终一致性
单线程下的指令重排序
int num1 = 1; //代码1
int num2 = 2; //代码2
int sum = num1 + num2;//代码3
//单线程:第1、2行的顺序可以重排,但代码3不可以
//重排序不会给单线程带来内存可见性问题
//多线程中程序交错执行时,重排序可能会造成内存可见性问题
//另一种可能造成线程安全问题情况:## 标题
public class Demo1 {
public static class ReaderThread implements Runnable{
private volatile boolean ready = false;
private int number;
@Override
public void run() {
while (!ready){
Thread.yield();
}
System.out.println("number:"+number +" ready:"+ ready); //存在指令重排序,导致number结果为0
}
}
public static void main(String[] args) throws InterruptedException {
ReaderThread readerThread = new ReaderThread();
Thread thread = new Thread(readerThread);
thread.start();
Thread.sleep(50);
readerThread.number = 50;
readerThread.ready = true;
thread.join();
}
}
2、双重检查锁(double checked locking)
//实例化对象
Object o = new Object();
//编译后的二进制码Code
public static void main(java.lang.String[]);
Code:
0: new #2 // class java/lang/Object //分配一块内存
3: dup
4: invokespecial #1 // Method java/lang/Object."<init>":()V //特殊调用-Object的init()方法
7: astore_1 //建立关联
8: return
public class Singleton {
private volatile static Singleton instance; //解决双重检查锁隐患办法:volatile关键字
private Singleton(){}
//1、未考虑多线程情况下实现单例模式
// public Singleton getSingleton(){
// if(instance == null){
// instance = new Singleton();
// }
// return instance;
// }
//2、在函数上加synchronized,但导致锁的粒度太大,开销太大,且加锁只需要在第一次初始化的时候用到,之后的调用无需加锁
// public synchronized Singleton getSingleton(){
// if(instance == null){
// instance = new Singleton();
// }
// return instance;
// }
//3.缩小锁的粒度,问题:只有一把锁,没有锁住
// public Singleton getSingleton(){
// if(instance == null){
// synchronized (Singleton.class){
// instance = new Singleton();
// }
// }
// return instance;
// }
//4、双重检查锁(double checked locking):先判断对象是否已经被初始化,再决定是否加锁
//多个线程同时通过了第一次检查,并且其中一个线程首先通过了第二次检查并且实例化了对象,那么剩余通过了第一次检查的线程就不会再去实例化对象
public Singleton getSingleton(){
if(instance == null){
synchronized (Singleton.class){
if(instance == null){
instance = new Singleton();//分三步:分配内存空间、初始化对象、将对象指向刚分配的内存空间 问题:二三步可能会重排序 导致:某线程访问到初始化未完成的对象
}
}
}
return instance;
}
public static void main(String[] args) {
System.out.println(new Singleton().getSingleton());
System.out.println(new Singleton().getSingleton());
}
}
存在问题:二三步可能会重排序
导致:某线程访问到初始化未完成的对象
解决办法:在instance前加入关键字volatile,使用volatile关键字后,重排序被禁止,所有的写(write)操作都将发生在读(read)操作之前。
volatile起内存屏障的作用
编译器和CPU会在不影响结果(这儿主要是根据数据依赖性)的情况下对指令重排序,使性能得到优化,但是实际情况里面有些指令虽然没有前后依赖关系,但是重排序之后影响到输出结果,这时候可以插入一个内存屏障,相当于告诉CPU和编译器限于这个命令的必须先执行,后于这个命令的必须后执行。
内存屏障的另一个作用是强制更新一次不同CPU的缓存,这意味着如果你对一个volatile字段进行写操作,你必须知道:
-
一旦你完成写入,任何访问这个字段的线程将会得到最新的值;
-
在你写入之前,会保证所有之前发生的事已经发生,并且任何更新过的数据值也是可见的,因为内存屏障会把之前的写入值都刷新到缓存。
内存屏障:特殊的cpu指令,执行到此时,前后指令不会换顺序,不同cpu指令不一样
当某一个变量加了volatile关键字,最终汇编执行的是一条Lock指令