众所周知,编写好并发程序往往不是一件容易的事,常常会出一些十分诡异的bug。事实上,要理解其根本原因,需要从计算机底层的运行原理来探究。
并发问题三大原因
一,可见性问题
public class Test {
private long count = 0;
private void add10K() {
int idx = 0;
while(idx++ < 10000) {
count += 1;
}
}
public static long calc() {
final Test test = new Test();
// 创建两个线程,执行 add() 操作
Thread th1 = new Thread(()->{
test.add10K();
});
Thread th2 = new Thread(()->{
test.add10K();
});
// 启动两个线程
th1.start();
th2.start();
// 等待两个线程执行结束
th1.join();
th2.join();
return count;
}
}
上面这段代码的执行结果,我们会下意识的认为是20000。而实际结果却是10000—20000之间的某个随机数。
这是因为,我们以为的成员变量count对于两个线程是共享的,两个线程是无时不刻都获取的是同一个count。
然而,事实上,由于现在几乎所有的计算机都是多核的,而每个cpu都有自己独立的cpu缓存,如果两个线程被分到两个cpu同时执行,那么它们各自从自己的缓存中获取到的count有可能是还没来得及同步的数据。于是就导致了两个线程的count并非总是我们以为的同一个。
你的并发程序为何会出诡异bug?一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。
由于cpu的缓存机制会导致多核cpu的并发场景时出现可见性问题。
二,原子性问题
假设我们的计算机都是单核的,那就永远只会有一个cpu缓存,那么还可能会出现并发时的数据问题吗?
——是的,仍然可能。
上面那段代码,当程序执行count+=1这句话时,我们又会下意识的认为它是一个不可再被分割的整体,就像一个原子一样。
然而,实际上,这段代码在cpu中分为了三个指令来执行:
指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
指令 2:之后,在寄存器中执行 +1 操作;
指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。
并且每个线程拥有自己独立的寄存器。
你的并发程序为何会出诡异bug?从图中可以看出,当线程切换出现这种情况时,就会使得两个线程都执行完各自的一次运算后,count仍然为1而不是我们以为的2。
我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。
线程的切换可能会打破高级编程语言中一个语句的(我们以为的)原子性,加上每个线程都拥有自己独立的寄存器,所以这又导致了并发时的数据共享的问题。
三,有序性问题
除了以上两种原因,还有一类原因也会引发并发的问题。
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
以上代码就是经典的双检锁创建单例。
从代码层面分析,我们担心多个线程同时执行到 if (instance == null) ,于是在后面的创建实例的代码块加锁,并增加instance == null判断。
此时 JVM 保证只有一个线程能够加锁成功(假设是线程 A),另外一个线程则会处于等待状态(假设是线程 B);线程 A 会创建一个 Singleton 实例,之后释放锁,锁释放后,线程 B 被唤醒,线程 B 再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程 B 检查 instance == null 时会发现,已经创建过 Singleton 实例了,所以线程 B 不会再创建一个 Singleton 实例。
单从代码分析似乎很完美。
但从cpu指令执行层面分析,不然。
我们以为的 new 操作应该是:
1.分配一块内存 M;
2.在内存 M 上初始化 Singleton 对象;
3.然后 M 的地址赋值给 instance 变量。
但是实际上cpu对指令优化后的执行路径却是这样的:
1.分配一块内存 M;
2.将 M 的地址赋值给 instance 变量;
3.最后在内存 M 上初始化 Singleton 对象。
如果线程1执行到第2步时发生了线程切换,轮到第二个线程执行getInstance()方法,线程2检查 instance == null就会发现instance不为空,于是直接返回一个空实例。
你的并发程序为何会出诡异bug?所以这就导致并发下可能会出现访问 instance 的成员变量就可能触发空指针异常。
编译器为了优化性能,可能会改变程序中语句的先后顺序,在并发场景下,有时就会导致意想不到的bug。
总结一下,导致并发编程诡异bug有三类原因。
1.缓存导致的可见性问题
2.线程切换带来的原子性问题
3.编译优化带来的有序性问题
都是性能优化带来的坑
由于cpu,内存,I/O 设备这三者的执行速度差异十分大,cpu最快,I/O 设备最慢。
为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系机构、操作系统、编译程序都做出了贡献,主要体现为:
1.CPU 增加了缓存,以均衡与内存的速度差异;
2.操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
3.编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。
而这三条优化正好对应了并发编程出诡异bug的三大原因。
——没有绝对好的解决方案,只有适合的场景。
我们在运用并发编程时一定要了解它背后可能藏着的坑。
网友评论