Java中的Race condition和Critical section(译)
race condition
,即竞态,是一种可能发生于critical section
中的特殊状态,critical section
一般翻译为临界区,我个人认为临界区不是很直观,因此不做翻译,仍然用英文。这里的critical section
实际上是一个可能会被多个线程并发执行的代码区段,并发线程的不同执行顺序会直接影响整个并发程序的执行结果。
由于并发线程的不同执行顺序直接影响这部分代码的执行结果,这时我们就说这部分代码为critical section
,而critical section
的代码被多个线程并发执行的时候,则处于被竞争执行的状态,即race condition
。
Critical section
事实上,同一段代码在多个线程并发执行时并不一定会引发问题,而是在多个线程同时竞争访问共享的资源时才有可能引发race condition
。这里的资源
包括内存资源(变量,数组或对象等),系统资源(数据库,web服务等)以及文件等。
实际上只有多个线程并发对共享的资源进行写操作时才有可能引发问题。多个线程读共享资源并不会引发什么问题。
下面的这段代码是一个critical section
例子,多线程并发执行时会引发问题:
public class Counter {
protected long count = 0;
public void add(long value) {
this.count = this.count + value;
}
}
假设两个不同的线程A和B,通过一个相同的Counter
对象,并发执行该对象的add
方法。操作系统何时进行线程的切换是不确定的。add
方法内部的这行代码编译成字节码在JVM内部执行时并不是一个原子操作,而是分解成了类似如下所示的几个字令来执行:
1、 从主内存将this.count
读到CPU的寄存器内;
2、 将value
值加到寄存器的值;
3、 将寄存器内的值写回到主内存。
将这个过程,对应到两个两个线程A、B并发执行,则可能以如下的顺序进行执行:
this.count = 0;
A: 将this.count的值从主内存读取到寄存器(0)
B: 将this.count的值从主内存读取到寄存器(0)
B: 将value 2加到寄存器上
B: 将寄存器的值(2)写回到主内存。这是this.count的值为2
A: 将value 3加到寄存器上
A: 将寄存器的值(3)写回到主内存。这是this.count的值为3
本来这两个线程A,B是想将2和3加到count
上,预期的结果应该是5才对。但是由于这两个线程是并发交叉执行的,导致线程A的计算结果3最终写回到主内存,同时也覆盖了现场B写会到主内存的2。执行结果不符合预期。当然,这里假象出来的这个执行流程只是一种可能发生的情况,也有可能执行结果是2或5。但是只要有这种竞态
发生的可能性,这种代码段就是critical section
,是我们需要极力杜绝的。
如何避免race condition
那么我们如何避免race condition
的发生呢?答案是原子性
。
我们需要将有可能发生竞态的critical section
包成一个原子操作,即如果一个线程正在执行这部分代码,那么其他线程只能等到该线程结束执行离开后才能开始执行。
具体来说,我们可以通过一些线程之间相互同步的手段来实现。比如:
1、 synchronized
代码块;
2、 锁;
3、 原子性变量,如java.util.concurrent.atomic.AtomicInteger
。
critical section的吞吐量
对于逻辑简单的critical section
,通过synchronized
代码块来避免竞态没啥问题,但是对于逻辑复杂、代码量大的critical section
来说,这种方式无疑会降低整个系统的吞吐量。这时我们可以尝试将其拆解成多个独立的、较小的critical section
。
举个例子:
public class TwoSums {
private int sum1 = 0;
private int sum2 = 0;
public void add(int val1, int val2) {
synchronized(this) {
this.sum1 += val1;
this.sum2 += val2;
}
}
}
这里我们为了避免竞态
的放生,用synchronized
将代码段包了下。这样在多线程并发执行时,这些线程只能挨个轮流执行该代码段。但是我们细想就会发现,这个代码段其实可以拆分成两个独立的、互不影响的子代码段:
public class TwoSums {
private int sum1 = 0;
private int sum2 = 0;
private Integer sum1Lock = new Integer(1);
private Integer sum2Lock = new Integer(2);
public void add(int val1, int val2) {
synchronized(this.sum1Lock) {
this.sum1 += val1;
}
synchronized(this.sum2Lock) {
this.sum2 += val2;
}
}
}
这样如果两个线程并发执行add
方法,则可以再一个线程执行第一个子代码段的同时另一个线程执行第二个代码段,因此避免了更长时间的相互等待,提升了吞吐量。
当然,这个例子非常简单,仅为了说明原理,实际项目中可能需要更加认真的分析才知道如何进行拆分。