同步
线程通信主要通过对域和域指向的对象引用的共享访问进行。这种方式的通信效率很高,但是可能引发两类错误:thread interference(线程干扰)和memory consistency errors (内存一致性错误)。synchronization(同步)可以防止这些错误。
然后,同步又会引起thread contention(线程争用)。当两个或以上线程同时访问同一资源,导致Java运行时系统执行某些线程特别缓慢,甚至暂停了它们的执行,这就是线程争用。饥饿和活锁都是线程争用的形式。参见。。
本文涵盖了以下主题:
- 线程干扰讲述了多个线程访问共享数据时如何引发错误
- 内存一致性错误讲述了由共享内存的不一致视图导致的错误
- 同步方法讲述了一种防止线程干扰及内存一致性错误的简单方法
- 隐式锁和同步讲述了一种更加通用的实现同步的方法,以及同步如何。。
- 原子访问讨论了不会被其他线程干扰的操作的通用思想
线程干扰
考虑一个叫Counter的简单类:
class Counter { private int c = 0; public void increment() { c++; } public void decrement() { c--; } public int value() { return c; } }
Counter的每次increment调用使c加1,每次decrement调用使c减1。然而,如果一个Counter对象被多个线程引用,线程间的干扰可能会使得这一行为和预期不一致。
不同线程中的两个操作交错地作用于同一数据时,就会发生线程干扰。这意味着这两个操作是由多个步骤组成,并且这些步骤的序列有重叠。
Counter实例上的操作看起来不可能重叠,因为对于c的操作都是单个简单的语句。然而,即使是简单的语句,也有可能被虚拟机翻译成多个步骤。对于虚拟机采取的具体步骤我们不做深入探讨——我们只需要知道单个表达式c++可以被分解成3个步骤:
- 获取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的结果丢失,也可能根本没有错误。由于发生线程干扰时,程序的行为难以预料,所以这类漏洞很难检测和修复。
内存一致性错误
当不同线程对于同一数据有着不一致的视图时,就发生了内存一致性错误。这种错误的原因很复杂,超出了本文的讨论范围。幸好,程序员不需要详细地了解其原由,只需要了解避免这种错误的策略。
避免内存一致性错误的关键在于理解happens-before(之前发生)关系。这种关系确保了某一语句写的内存对于另一语句是可见的。为了帮助理解,考虑下面这个例子。假设我们定义并初始化了一个简单的int域:
int counter = 0;
counter域由A,B两个线程共享。假设线程A增加了counter:
counter++;
紧接着,线程B打印了counter:
System.out.println(counter);
如果两个语句是在同一线程中执行的,我们可以确定打印出来的值是“1”。但是如果两个语句执行在不同的线程中,打印出来的值可能为“0”,因为无法保证A对counter的改变对B是可见的——除非程序员在这两语句之间建立一个之前发生关系。
有些动作可以建立之前发生关系,其中一个就是同步。
我们已经见过了可以建立之前发生关系的动作:
- 如果一个语句调用Thread.start,那么和该语句存在之前发生关系的语句与新线程中执行的所有语句都存在之前发生关系。创建新线程之前的代码的效果对于新线程是可见的。
- 如果一个线程结束并导致另一线程中的Thread.join返回,那么结束的线程中执行的语句和所有join之后的语句存在之前发生关系。也就是说,线程中的代码的效果对于完成join的线程是可见的。
同步方法
Java语言提供了两种基本的同步用法:synchronized methods(同步方法)以及synchronized statements(同步语句块)。两者中比较复杂的同步语句块将在下节中讲述,本节讲述同步方法。
要让一个方法同步,只需要将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关键词会导致语法错误。同步构造器没有实际意义,因为在构造对象时,只有创建对象的线程才能访问该对象。
警告:当构造一个会在线程间共享的对象时,要小心对象引用过早地泄露。比如,假设你想要维护一个叫做instances的List,用来包含每个实例。你可能试图将下面这行代码加到构造方法中:
instances.add(this);
但其他线程就能在对象构造完成之前通过instances访问对象。
同步方法使我们能够用一个简单的策略来防止线程干扰及内存一致性错误:如果一个对象对多个线程可见,所有对该对象的变量的读写都应该通过同步方法完成。(一个重要的例外是:final域,它在对象构造完成后便不能修改,因此可以通过非同步方法安全地读取)这种策略很有效,但是会出现有关liveness(活性)的问题。
内部锁和同步
同步是在一个被称为intrinsic lock(内部锁)或monitor lock(监控锁)的内部实体的基础上构建的。(API规范中通常将其简称为“monitor”)。内部锁在同步的两个方面都起到了作用:实施对象状态的互斥访问以及建立对可视性很重要的之前发生关系。
每个对象关联了一个内部锁。根据约定,线程在对一个对象的域进行互斥和一致性访问之前,必须获得该对象的内部锁,并在访问结束后释放内部锁。线程在获得内部锁到释放内部锁的这段时间内,被认为拥有该内部锁。只要一个线程拥有内部锁,其他的线程就不能获得该内部锁,它们在尝试获取内部锁时会阻塞。
线程释放内部锁后,就在该动作和之后对同一个锁的获取之间建立了之前发生的关系。
同步方法中的锁
线程调用同步方法时,会自动获取该方法的对象的内部锁,并在方法返回时释放内部锁。即使方法通过未捕获的异常返回,内部锁也会被释放。
由于静态方法和类而不是对象关联,你可能会好奇调用静态同步方法时会发生什么。在这种情况下,线程会获取和类关联的Class对象的内部锁。因此,对类的静态域的访问也是由一个锁控制的,但这个锁不同于类的任意实例的锁。
同步语句
创建同步代码的另一种方法是使用synchronized statements。和同步方法不同的是,同步语句必须指定提供内部锁的对象:
public void addName(String name) { synchronized(this) { lastName = name; nameCount++; } nameList.add(name); }
在这个例子中,addName方法不仅需要同步对lastName以及nameCount的修改,还要避免同步其他对象的方法调用。(在同步代码块中调用其他对象的方法会导致Liveness的问题)如果不使用同步语句,必须得有另一个非同步的方法来调用nameList.add。
同步语句对于用细粒度的同步改善并发也很有用。比如,类MsLunch有两个实例域c1和c2,并且这两个域不会一起使用。对这些域的更新操作必须同步,但是没理由阻止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++; } } }
使用这种方式要特别小心。你必须确保对影响域的交错访问是安全的。
可重入同步
线程无法获取别的线程占有的锁,但是可以获取它已经占有的锁。允许线程多次获取一个锁使reentrant synchronization(可重入同步)成为可能。当同步代码直接或间接地调用了一个同样包含同步代码的方法时,就需要可重入同步。如果没有可重入同步,同步代码必须采取很多额外的预防措施来避免线程导致自身的阻塞。
原子访问
在编程中,原子动作是指同时有效发生的动作。一个原子动作不能中途停止:它要么完全发生,要么完全不发生。原子动作的副作用直到该动作完成才可见。
我们已经知道,像c++这样的一个增量表达式,并不是一个原子动作。即使是非常简单的表达式也可能定义了能分解成其他动作的复杂动作。不过,有些动作你可以认为是原子动作:
- 引用变量和大多数基本类型变量(除了long和double之外的所有基本类型)的读和写是原子的
- 所有声明为volatile的变量(包括long和double)的读和写是原子的
原子动作不会交错,因此使用它们就无需担心线程干扰。然而,这并没有达到同步原子动作的所有要求,因为内存一致性错误依然可能发生。可以使用volatile变量来降低发生内存一致性错误的风险,因为任何对volatile变量的写操作都与之后对其的读操作建立了之前发生关系。这意味着volatile变量的变化对其他线程总是可见的。而且,当线程读取一个volatile变量时,它不仅能看到变量最近的变化,还能看到导致该变化的代码的副作用。
使用简单的原子变量访问比通过同步代码访问变量效率更高,但需要程序员更加小心地避免内存一致性问题。额外的努力是否值得取决于应用的规模和复杂程度。
java.util.concurrent包中的一些类提供了不依赖于同步的原子方法,我们将在之后讲到。