前言
线程主要通过共享对字段和引用字段所引用的对象的访问进行通信。 这种交流方式非常有效,但是有可能出现两种错误:线程干扰(thread interference)和存储一致性(memory consistency) 错误。_synchronization是防止这些错误所必需的工具。_
然而,当两个或者更多的尝试同时访问相同的资源的时候,synchronization 会引入线程争夺,然后导致Java运行时以更慢的速度去执行一个或者多个线程,或者甚至挂起他们的执行。饥饿和活锁是线程争用的两种形式。
目录
一、线程干扰。描述了错误如何在多线程访问共享数据的时候引进错误。
二、内存一致性错误。描述由于共享内存的不一致意见而导致的错误。
三、同步方法。描述了一个可以有效的防止线程干扰和内存一致性错误的简单语法。
四、内在锁(隐式锁)和同步。描述了一个更加普遍的同步语法,以及描述了如何基于隐式锁进行同步。
五、原子访问。讨论了不能被其他线程干扰的操作的一般思想。
一、线程干扰
首先来看看一个简单的场景。
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。但是,如果计数器对象是从多个线程引用的,线程之间的干扰可能会阻止这种预期的情况发生(也就是说这个代码在多线程的环境下运行的话,可能达不到调用 increment() 加1,调用 decrement() 减1的效果)。
当在不同线程中运行但作用于相同数据的两个操作交错时,就会发生干扰。这意味着这两个操作由多个步骤组成,并且步骤序列重叠。
看起来似乎Counter实例的操作不可能交错,因为针对变量c的两个操作都是单一的、简单的语句。然而,尽管是简单的语句也要被虚拟机转化为多步。我们不用检查虚拟机采用的具体步骤也足以知道单独的表达式c++
会被拆分为3步。
- 检索c变量的当前值。
- 将检索到的值加1。
- 将操作后的值存储会c变量。
表达式c--
会以同样地方式被分解,除了第二步是减而不是加。
假设线程A调用 increment() 的同时,线程B调用 decrement() 。如果变量c的初始化的值为0,那么交错的动作将和下面的顺序一样:
- 线程A查询变量
c==0
- 线程B查询变量
c==0
- 线程A 自增查询到的值,结果为
c==1
- 线程B 自减查询到的值,结果为
c==-1
- 线程A存储变量c的结果,
c==1
- 线程B存储变量c的结果是,
c==-1
线程A的结果c==1
丢失了,被线程B的c==-1
覆盖了。这个部分的交错只是一种可能性。在不同的环境下,可能是线程B的值丢失,或者不会有错误。因为结果是无法预知的,所以线程干扰bugs很难被测出和修复。
二、内存一致性错误
当不同的线程对应该相同的数据有不一致的意见的时候,内存一致性错误就会发生。内存一致性错误的元婴是很复杂的,超出了本次教程的范围。幸运的是,程序员不需要细节地明白这些原因。所需要的是一个策略来避免他们。
避免内存一致性错误的关键是明白 happens-before 的关系。这个关系是简单地保证内存被一个指定的语句写入对另外的特定的语句是可见的。
案例1:
假设一个简单的int字段被定义和初始化int counter = 0;
这个counter变量在线程A和线程B两个线程之间共享。假设线程A对counter增加counter++
然后,不久之后,线程B打印counter字段System.out.println(counter);
如果这两个语句在同一个线程里面被执行的话,它可以放心地认为打印的值会是“1”。但是如果两个语句在不同的线程中被执行,这个打印的值可能是“0”,因为无法保证线程A对counter字段的改变对线程B是可见的,除非程序员有在这两个语句之间建立happen-before 关系。
有几种动作来创建happens-before关系。其中的一种是synchronization。我们已经看过了两个创建happen-before关系的动作。
- 当某个语句调用Thread.start() 的时候,和该语句有happens-before关系的每一个语句都和新创建的线程所执行的每一个语句都具备happens-before关系。导致创建新线程的代码的效果对新线程可见。
- 当一个语句终止的导致Thread.join方法在另外的线程中return,然后被终止的线程所执行的所有语句和所有在成功join之后的所有语句都有happens-before关系。在线程中的代码效果此刻被执行join的线程可见。
想要知道创建happens-before关系的列表,请参考 Summary page of thejava.util.concurrentpackage..
三、同步方法
Java编程语言提供了两种基本的synchronization语法:synchronization 方法和 synchronization 语句块。两个之中,同步语句块比同步方法复杂一些,但是也更推荐使用同步语句块。
只需简单地将 synchronized 关键字添加到方法的声明中就可以让方法同步。将上面的Counter类改写一下
class SynchronizedCounter {
private int c = 0;
public synchronized void increment() {
c++;
}
public synchronized void decrement() {
c--;
}
public synchronized int value() {
return c;
}
}
使这些方法同步之后有两种影响:
- 首先,对同一个对象的的同步方法两次调用不可能有交错。当一个线程执行某个对象的同步方法的时候,其余的所有线程调用同一个对象的同步方法的时候就会阻塞直到第一个线程处理完该对象。
- 其次,当同步方法退出时,它会自动与同一对象的同步方法的任何后续调用建立 happens-before 关系。这保证了对象状态的更改对所有的线程都可见。
注意:构造方法不能同步,给构造器使用 synchronized 关键字是一个语法错误。同步构造器没有意义,因为只有创建对象的线程才应该在对象被构造时访问它。
同步方法提供了一种防止线程干扰和内存一致性错误的简单策略:如果对象被多个线程可见,所有的对对象的变量的读/写都通过 synchronized 方法完成。(一个重要的例外就是一旦一个在对象构造之后不能被修改的final字段的对象被构造之后,可以通过非synchronized方法安全的读取)这种方式是有效的,但是会存在liveness 的问题。
四、隐式锁和同步
同步是围绕隐称为隐式锁或监视锁的内部实体所构建的(API规范通常简单地将它称之为监视器)。隐式锁在同步的强制对对象状态的独占访问 和 在建立对可见性至关重要的关系之前先建立关系等两个方面都起到作用。
每一个对象都有和同步有关联的隐式锁。按照惯例,需要对对象字段进行排他性和一致性访问的线程必须在访问对象字段之前获取对象的内在锁,然后在使用完它们之后释放内在锁。线程在获取隐式锁和释放隐式锁期间内拥有隐式锁。只要一个线程拥有隐式锁,那么就没有其他的线程可以获取到相同的锁,当另一个线程想要获取锁的时候就会阻塞。
当线程释放隐式锁的时候,会在该操作和后续获取相同的锁之间建立 happens-before 关系。
同步方法的锁
当线程调用同步方法的时候,它会自动地获取该方法对象的隐式锁,在方法return的时候再释放。尽管如果是没有捕获异常到导致的return ,锁也会释放。
你可能想知道静态同步方法被调用的时候会发生什么,因为静态方法是和类相关联的,而不是对象。在这种情况下,线程获取与类关联的Class对象的内部锁。因此,对类的静态字段的访问由一个与类的任何实例的锁不同的锁控制。
同步语句块的锁
同步语句块是创建同步代码的的另一个方式。和同步方法不同,同步语句块必须指定提供隐式锁的对象。
public void addName(String name) {
synchronized(this) {
lastName = name;
nameCount++;
}
nameList.add(name);
}
在这个方法中,只是对lastName = name; nameCount++;
这两行代码进行了同步,而对nameList.add(name);
没有进行同步,如果没有同步语句块的话,则需要将两部分进行差拆分,然后为了执行nameList.add(name);
专门使用非同步的方法。
同步语句块也可用于细粒度的同步来提高并发性。看看简单的场景:
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++;
}
}
}
假设,MsLunch类有两个从未一起使用的字段 c1 和 c2 。这些字段的全部更新都必须同步,但是没有理由阻止c1的更新与c2的更新交织,这样做通过创建不必要的阻塞来减少并发性。创建两个对象来单独提供锁,而不是使用同步方法或使用与此相关联的锁。要非常小心地使用这种语法。您必须绝对确保交叉访问受影响字段真的是安全的
可重入同步
回想一下,一个线程无法获取到被另外的线程所拥有的锁。但是线程可以获取到它已经拥有的锁。允许线程多次获取相同的锁可以实现可重入同步。这描述了这样一种情况:同步代码直接或间接地调用同样包含同步代码的方法,并且这两组代码使用相同的锁。如果没有可重入同步,同步代码将不得不采取许多额外的预防措施,以避免线程导致自己阻塞。
五、原子访问
在编程中,原子动作是一种有效地同时发生的动作。原子动作不能中途停止:要么全部发生,要么都不发生。原子操作的副作用在操作完成之前是不可见的。
之前说到就算是c++
这种单一的语句也会被分解成许多操作。以下的操作可以指定为原子的:
- 对引用变量和大多数原始的变量(除了long 和double之外的所有原始数据类型)的读/写。
- 对所有声明为volatile的变量的读/写(包括long 和double变量)。
原子操作不能被交错,所以可以不用害怕线程干扰地去使用。然而,这不能消除同步原子操作的全部需要,因为内存一致性错误还是可能发生的。使用volatile变量可以降低内存一致性错误的风险,因为对volatile变量的任何写入都会与该变量的后续读取建立 happens-before 的关系。这就意味着对 volatile 变量的更改总是对其他的线程可见。而且,这也意味着当线程读取 volatile 变量的时候,它看到的不仅是volatile最新的更改,还有导致更改的代码的副作用。
使用简单的原子变量访问比通过同步代码访问这些变量更有效,但需要程序员更加小心以避免内存一致性错误。这些额外的工作是否值得取决于应用程序的大小和复杂性。