Synchronization
线程主要通过共享对字段的访问和引用字段引用的对象来进行通信。 这种形式的通信是非常有效的,但是使两种错误成为可能:线程干扰和内存一致性错误。 而防止这些错误所需的工具则是同步。
然而,同步可能引入线程竞争,这发生在当两个或更多个线程尝试同时访问相同的资源,这时候运行时Java会更慢地执行一个或多个线程,甚至暂停执行。饥饿和活动锁(Starvation and livelock)是线程竞争的常见形式。
线程干扰
考虑一个简单的Counter类
class Counter {
private int c = 0;
public void increment() {c++;}
public void decrement() {c--;}
public int value() {return c;}
}
Counter被设计成这样使得每次调用increment将增加1到c,每次调用decrement将从c中减去1。 但是如果有多个线程引用了Counter对象,则线程间干扰可能会阻止Counter产生预期的行为。
当两个操作运行在不同的线程,但作用于相同的数据(交错),会发生线程干扰。 这意味着这两个操作由多个步骤组成,并且步骤序列产生了重叠。
由于c上的这两个操作都是单个语句,所以看起来Counter实例的操作交错是不大可能的。 然而,即使是单条语句也可以转换为虚拟机的多个步骤。 我们不会检查虚拟机所采取的具体步骤 - 只需要知道单条表达式c ++可以分解成三个步骤(c–同理):
- 获取c当前值
- 将当前值加1
- 将增加后的值存储到c
假设A线程在调用increment的同时线程B调用了decrement,假设c的初始值为0,那么A,B操作交错可能会以下面的顺序进行:
- A得到c当前值
- B得到c当前值
- A将当前值加1,得到1
- B将当前值减1,得到-1
- A将增加后的值存储到c, c = 1
- B将减小后的值存储到c, c = -1
A线程的结果丢失了,被线程B的结果给覆盖了。这样一个特殊的操作交错只是其中一种情况。还有可能B线程的结果丢失了,或者没有发生错误。因为结果是不能预测的,因此线程干扰产生的bug是很难检查到并修复的。
内存一致性错误
当不同的线程对于本应该相同的数据拥有不一致的view时就会发生内存一致性错误。 内存一致性错误的原因很复杂,超出了本教程的范围。 幸运的是,程序员不需要详细了解这些原因。 而只需要知道避免这种情况的策略。
避免内存一致性错误的关键在于理解happens-before关系。 这种关系是要保证一个特定语句的内存写入对另一个特定语句是可见的。 要理解这一点,请看示例。 假设一个int字段被定义和初始化:
int counter = 0;这个counter字段是由A,B两个线程所共享的。假设A线程增加了counter:
counter++;之后,B线程打印counter的值:
System.out.println(counter);
如果两个语句实在同一个线程里面执行的话,那么可以安全的假设打印出来的值是1。但是如果两个语句是在不同的线程里面执行的话,那么打印出来的值就可能是0,因为没有办法保证A线程对于counter值的修改对于线程B是可见的,除非程序员已经在两条语句中间建立了happens-before关系(以下称为先行发生关系)。
有几种可以创建先行发生关系的动作,其中一个是同步。我们已经看到了两个可以创建先行发生关系的动作了。
- 当一个语句调用Thread.start时,每条与之有先行发生关系的语句同样与新线程执行的每条语句有先行发生关系。 所以形成创建新线程的代码所产生的结果对于新线程是可见的。
- 当线程终止并导致另一个线程中的Thread.join返回时,则被终止的线程执行的所有语句与成功join之后的所有语句都有一个先行发生关系。 线程中的代码的效果现在对执行join的线程是可见的。
同步方法
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的一个实例,那么将这些方法同步化会带来两个效果:
- 第一:不可能有对于同一个对象的两个同步方法交错调用。当一个线程正在对于一个对象执行一个同步方法,所有其他调用相同对象的同步方法的线程都会阻塞(挂起执行)直到第一个线程调用结束。
- 第二:当一个同步方法结束后,会自动同后面的对相同对象的同步方法调用建立先行发生关系,这就保证了对对象状态的改变对于其他所有进程是可见的。
注意构造器是不能同步化的,在构造器之前加入 synchronized关键字会发生语法错误。同步构造器是不合理的,因为只有创造了对象的线程才有资格在它构造的时候访问它。
- 在构造函数一开始,this就是可用的了。
- 构造函数和普通函数一样,并不是默认被synchronized 的,有可能出现同步问题。
- 如果构造函数中访问静态变量的话,必须同步这个静态变量,否则一定会出问题。
- 如果只访问成员变量的话,无论在任何线程中,每一次构造函数被调用,其中的成员变量都是新建造出来的,因此不可能出现说在这个线程中运行的构造函数会访问到另一个线程中运行的构造函数中的成员变量的情况,因此这就是“访问成员变量不可能出现同步问题”的意思。这里的前提是,两个线程中都运行的是构造函数,而不是其他方法(例如start所启动的run方法).
- 如果在构造函数中,把this交给其他的线程去访问,则其他线程可能在this实例还未初始化完毕时就访问了其中的变量,这有可能产生同步问题。这时需要显式同步this对象。
同步方法可以实现一个简单的策略,用于防止线程干扰和内存一致性错误:如果对象对多个线程可见,则对该对象变量的所有读取或写入都将通过同步方法完成。(一个重要的例外:构建对象后无法修改的final字段,一旦对象被构造,就可以通过非同步方法安全地读取)这个策略是有效的,但是还可能出现关于liveness的问题,这个问题在后面详述。
内在锁和同步
同步是建立在一个被称作内在锁或者监视锁的内部实体上。(API中经常将这个实体简单的称作“监视器”)内在锁在同步的两方面都发挥了作用:执行对对象状态的独自访问,并建立对可见性至关重要的先行发生关系。
每个对象都有一个与之关联的内在锁。按照惯例,需要对对象的字段进行排他性和一致性访问的线程必须在访问对象之前获取该对象的内在锁,然后在完成对象时释放内部锁。线程在获取锁和释放锁之间的时间段内被称之为拥有此锁。只要一个线程拥有一个内在锁,就没有任何其他线程可以获得这个锁。另一个线程会在尝试获取这个锁时发生阻塞。当线程释放内在锁时,会在该动作与任何后续获取这个锁的行为之间建立起先行发生关系。
同步方法中的锁
当线程调用同步方法时,它会自动获取该方法的对象的内在锁并在方法结束后释放锁。即使返回是由于一个未捕获异常导致的,也会释放锁。
你可能想知道当一个static同步方法被调用的时候会发生什么,由于静态方法是与个类相关联的,而不是一个对象。在这种情况下,线程会获取这个类的字节码(即Class对象)的内在锁。因此,控制类静态字段访问权限的锁与类实例的锁是有明显区别的。
同步语句块
另一种创建synchronized代码的方法是同步语句块。与同步方法不同,同步语句块必须明确指定提供内在锁的的对象:
public void addName(String name) {
synchronized(this) {
lastName = name;
nameCount++;
}
nameList.add(name);
}
在这个例子中,addName方法需要对lastName和nameCount字段有一个同步改变,所以需要避免其他对象方法的同步调用。(从同步代码中调用其他对象的方法会引发一些问题,这些会在Liveness这部分讲述)。如果没有同步代码块,将不得不有一个分开的非同步方法来调用nameList.add。
同步块在提升有细粒度并发也大有用处。例如,假设类MsLunch有两个实例字段,它们从不一起使用。所有这些字段的更新都是同步的。但是没有理由阻止更新c1和更新c2交错——且这样做会通过创建非必要阻塞而减少了并发。这里单独的创建两个对象来提供锁,而不是使用同步方法或者使用和this相关联的锁,这里使用要小心。
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++;
}
}
}
可重入同步
线程是不能获取另一个线程拥有的锁的,但线程可以获取它已经拥有的锁。允许线程多次获取同样的锁使得可重入同步成为可能。这说明同步代码直接或间接调用了包含同步代码的方法,并且两组代码使用相同的锁。如果没有可重入同步,同步代码必须采取许多额外的预防措施,以避免线程本身阻塞。
原子访问
在编程中,原子动作是一次有效发生的动作。原子行动不能在中间停止,要么进行到底,要么根本不发生。且在动作完成之前是看不到原子动作带来的副作用的。
我们已经看到了像c++这样的增量表达式并不是一个原子动作。即使是非常简单的表达式也可以定义可以分解为其他操作的复杂动作。但是,Java里面是是可以指定原子动作的。
- 读取和写入对于引用变量和大多数原始变量(除long和double之外的所有类型)是原子的。
- 读取和写入对于声明为volatile的所有变量(包括long和double变量)都是原子的。
原子动作不能交错,所以不用担心线程干扰。然而,这并不能排除所有需要同步原子操作的原因,因为内存一致性错误仍然是可能的。使用volatile变量可以降低内存一致性错误的风险,因为对volatile变量的任何写入都会建立与该变量的后续读取的先行发生关系。这意味着对volatile变量的更改对其他线程总是可见的。更重要的是,这也意味着当一个线程读取一个volatile变量时,它不仅仅看到了对volatile的最新变化,而且还会看到导致此变化的代码带来的副作用。
使用简单的原子变量访问比通过同步代码访问这些变量更有效,但是程序员需要更多的谨慎以避免内存一致性错误。额外的努力是否值得取决于应用程序的大小和复杂性。
java.util.concurrent包中的一些类提供了不依赖于同步的原子方法。我们将在高级并发对象部分中讨论它们。