注:该篇文章已与我的个人博客同步更新。欢迎移步https://cqh-i.github.io/体验更好的阅读效果。
前言 多核并发缓存架构
随着CPU技术的发展,CPU的执行速度越来越快。而由于内存的技术并没有太大的变化,所以从内存中读取和写入数据的过程和CPU的执行速度比起来差距就会越来越大,这就导致CPU每次操作内存都要耗费很多等待时间。(木桶效应)
CPU的摩尔定律:当价格不变时[集成电路上可容纳的元器件的数目,约每隔18-24个月便会增加一倍,性能也将提升一倍。换言之,每一美元所能买到的电脑性能,将每隔18-24个月翻一倍以上。(这个定律好像现在没用了)
人们想出来了一个好的办法,就是在CPU和内存之间增加高速缓存。缓存的概念大家都知道,就是保存一份数据拷贝。它的特点是速度快,内存小,并且昂贵。
那么,程序的执行过程就变成了:
当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。
而随着CPU能力的不断提升,一层缓存就慢慢的无法满足要求了,就逐渐的衍生出多级缓存。
那么,在有了多级缓存之后,程序的执行就变成了:
当CPU要读取一个数据时,首先从一级缓存中查找,如果没有找到再从二级缓存中查找,如果还是没有就从三级缓存或内存中查找。
Java内存模型(JMM)
Java线程内存模型与CPU缓存模型类似,是基于CPU缓存模型来建立的。
可见性问题
public class Teste {
/*volatile*/ boolean running = true;
void m() {
System.out.println("m start");
while (running) {
}
System.out.println("m end!");
}
public static void main(String[] args) throws InterruptedException {
Teste t = new Teste();
new Thread(() -> t.m(), "t1").start();
Thread.sleep(1000);// main线程睡眠1s
t.running = false;
}
}
上面程序中,线程t1将处于死循环中,即使main线程将running的值改为false; 因为每个线程是将running变量从主内存读到自己的工作空间进行操作,线程间感觉不到对方已经将running这个变量修改为false。
要解决这个问题,可以给running加上volatile关键字。
JMM数据原子操作
- read(读取): 从主内存读取数据
- load(载入): 将主内存读取到的数据写入工作内存
- use(使用):从工作内读取数据来计算
- assign(赋值):将计算好的值重新赋值到工作内存中
- store(存储): 将工作内存数据写入到主内存
- write(写入):将store过去的变量值赋值给主内存中的变量
- lock(锁定): 将主内存变量加锁,标识为线程独占状态
- unlock(解锁): 将主内存变量解锁,解锁后其他线程可以锁定该变量
解决JMM缓存不一致问题
-
总线加锁(性能太低)
CPU从主存读取数据到高速缓存,会在总在总线对这个数据加锁,这样其他CPU没法去读或写这个数据,直到这个CPU使用完数据释放锁之后其他CPU才能读取该数据。(从读取数据就开始加锁,锁的粒度比较大,并且程序从并行执行变为串行执行,效率很低)
-
MESI 缓存一致性协议
多个CPU从主内存读取同一个数据到各自的高速缓存,当其中某个CPU修改了缓存里的数据,该数据会马上同步回主内存,其他CPU通过总线嗅探机制可以感知到数据的变化从而将自己缓存里的数据失效。
在写回主内存里到赋值给主内存中的变量这个过程会进行加锁,这也减小了锁的力度,比总线加锁更加高效。解锁后,其他线程才可以锁定该变量。
volatile 可见性底层实现原理
上边的过程基本上就是volatile 可见性底层实现原理,主要是通过汇编lock前缀指令,它会锁定这块内存区域的缓存并写回到主内存,此操作被称为"缓存锁定", MESI缓存一致性协议机制会阻止同时修改被两个以上处理器缓存的内存区域数据。一个处理器的缓存值通过总线回写到内存会导致其他处理器相应的缓存失效。
并发编程三大特性
并发编程三大特性:可见性、原子性、有序性
volatile保证可见性与有序性,但是不保证原子性,保证原子性需要借助synchronized这样的锁机制。
原子性
对基本数据类型的变量读取和赋值是保证了原子性的,要么都成功,要么都失败,这些操作不可被中断。
a = 10; 满足原子性
b = a; 不满足;原因:步骤:1.read a; 2.assign b;
c++; 不满足; 原因:步骤: 1.read c; 2. add; 3. assign to c;
c=c+1; 不满足;原因:步骤:1.read c; 2. add; 3. assign to c;
关于原子性的例子,可看上篇文章:点我
有序性
有序性指的是程序按照代码的先后顺序执行。
-
synchronized
synchronized语义表示锁在同一时刻只能由一个线程进行获取,当锁被占用后,其他线程只能等待。因此,synchronized语义就要求线程在访问读写共享变量时只能“串行”执行,因此synchronized具有有序性。
-
volatile
在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。(例如:重排的时候某些赋值会被提前)
在Java里面,可以通过volatile关键字来保证一定的“有序性”。
Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为happens-before 原则(先行发生原则)。
happens-before 原则(先行发生原则)
- 程序次序规则
一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。
- 锁定规则
unlock 必须发生在lock之后
- volatile 变量规则
对一个变量的写操作先行发生于后面对这个变量的读操作。如果一个线程先去写一个变量,然后一个线程去进行读取,那么写入操作肯定会先行发生于读操作。
- 传递规则
如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C。
- 线程启动规则(看看就好)
Thread对象的start()方法先行发生于此线程的每个一个动作。
- 线程中断规则(看看就好)
对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
- 线程终结规则(看看就好)
线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行。
- 对象终结规则(看看就好)
一个对象的初始化完成先行发生于他的finalize()方法的开始。
指令重排序
Java语言规范规定JVM线程内部维持顺序化语义。即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。
比如程序:
a =``5;``//1
b =``20;``//2
c = a + b;``//3
编译器优化后可能变成
b =``20;``//1
a =``5;``//2
c = a + b;``//3
在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果。
在单例模式的实现上有一种双重检验锁定的方式(Double-checked Locking)。代码如下:
public class Singleton {
private Singleton() { }
private volatile static Singleton instance;
public Singleton getInstance(){
if(instance==null){
synchronized (Singleton.class){
if(instance==null){
instance = new Singleton();
}
}
}
return instance;
}
}
这里为什么要加volatile了?我们先来分析一下不加volatile的情况,有问题的语句是这条:
instance = new Singleton();
这条语句实际上包含了三个操作:1.分配对象的内存空间;2.初始化对象;3.设置instance指向刚分配的内存地址。但由于存在重排序的问题,可能有以下的执行顺序:
如果2和3进行了重排序的话,线程B进行判断if(instance==null)时就会为true,而实际上这个instance并没有初始化成功,显而易见对线程B来说之后的操作就会是错得。而用volatile修饰的话就可以禁止2和3操作重排序,从而避免这种情况。volatile包含禁止指令重排序的语义,其具有有序性。