2.1 揭秘多线程环境下的原子性问题

什么是原子性呢?

在数据库事务的ACID特性中就有原子性,它是指当前操作中包含的多个数据库事务操作,要么全部成功,要么全部失败,不允许存在部分成功、部分失败的情况。而在多线程中的原子性与数据库事务的原子性相同,它是指一个或多个指令操作在CPU执行过程中不允许被中断。

下面我们来演示一个多线程中出现原子性问题的例子。

在上述代码中启动了两个线程,每个线程对成员变量i累加10000次,然后打印出累加后的结果。我们从结果中发现,原本期望的值是20000,但是打印出来的i值都是一个小于20000的数,和预期的结果不一致,导致这个现象产生的原因就是原子性问题。

2.1.1 深入分析原子性问题的本质

从本质上说,原子性问题产生的原因有两个。

• CPU时间片切换。

• 执行指令的原子性,也就是线程运行的程序或者指令是否具备原子性。

CPU时间片切换

在第1章中,笔者详述了CPU时间片切换的原理,也就是当CPU不管因为何种原因处于空闲状态时,CPU会把自己的时间片分配给其他线程来处理,整体过程如图2-1所示,CPU通过上下文切换来提升资源利用率。

图2-1 CPU时间片切换

i++指令的原子性

在Java程序中,i++操作看起来是一个完整的不可分割的指令,但是实际上并不是这样的。我们通过javap -v命令来查看AtomicExample类中incr()方法的字节码,运行结果如下。

可以发现,i++操作实际上是三个指令:getfield、iadd、putfield。

• getfield,把变量i从内存加载到CPU的寄存器中。

• iadd,在寄存器中执行+1操作。

• putfield,把结果保存到内存。

需要注意,这三个指令并不具备原子性,也就是说,CPU在执行的过程中会存在中断的情况,这种中断就会导致原子性问题。

如图2-2所示,假设有两个线程同时对变量i进行修改,那么可能的执行过程如下:

• 线程1先获得CPU的执行权,在CPU将i=0加载到寄存器中后出现线程切换,CPU把执行权切换给线程2并保留当前的CPU上下文。

• 线程2同样去内存中将i加载到寄存器中进行计算,然后把计算结果写回内存。

• 线程2释放了CPU资源,线程1重新获得执行权后恢复CPU上下文,而这时i的值还是0。

• 最终计算后i的结果比预期结果要小。

图2-2 线程切换导致原子性问题

除上述这种情况外,在多核CPU中,线程的并行执行也会导致原子性问题。如图2-3所示,两个线程并行执行,同时从内存中将i加载到寄存器中并进行计算,最终导致i的结果小于我们的预期值。

图2-3 多线程并行执行导致原子性问题

2.1.2 关于原子性问题的解决办法

通过上述问题的分析,我们发现,多线程环境下线程的并行或切换导致最终执行结果不符合预期,解决问题的办法可以从两个方面考虑。

• 不允许当前非原子指令在执行过程中被中断,也就是说保证i++操作在执行过程中不存在上下文切换。

• 多线程并行执行导致的原子性问题可以通过一个互斥条件来实现串行执行。

在Java中,synchronized关键字提供了这样一个功能,在incr()方法上增加synchronized关键字后,可以保证下面这段代码中i变量最终的输出结果必然是20000。