要编写正确的并发程序,关键问题在于:在访问共享的可变状态时需要进行正确的管理。第2章介绍了如何通过同步来避免多个线程在同一时刻访问相同的数据, 而本章将介绍如何共享和发布对象,从而使它们能够安全地由多个线程同时访问。 这两章合在一 起, 就形成了构建线程安全类以及通过java.util.concurrent 类库来构建并发应用程序的重要基础。
我们已经知道了同步代码块和同步方法可以确保以原子的方式执行操作, 但一种常见的误解是,认为关键字synchronized只能用于实现原子性或者确定 “临界区(Critical Section)"。同步还有另一个重要的方面:内存可见性(MemoryVisibility)。 我们不仅希望防止某个线程正在使用对象状态而另一个线程在同时修改该状态, 而且希望确保当一个线程修改了对象状态后,其他线程能够看到发生的状态变化。 如果没有同步, 那么这种情况就无法实现。 你可以通过显式的同步或者类库中内置的同步来保证对象被安全地发布。
可见性
可见性是一种复杂的属性,因为可见性中的错误总是会违背我们的直觉。 在单线程环境中, 如果向某个变量先写入值, 然后在没有 其他写入操作的情况下读取这个变量, 那么总能得到相同的值。 这看起来很自然。 然而,当读操作和写操作在不同的线程中执行时, 情况却并非如此, 这听起来或许有些难以接受。 通常, 我们无法确保执行读操作的线程能适时地看到其他线程写入的值,有时甚至是根本不可能的事情。 为了确保多个线程之间对内存写入操作的可见性, 必须使用同步机制。
在程序清单3-1中的NoVisibility说明了当多个线程在没有同步的情况下共享数据时出现的错误。在代码中,主线程和读线程都将访问共享变量ready和number。主线程启动读线程,然后将number 设为42, 并将ready设为true。读线程一直循环直到发现ready的值变为true,然后输出number的值。虽然NoVisibility看起来会输出42, 但事实上很可能输出0, 或者根本无法终止。这是因为在代码中没有使用足够的同步机制,因此无法保证主线程写入的ready值和number 值对于读线程来说是可见的。
NoVisibility可能会持续循环下去, 因为读线程可能永远都看不到ready的值。一种更奇怪的现象是, No Visibility可能会输出0, 因为读线程可能看到了写入ready的值,但却没有看到之后写入number的值, 这种现象被称为“ 重排序(Reordering) "。只要在某个线程中无法检测到重排序情况(即使在其他线程中可以很明显地看到该线程中的重排序), 那么就无法确保线程中的操作将按照程序中指定的顺序来执行. 当主线程首先写入number, 然后在没有同步的情况下写入ready, 那么读线程看到的顺序可能与写入的顺序完全相反。
在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整。在缺乏足够同步的多线程程序中,要想对内存操作的执行顺序进行判断,几乎无法得出正确的结论。
失效数据
NoVisibility展示了在缺乏同步的程序中可能产生错误结果的一种情况:失效数据。当读线程查看ready变量时, 可能会得到一个已经失效的值。除非在每次访问变量时都使用同步, 否则很可能获得该变量的一个失效值。更糟糕的是,失效值可能不会同时出现: 一个线程可能获得某个变量的最新值,而获得另一个变量的失效值。通常, 当食物过期(即失效)时,还是可以食用的,只不过味道差了一些。但失效的数据可能导致更危险的情况。 虽然在Web应用程序中失效的命中 计数器可能不会导致太糟糕的情况 , 但在其他情况中,失效值可能会导致一些严重的安全问题或者活跃性问题。在No Visibility中, 失效数据可能导致输出错误的值, 或者使程序无法结束。如果对象的引用(例 如链表中的指针)失效, 那么情况会更复杂。失效数据还可能导致一些令人困惑的故障, 例如意料之外的异常、被破坏的数据结构、 不精确的计算以及无限循环等 。
非原子的64位操作
当线程在没有同步的情况下读取变量时, 可能会得到一个失效值, 但至少这个值是由之前某个线程设置的值, 而不是一个随机值。 这种安全性保证也被称为最低安全性(out-of-thin-air safety)。
最低安全性适用于绝大多数变量,但是存在一个例外:非volatile类型的64位数值变量 (double和long,请参见3.1.4节)。Java内存模型要求,变量的读取操作和写入操作都必须是原子操作,但对于非volatile类型的long和double变量,JVM允许将64位的读操作或写操作分解为两个 32 位的操作。读取一个非 volatile 类型的 long 变量时,如果对该变量的读操作和写操作在不同的线程中执行, 那么很可能会读取到某个值的高 32 位和另一个值的低 32 位。因此, 即使不考虑失效数据问题, 在多线程程序中使用共享且可变的 long和double等类型的变量也是不安全的,除非用关键字volatile来声明它们,或者用锁保护起来。
加锁与可见性
内置锁可以用于确保某个线程以一种可预测的方式来查看另一个线程的执行结果, 如图 3-1 所示。 当线程A执行某个同步代码块时, 线程B随后进入由同一个锁保护的同步代码块, 在这种情况下可以保证, 在锁被释放之前, A 看到的变量值在 B 获得锁后同样可以由B看到。换句话说, 当线程B执行由锁保护的同步代码块时,可以看到线程A之前在同一个同步代码块中的所有操作结果。如果没有同步,那么上述实现无法实现。
现在, 我们可以进一步理解为什么在访问某个共享且可变的变量时要求所有线程在同一个锁上同步, 就是为了确保某个线程写入该变量的值对于其他线程来说都是可见的。否则,如果一个线程在未持有正确锁的情况下读取某个变量, 那么读到的可能是一个失效值。
加锁的含义不仅仅局限于互斥行为,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读操作或者写操作的线程都必须在同一个锁上同步。
Volatile变量
Java 语言提供了一种稍弱的同步机制, 即volatile 变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为 volatile 类型后, 编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。 volatile 变量不会被缓存在寄存器或者对其他处理器不可见的地方, 因此在读取 volatile 类型的变量时总会返回最新写入的值。
在访问 volatile变量时不会执行加锁操作, 因此也就不会使执行线程阻塞, 因此 volatile变量是一 种比sychronized关键字更轻量级的同步机制。
volatile变量对可见性的影响比volatile变量本身更为重要。当线程 A 首先写入一 个volatile 变量并且线程B随后读取该变量时, 在写入 volatile 变量之前对A可见的所有变量的值,在B读取了volatile 变量后,对B也是可见的。因此,从内存可见性的角度来看,写入 volatile 变量相当于退出同步代码块,而读取 volatile 变量就相当于进入同步代码块。然而,我们并不建议过度依赖 volatile 变量提供的可见性。如果在代码中依赖 volatile 变量来控制状态的可见性通常比使用锁的代码更脆弱,也更难以理解。
仅当Volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用它们。如果在验证正确性时需要对可见性进行复杂的判断,那么就不要使用volatile变量。volatile变量的正确使用方法包括:确保它们自身状态可见性,确保它们所引用对象的状态的可见性,以及标识一些重要的程序生命周期时间的发生(例如初始化和关闭)。
当且满足以下所有条件时,才应该使用volatile:
a.对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
b.该变量不会与其他状态变量一起纳入不变性条件中
c.在访问变量时不需要加锁
发布与逸出
“发布(Publish)" 一个对象的意思是指, 使对象能够在当前作用域之外的代码中使用。例如,将一个指向该对象的引用保存到其他代码可以访问的地方,或者在某一个非私有的方法中返回该引用,或者将引用传递到其他类的方法中。在许多情况中,我们要确保对象及其内部状态不被发布。而在某些情况下, 我们又需要发布某个对象, 但如果在发布时要确保线程安全性, 则可能需要同步。发布内部状态可能会破坏封装性, 并使得程序难以维持不变性条件。例如, 如果在对象构造完成之前就发布该对象, 就会破坏线程安全性。当某个不应该发布的对象被发布时, 这种情况就被称为逸出(Escape)。
发布对象的最简单方法是将对象的引用保存到一个公有的静态变量中, 以便任何类和线程都能看见该对象。
如果按照上述方式来发布states, 就会出现问题, 因为任何调用者都能修改这个数组的内 容。 在这个示例中, 数组states 已经逸出了它所在的作用域, 因为这个本应是私有的变量已经 被发布了。
当发布一个对象时, 在该对象的非私有域中引用的所有对象同样会被发布。一般来说, 如果一个已经发布的对象能够通过非私有的变最引用和方法调用到达其他的对象, 那么这些对象也都会被发布。假定有一个类C, 对于C来说,“外部(Alien)方法” 是指行为并不完全由C来规定的方法,包括其他类中定义的方法以及类C中可以被改写的方法(既不是私有[private]方法也不是终结[final]方法)。当把一个对象传递给某个外部方法时, 就相当于发布了这个对象。你无法知道哪些代码会执行, 也不知道在外部方法中究竟会发布这个对象, 还是会保留对象的引用并在随后由另一个线程使用。
无论其他的线程会对已发布的引用执行何种操作,其实都不重要, 因为误用该引用的风险始终存在。当某个对象逸出后,你必须假设有某个类或线程可能会误用该对象。这正是需要使用封装的最主要原因:封装能够使得对程序的正确性进行分析变得可能,并使得无意中破坏设计约束条件变得更难。
最后一种发布对象或其内部状态的机制就是发布一个内部的类实例, 如程序清单3-7的ThisEscape 所示。当ThisEscape发布Even心stener时, 也隐含地发布了ThisEscape 实例本身,因为在这个内部类的实例中包含了对ThisEscape 实例的隐含引用。
安全的对象构造过程
在ThisEscape中给出了逸出的一个特殊示例,即this引用在构造函数中逸出。当内部的EventListener实例发布时, 在外部封装的ThisEscape实例也逸出了。当且仅当对象的构造函数返回时, 对象才处于可预测的和一致的状态。因此,当从对象的构造函数中发布对象时, 只是发布了一个尚未构造完成的对象。即使发布对象的语句位于构造函数的最后一行也是如此。如果this引用在构造过程中逸出, 那么这种对象就被认为是不正确构造.
在构造过程中使this引用逸出的一个常见错误是,在构造函数中启动一个线程。当对象在其构造函数中创建一个线程时, 无论是显式创建(通过将它传给构造函数)还是隐式创建(由Thread或Runnable是该对象的一个内部类),this引用都会被新创建的线程共享。在对象尚未完全构造之前, 新的线程就可以看见它。在构造函数中创建线程并没有错误,但最好不要立即启动它, 而是通过一个start或initialize方法来启动。在构造函数中调用一个可改写的实例方法时(既不是私有方法, 也不是终结方法),同样会导致this引用在构造过程中逸出。
如果想在构造函数中注册一个事件监听器或启动线程,那么可以使用一个私有的构造函数和一个公共的工厂方法(Factory Method), 从而避免不正确的构造过程,如程序清单3-8中Safe Listener所示。