线程间通过共享对象和域引用实现通信。这种形式的交流十分有效,但可能引用两类错误:线程冲突(thread interference)和内存不一致(memory consistency errors)。
解决该问题的方法就是:同步。
但是,同步会导致线程争用(thread contention):多个线程同时读取同一数据时,使一个或多个线程执行缓慢,或者干脆挂起。饥渴和活锁(Starvation and livelock)就是线程争用的例子。
下面主要介绍以下内容:
- 线程冲突(Thread interference),描述多个线程同时读取共享数据时引起的错误。
- 内存不一致(Memory Consistency Errors),共享内存不一致引发的错误。
- 同步方法(Synchronized Methods),描述了一个可以有效避免线程干扰和内存不一致问题的简单方法。
- 隐式锁定和同步(Implicit Locks and Synchronization),描述一个更通用的同步方法,并且说明同步如何通过隐式锁定实现的。
- 原子访问(Atomic Access),说明不被其他线程干扰操作的思路。
1. 线程冲突(Thread Interference)
先看一个简单的计数类Counter:
class Counter {
private int c = 0;
public void increment() {
c++;
}
public void decrement() {
c--;
}
public int value() {
return c;
}
}
每次调用increment,c值加1,调用decrement,c值减1。不过,如果Counter类对象被多个线程引用,线程冲突就会导致Counter的运行结果和预期不一致。
当多个线程操作同一数据,就可能导致线程冲突。即两个线程的操作包含的执行步骤,有交叉的地方。
Counter类的操作看上去似乎没什么可交叉的地方,作用c上的两个操作都很简单。然而,即使简单的声明操作到虚拟机上,也被分为了多步。如c++可以分为三步:
- 获得当前c的值;
- 将得到的c值加1;
- 将增加后的值保存到c中。
c--操作同样如此。假设线程A调用increment,而线程B同时调用了decrement。如果c的起始值为0,它们的操作行为可能如下:
- 线程A:获得c值
- 线程B:获得c值
- 线程A:将获得的值加1,结果为1
- 线程B:将获得的值减1,结果为-1
- 线程A:将值存入c,现在c是1
- 线程B:将值存入c,线程c是-1
线程A的值被线程B的值覆盖了。这只是一种可能,也可能是线程B的值被A的值覆盖,也可能正好是对的。结果完全是不确定的,线程冲突的错误很难发现,也不好修改。
2. 内存不一致(Memory Consistency Errors)
在不同线程对数据是否相同的一致性存在问题时,就可能导致内存不一致的问题。引起内存不一致的具体原因比较复杂,不在讨论范围内。不过,我们只需要知道如何避免,而不需要详细了解具体原因。
避免内存不一致的关键是要理解发生序(happends-before)关系。该关系用于保证一个引起写内存操作的声明对另外一个声明可见。 看下面的例子,定义一个int类型:
int counter = 0;
线程A和B共享counter。假设线程A增加counter:
<span style="white-space:pre"> </span>counter++;
随后,线程B打印counter值:
System.out.println(counter);
如果两条语句在同一个线程中执行,则可以肯定打印打印值为"1"。如果两个语句在两个不同线程中执行,打印结果则可能为"0",因为线程A对counter值的改变对线程B不一定可见——除非程序员在两条语句间建立的
发生序(happens-before)关系。
建立发生序关系的操作有很多,其中一个就是同步,下一节将对其进行描述。
我们已知建立发生序(happens-before)关系的操作有:
- 当某个声明语句调用Thread.start,则其他和该声明语句具有发生序关系的声明和新线程中的语句都具有发生序关系。创建新线程的代码对新线程可见。
- 当一个线程终止,从而使另外一个线程的Thread.join方法返回,则终止的进程中所有语句和join之后的语句具有发生序关系。
3. 同步方法(Synchronized Methods)
Java提供了两种同步风格:同步方法和同步语句。其中较为复杂的同步语句在下一部分介绍,这部分介绍同步方法。
要使一个方法同步,在声明中添加synchronized关键字即可:
public class SynchronizedCounter {
private int c = 0;
public synchronized void increment() {
c++;
}
public synchronized void decrement() {
c--;
}
public synchronized int value() {
return c;
}
}
如果count是SynchronizedCounter的一个实例,则使这些方法同步有两个效果:
- 首先,不能对同一对象同时调用两个同步方法。当一个线程一对象调用同步方法,则其他线程对该对象同步方法的调用被阻塞,直到第一个线程执行结束。
- 其次,当一个同步方法结束,它同其后在同一对象上调用的同步方法都具有发生序(happens-before)关系。从而保证对象上发生的所有操作对所有线程都是可见的。
目前,构造器不能同步——构造器不能使用synchronized关键字。同步的构造器是没有意义的,因为对象创建时,仅仅创建该对象的线程才能访问该对象。
同步方法是防止线程冲突和内存不一致的一个鉴定策略:如果一个对象对多个线程可见,则所有对该对象数据的读写操作都通过同步方法实行。(例外:final字段可以在非同步方法中安全读,因为一旦构造不能修改)。该策略十分有效,不过可能会引起活度(liveness)问题,后面我们会介绍。
4. 内置锁和同步(Intrinsic Locks and Synchronization)
同步是围绕称为内置锁(intrinsic lock)或监视锁(monitor lock)的内置实体构建的。内置锁在同步的两个方面都起到作用:对象访问的强制独占和建立发生序关系。
每个对象都有其内置锁。按照约定,一个线程若要肚子访问一个对象字段,首先要获得该对象的内置锁,访问结束后,再释放该内置锁。在获得和释放内置锁之间的时间,称该线程持有该内置锁。一个线程持有一个内置所,其他线程就不能获得同样的内置锁。其他线程此时被阻塞。
当一个线程释放一个内置锁,该操作和其后获取同样内置锁的操作具有发生序关系。
同步方法中的锁
当一个线程调用同步方法,它自动获得了同步方法对象的内置锁,方法返回时自动释放内置锁。即使是通过抛出异常返回,内置锁也被释放。
如果一个static synchronized的方法被调用呢?因为static方法和类关联,而不是对象,因此,线程将获得Class对象的内置锁。
同步块
另外一种创建同步代码的方法是使用同步块(synchronized statements)。和同步方法不同,同步块必须指定提供内置锁的对象:
public void addName(String name) {
synchronized(this) {
lastName = name;
nameCount++;
}
nameList.add(name);
}
如上所示,addName方法需要保证lastName和nameCount的同步,但又要避免使其他对象的方法同步。(使其他对象的方法同步的问题在Liveness中介绍)。除去同步块,需要非同步的nameList.add操作。
同步块通过细粒度的同步提供的并发性能。假设,class MsLunch有两个不一起使用的实例字段c1和c2。这些字段的更新都必须同步,但是没必要组织c1的更新和c2更新的交错——这样做只会因为阻塞而降低并发性能。通过使用同步块而不是同步方法可解决该问题:
public class MsLunch {
private long c1 = 0;
private long c2 = 0;
private Object lock1 = new Object();
private Object lock2 = new Object();
public void inc1() {
synchronized(lock1) {
c1++;
}
}
public void inc2() {
synchronized(lock2) {
c2++;
}
}
}
要小心使用这种方式。必须肯定收影响在字段互相交错是没有影响的。
可重入的同步
我们知道,一个线程不能获得被另外一个线程持有的内置锁。但一个线程可获得它已经持有的锁。允许一个线程多次获得持有的锁称为可重入同步。具体情形是:一段同步代码,直接会间接的,调用了另外一个包含同步代码的方法,并且两部分代码使用同一个锁。如果没有可重入同步,同步代码可能需要特别小心才能避免线程自我阻塞。
5. 原子访问(Atomic Access)
在编程中,原子操作指高效的瞬间发生的操作。原子操作不可中断:要么完全发生,要么不发生。原子操作在结束前看不到副作用。
我们已经看到的一个递增表达式,例如c++,不是一个原子操作。即使一些非常简单的表达式,都可以分解为多个操作。可以称为原子操作的有:
- 对基本类型变量和引用变量的读写操作为原子操作
- 声明为volatile的变量的读写操作为原子操作
原子操作不会交错,所以可以放心使用而不用担心线程冲突。但也不是完全不用同步原子操作,因为内存不一致的问题还是有可能发生。使用volatile变量减少了内存不一致的可能,因为volatile变量的写操作和随后读操作具有发生序关系。即volatile变量的改变对所有线程可见。另外,当一个线程读取volatile变量时,不仅仅会看到volatile变量最近的改变,一些副作用引起的改变也会看到。
使用简单的原子变量访问比使用synchronized块更有效,但需要注意内存不一致的问题。