这篇文章前后用了两周时间整理,文字较多,希望耐心阅读。
一、CPU多级缓存架构剖析
从计算机的角度看:
(1)指令会被控制器(CU)获取
(2)CU解析指令交给ALU
从程序本身看:
(1)当程序运行的时候程序和数据会被从磁盘装入到内存
(2)源文件会被替换成可执行文件
1.2 计算机存储器的层次结构
图1-2 计算机存储器的层次结构
寄存器 > L1 > L2 > L3 > 主内存 > 本地存储 > 远程存储
1.3 多核CPU内部结构
局部性分两点:
1.5 MESI Cache一致性协议
CPU引入了高速缓存后,会导致多个CPU核之间的缓存一致性问题。首先CPU提供了一种总线锁的机制来解决缓存一致性问题,即通过锁总线的方式来让多个CPU核访问主存时互斥,属于粗粒度的加锁方式,会导致CPU性能急剧下降,所以CPU又采用了缓存一致性协议来解决缓存一致性问题。通常使用的缓存一致性协议为MESI,MESI分别代表缓存行数据所处的四种状态,通过对这四种状态的切换,来达到对缓存数据进行管理的目的。
https://www.scss.tcd.ie/Jeremy.Jones/VivioJS/caches/MESI.htm
缓存行必须时刻监听所有试图读该缓存行相对应的内存的操作,其他缓存须在本缓存行写回内存并将状态置为E之后才能操作该缓存行对应的内存数据。
状态 | 描述 | 监听任务 |
---|---|---|
本地缓存独占; | 缓存行必须监听其他缓存读主内存中该缓存行相对应的内存的操作,一旦有这种操作,该缓存行需要变成S状态。 | |
多个CPU缓存共享; | ||
多个CPU缓存共享; |
MESI协议带来的问题:就是各个CPU缓存行的状态是通过消息传递来进行的。如果 CPU0 要对一个在缓存中共享的变量进行写入,首先需要发送一个失效的消息给到其它缓存了该数据的CPU。并且要等到它们的确认回执。CPU0 在这段时间内都会处于阻塞状态。为了提高并行度,现代的CPU都会增加写缓冲区(Store Buffer)和失效队列(Invalidation Queue)将MESI协议的请求异步化。
失效队列:先把其它核心发过来的 RFO 请求放到失效队列,然后直接返回 ACK,等当前核心处理完任务后再去处理失效队列中的失效请求。
理想下的顺序一致性模型由两个特征组成:
如果程序完全按照顺序一致性模型来实现,那么处理器和编译器的很多重排序优化都要被禁止:编译器和处理器不能重排列没有依赖关系的指令 、 CPU 不能使用写缓冲区和失效队列机制,这些对程序的并行度会有影响,所以在 Java 虚拟机和处理器实现中,实际上使用的是弱顺序一致性模型,对应也有两个特征:
(1)所有共享变量存储在主内存,每一个线程都有自己独立的工作内存,里面保存了共享变量的副本;
(2)线程不能直接读写主内存中的变量,所有操作均在工作内存中完成;
如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行read和load操作,如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。Java内存模型只要求上述两个操作必须按顺序执行,而没有保证必须是连续执行。也就是read和load之间,store和write之间是可以插入其他指令的,如对主内存中的变量a、b进行访问时,可能的顺序是read a,read b,load b, load a。
上面提到的CPU缓存架构、JMM模型其实都是铺垫,但是对于理解voaltile关键字原理又很重要。接下来我们一起看下volatile关键字的可见性和禁止指令重排序都是怎么实现的。
3.1 可见性保证
Java volatile关键字保证了跨线程变量更改的可见性。这听起来可能有点抽象,在这里我们进行一下详细分析。
对于non-volatile变量,Java虚拟机(JVM)不能保证何时将数据从主存读取到CPU缓存,或何时将数据从CPU缓存写入主存。这就导致了可见性问题,例如有一个boolean类型的共享变量flag,线程B更新flag的值为true,线程A只读取flag变量的值。
public class SharedObject {
public boolean flag;
}
如果flag变量没有被声明为volatile,则不能保证flag变量的值何时从CPU缓存写入主存。这意味着CPU缓存中的flag变量的值可能与主存中的不相同。这种情况如下所示:
不管线程A是在线程B修改flag变量之前还是之后去读取flag变量的值,只要线程B的修改结果没有同步到主存中,线程A从主存读取到的flag变量的值就永远为false,这就是可见性问题:一个线程的更新对另一个线程不可见。
public class SharedObject {
public volatile boolean flag;
}
在这个例子中,不管线程A是在线程B修改flag变量之前还是之后去读取flag变量的值,都能从主存读取到flag变量最新的值。我们结合JMM中的八大原子操作用几张图来解释下volatile关键字实现可见性的整个过程。如下图3-3,假设线程A和线程B都在同一时刻将flag变量读到了自己的工作内存中:
图3-4 store-lock操作示意图
上图3-4中,线程B执行完assign操作,将自己工作内存中变量flag的值更新为了true,同时执行store操作将最新值写回主存,此时会触发lock操作将flag变量所在的缓存行锁住,同时通过总线嗅探技术告知其它拥有此缓存行的CPU此缓存行已被修改,其它CPU将此缓存行置为无效状态,然后flag变量的最新值通过write操作被写回主内存,在写回主存期间其它线程读取不到这个缓存行里的值,只有最后执行完unlock操作,其它线程才可以读取。
图3-5 write-unlock操作示意图
3.2 不保证原子性
我们以非原子操作i++为例,分析一下volatile为什么不能保证原子性。假设线程A和线程B同一时间都将i的初始值0从主存加载到了自己的工作内存,且都执行了i++操作,这个时候线程A和线程B的工作内存中i的值都为1,如下图3-6所示:
图3-6 volatile不保证原子性的示意图(第1步)
3.3 禁止指令重排序
为什么会出现指令重排序的问题呢? 除了编译器层面的代码优化,最根本原因就在于CPU的乱序执行:为了提升效率,在保证最终执行结果一样的前提下,在等待某一指令执行结果时同时执行了其它的指令(比如指令1是去内存读数据,访问速度比读高速缓存慢100倍,在这期间cpu会执行和指令1没有依赖关系的指令2),入下图3-8所示:
图3-8 指令重排示意图
乱序执行,说白了就是CPU对指令的并行执行,下面两条指令彼此不依赖,因此可以并行执行:
a = b + c
d = e + f
但是,下面两条指令不能并行执行,因为第二条指令取决于第一条指令的结果:
a = b + c
d = a + e
假设上面的两条指令是某个大指令集的一部分,就像下面这样:
a = b + c
d = a + e
l = m + n
y = x + z
则指令可以重新排序如下:
a = b + c
l = m + n
y = x + z
d = a + e
然后CPU可以并行执行至少前3条指令,一旦第一条指令执行完成,它就可以开始执行第四条指令。由此可见,重新排序指令可以增加CPU中指令的并行执行度,提高并行度意味着提高性能。
接下来我们看一个例子证明乱序执行的存在:
public class ReorderingTest {
static int x = 0, y = 0;
public static void main(String[] args) throws InterruptedException {
Set<String> resultSet = new HashSet<>();
Map<String, Integer> resultMap = new HashMap<>();
for (int i = 0; i < 1000000; i++) {
x = 0;
y = 0;
resultMap.clear();
Thread t1 = new Thread(() -> {
int a = y;
x = 1;
resultMap.put("a", a);
});
Thread t2 = new Thread(() -> {
int b = x;
y = 1;
resultMap.put("b", b);
});
t1.start();
t2.start();
t1.join();
t2.join();
resultSet.add("a=" + resultMap.get("a") + "," + "b=" + resultMap.get("b"));
System.out.println(resultSet);
}
}
}
在上述代码中,除主线程外额外开启了两个线程t1和t2,且t1、t2线程中的两条指令没有依赖关系。在每次循环中,都会把本次得到的变量a和变量b的值的组合存入一个set集合中,且每次循环都会把目前为止遇到的a和b值的组合情况打印出来。接下来我们看下遇到的组合情况有几种:
[a=0,b=1]
[a=0,b=1]
[a=0,b=1]
这是循环刚开始的时候打印出的a、b值的组合【a=0,b=1】,从这个结果来看是t1线程执行之后,x值变为了1,然后t2线程才开始执行,t1和t2线程内部没有发生指令重排,接着往下看:
[a=0,b=1]
[a=0,b=1]
[a=0,b=0, a=0,b=1]
跟上次比多了一种组合【a=0,b=0】,这种组合结果说明在t1线程执行完int a = y指令后且在执行x = 1指令之前,t2线程执行了int b = x指令,t1和t2线程内部没有发生指令重排,接着往下看:
[a=0,b=0, a=0,b=1]
[a=0,b=0, a=0,b=1]
[a=0,b=0, a=1,b=0, a=0,b=1]
随着循环次数的增多,跟前面比又出现了一种新的组合【a=1,b=0】,出现这个组合的原因是t2线程先执行,将y的值变为了1,然后t1线程才开始执行,t1和t2线程内部没有发生指令重排,再接着往下看:
[a=0,b=0, a=1,b=0, a=0,b=1]
[a=0,b=0, a=1,b=0, a=0,b=1]
[a=0,b=0, a=1,b=0, a=0,b=1, a=1,b=1]
3.4 内存屏障
1、写屏障:
(3)其目的是保证当前CPU的写操作,能够通知到其他CPU并响应失效ACK。
2、读屏障:
(1)保证读事务的强一致性。
(2)当CPU遇到读屏障时,必须强制处理完当前失效队列的所有无效事务。
3、全能屏障:
1、硬件层面(X86架构CPU内存屏障指令):
(3)mfence(全能屏障)
(1)LoadLoadBarries:Load1; LoadLoad; Load2 确保Load1数据的装载先于Load2以及所有后续装载指令。
(2)StoreStoreBarries:Store1; StoreStore; Store2 确保Store1的数据对其他处理器可见(会使缓存行无效,并刷新到内存中)先于Store2及所有后续存储指令 的装载。
(3)LoadStoreBarries:Load1; LoadStore; Store2 确保Load1数据装载先于Store2及所有后续存储指令刷新到内存。
处理器 | Load-Load | Load-Store | Store-Store | Store-Load | 数据转换 |
X86 | N | N | N | Y | N |
(1)mfence指令:上文提到过,这是一个全能屏障,具备lfence和sfence的能力。
(2)cpuid指令:cpuid操作码是一个面向x86架构的处理器补充指令,它的名称派生自CPU识别,作用是允许软件发现处理器的详细信息。
不同的虚拟机对volatile的实现不同。我们常用的HotSpot虚拟机对volatile的实现就是使用的lock指令前缀。接下来我们结合jdk源码看一下:
jdk8-hotspot源码地址:
https://hg.openjdk.org/jdk8u/jdk8u/hotspot/file/69087d08d473/
字节码解释执行器目录:src/share/vm/interpreter/bytecodeInterpreter.cpp
从上图3-9的源码截图可以看出,对volatile变量进行写操作的时候会执行OrderAccess::storeload()方法,对于这个方法不同的cpu或操作系统有不同的实现,我们看下Linux_x86版本下这个方法的实现:
文件目录:src/os_cpu/linux_x86/vm/orderAccess_linux_x86.inline.hpp
(1)确保后续指令执行的原子性。在Pentium及之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其它处理器暂时无法通过总线访问内存,很显然,这个开销很大。在新的处理器中,Intel使用缓存锁定来保证指令执行的原子性,缓存锁定将大大降低lock前缀指令的执行开销。
(2)禁止该指令与前面和后面的读写指令重排序。
happens-before(先行发生)原则主要内容如下:
(1)程序次序规则(Program Order Rule):在同一个线程中,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确的说是程序的控制流顺序,考虑分支和循环等。
(2)管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面(时间上的顺序)对同一个锁的lock操作。
(3)volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面(时间上的顺序)对该变量的读操作。
(4)线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。
(5)线程终止规则(Thread Termination Rule):线程的所有操作都先行发生于对此线程的终止检测,可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
(6)线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断时事件的发生。Thread.interrupted()可以检测是否有中断发生。
(7)对象终结规则(Finilizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()的开始。
public class VolatileO1 {
private static volatile boolean flag = false;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true;
System.out.println("t1 set flag true.");
});
Thread t2 = new Thread(() -> {
while (true) {
if (flag) {
System.out.println("t2 stopped.");
break;
}
}
});
t1.start();
t2.start();
}
}
t1 set flag true.
t2 stopped.
我们再稍微延伸一下,看另一个例子,这个例子中我们新增一个变量a并且用volatile修饰,flag不用volatile修饰:
public class VolatileO2 {
private static boolean flag = false;
private static volatile Integer a = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true;
a = 1;
System.out.println("t1 set flag true.");
});
Thread t2 = new Thread(() -> {
while (true) {
Integer b = VolatileO2.a;
if (flag) {
System.out.println("t2 stopped.");
break;
}
}
});
t1.start();
t2.start();
}
}
t1 set flag true.
t2 stopped.
如果从happens-before原则角度分析,基于程序次序规则我们可以得出:
(1)【flag = true】先行发生于【a = 1】
(2)【Integer b = VolatileO2.a】先行发生于【if (flag)】
基于volatile变量规则我们可以得出:
(3)【a = 1】先行发生于【Integer b = VolatileO2.a】
得出上面3个公式后,我们再根据传递性可得:
(4)【flag = true】先行发生于【if (flag)】
从第二个例子又引申出一个技术名词叫“借助同步”,即,虽然变量flag没有加volatile,但是它借助了被volatile修饰的变量a的happens-before关系完成了同步操作。“借助同步”在jdk源码中也有应用,java.util.concurrent包下的FutureTask类就使用了这种技术。我们拿这个类的set和get方法分析一下,这两个方法中都会用到两个字段:state(任务运行状态),和outcome(任务运行结果),我们先看看这个类的set方法:
在set方法中,如果if条件成立(对state进行CAS操作成功),会给outcome赋值,然后将state的值变为NORMAL。然后看看get方法:
主线程通过submit方法将任务提交到线程池后,会返回FutureTask,主线程通过调用FutureTask的get方法获取这个任务的返回值,这个时候的情况就是:outcome的值是由线程池里的线程写入,outcome的读取是由主线程读取。那是怎么保证outcome在主线程的可见性的呢?对outcome字段加volatile?我们来看看这个类的字段定义:
public class Singleton {
private static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
讨论这个问题之前,我们先看看创建一个对象的过程是什么样的,假设有如下代码:
public class Obj {
int a = 3;
public static void main(String[] args) {
Obj obj = new Obj();
}
}
0 new #3 <Obj> //开辟内存空间,此时a=0
3 dup
4 invokespecial #4 <Obj.<init>> //调用构造函数,此时a=3
7 astore_1 //将obj引用指向这个内存地址,此时obj != null
8 return
正常情况下,这几个指令会按顺序执行,但是指令invokespecial和astore_1不存在依赖关系,会有可能发生重排序,导致创建对象的指令变成如下顺序:
0 new #3 <Obj> //开辟内存空间,此时a=0
3 dup
7 astore_1 //将obj引用指向这个内存地址,此时obj != null
4 invokespecial #4 <Obj.<init>> //调用构造函数,此时a=3
8 return
5.1 synchronized 的可见性
第一个例子:
public class SyncTest_01 {
private static boolean flag = true;
public static void main(String[] args) throws InterruptedException{
Thread t1 = new Thread(() -> {
System.out.println("t1线程开始运行。。。");
while (flag) {
synchronized (SyncTest_01.class){
// 同步代码快在执行
}
}
System.out.println("t1线程结束");
});
t1.start();
Thread.sleep(1000);
flag = false;
System.out.println("main线程将flag的值改为false");
}
}
运行结果:
t1线程开始运行。。。
main线程将flag的值改为false
t1线程结束
接下来看第二个例子:
public class SyncTest_02 {
private static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
System.out.println("t1线程开始运行。。。");
synchronized (SyncTest_02.class) {
while (flag) {
// 同步代码快在执行
}
}
System.out.println("t1线程结束");
});
t1.start();
Thread.sleep(1000);
flag = false;
System.out.println("main线程将flag的值改为false");
}
}
运行结果:
t1线程开始运行。。。
main线程将flag的值改为false
5.2 synchronized 块内部是否会发生指令重排
public class SyncReorderingTest {
static int x = 0, y = 0;
public static void main(String[] args) throws InterruptedException {
Set<String> resultSet = new HashSet<>();
Map<String, Integer> resultMap = new HashMap<>();
for (int i = 0; i < 1000000; i++) {
x = 0;
y = 0;
resultMap.clear();
Thread t1 = new Thread(() -> {
synchronized (new Object()) {
int a = y;
x = 1;
resultMap.put("a", a);
}
});
Thread t2 = new Thread(() -> {
synchronized (new Object()) {
int b = x;
y = 1;
resultMap.put("b", b);
}
});
t1.start();
t2.start();
t1.join();
t2.join();
resultSet.add("a=" + resultMap.get("a") + "," + "b=" + resultMap.get("b"));
System.out.println(resultSet);
}
}
}
运行结果:
[a=0,b=0, a=1,b=0, a=0,b=1, a=1,b=1]
(1)volatile关键字主要用于修饰变量,用来保证变量在多线程之间的可见性。
(2)synchronized关键字主要用于修饰代码块和方法,用来保证代码块和方法在多线程之间的同步性。
(3)volatile关键字只保证了变量的可见性,但不保证原子性,也就是说,对于volatile变量的读写操作不能保证线程安全。
2、volatile关键字的使用场景和注意事项
(1)volatile关键字适用于对变量的读操作比写操作频繁的场景,例如计数器、状态标志等。
(2)volatile关键字不能保证原子性,如果需要保证原子性,可以使用Atomic类或synchronized关键字。
3、synchronized关键字的使用场景和注意事项
(1)synchronized关键字适用于对共享数据的读写操作,可以保证数据的原子性、可见性和有序性。
(2)synchronized关键字可以用于修饰代码块和方法,对于需要保证原子性的代码块和方法,应该使用synchronized关键字进行同步。
(1)volatile关键字和synchronized关键字都能保证线程的安全性和数据的正确性,但针对不同的场景使用效果不同。
(2)如果需要保证变量的可见性,可以使用volatile关键字;如果需要保证代码块和方法的原子性,可以使用synchronized关键字。