线程安全性之可见性和有序性
什么是线程的可见性
在以下案例中,就是t1线程中用到了stop这个属性,接在在main线程中修改了 stop 这个属性的值来使得t1线程结束,但是t1线程并没有按照期望的结果执行。
public class VolatileDemo01 {
private static boolean stop = false;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
int i = 0;
while (!stop) {
i++;
}
System.out.println("子线程即将结束------");
});
t1.start();
System.out.println("子线程即将运行-------");
Thread.sleep(1000);
stop = true;
}
}
运行结果并没有像预期的那样停止,而是一直运行。
volatile解决可见性问题
在上面的程序中,可以增加 volatile 这个关键字来解决,代码如下:
public class VolatileDemo01 {
private volatile static boolean stop = false;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
int i = 0;
while (!stop) {
i++;
}
System.out.println("子线程即将结束------");
});
t1.start();
System.out.println("子线程即将运行-------");
Thread.sleep(1000);
stop = true;
}
}
运行结果符合预期:
缓存一致性问题
缓存一致性问题导致了可见性问题的出现。在此之前,需要了解什么是缓存一致性
为提升性能,cpu所做的优化
在整个计算机的发展历程中,除了CPU、内存以及I/O设备不断迭代升级来提升计算机处理性能之外,还有一个非常核心的矛盾点,就是这三者在处理速度的差异。CPU的计算速度是非常快的,其次是内存、最后是IO设备(比如磁盘),也就是CPU的计算速度远远高于内存以及磁盘设备的I/O速度。如下图所示,计算机是利用CPU进行数据运算的,但是CPU只能对内存中的数据进行运算,对于磁盘中的数据,必须要先读取到内存,CPU才能进行运算,也就是CPU和内存之间无法避免的出现了IO操作。
而cpu的运算速度远远高于内存的IO速度,比如在一台2.4GHz的cpu上,每秒能处理2.4x109次,每次处理的数据量,如果是64位操作系统,那么意味着每次能处理64位数据量。
虽然CPU从单核升级到多核甚至到超线程技术在最大化的提高CPU的处理性能,但是仅仅提升CPU性能是不够的,如果内存和磁盘的处理性能没有跟上,就意味着整体的计算效率取决于最慢的设备,为了平衡这三者之间的速度差异,最大化的利用CPU。所以在硬件层面、操作系统层面、编译器层面做出了很多的优化
- CPU增加了高速缓存
- 操作系统增加了进程、线程。通过CPU的时间片切换最大化的提升CPU的使用率
- 编译器的指令优化,更合理的去利用好CPU的高速缓存
每一种优化,都会带来相应的问题,而这些问题是导致线程安全性问题的根源,那接下来我们逐步去了解这些优化的本质和带来的问题。
CPU层面上的缓存
CPU在做计算时,和内存的IO操作是无法避免的,而这个IO过程相对于CPU的计算速度来说是非常耗时,基于这样一个问题,所以在CPU层面设计了高速缓存,这个缓存行可以缓存存储在内存中的数据,CPU每次会先从缓存行中读取需要运算的数据,如果缓存行中不存在该数据,才会从内存中加载,通过这样一个机制可以减少CPU和内存的交互开销从而提升CPU的利用率。
对于主流的x86平台,cpu的缓存行(cache)分为L1、L2、L3总共3级。
缓存行
cpu上的缓存是由缓存行构成的。x86平台,每个缓存行是64个字节。cpu每次读取数据会读取64个字节的数据(不管这些数据是否都会用到,都会一次性读取这64个字节)
伪共享
缓存行主要是为了减少cpu与内存过多的交互。所以每次加载会加载64个字节。但是这里又存在一个问题。如下图所示:cpu0想要获取x数据,缓存行一次性加载了x,y,z,而cpu0只需要运算x数据。如果此时cpu1加载y数据,此时也要加载缓存行数据,假如这时的缓存行数据也是x,y,x。虽然cpu1只运算y,但是cpu0与cpu1存在缓存行竞争。如果cpu0获得了执行权限,那么就会使得cpu1的缓存失效。这种情况如果大量存在,就会影响性能,因为增加了cpu与内存的交互。这时就存在了伪共享的问题。
对齐填充
对齐填充就是如果加载x数据,那就只加载x数据,缓存行多出的部分直接填满64个字节。这样就不需要加载多余的数据。也就不会出现上述的伪共享的问题了。对齐填充也是一种用空间换时间的操作方式。
缓存一致性问题
CPU高速缓存的出现,虽然提升了CPU的利用率,但是同时也带来了另外一个问题–缓存一致性问题,这个一致性问题体现在。
在多线程环境中,当多个线程并行执行加载同一块内存数据时,由于每个CPU都有自己独立的L1、L2缓存,所以每个CPU的这部分缓存空间都会缓存到相同的数据,并且每个CPU执行相关指令时,彼此之间不可见,就会导致缓存的一致性问题,据图流程如下图所示:
缓存一致性协议
为了达到数据访问的一致,这里引入了总线锁和缓存锁的概念。缓存锁就是通过缓存一致性协议来保证数据一致性的,常见的协议有MSI,MESI,MOSI等。最常见的就是MESI协议。
MESI表示缓存行的四种状态,分别是
1. M(Modify[ˈmɒdɪfaɪ]) 表示共享数据只缓存在当前CPU缓存中,并且是被修改状态,也就是缓存的
数据和主内存中的数据不一致\
2. E(Exclusive[ɪkˈskluːsɪv]) 表示缓存的独占状态,数据只缓存在当前CPU缓存中,并且没有被修改
3. S(Shared[ʃerd]) 表示数据可能被多个CPU缓存,并且各个缓存中的数据和主内存数据一致
4. I(Invalid[ˈɪnvəlɪd]) 表示缓存已经失效
在CPU的缓存行中,每一个Cache一定会处于以下三种状态之一
Shared
Exclusive
Invalid
cpu基于缓存一致性协议,通过storebuffer和storeforwarding技术使得cpu能够异步执行,既能解决缓存一致性问题,又能使得其他指令异步执行。但是因为是异步的,这样会导致指令重排问题。
指令重排问题
public class VilatitleDemo2 {
private static int a = 0;
private static int b = 0;
private static int x = 0;
private static int y = 0;
public static void main(String[] args) throws InterruptedException {
int i = 0;
for (; ; ) {
i++;
x = 0;
y = 0;
a = 0;
b = 0;
Thread t1 = new Thread(() -> {
a = 1;
x = b;
});
Thread t2 = new Thread(() -> {
b = 1;
y = a;
});
t1.start();
t2.start();
t1.join();
t2.join();
if (x == 0 && y == 0) {
System.out.println("本次运算共运算:" + i);
break;
}
}
如果不存在指令重排问题,x == 0 && y == 0是永远不会成立的,但是实际我们运行之后才发现,x == 0 && y == 0等式是有可能成立的。结果如下:
这就是StoreBuffer和Storeforwarding异步优化之后导致的指令重排问题。基于以上问题,诞生了内存屏障的概念。cpu提供了内存屏障的指令,开发者可以在需要的地方使用,从而解决指令重排序问题。
以上优化需要在代码层面给cpu一个指令来完成。这个“指令”就是volatile。(用volatile修饰的变量,生成字节码指令时,该变量下会有一个标签记录该变量存在volatile属性。jvm会根据这个属性给cpu下发相应的指令来解决可见性问题和指令重排序问题)
内存屏障
CPU在性能优化道路上导致的顺序一致性问题,在CPU层面无法被解决,原因是CPU只是一个运算工具,它只接收指令并且执行指令,并不清楚当前执行的整个逻辑中是否存在不能优化的问题,也就是说硬件层面也无法优化这种顺序一致性带来的可见性问题。
因此,在CPU层面提供了写屏障、读屏障、全屏障这样的指令,在x86架构中,这三种指令分别是
SFENCE、LFENCE、MFENCE指令,
sfence:也就是save fence,写屏障指令。在sfence指令前的写操作必须在sfence指令后的写操作前完成。
lfence:也就是load fence,读屏障指令。在lfence指令前的读操作必须在lfence指令后的读操作前完成。
mfence:也就是modify/mix,混合屏障指令,在mfence前得读写操作必须在mfence指令后的读写操作前完成。
在Linux系统中,将这三种指令分别封装成了, smp_wmb-写屏障 、 smp_rmb-读屏障 、 smp_mb-读写屏障 三
个方法
cpu优化之路: