Java并发编程bug的恶源:可见性、原子性和有序性问题


Java并发编程bug的恶源:可见性、原子性和有序性问题

并发是一把双刃剑,用的好会极大地释放硬件的潜力,提高系统的性能,用的不好就像是打破了潘多拉的魔盒,释放出无数恶魔,宛若进入恶魔的深渊。这些恶魔就是并发编程中的 bug,这些 bug 往往会诡异地出现,然后又会诡异的消失,难以捉摸,难以重现,让人抓狂。

要关闭潘多拉的魔盒,快速而精准地解决并发bug,就要理解潘多拉背后的万恶之源,也就是深入地理解并发编程 bug 的源头到底在哪里,明白并发到底是怎么回事,只有这样,才能收回并发 bug 释放的无尽恶魔。

每个成功的并发程序背后都离不开CPU、内存以及I/O的完美掌控

半导体集成电路的性能每18个月翻一番。 -- 摩尔


几十年来,IT行业始终遵循着摩尔定律预测的速度发展,CPU、内存以及I/O 设备在不断的发展,这也促使软件行业不断发展。但是在这个发展的过程中,这三个硬件设备之间始终存在着一个矛盾,那就是

这三者之间的速度存在巨大的差异

「CPU是天上一天,内存是地上一年」可以很形象地说明CPU和内存之间的速度对比,CPU执行一条命令如果需要一天的话,那么CPU读写内存就需要等待一年的时间了。I/O设备的速度就更慢了,可以说「内存是天上一天,I/O设备是地上十年」。

程序的执行速度取决于三者中速度最慢的I/O设备的速度。为什么呢?这是因为程序的执行会涉及这3者,那么即使CPU再快,程序的速度也不会快起来,因为内存和I/O 设备会拖慢程序的速度。这就像是一个水桶能装多少水主要取决于它最短的那块板,而不是最长的。

那如何才能更好的释放CPU的潜力呢?那就需要平衡三者的速度差异:

  • CPU增加多级缓存,以均衡与内存的速度差;
  • 操作系统增加了进程、线程,以分时复用CPU,从而均衡CPU与I/O设备之间的速度差;
  • 编译程序优化指令的执行次序,使得CPU的多级缓存能够更加高效、合理地使用。


硬件的均衡以及硬件与底层软件的协调,很好地均衡了CPU、内存以及I/O 设备之间的速度差,让CPU的能力更好的释放,但是释放天使的同时,也让恶魔重现人间,正是这3个释放硬件实力的方法,变成了并发bug的万恶之源,引发了并发程序的可见性、原子性以及有序性问题。

CPU 缓存导致的可见性问题

可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到该修改,即为可见性。


CPU 在进行计算的时候,首先需要将内存的数据读入 CPU 缓存中,由线程来操作数据,然后再将操作的数据写回到内存中。如果计算都是在一个 CPU 上执行的话,那么这个过程并没有什么问题,因为所有的线程都会操作同一个 CPU 缓存,一个线程对缓存中的共享变量的写,对其他的线程是可见的。

如下图所示,线程 1 和线程 2 操作同一个 CPU 的缓存,当线程 1 更新了共享变量 V 的值,线程 2 访问变量 V 时,访问的必然是线程 1 更新之后的最新值。在单核的情况下,CPU 的缓存的值始终与内存的值是相同的。

Java并发编程bug的恶源:可见性、原子性和有序性问题

图:单核 CPU 缓存与内存的关系

但是,当一个机器 CPU 多了之后,这个过程就变了,想要保证 CPU 缓存与内存的数据一致性就很难了。当多个线程在不同的 CPU 上执行时,它读写的是不同的 CPU 缓存,那想要同步数据,就需要通过内存这个媒介,而不再是直接读写相同的 CPU 缓存,既然引入了第三方,那么保证不同 CPU 缓存与内存之间的同步就比较难了。

如下图所示 ,线程 1 和 线程 2 分别在不同的 CPU1 和 CPU2 上执行任务,此时当线程 1 修改了共享变量 V 的值之后,线程2 并不能立看到共享变量的值,而是需要通过内存来知晓这个更新之后的值,此时,线程 1 对共享变量的操作就不具备可见性了。正是这个不可见性,造成了并发程序的bug,这可以说是「硬件工程师给软件工程师挖的大“坑”了」。

Java并发编程bug的恶源:可见性、原子性和有序性问题

图:多核 CPU 缓存与内存的关系

下面通过一个示例来说明一个多核场景下的可见性是如何造成并发程序的bug的。

示例代码1:多核场景下的可见性示例

Java并发编程bug的恶源:可见性、原子性和有序性问题

上述代码中,calc()方法开启了2个线程,每个线程都执行一次add10K()方法,最终calc()计算结果是多少呢?是20000吗?答案是否定的。事实上,答案是10000到20000之间的随机数。这是因为啥呢?

假设2个线程同时启动,那么第一次都会将 count = 0 从内存读入到各自的 CPU 缓存之中,执行完 count+= 1,之后各自缓存的值都是 1, 然后将 1 同时写入内存中,此时内存的值是 1,并不是我们期望的 2。2个线程由于无法可见对方 CPU 缓存中的值,因此各自基于自己的缓存计算,最后导致 count 的值小于 20000。

这就是CPU 缓存造成的可见性问题。

线程切换带来的原子性问题

原子性:一个或多个操作在 CPU 执行的过程中,不被中断的特性称为原子性。


Java 并发程序都是基于多线程的,线程在 CPU 中执行的时候,并不是某个线程一直霸占 CPU,而是会进行任务的切换,任务切换的时机多数是时间片结束的时候,不同的时间片执行不同的线程。线程的切换有效地均衡了 CPU 和 I/O 设备之间的速度差异。当某个线程在执行开始执行耗时更长的 I/O 操作时,就会让出CPU,这样 CPU 就可以在该线程 I/O 操作的时候,去执行其他的计算任务,这真的是「铁打的 CPU,流水的线程」。

作为一门高级语言,Java 执行一条语句往往需要多条 CPU 指令完成,例如示例代码 1 中的 count += 1,至少需要 3 条 CPU 指令:

  • 指令1、首先,将变量 count 从内存加载到 CPU 的寄存器;
  • 指令2、然后,在寄存器中执行 + 1 操作;
  • 指令3、最后,将计算结果写入内存。


操作系统在进行线程切换的时候,可以发生在任何一条 CPU 指令结束的时候,而不是高级语言的一条语句结束的时候。对于 count += 1 这个语句来说,假设 count = 0,那么线程 1 在指令 1 执行完后做线程切换,之后按照下图所示的过程接着执行操作,我们会发现尽管 2 个线程都执行了count += 1 这个语句,但是最终的结果是 1,而不是我们期望的 2。

count += 1 这个语句并不满足原子性,因为它在执行的过程中,是有可能被中断的。正是线程切换造成了原子性的破坏,紧接着导致了并发程序的 bug,这种蝴蝶效应也算是「硬件工程师给软件工程师挖的又一大“坑”了」。

编译优化带来的有序性问题

有序性:有序性是指程序按照代码的先后顺序执行。


编译器为了更好地利用 CPU ,优化性能,会改变代码的执行顺序,例如,“a=1; b=2” 编译器优化之后就可能变“b=2;a=1”,这种情况下,尽管编译器调整了语句的执行顺序,但是并不影响程序的执行结果,但是有些时候,编译优化会带来意想不到的 bug。

例如,下面的示例代码演示了利用双重检查创建单例对象。

示例代码2:利用双重检查创建单例对象

Java并发编程bug的恶源:可见性、原子性和有序性问题

假设有 2 个线程一起调用 getInstance() 方法,他们会发现 instance == null,于是对Singleton.class 加锁,此时,只有一个线程可以加锁成功(假设是线程 1 成功),并且线程 1 创建一个 Singleton 实例,之后释放锁,此时线程 B 被唤醒,尝试再次加锁,成功之后,发现instance != null,于是不再创建 Singleton 实例,直接返回了。

虽然从代码逻辑来看,这个逻辑是没有问题的,但是编译优化会改变一个操作,什么操作呢?那就是 new 的操作。

我们以为的 new 操作是这样的:

  • 1、分配一块内存 M;
  • 2、在内存 M 上初始化 Singleton 对象;
  • 3、然后将 M 的地址赋值给 instance。


但是,编译优化不要「你以为,而是我以为」,编译优化之后的路径变成了这样:

  • 1、分配一块内存 M;
  • 2、然后将 M 的地址赋值给 instance。
  • 3、在内存 M 上初始化 Singleton 对象;


那编译优化后会导致什么问题呢?

假设线程 1 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 2 上;如果此时线程 2 也执行 getInstance() 方法,那么线程 2 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的(因为没有执行第3步),如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。

总结

要写好 Java 并发程序,必须要深入地理解可见性、原子性以及有序性背后的原理。尽管 CPU 缓存、多线程以及编译优化和并发程序一样都是为了提高程序的性能,但是我们要正确的掌握它,只有正确地使用它,它才是“屠龙之技”,如果错误地使用它,那就有可能打开潘多拉的魔盒,带来的也只有无尽 bug 了。


分享到:


相關文章: