作者:Jakob Jenkov,2020-04-6
翻译:GentlemanTsao,2020-4-27
多个线程执行一个临界区,可能因线程执行的顺序不同而带来不同的结果,在这种情况下,该临界区称为含有竞态条件。术语竞态条件源于这样一个隐喻,即线程在争抢临界区,而争抢的结果会影响临界区的执行结果。
这听起来可能有点复杂,所以我将在下面的章节中详细介绍竞态条件和临界区。
在同一个应用程序中运行多个线程本身不会导致问题。问题出现在当多个线程访问同一资源时。例如,访问相同的内存(变量、数组或对象)、系统(数据库、web服务等)或文件。
事实上,只有当一个或多个线程写入这些资源时,才会出现问题。只要资源不变,允许多个线程读取同样的资源是安全的。
下面是一个临界区Java代码示例,如果由多个线程同时执行,则可能会出错:
public class Counter {
protected long count = 0;
public void add(long value){
this.count = this.count + value;
}
}
假设两个线程A和B在Counter类的同一个实例上执行add方法。我们无法知道操作系统何时在两个线程之间切换。Java虚拟机不会将add()方法中的代码作为单个原子指令执行,而是作为一组较小的指令执行的,类似于:
把this.count从内存读入寄存器中。
寄存器加上value。
将寄存器写入内存。
观察以下线程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
两个线程希望将值2和3添加到计数器中。因此,这两个线程执行完之后,该值应该是5。但是,由于两个线程是交错执行的,最后的结果却不一样了。
在上面列出的执行序列示例中,两个线程都从内存中读取值0。然后,他们加上各自的值2和3,并将结果写回内存。可最终this.count的值不是5,而是最后一个线程写入的值。在上述情况下,它是线程A,但它也可能是线程B。
在前面示例中,add()方法中的代码包含一个临界区。当多个线程执行此临界区时,就出现了竞态条件。
更正式地说,当两个线程竞争同一资源,且访问该资源的顺序又十分重要时,这种情况称为竞态条件。导致竞态条件的代码段称为临界区。
为了防止竞态条件发生,必须确保临界区作为原子指令执行。也就是说一旦一个线程执行它,在第一个线程离开临界区之前,其他线程都不能执行它。
通过在临界区进行适当的线程同步,可以避免竞态条件。线程同步可以使用同步的Java代码块来实现。线程同步也可以使用其他同步结构(例如锁)或原子变量(例如java.util.concurrent.atomic.AtomicInteger)来实现。
对于较小的临界区,把整个临界区作为同步块是可行的。但是,对于较大的临界区,好的做法可能是将临界区分成较小的临界区,以便允许多个线程执行每个较小的临界区。这可以减少对共享资源的争用,从而增加整个临界区的吞吐量。
这里有一个非常简单的Java代码示例来说明:
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;
}
}
}
上例中有两个不同的sum成员变量,注意add()方法是如何增加它们的值的。为了避免竞态条件,求和被放在Java同步块内执行。这样实现的结果是,同一时间只有一个线程可以执行求和。
但是,由于两个求和变量彼此独立,因此可以将它们的求和拆分为两个单独的同步块,如下所示:
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()方法了。一个线程在第一个同步块中执行,另一个线程在第二个同步块中执行。两个同步块在不同的对象上同步,因此两个不同的线程可以独立地执行这两个块。这样就减少了等对方线程执行add()方法的时间。
当然,这个例子很简单。在现实中的共享资源中,临界区的分解可能要复杂得多,需要对可能的执行顺序进行更多的分析。
下一篇:
java并发和多线程教程(九):线程安全和共享资源
更多阅读:
系列专栏:java并发和多线程教程