[Java并发]1,入门:并发编程Bug的源头

30次阅读

共计 2225 个字符,预计需要花费 6 分钟才能阅读完成。

介绍
如何坚决并发问题,首先要理解并发的实际源头怎么发生的。
现代大家使用的计算机的不同硬件的运行速度是不一样的,这个大家应该都是知道的。
计算机数据传输运行速度上的快慢比较:CPU > 缓存 > I/O
如何最大化的让不通速度的硬件可以更好的协调执行,需要做一些“撮合”的工作

CUP 增加了高速缓存来均衡与缓存间的速度差异
操作系统增加了 进程,线程,以分时复用 CPU,进而均衡 CPU 与 I / O 的速度差异(当等待 I / O 的时候切换给其他 CPU 去执行)
现代编程语言的编译器优化指令顺序,使得缓存能够合理的利用

上面说来并发才生问题的背景,下面说下并发才生的具体原因是什么
缓存导致的可见性问题
先看下单核 CPU 和缓存之间的关系:
单核情况下,也是最简单的情况,线程 A 操作写入变量 A,这个变量 A 的值肯定是被线程 B 所见的。因为 2 个线程是在一个 CPU 上操作,所用的也是同一个 CPU 缓存。
这里我们来定义
一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为“可见性”
多核 CPU 时代下,我们在来看下具体情况:

很明显,多核情况下每个 CPU 都有自己的高速缓存,所以变量 A 的在每个 CPU 中可能是不同步的,不一致的。结果程 A 刚好操作来 CPU1 的缓存,而线程 B 也刚好只操作了 CPU2 的缓存。所以这情况下,当线程 A 操作变量 A 的时候,变量并不对线程 B 可见。
我们用一段经典的代码说明下可见性的问题:
private void add10K() {
int idx = 0;
while (idx++ < 100000) {
count += 1;
}
}

@Test
public void demo() {

// 创建两个线程,执行 add() 操作
Thread th1 = new Thread(() -> {
add10K();
});
Thread th2 = new Thread(() -> {
add10K();
});
// 启动两个线程
th1.start();
th2.start();
// 等待两个线程执行结束

try {
th1.join();
th2.join();
} catch (Exception exc) {

exc.printStackTrace();
}

System.out.println(count);

}
大家应该都知道,答案肯定不是 200000 这就是可见性导致的问题,因为 2 个线程读取变量 count 时,读取的都是自己 CPU 下的高速缓存内的缓存值,+ 1 时也是在自己的高速缓存中。
线程切换带来的原子性问题
进程切换最早是为了提高 CPU 的使用率而出现的。
比如,50 毫米操作系统会重新选择一个进程来执行(任务切换),50 毫米成为“时间片”

早期的操作系统是进程间的切换,进程间的内存空间是不共享的,切换需要切换内存映射地址,切换成本大。
而一个进程创建的所有线程,内存空间都是共享的。所以现在的操作系统都是基于更轻量的线程实现切换的,现在我们提到的“任务切换”都是线程切换。
任务切换的时机大多数在“时间片”结束的时候。
现在我们使用的基本都是高级语言,高级语言的一句对应多条 CPU 命令,比如 count +=1 至少对应 3 条 CPU 命令,指令:
1, 从内存加载到 CPU 的寄存器 2, 在寄存器执行 +13, 最后,讲结果写回内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)
操作系统做任务切换,会在 任意一条 CPU 指令执行完就行切换。所以会导致问题

如图所示,线程 A 当执行完初始化 count= 0 时候,刚好被线程切换给了线程 B。线程 B 执行 count+1= 1 并最后写入值到缓存中,CPU 切换回线程 A 后,继续执行 A 线程的 count+1= 1 并再次写入缓存,最后缓存中的 count 还是为 1. 一开始我们任务 count+1= 1 应该是一个不能再被拆开的原子操作。
我们把一个或多个操作在 CPU 执行过程中的不被中断的特性称为 原子性。
CPU 能够保证的原子性,是 CPU 指令级别的。所以高级语言需要语言层面 保证操作的原子性。
编译优化带来的有序性问题
有序性。顾名思义,有序性指的是程序按照代码的先后顺序执行。
编译器为了优化性能,有时候会改变程序中语句的先后顺序,例如程序中:a=6;b=7;编译器优化后可能变成 b =7;a=6;,在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果。不过有时候编译器及解释器的优化可能导致意想不到的 Bug。
Java 中的经典案例,双重检查创建单例对象;
public class Singleton {

static Singleton instance;

static Singleton getInstance(){

if (instance == null) {

synchronized(Singleton.class) {

if (instance == null)

instance = new Singleton();

}

}
return instance;
}
}
看似完美的代码,其实有问题。问题就在 new 上。
想象中 new 操作步骤:1,分配一块内存 M2,在内存 M 上 初始化对象 3,把内存 M 地址赋值给 变量
实际上就行编译后的顺序是:1,分开一块内存 M2,把内存 M 地址赋值给 变量 3,在 内存 M 上 初始化对象
优化导致的问题:

如图所示,当线程 A 执行到第二步的时候,被线程切换了,这时候,instance 未初始化实例的对象,而线程 B 这时候执行到 instance == null ? 的判断中,发现 instance 已经有“值”了,导致了返回了一个空对象的异常。
总结
1,缓存引发的可见性 2,切换线程带来的原子性 3,编译带来的有序性
深刻理解这些前因后果,可以诊断大部分并发的问题!

正文完
 0