`
623deyingxiong
  • 浏览: 188360 次
  • 性别: Icon_minigender_1
  • 来自: 北京
社区版块
存档分类
最新评论

同步(Concurrency Tutorial 3)

阅读更多
同步(Synchronization)

线程之间的通信主要是通过共享变量和变量指向的对象。这种形式的通信非常高效,但是产生了两种可能的错误:线程干扰(thread interference)和读脏数据(memory consistency errors)。我们需要使用同步来避免这些错误。
  • 线程干扰 描述了当多个线程同时访问共享资源时错误是怎样产生的。
  • 读脏数据 描述了访问共享资源时得到结果与预想结果不一致的错误。
  • 同步方法 描述了可以高效地避免线程干扰和读脏数据的一个简单的惯用法(a simple idiom)。
  • 隐含锁与同步 描述了一个更普适(general)的同步惯用法,描述了同步是如何基于隐含锁实现的。
  • 原子访问 讨论了操作(operations)如何不会被其他线程干扰的方法。


线程干扰

考虑一个叫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对象被多个线程引用,线程间的干扰将导致意外的调用结果。

当加减操作分别运行在不同的线程中,交替(interleave)地对c做操作。这意味着每次调用要分几步完成,两次调用的执行过程存在重叠(overlap)部分。

表面上看起来,让多个Counter实例的两个操作交替执行是不可能的,因为对c的加减操作都只有一条语句。然而虚拟机在执行单一语句时可能会分几步完成。我们不会测试虚拟机到底用几步完成一个操作。但我们已经知道单个语句c++会被分解成三步来执行:
  1. 得到当前c的值。
  2. 对当前值加1.
  3. 将结果存回c.

c--语句也会用同样的方式被分解,除了第二步是减操作而不是加操作。

假设A线程调用加操作,同时B线程调用减操作。如果c的初始值是0,他们的交替执行将可能是这样进行的:
  1. 线程A:得到c的值
  2. 线程B:得到c的值
  3. 线程A:当前值+1;结果是1
  4. 线程B:当前值-1;结果是-1
  5. 线程A:将结果存回c;c=1
  6. 线程B:将结果存回c;c=-1


线程A的结果被B覆盖了,丢掉了。上面所说的只是交替执行的一种情形。不同的情形下,线程B的结果也可能丢失,又或者执行的结果并没有错误。因为执行过程是无法预测的,线程干扰的bugs很难被检测和修改。

读脏数据(Memory Consistency Errors)

读脏数据就是不同线程读同一数据时不一致。导致读脏数据的原因是复杂的,超出了本指南的范围。幸运的是,程序员不需要详细地理解这些原因。你需要的只是避免他们发生的策略。

避免读脏数据的关键是理解一个先后(happens-before)关系。这个关系简单地说就是要保证一个线程的写操作必须对另一个线程可见。为了更容易理解,我们考虑下面的一个例子。假定一个int型字段被这样定义并初始化:
	int counter =0;


counter在两线程间共享,线程A和线程B。假定线程A对counter做加操作:
counter++;
不久以后(shortly afterwards),线程B打印出了counter:
	System.out.println(counter);

假如两条语句运行在同一线程中,你说打印出的结果是1,毫无疑问你是对的。但是如果两个语句在不同线程中运行,那么你就不能保证线程B打印出的结果一定是0,因为你不能保证线程A对counter的改变会影响线程B(对B可见)——除非你能建立两条语句执行的先后关系。

建立先后关系有这几种方法。其中一种就是同步,我们将在下面的章节中见到。

在多线程情形下,我们有两种建立先后关系的方法:
  1. 当我们调用thread.start时,与此调用有先后关系的其他语句和start所运行的语句也会建立先后关系。导致创建新线程的线程中的代码结果对新线程是可见的。
  2. 若线程A被线程B邀请执行(通过thread.join)。当线程A终止,邀请(Thread.join)线程A执行的线程B重新恢复执行,此时线程A执行的代码和线程B中thread.join之后的代码就建立了先后关系。线程A中代码执行的结果对线程B就是可见的。
同步方法(synchronized methods)

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的实例,此同步策略同步将导致两种结果:
  • 不可能交替地执行count的increment和decrement(注意,此处的交替是指increment执行到一半时,CPU时间结束,转而执行另一线程的decrement,decrement执行到一半,CPU时间结束,转而执行原线程的increment。如此交替。)。在一个线程执行一个对象的同步方法时,若此时其他线程调用此对象的同步方法(不论是不是同一个同步方法)将导致等待,直到已经运行的同步方法退出。
  • 当一个同步方法退出时,它将自动与后续调用中的同属一个对象的同步方法建立一个先后关系。这保证了对象状态的改变对其他线程是可见的。


注意,构造器是不能同步的,在构造器声明中使用synchronized关键字将被认为一个语法错误。同步构造器没有任何意义,因为只有创建对象的那个线程能够在对象创建时访问其构造器,不存在同一对象的创建有多个线程参与的情形。

警告:当构造一个对象在多个线程间共享时,要小心对象的引用不要过早地暴露出去。譬如,假设你维护了一个叫作instances的List,该List中包含了所有类的实例。你可能尝试在你的类构造器中添加下面的代码:
	Instances.add(this);//(这样写就过早地暴露了你的对象引用)

其他线程就可以在对象没有完全构造完成时访问instances中该对象的引用。

同步方法为避免线程干扰和读脏数据提供了一个简单的策略:如果你的对象对多个线程可见,那么所有对对象数据成员的读写操作都要通过同步方法进行。(一个重要的例外:final 字段在对象创建后是不能被修改的,可以很安全地被非同步方法访问)。这个策略是高效的,但存在线程活性问题,线程活性问题我们将在以后的课程中讲到。

(译者注:同步方法被挂起的状态与wait不同,它不需要用notify来唤醒。同样,一个同步方法退出时释放锁,并不会唤醒wait该锁的线程,只会导致另一调用同步方法挂起线程的执行。)

内含锁(intrinsic locks)与同步

同步是围绕一个内部实体(internal entity)建立的,这个内部实体叫作内含锁(intrinsic lock)或监控锁(monitor lock)。(API规范经常把此实体简单地叫作“监控”)。内含锁同时扮演了同步中两方面的角色:强制对对象状态的独占访问和建立必要的先后关系以实现可见性(visibility)。

每一个对象都有一个关联的内含锁。通常,一个需要独占并一致性地访问对象数据成员的线程都要在访问之前先取得(acquire)对象的内含锁,然后在访问操作完成后释放(release)对象的内含锁。在线程获取内含锁之后,释放内含锁之前的这段时间,我们说线程拥有(own)内含锁。一旦线程拥有了内含锁,其他的线程不能再获取到相同的锁。其他的线程尝试获得该锁将阻塞(block)。

当一个线程释放一个内含锁时,拥有锁期间的操作与后续(subsequent)的对同一锁的请求之间建立了先后关系。

同步方法的锁

当一个线程调用一个同步方法时,它自动获得了一个方法所属对象的内含锁,当调用方法退出时,自动释放该内含锁。即使退出时因为抛出了未能捕获的异常,锁也会被释放。

你也许想知道当静态方法被调用时会发生什么,因为静态方法是绑定到类而不是绑定到对象的。此种情况下,线程会请求(acquires)类关联的类对象(每一个类都有一个class对象)的内含锁。这样,所有对静态成员变量的访问都被一个与任何实例无关的锁控制。

同步代码块(synchronized statements)

创建同步代码的另一种方式是同步代码块。与同步方法不同,同步代码块必须显式指定提供内含锁的对象:
public void addName(String name) {
    synchronized(this) {
        lastName = name;
        nameCount++;
    }
    nameList.add(name);
}
在本例中,addName方法需要同步对lastName和nameCount的改变。但是还需要避免对对象中其他同步方法的调用。(在同步代码块中调用对象的其他方法将导致在上一节中提到的活性问题(貌似此例中不存在此问题,因为提供锁的是this,而同步方法用的锁也是this提供的,这个叫作重入,下面有讲。))。如果不使用同步块,我们必须分出一个方法专门(for the sole purpose of)调用nameList.add。

同步代码块也用来改善同步的并发粒度。举个例子,假如MsLunch类有两个成员变量c1和c2,它们之间没有任何关系。要求所有对这些成员变量的更新必须是同步的,但是没有必要阻止c1更新操作和c2更新操作的交叉执行,同步方法或使用相同的对象锁会导致不必要的阻塞,降低并发程度。为此,我们创建两个专门提供锁的对象,而不是使用同步方法或者使用this关联的锁。(笔者注:对方法声明加sychronized关键字和对整个方法体用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)

一个线程不能获取另一个线程已经拥有的锁。但是一个线程可以获取它已经拥有的锁。允许一个线程多次获取同一个锁叫作同步重入。这里描述了一种情形,一个同步代码块可以直接或间接地调用一个同样包含同步代码块的方法,前提是两个代码块都使用同一个锁。如果没有同步重入机制,被同步的代码必须采取额外的措施避免线程自己把自己锁死。

笔者心得:
  • 为了提高并发,你需要确保你同步的代码是原子性的,即一定是不可分割(要么全做,要么一点也不做)的操作。使用相同锁的同步方法或同步代码块是有先后关系的,反之使用不同的锁,意味着可以交叉执行。
  • 当一个线程退出时,它会自动唤醒所有等待队列中的其它线程。


原子访问(Atomic Access)

在编程中,原子操作是一旦执行立刻生效的操作。原子操作不能中断:要么全做,要么不做。原子操作的结果在执行完成前是不可见的。

我们已经见识过加操作c ++,它不是原子操作。即使是最简单的表达式也可以是一个复杂的操作,复杂的操作可以分解成其他操作。然而,有些操作一定是原子的:
  • 对引用类型变量(reference variables)和大多数基本类型变量(primitive variables)(除了long 和double以外的所有类型)的读写操作是原子的。
  • 对于所有声明为volatile(包括long和double变量)的变量的读写操作是原子的。


多个原子操作不能交叉进行,所以使用它们时不用担心线程干扰。然而,即便是这样,我们还需要对多个原子操作进行同步。因为读脏数据还是有可能发生的。使用volatile变量降低了读脏数据的风险,因为对volatile变量的任何写操作都建立了与后续对同一变量读操作的先后关系。这意味着对volatile变量的改变对其他线程始终可见的。而且(What’s more),这也意味着当线程读到volatile变量时,它看到的不仅仅是对volatile变量的最后修改,也看到了修改变量的代码的最终执行结果。

使用简单的原子变量访问比通过同步代码访问变量更高效(efficient),但是程序员需要小心避免读脏数据。额外的代码是否值得付出取决于程序的大小和复杂度。

java.util.concurrent包中的一些类提供了不依赖同步的原子方法。我们将在高级并发对象(High Level Concurrency Objects)章节中讨论它们。
分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics