CPU缓存一致性协议MESI
CPU在摩尔定律的指导下以每18个月起一番的速度在发展,然而内存和硬盘的发展速度远远不及CPU。这就造成了高性能能的内存和硬盘价格及其昂贵。然而CPU的高度运算需要高速的数据。
CPU在摩尔定律的指导下以每18个月起一番的速度在发展,然而内存和硬盘的发展速度远远不及CPU。这就造成了高性能能的内存和硬盘价格及其昂贵。然而CPU的高度运算需要高速的数据。
为了解决这个问题,CPU厂商在CPU中内置了少量的高速毁存以解决T\O速度和CPU运算速度之间的不P有问题。
在CPU访问存储设备时,无论是存取数据抑或存取指令,都趋于聚集在一片连续的区域中,这就被称为局部性原理:
带有高速缓存的CPU执行计算的流程:
多核CPU的情况下有多个一级缓存,如何保证缓存内部数据的一致,不让系统数据混乱。这里就引出了一个一致性的协议MESI。
MESI是指4中状态的首字母。每个Cache line有4个状态,可用2个bit表示。
原子性指一系列的操作,要么全部执行成功,要么全部不执行,不会出现执行一半的情况,是不可分的。
创建对象实际上有3个步骤,并不是原子性的(常应用于双重检查+volatile创建单例场景)
可见性指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值。
对于多线程程序而言,线程将共享变量拷贝到各自的工作内存进行操作。线程A读取共享变量后,其他线程再对共享变量的修改,对于线程A来说并不可见,这就造成了可见性问题。
volatile,synchronized可见性,有序性,原子性代码证明:https://blog.csdn.net/duyabc/article/details/111561857
在线程内部的两行代码的实际执行顺序和代码在Java文件中的逻辑顺序不一致,代码指令并不是严格按照代码语句顺序执行的,他们的顺序被改变了,这就是重排序。
JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。
重排序会遵循as-if-serial与happens-before原则
as-if-serial原则
不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
为了遵守as-f-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。
happens-before原则
从JDK5开始,提供了happens-before 原则来辅助保证程序执行的原子性、可见性以及有序性的问题,它是判断数据是否存在竞争、线程是否安全的依据,happens-before原则内容如下:
双重检查锁定DCL减少了锁粒度,不需要对整个getInstance()方法加synchronized锁,提高了方法被多个线程频繁的调用时的性能。
在对象创建好之后,执行getInstance()方法将不需要获取锁,检查instance不为空,就直接返回。
public class Singleton {
// volatile禁止指令重排,保证构造方法中的数据实例化
//private static volatile Singleton singleton;
private static Singleton singleton;
private Singleton() {}
public static Singleton getInstance() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
上述代码由于Singleton没有加volatile,代码instance = new Singleton();
创建一个对象时,这一行代码可以分解为如下的三行伪代码:
// instance = new Singleton();
memory = allocate(); //1:分配对象的内存空间
ctorInstance(memory); //2:初始化对象
instance = memory; //3:设置instance指向刚分配的内存地址
而代码中的2和3之间,可能会被重排序。即instance先指向刚分配的内存地址,之后再进行对象的初始化。这可能会导致一个线程可能读到尚未初始化的Bean,而这个instance的确是!=null的。
测试代码:
@Data
public class Singleton {
private static Singleton instance;
private String name;
public static Singleton getInstance() throws InterruptedException {
System.out.println("运行 "+Thread.currentThread().getName());
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
System.out.println("new Singleton()执行完毕");
//耗时的初始化
TimeUnit.SECONDS.sleep(3);
instance.setName("chenchenchen");
}
}
}
return instance;
}
public static void main(String[] args) throws InterruptedException {
Runnable runnable = new Runnable() {
@Override
public void run() {
try {
Singleton instance = Singleton.getInstance();
System.out.println("get name :"+instance.getName().toString());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
Thread thread1 = new Thread(runnable);
Thread thread2 = new Thread(runnable);
Thread thread3 = new Thread(runnable);
thread1.start();
thread2.start();
Thread.sleep(2000);
thread3.start();
}
}
运行结果:
运行 Thread-0
运行 Thread-1
new Singleton()执行完毕
Exception in thread "Thread-0" java.lang.NullPointerException
at com.aspire.mall.task.carwash.job.Singleton$1.run(Singleton.java:39)
at java.lang.Thread.run(Thread.java:748)
运行 Thread-2
Exception in thread "Thread-2" java.lang.NullPointerException
at com.aspire.mall.task.carwash.job.Singleton$1.run(Singleton.java:39)
at java.lang.Thread.run(Thread.java:748)
get name :chenchenchen
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tOqQa063-1623570849031)(upload%5Cimage-20210605205532011.png)]
我们可以想出两个办法来实现线程安全的延迟初始化。
基于volatile的双重检查锁定的解决方案
private volatile static Instance instance;
基于类初始化的解决方案
public class InstanceFactory {
private static class InstanceHolder {
public static Instance instance = new Instance();
}
public static Instance getInstance() {
//InstanceHolder类在这里会被初始化
return InstanceHolder.instance ;
}
}
通俗来说,JMM是一套多线程读写共享数据时,对数据的可见性,有序性和原子性的规则。
JVM实现不同会造成“翻译”的效果不同,不同CPU平台的机器指令有千差万别,无法保证同一份代码并发下的效果一致。所以需要一套统一的规范来约束JVM的翻译过程,保证并发效果一致性。
Java多线程内存模型跟cpu缓存模型类似,是基于cpu缓存模型来建立的。Java线程内存模型是标准化的,屏蔽掉了底层不同计算机的区别。
即前一个操作的结果可以被后续的操作获取。
volatile是一种同步机制,比synchronized或者Lock相关类更轻量级,因为使用volacile并不会发生上下文切换等开销很大的行为 volatile是无锁的,并且只能修饰单个属性
一个共享变量始终只被各个线程赋值,没有其他操作 作为刷新的触发器,引用刷新之后使修改内容对其他线程可见(如CopyOnRightArrayList底层动态数组通过volatile修饰,保证修改完成后通过引用变化触发volatile刷新,使其他线程可见)
可见性保障:修改一个volatile修饰变量之后,会立即将修改同步到主内存,使用一个volatile修饰的变量之前,会立即从主内存中刷新数据。保证读取的数据都是最新的,之前的修改都是可见的。
有序性保障(禁止指令重排序优化):有volatile修饰的变量,赋值后多了一个“内存屏障“( 指令重排序时不能把后面的指令重排序到内存屏障之前的位置)
volatile 的读性能消耗与普通变量几乎相同,但是写操作稍慢,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。
lock前缀指令 + MESI缓存一致性协议
对volatile修饰的变量执行写操作时,JVM会发送一条lock前缀指令给CPU,CPU在执行写操作之后会立即将这个值写回主内存。
同时因为有MESI缓存一致性协议,各个CPU都会对总线进行嗅探,如果本地缓存中的数据被修改了,就会将自己本地缓存的数据过期掉。再次读取变量时,就会从主内存重新加载最新的数据了。
如果发现别人修改了某个缓存的数据,那么CPU就会将自己本地缓存的数据过期掉,然后这个CPU上执行的线程在读取那个变量的时候,就会从主内存重新加载最新的数据了
IA-32和Intel 64架构软件开发者手册对lock指令的解释:
内存屏障
LoadLoad读读屏障:确保Load1数据的装载先于Load2后所有装载指令,Load1对应的代码和Load2对应的代码,是不能指令重排的。
Load1:int localVar = this.variable
LoadLoad读读屏障
Load2:int localVar = this.variable2
StoreStore写写屏障:确保Store1的数据一定刷回主存,对其他cpu可见,先于Store2以及后续指令。
Store1:this.variable = 1
StoreStore写写屏障
Store2:this.variable2 = 2
LoadStore读写屏障:确保Load1指令的数据装载,先于Store2以及后续指令。
Load1:int localVar = this.variable
LoadStore读写屏障
Store2:this.variable = 2
StoreLoad写读屏障:确保Store1指令的数据一定刷回主存,对其他cpu可见,先于Load2以及后续指令的数据装载
Store1:this.variable = 2
StoreLoad写读屏障
Load2:int localVar = this.variable
下载查看运行代码的汇编指令,放到JDK所在的/jre/bin路径下。
测试代码
JVM启动参数添加上VolatileVisibilityTest.java的测试类名的执行方法prepareData:-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*VolatileVisibilityTest.prepareData
参考:
JVM内存结构和Java内存模型别再傻傻分不清了:https://blog.csdn.net/qq_41170102/article/details/104650162
双重检查锁定(DCL)与延迟初始化:https://blog.csdn.net/u013190088/article/details/83154443
双重检查锁(DCL)问题:https://blog.csdn.net/Dongguabai/article/details/82828125
https://processon.com/view/6061d2ee1e0853028ab68bd5?fromnew=1
https://processon.com/view/5fcb5f777d9c0837c09e0025?fromnew=1