首先,线程执行的任意顺序不是数据竞争本身,即使它可能导致它。如果您需要同步 2 个或更多线程以按特定顺序执行它们的代码,则必须使用像监视器这样的等待机制。监视器是可以进行互斥(锁定)和等待的结构。监视器也称为条件变量,Java支持它们。
现在的问题是数据竞赛是什么。当 2 个或更多线程同时访问同一内存位置并且其中一些访问是写入时,就会发生数据竞争。这种情况会导致内存位置可能包含的不可预测的值。
一个经典的例子。让我们有一个 32 位操作系统和 64 位长的变量,如long
ordouble
类型。让我们有long
变量。
long SharedVariable;
以及执行以下代码的线程 1。
SharedVariable=0;
以及执行以下代码的线程 2。
SharedVariable=0x7FFF_FFFF_FFFF_FFFFL;
如果对该变量的访问不受锁保护,则在两个线程执行后,SharedVariable
可以具有以下值之一。
SharedVariable==0
SharedVariable==0x7FFF_FFFF_FFFF_FFFFL
**SharedVariable==0x0000_0000_FFFF_FFFFL**
**SharedVariable==0x7FFF_FFFF_0000_0000L**
最后 2 个值是意外的 - 由数据竞争引起。
这里的问题是,在 32 位操作系统上,可以保证对 32 位变量的访问是原子的 - 因此平台保证即使 2 个或更多线程同时访问相同的 32 位内存位置,访问该内存位置是原子的 - 只有一个线程可以访问这样的变量。但是因为我们有 64 位变量,在 CPU 级别上,写入 64 位长变量将转换为 2 个 CPU 指令。所以代码SharedVariable=0;
被翻译成这样的:
mov SharedVariableHigh32bits,0
mov SharedVariableLow32bits,0
代码SharedVariable=0x7FFF_FFFF_FFFF_FFFFL;
被翻译成这样的:
mov SharedVariableHigh32bits,0x7FFFFFFF
mov SharedVariableLow32bits,0xFFFFFFFF
在没有锁的情况下,CPU 可以按以下顺序执行这 4 条指令。
订单 1。
mov SharedVariableHigh32bits,0 // T1
mov SharedVariableLow32bits,0 // T1
mov SharedVariableHigh32bits,0x7FFFFFFF // T2
mov SharedVariableLow32bits,0xFFFFFFFF // T2
结果是:0x7FFF_FFFF_FFFF_FFFFL
。
订单 2。
mov SharedVariableHigh32bits,0x7FFFFFFF // T2
mov SharedVariableLow32bits,0xFFFFFFFF // T2
mov SharedVariableHigh32bits,0 // T1
mov SharedVariableLow32bits,0 // T1
结果是:0
。
订单 3。
mov SharedVariableHigh32bits,0x7FFFFFFF // T2
mov SharedVariableHigh32bits,0 // T1
mov SharedVariableLow32bits,0 // T1
mov SharedVariableLow32bits,0xFFFFFFFF // T2
结果是:0x0000_0000_FFFF_FFFFL
。
订单 4。
mov SharedVariableHigh32bits,0 // T1
mov SharedVariableHigh32bits,0x7FFFFFFF // T2
mov SharedVariableLow32bits,0xFFFFFFFF // T2
mov SharedVariableLow32bits,0 // T1
结果是:0x7FFF_FFFF_0000_0000L
。
因此,竞争条件导致了一个严重的问题,因为您可以获得一个完全出乎意料且无效的值。通过使用锁,您可以阻止它,但是仅仅使用锁并不能保证执行顺序——哪个线程首先执行它的代码。因此,如果您使用锁,您将只获得 2 个执行顺序 - 顺序 1 和顺序 2,不会获得意外值0x0000_0000_FFFF_FFFFL
和0x7FFF_FFFF_0000_0000L
. 但是,如果您需要同步哪个线程先执行哪个第二个,您不仅需要使用锁,还需要使用监视器(条件变量)提供的等待机制。
顺便说一句,根据这篇文章long
,Java 保证对除and之外的所有原始类型变量进行原子访问double
。在 64 位平台上,甚至可以访问long
并且double
应该是原子的,但看起来标准并不能保证它。
即使标准保证原子访问,使用锁总是更好。锁定义了阻止某些编译器优化的内存屏障,这些优化可以在 CPU 指令级别对代码重新排序,并在使用变量控制执行顺序时导致问题。
所以这里的简单建议是,如果你不是并发编程专家(我也不是),并且不要编写需要通过使用无锁技术获得绝对最大性能的软件,请始终使用锁——即使访问保证具有原子访问的变量。