第一部分 基础知识
第一章 简介
1.1 并发简史
之所以在计算机中加入操作系统来实现多个程序的同时执行,主要是基于原因:资源利用率、公平性、便利性。
线程也被称为轻量级进程。在大多数现代操作系统中,都是以线程为基本的调度单位,而不是进程。如果没有明确的协同机制,那么线程将彼此独立执行。由于同一个进程中的所有线程都将共享进程的内存地址空间,因此这些线程都能访问相同的变量并在同一个堆上分配对象,这就需要实现一种比在进程间共享数据力度更细的数据共享机制。如果没有明确的同步机制来协同对共享数据的访问,那么当一个线程正在使用某个变最时,另一个线程可能同时访问这个变量,这将造成不可预测的结果。
1.2 线程的优势
如果使用得当,线程可以有效地降低程序的开发和维护等成本,同时提升复杂应用程序的性能。线程能够图大部分的异步工作流转换成串行工作流,因此能更好地模拟人类的工作方式和交互方式。此外,线程还可以降低代码的复杂度,使代码更容易编写、阅读和维护。
在GUI (Graphic User Interface, 图形用户界面)应用程序中,线程可以提高用户界面的响应灵敏度,而在服务器应用程序中,可以提升资源利用率以及系统吞吐率。线程还可以简化JVM 的实现,垃圾收集器通常在一个或多个专门的线程中运行。在许多重要的Java 应用程序中,都在一定程度上用到了线程。
1.2.1 发挥多处理器的强大能力
多线程程序可以通过提高处理器资源的利用率来提升系统吞吐率。
多个线程还有助于在单处理器系统上获得更高的吞吐率。
1.2.2 建模的简单性
如果在程序中只包含一种类型的任务,那么比包含多种不同类型任务的程序要更易于编写,错误更少,也更容易测试。如果为模型中每种类型的任务都分配一个专门的线程,那么可以形成一种串行执行的假象,并将程序的执行逻辑与调度机制的细节,交替执行的操作,异步1/0 以及资源等待等问题分离开来。通过使用线程,可以将复杂并且异步的工作流进一步分解为一组简单并且同步的工作流,每个工作流在一个单独的线程中运行令并在特定的同步位置进行交互。
1.2.3 异步事件的简化处理
如果某个应用程序对套接字执行读操作而此时还没有数据到来,那么这个读操作将一直阻塞,直到有数据到达。在单线程应用程序中,这不仅意味着在处理请求的过程中将停顿,而且还意味着在这个线程被阻塞期间,对所有请求的处理都将停顿。为了避免这个问题,单线程服务器应用程序必须使用非阻塞1/0, 这种1/0 的复杂要远远高于同步1/0, 并且很容易出错。然而,如果每个请求都拥有自己的处理线程,那么在处理某个请求时发生的阻塞将不会影响其他请求的处理.
1.2.4 响应更灵敏的用户界面
在现代的GUI 框架中,例如AWT 和Swing 等工具,都采用一个事件分发线程(Event Dispatch Thread, EDT) 来替代主事件循环。当某个用户界面事件发生时(例如按下一个按钮),在事件线程中将调用应用程序的事件处理器。由于大多数GUI 框架都是单线程子系统,因此到目前为止仍然存在主事件循环,但它现在处于GUI 工具的控制下并在其自己的线程中运行,而不是在应用程序的控制下。
1.3 线程带来的风险
1.3.1 安全性问题
程安全性可能是非常复杂的,在没有充足同步的情况下,多个线程中的操作执行顺序是不可预测的,甚至会产生奇怪的结果。
由于多个线程要共享相同的内存地址空间,并且是并发运行,因此它们可能会访问或修改其他线程正在使用的变益。当然,这是一种极大的便利,因为这种方式比其他线程间通信机制更容易实现数据共享。但它同样也带来了巨大的风险:线程会由于无法预料的数据变化而发生错误。当多个线程同时访问和修改相同的变量时,将会在串行编程模型中引入非串行因素,而这种非串行性是很难分析的。要使多线程程序的行为可以预测,必须对共享变量的访问操作进行协同,这样才不会在线程之间发生彼此干扰。
1.3.2 活跃性问题
安全性的含义是“永远不发生糟糕的事情”,而活跃性则关注于另一个目标,即“某件正确的事情最终会发生”。当某个操作无法继续执行下去时,就会发生活跃性问题。在串行程序中,活跃性问题的形式之一就是无意中造成的无限循环,从而使循环之后的代码无法得到执行。线程将带来其他一些活跃性问题。
第10 章将介绍各种形式的活跃性问题,以及如何避免这些问题,包括死锁,饥饿,以及活锁。与大多数并发性错误一样,导致活跃性问题的错误同样是难以分析的,因为它们依赖于不同线程的事件发生时序,因此在开发或者测试中井不总是能够重现。
1.3.3 性能问题
性能问题包括多个方面,例如服务时间过长,响应不灵敏,吞吐率过低,资源消耗过高,或者可伸缩性较低等。与安全性和活跃性一样,多线程程序中不仅存在与单线程程序相同的性能问题,而且还存在由于使用线程而引入的其他性能问题。
在多线程程序中,当线程调度器临时挂起活跃线程并转而运行另一个线程时,就会频繁地出现上下文切换操作(Context Switch) ,这种操作将带来极大的开销:保存和恢复执行上下文,丢失局部性,并且CPU 时间将更多地花在线程凋度而不是线程运行上。当线程共享数据时,必须使用同步机制,而这些机制往往会抑制某些编译器优化,使内存缓存区中的数据无效,以及增加共享内存总线的同步流量。所有这些因素都将带来额外的性能开销。
1.4 线程无处不在
每个Java 应用程序都会使用线程。当JVM 启动时,它将为JVM 的内部任务(例如,垃圾收集、终结操作等)创建后台线程,并创建一个主线程来运行main 方法。AWT (AbstractWindow Toolkit, 抽象窗口工具库)和Swing 的用户界面框架将创建线程来管理用户界面事件。Timer 将创建线程来执行延迟任务。一些组件框架,例如Servlet 和RMI, 都会创建线程池并调用这些线程中的方法。
下面给出的模块都将在应用程序之外的线程中调用应用程序的代码。尽管线程安全性需求可能源自这些模块,但却不会止步于它们,而是会延伸到整个应用程序。
Timer 。Timer 类的作用是使任务在稍后的时刻运行,或者运行一次,或者周期性地运行。引入Timer 可能会使串行程序变得复杂,因为 TimerTask 将在Timer 管理的线程中执行,而不是由应用程序来管理。如果某个 TimerTask 访问了应用程序中其他线程访问的数据,那么不仅 TimerTask 需要以线程安全的方式来访问数据,其他类也必须采用线程安全的方式来访问该数据。通常,要实现这个目标,最简单的方式是确保TimerTask 访问的对象本身是线程安全的,从而就能把线程安全性封装在共享对象内部。
Servlet 和 JavaServer Page (JSP) 。Servlet 框架用于部署网页应用程序以及分发来自HTTP 客户端的请求。到达服务器的请求可能会通过一个过滤器链被分发到正确的Servlet 或JSP 。每个Servlet 都表示一个程序逻辑组件,在高吞吐率的网站中,多个客户端可能同时请求同一个Servlet 的服务。在Servlet 规范中, Servlet 同样需要满足被多个线程同时调用,换句话说, Servlet 需要是线程安全的。
远程方法调用(Remote Method Invocation, RMI). RMI 使代码能够调用在其他JVM 中运行的对象。当通过RMI 调用某个远程方法时,传递给方法的参数必须被打包(也称为列集[Marshaled] )到一个字节流中,通过网络传输给远程JVM, 然后由远程JVM 拆包(或者称为散集[Unmarshaled]) 并传递给远程方法。
远程对象必须注意两个线程安全性问题:正确地协同在多个对象中共享的状态,以及对远程对象本身状态的访问(由于同一个对象可能会在多个线程中被同时访问)。与Servlet 相同,RMI 对象应该做好被多个线程同时调用的准备,并且必须确保它们自身的线程安全性。****
Swing 和AWT 。GUI 应用程序的一个固有属性是异步性。用户可以在任意时刻选择一个菜单项或者按下一个按钮,应用程序就会及时响应,即使应用程序当时正在执行其他的任务。Swing 和AWT 很好地解决了这个问题,它们创建了一个单独的线程来处理用户触发的事件,并对呈现给用户的图形界面进行更新。
Swing 的一些组件并不是线程安全的,例如JTable 。相反, Swing 程序通过将所有对GUI组件的访问局限在事件线程中以实现线程安全性。如果某个应用程序希望在事件线程之外控制GUI, 那么必须将控制GUI 的代码放在事件线程中运行。
第二章 线程安全性
一个对象是否需要是线程安全的,取决于它是否被多个线程访问。这指的是在程序中访问对象的方式,而不是对象要实现的功能。要使得对象是线程安全的,需要采用同步机制来协同对对象可变状态的访问。如果无法实现协同,那么可能会导致数据破坏以及其他不该出现的结果。
当多个线程访问某个状态变量并且其中有一个线程执行写入操作时,必须采用同步机制来协同这些线程对变量的访问。Java 中的主要同步机制是关键字synchronized, 它提供了一种独占的加锁方式,但“同步”这个术语还包括volatile类型的变量,显式锁(Explicit Lock)以及原子变量。
如当多个线程问同一个可变的状态量时没有使用合适的同步,那么程序就会出错。有3种方式可以修复这个问题:
- 不在线程之间共享该状态变量。
- 将状态变量修改为不可变的变量。
- 在访问状态变量时使用同步。
2.1 什么是线程安全性
当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且调试代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。
2.2 原子性
在并发编程中,由于不恰当的执行时序而出现不正确的结果是一种非常重要的情况,它有一个正式的名字:竞态条件(RaceCondition) 。
2.2.1 竞态条件
当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件。最常见的竞态条件类型就是“先检查后执行(Check-Then-Act)" 操作,即通过一个可能失效的观测结果来决定下一步的动作。
使用“先检查后执行”的一种常见情况就是延迟初始化。延迟初始化的目的是将对象的初始化操作推迟到实际被使用时才进行,同时要确保只被初始化一次。下方代码 LazylnitRace 说明了这种延迟初始化情况。getInstance 方法首先判断 ExpensiveObject 是否已经被初始化,如果已经初始化则返回现有的实例,否则,它将创建一个新的实例,并返回一个引用,从而在后来的调用中就无须再执行这段高开销的代码路径。
@NoThreadSafe
public class LazyinitRace{
private ExpensiveObject instance = null;
public ExpensiveObject getInstance(){
if(instance == null){
instance = new ExpensiveObject();
}
return instance;
}
}
2.2.3 复合操作
要避免竞态条件问题,就必须在某个线程修改该变晕时,通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态,而不是在修改状态的过程中。
在实际情况中,应尽可能地使用现有的线程安全对象(例如Acomi~Long) 来管理类的状态。与非线程安全的对象相比,判断线程安全对象的可能状态及其状态转换情况要更为容易,从而也更容易维护和验证线程安全性。
2.3 加锁机制
在线程安全性的定义中要求,多个线程之间的操作无论采用何种执行时序或交替方式,都要保证不变性条件不被破坏。当在不变性条件中涉及多个变量时,各个变最之间并不是彼此独立的,而是某个变量的值会对其他变量的值产生约束。因此,当更新某一个变最时,需要在同一个原子操作中对其他变量同时进行更新。如果只修改了其中一个变量,那么在这两次修改操作之间,其他线程将发现不变性条件被破坏了。同样,我们也不能保证会同时获取两个值:在线程A 获取这两个值的过程中,线程B 可能修改了它们,这样线程A也会发现不变性条件被破坏了。
2.3.1 内置锁
Java 提供了一种内置的锁机制来支持原子性:同步代码块(Synchronize~Bl?ck) 。(第3将介绍加锁机制以及其他同步机制的另一个重要方面:可见性)同步代码块包括两部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。以关键字sytrchronized 来修饰的方法就是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象。静态的 synchronized 方法以Class 对象作为锁。
synchronized(lock){
// 访问或修改由锁保护的共享状态
}
每个Java 对象都可以用做一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock) 或监视器锁(Monitor Lock) 。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁,而无论是通过正常的控制路径退出,还是通过从代码块中抛出异常退出。获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。
Java 的内置锁相当于一种互斥体(或互斥锁),这意味着最多只有一个线程能持有这种锁。当线程A 尝试获取一个由线程B 持有的锁时,线程A 必须等待或者阻塞,直到线程B 释放这个锁。如果B 永远不释放锁,那么A 也将永远地等下去。
由于每次只能有一个线程执行内置锁保护的代码块,因此,由这个锁保护的同步代码块会以原子方式执行,多个线程在执行该代码块时也不会相互干扰。并发环境中的原子性与事务应用程序中的原子性有着相同的含义一一组语句作为一个不可分割的单元被执行。任何一个执行同步代码块的线程,都不可能看到有其他线程正在执行由同一个锁保护的同步代码块。
2.3.2 重入
当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,由于内锁是可重人的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。“重入”意味着获取锁的操作的粒度是“线程”,而不是“调用“e 。重入的一种实现方法为每个锁关联一个获取计数值和一个所有者线程。当计数值为0 时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时, JVM 将记下锁的持有者,并且将获取计数值置为1 。如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应地递减。当计数值为0 时,这个锁将被释放.
2.4 用锁来保护状态
由于锁能使其保护的代码路径以串行形式e来访问,因此可以通过锁来构造一些协议以实现对共享状态的独占访问。只要始终遵循这些协议,就能确保状态的一致性。
如果用同步来协调对某个变量的访问,那么在访问这个变量的所有位置上都需要使用同步。而且,当使用锁来协调对某个变量的访问时,在访问变量的所有位置上都要使用同一个锁。
对象的内置锁与其状态之间没有内在的关联。虽然大多数类都将内置锁用做一种有效的加锁机制,但对象的域并不一定要通过内置锁来保护。当获取与对象关联的锁时,并不能阻止其他线程访问该对象,某个线程在获得对象的锁之后,只能阻止其他线程获得同一个锁。之所以每个对象都有一个内置锁,只是为了免去显式地创建锁对象。你需要自行构造加锁协议或者同步策略来实现对共享状态的安全访问,并且在程序中自始至终地使用它们。
每个共享的和可变的变量都应该只由一个锁来保护,从而使维护人员知道是哪一个锁。
一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。在许多线程安全类中都使用了这种模式,例如Vector 和其他的同步集合类。在这种情况下,对象状态中的所有变扯都由对象的内置锁保护起来。然而,这种模式并没有任何特殊之处,编译器或运行时都不会强制实施这种(或者其他的)模式。如果在添加新的方法或代码路径时忘记了使用同步,那么这种加锁协议会很容易被破坏。
并非所有数据都需要锁的保护,只有被多个线程同时访问的可变数据才需要通过锁来保护。
对于每个包合多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护。
如果只是将每个方法都作为同步方法,例如Vector, 那么并不足以确保Vector 上复合操作都是原子的。
2.5 活跃性与性能
设计同步代码的时候需要再简单性(对整个方法进行同步)与并发性(对尽可能短的代码路径进行同步)之间取得平衡,要判断同步代码块的合理大小,需要在各种设计需求之间进行权衡,包括安全性(这个需求必须得到满足)、简单性和性能。
当使用锁时,你应该清楚代码块中实现的功能,以及在执行该代码块时是否需要很长的时间。无论是执行计算密集的操作,还是在执行某个可能阻塞的操作,如果持有锁的时间过长,那么都会带来活跃性或性能问题。
当执行实践较长的计算或者可能无法快速完成的操作时(例如,网络I/O或控制台I/O),一定不要持有锁。
第三章 对象的共享
3.1 可见性
为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。
下方代码中,主线程启动读线程,然后将number 设为42, 并将ready 设为true 。读线程一直循环直到发现ready 的值变为true,然后输出number 的值。虽然NoVisibility 看起来会输出42, 但事实上很可能输出0, 或者根本无法终止。这是因为在代码中没有使用足够的同步机制,因此无法保证主线程写入的ready 值和number 值对于读线程来说是可见的。
package net.jcip.examples;
/**
* NoVisibility
* <p/>
* Sharing variables without synchronization
*
* @author Brian Goetz and Tim Peierls
*/
public class NoVisibility {
private static boolean ready;
private static int number;
private static class ReaderThread extends Thread {
public void run() {
while (!ready)
Thread.yield();
System.out.println(number);
}
}
public static void main(String[] args) {
new ReaderThread().start();
number = 42;
ready = true;
}
}
在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整。在缺乏足够同步的多线程程序中,要想对内存操作的执行顺序进行判断, 几乎无法得出正确的结论。
3.1.1 失效数据
No Visibility 展示了在缺乏同步的程序中可能产生错误结果的一种情况:失效数据。当读线程查看ready 变量时,可能会得到一个已经失效的值。除非在每次访问变量时都使用同步,否则很可能获得该变量的一个失效值。更糟糕的是,失效值可能不会同时出现:一个线程可能获得某个变量的最新值,而获得另一个变量的失效值。
3.1.2 非原子的64 位操作
当线程在没有同步的情况下读取变掀时,可能会得到一个失效值,但至少这个值是由之前某个线程设置的值,而不是一个随机值。这种安全性保证也被称为最低安全性(out-of-thin-air-safety) 。
最低安全性适用于绝大多数变量,但是存在一个例外:非 volatile 类型的64 位数值变量(double 和Jong)。
Java 内存模型要求,变扯的读取操作和写入操作都必须是原子操作,但对于非volatile 类型的long 和double 变量,JVM 允许将64 位的读操作或写操作分解为两个32 位的操作。当读取一个非v olatile 类型的long 变量时,如果对该变量的读操作和写操作在不同的线程中执行,那么很可能会读取到某个值的高32 位和另一个值的低32 位。因此,即使不考虑失效数据问题,在多线程程序中使用共享且可变的long 和double 等类型的变量也是不安全的,除非用关键字volatile 来声明它们,或者用锁保护起来。
3.1.3 加锁与可见性
内置锁可以用于确保某个线程以一种可预测的方式来查看另一个线程的执行结果。
当线程A 执行某个同步代码块时,线程B 随后进人由同一个锁保护的同步代码块,在这种情况下可以保证,在锁被释放之前, A 看到的变量值在B 获得锁后同样可以由B到。换句话说,当线程B 执行由锁保护的同步代码块时,可以看到线程A 之前在同一个同步代码块中的所有操作结果。如果没有同步,那么就无法实现上述保证。
现在,我们可以进一步理解为什么在访问某个共享且可变的变量时要求所有线程在同一个锁上同步,就是为了确保某个线程写入该变量的值对于其他线程来说都是可见的。否则,如果一个线程在未持有正确锁的情况下读取某个变址,那么读到的可能是一个失效值。
加锁的含义不仅仅局限于互斥行为,还包括内存可见性,为了确保所有线程都能看到共享变量的最新值,所有执行读写操作的线程都必须在同一个锁上同步。
3.1.4 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 变量的一种典型用法:volatile 变扯通常用做某个操作完成、发生中断或者状态的标志。
volatile boolean asleep;
...
while(!asleep)
countSomeSheep();
检查某个状态标记以判断是否退出循坏。在这个示例中,线程试图通过类似于数绵羊的传统方法进入休眠状态。为了使这个示例能正确执行, asleep 必须为volatile 变最。否则,当asleep 被另一个线程修改时,执行判断的线程却发现不了。我们也可以用锁来确保asleep 更新操作的可见性,但这将使代码变得更加复杂。
加锁机制既可以确保可见性又以确保原子性 ,而 volatile 变量只能确保可见性。
当且仅当满足以下所有条件时,才应该使用volatile 变显:
- 对变量的写人操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
- 该变量不会与其他状态变量一起纳人不变性条件中。
- 在访问变量时不需要加锁。
3.2 发布与逸出
“发布(Publish)" 一个对象的意思是指,使对象能够在当前作用域之外的代码中使用。
当某个不应该发布的对象被发布时,这种情况就被称为逸出(Escape) 。
在许多情况中,我们要确保对象及其内部状态不被发布。而在某些情况下,我们又需要发布某个对象,但如果在发布时要确保线程安全性,则可能需要同步。发布内部状态可能会破坏封装性,并使得程序难以维持不变性条件。
class UnsafeStates{ private String[] states = new String[]{"AK","AL",...}; public String[] getStates() {return states;} }
如果按照上述方式来发布states, 就会出现问题,因为任何调用者都能修改这个数组的内容。在这个示例中,数组states 已经逸出了它所在的作用域,因为这个本应是私有的变量已经被发布了。
当发布一个对象时,在该对象的非私有域中引用的所有对象同样会被发布。一般来说,如果一个已经发布的对象能够通过非私有的变最引用和方法调用到达其他的对象,那么这些对象也都会被发布。
无论其他的线程会对已发布的引用执行何种操作,其实都不重要,因为误用该引用的风险始终存在。当某个对象逸出后,你必须假设有某个类或线程可能会误用该对象。这正是需要使用封装的最主要原因:封装能够使得对程序的正确性进行分析变得可能,并使得无意中破坏设计约束条件变得更难。
// 当ThisEscape 发布EventListener 时,也隐含地发布了ThisEscape 实例本身
// 因为在这个内部类的实例中包含了对ThisEscape 实例的隐含引用。
public class ThisEscape {
public ThisEscape(EventSource source) {
source.registerListener(new EventListener() {
public void onEvent(Event e) {
// 通过下面方法可以修改 ThisEscape 中的属性或者调用 ThisEscape 中的其他方法。
// 因此产生隐式溢出
doSomething(e);
}
});
}
void doSomething(Event e) {
// 此处可以使用 this ,指向 ThisEscape
}
interface EventSource {
void registerListener(EventListener e);
}
interface EventListener {
void onEvent(Event e);
}
interface Event {}
}
线程 A 和线程 B 同时访问 ThisEscape 构造方法,这时线程 A 访问构造方法还未完成(可以理解为 ThisEscape 为初始化完全),此时由于 this 逸出,导致 this 在 A 和 B 中都具有可见性,线程 B 就可以通过 this 访问 doSomething(e) 方法,导致修改 ThisEscape 的属性。也就是在 ThisEscape 还为初始化完成,就被其他线程读取,导致出现一些奇怪的现象。
安全的对象构造过程
不要再构造过程中使用this引用溢出
在构造过程中使this 引用逸出的一个常见错误是,在构造函数中启动一个线程。当对象在其构造函数中创建一个线程时,无论是显式创建(通过将它传给构造函数)还是隐式创建(由Thread 或Runnable 是该对象的一个内部类), this 引用都会被新创建的线程共享。
在对象尚未完全构造之前,新的线程就可以看见它。在构造函数中创建线程并没有错误,但最好不要立即启动它,而是通过一个start 或initialize 方法来启动。
在构造函数中调用一个可改写的实例方法时(既不是私有方法,也不是终结方法),同样会导致this 引用在构造过程中逸出。
// 如果想在构造函数中注册一个事件监听器或启动线程,那么可以使用一个私有的构造函数和一个公共的工厂方法(Factory Method) ,从而避免不正确的构造过程
public class SafeListener {
private final EventListener listener;
private SafeListener() {
listener = new EventListener() {
public void onEvent(Event e) {
doSomething(e);
}
};
}
public static SafeListener newInstance(EventSource source) {
// 当构造好了SafeListener对象(通过构造器构造)之后
// 才启动了监听线程,也就确保了SafeListener对象是构造完成之后再使用的。
SafeListener safe = new SafeListener();
source.registerListener(safe.listener);
return safe;
}
void doSomething(Event e) {}
interface EventSource {
void registerListener(EventListener e);
}
interface EventListener {
void onEvent(Event e);
}
interface Event {}
}
具体来说,只有当构造函数返回时,this引用才应该从线程中逸出。构造函数可以将this引用保存到某个地方,只要其他线程不会在构造函数完成之前使用它。
3.3 线程封闭
当访问共享的可变数据时,通常需要使用同步。一种避免使用同步的方式就是不共享数据。如果仅在单线程内访问数据,就不需要同步。这种技术被称为线程封闭( ThreadConfinement ) ,它是实现线程安全性的最简单方式之一。当某个对象封闭在一个线程中时,这种用法将自动实现线程安全性,即使被封闭的对象本身不是线程安全的。
在 Swing 中大量使用了线程封闭技术。Swing 的可视化组件和数据模型对象都不是线程安全的, Swing 通过将它们封闭到Swing 的事件分发线程中来实现线程安全性。要想正确地使用Swing, 那么在除了事件线程之外的其他线程中就不能访问这些对象。Swing 应用程序的许多并发错误都是由于错误地在另一个线程中使用了这些被封闭的对象。
线程封闭技术的另一种常见应用是 JDBC(Java Database Connectivity) 的Connection 对象。JDBC 规范并不要求Connection 对象必须是线程安全的。在典型的服务器应用程序中,线程从连接池中获得一个Connection 对象,并且用该对象来处理请求,使用完后再将对象返还给连接池。由于大多数请求都是由单个线程采用同步的方式来处理,并且在Connection 对象返回之前,连接池不会再将它分配给其他线程,因此,这种连接管理模式在处理请求时隐含地将Connection 对象封闭在线程中。
在 Java 语言中并没有强制规定某个变量必须由锁来保护,同样在 Java 语言中也无法强制将对象封闭在某个线程中。线程封闭是在程序设计中的一个考虑因素,必须在程序中实现。Java 语言及其核心库提供了一些机制来帮助维持线程封闭性,例如局部变量和 ThreadLocal 类,但即便如此,程序员仍然需要负责确保封闭在线程中的对象不会从线程中逸出。
3.3.1 Ad-hoc 线程封闭
Ad-hoc 线程封闭是指,*维护线程封闭性的职责完全由程序实现来承担。*Ad-hoc 线程封闭是非常脆弱的,因为没有任何一种语言特性,例如可见性修饰符或局部变盘,能将对象封闭到目标线程上。事实上,对线程封闭对象的引用通常保存在公有变量中。
当决定使用线程封闭技术时,通常是因为要将某个特定的子系统实现为一个单线程子系统。在某些情况下,单线程子系统提供的简便性要胜过Ad-hoc 线程封闭技术的脆弱性。
在 volatile 变量上存在一种特殊的线程封闭。只要你能确保只有单个线程对共享的volatile变量执行写入操作,那么就可以安全地在这些共享的 volatile 变量上执行“读取-修改-人”的操作。在这种情况下,相当于将修改操作封闭在单个线程中以防止发生竞态条件,并且 volatile 变量的可见性保证还确保了其他线程能看到最新的值。
由于Ad-hoc 线程封闭技术的脆弱性,因此在程序中尽扯少用它,在可能的情况下,应该使用更强的线程封闭技术。
3.3.2 栈封闭
栈封闭是线程封闭的一种特例,在栈封闭中,只能通过局部变量才能访问对象。正如封装能使得代码更容易维持不变性条件那样,同步变最也能使对象更易于封闭在线程中。局部变固有属性之一就是封闭在执行线程中。它们位于执行线程的栈中,其他线程无法访问这个栈。栈封闭(也被称为线程内部使用或者线程局部使用,不要与核心类库中的ThreadLocal 混淆)比Ad-hoc 线程封闭更易于维护,也更加健壮。
对于基本类型的局部变量,无论如何都不会破坏栈封闭性。由于任何方法都无法获得对基本类型的引用,因此 Java 语言的这种语义就确保了基本类型的局部变量始终封闭在线程内。
public int loadTheArk(Collection<Animal> candidates) {
SortedSet<Animal> animals;
int numPairs = 0;
Animal candidate = null;
// animals 被限制在方法范围内,别让他们逃逸
animals = new TreeSet<Animal>(new SpeciesGenderComparator());
animals.addAll(candidates);
for (Animal a : animals) {
if (candidate == null || !candidate.isPotentialMate(a)){
candidate = a;
} else {
ark.load(new AnimalPair(candidate, a));
++numPairs;
candidate = null;
}
}
return numPairs;
}
在维持对象引用的栈封闭性时,程序员需要多做一些工作以确保被引用的对象不会逸出。在loadTheArk 中实例化一个 TreeSet 对象,并将指向该对象的一个引用保存到animals 中。此时,只有一个引用指向集合 animals, 这个引用被封闭在局部变量中,因此也被封闭在执行线程中。然而,如果发布了对集合 animals (或者该对象中的任何内部数据)的引用,那么封闭性将被破坏,并导致对象 animals 的逸出。
如果在线程内部(Within-Thread) 上下文中使用非线程安全的对象,那么该对象仍然是线程安全的。然而,要小心的是,只有编写代码的开发人员才知道哪些对象需要被封闭到执行线程中,以及被封闭的对象是否是线程安全的。如果没有明确地说明这些需求,那么后续的维护人员很容易错误地使对象逸出。
3.3.3 Threadlocal 类
维持线程封闭性的一种更规范方法是使用 ThreadLocal, 这个类能使线程中的某个值与保存值的对象关联起来。ThreadLocal 提供了 get 与 set 等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此 get 总是返回由当前执行线程在调用 set 时设置的最新值。
ThreadLocal 对象通常用于防止对可变的单实例变量(Singleton) 或全局变量进行共享。
public class ConnectionDispenser { static String DB_URL = "jdbc:mysql://localhost/mydatabase"; private ThreadLocal<Connection> connectionHolder = new ThreadLocal<Connection>() { public Connection initialValue() { return DriverManager.getConnection(DB_URL); }; }; public Connection getConnection() { return connectionHolder.get(); } }
在单线程应用程序中可能会维持一个全局的数据库连接,井在程序启动时初始化这个连接对象,从而避免在调用每个方法时都要传递一个Connection 对象。由于JDBC 的连接对象不一定是线程安全的,因此,当多线程应用程序在没有协同的情况下使用全局变量时,就不是线程安全的。通过将JDBC 的连接保存到ThreadLocal 对象中,每个线程都会拥有属于自己的连接。
在Java 5.0 之前, Integer.to String()方法使用ThreadLocal 对象来保存一个12 字节大小的缓冲区,用于对结果进行格式化,而不是使用共享的静态缓冲区(这需要使用锁机制)或者在每次调用时都分配一个新的缓冲区。
当某个线程初次调用 ThreadLocal.get 方法时,就会调用 initialValue 来获取初始值。从概念上看,你可以将 ThreadLocal
假设你需要将一个单线程应用程序移植到多线程环境中,通过将共享的全局变址转换为ThreadLocal 对象(如果全局变量的语义允许),可以维持线程安全性。然而,如果将应用程序范围内的缓存转换为线程局部的缓存,就不会有太大作用。
在实现应用程序框架时大量使用了ThreadLocal 。
在EJB 调用期间, J2EE 容器需要将一个事务上下文(Transaction Context) 与某个执行中的线程关联起来。通过将事务上下文保存在静态的ThreadLocal 对象中,可以很容易地实现这个功能:当框架代码需要判断当前运行的是哪一个事务时,只需从这个ThreadLocal 对象中读取事务上下文。这种机制很方便,因为它避免了在调用每个方法时都要传递执行上下文信息,然而这也将使用该机制的代码与框架耦合在一起。
3.4 不变性
如果某个对象在被创建后其状态就不能被修改,那么这个对象就称为不可变对象。全性是不可变对象的固有属性之一,它们的不变性条件是由构造函数创建的,只要它们的状态不改变,那么这些不变性条件就能得以维持。
不可变对象一定是线程安全的。
不可变对象很简单。它们只有一种状态,并且该状态由构造函数来控制。在程序设计中,一个最困难的地方就是判断复杂对象的可能状态。然而,判断不可变对象的状态却很简单。
当满足以下条件时 , 对象才是不可变的:
- 对象建以后其状态就不能修改 。
- 对象的所有域都是 final 类型
- 对象是正确创建的 ( 在对象的创建期间,this 引用没逸出 )
3.4.1 Final 域
关键字final 可以视为C++中const 机制的一种受限版本,用于构造不可变性对象。final类型的域是不能修改的(但如果final 域所引用的对象是可变的,那么这些被引用的对象是可以修改的)。final 域能确保初始化过程的安全性,从而可以不受限制地访问不可变对象,并在共享这些对象时无须同步。
因式分解Servlet 将执行两个原子操作:更新缓存的结果,以及通过判断缓存中的数值是否等于请求的数值来决定是否直接读取缓存中的因数分解结果。每当需要对一组相关数据以原子方式执行某个操作时,就可以考虑创建一个不可变的类来包含这些数据,如OneValueCache
@Immutable
public class OneValueCache {
private final BigInteger lastNumber;
private final BigInteger[] lastFactors;
public OneValueCache(BigInteger i,
BigInteger[] factors) {
lastNumber = i;
lastFactors = Arrays.copyOf(factors, factors.length);
}
public BigInteger[] getFactors(BigInteger i) {
if (lastNumber == null || !lastNumber.equals(i))
return null;
else
return Arrays.copyOf(lastFactors, lastFactors.length);
}
}
VolatileCachedFactorizer 使用了OneValueCache 来保存缓存的数值及其因数。当一个线程将volatile 类型的cache 设置为引用一个新的OneValueCache 时,其他线程会立即看到新缓存的数据。
与cache 相关的操作不会相互于扰,因为 OneValueCache 是不可变的,并且在每条相应的代码路径中只会访问它一次。通过使用包含多个状态变量的容器对象来维持不变性条件,并使用一个volatile 类型的引用来确保可见性,使得 Volatile Cached Factorizer 在没有显式地使用锁的情况下仍然是线程安全的。
@ThreadSafe
public class VolatileCachedFactorizer extends GenericServlet implements Servlet {
private volatile OneValueCache cache = new OneValueCache(null, null);
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = cache.getFactors(i);
if (factors == null) {
factors = factor(i);
cache = new OneValueCache(i, factors);
}
encodeIntoResponse(resp, factors);
}
void encodeIntoResponse(ServletResponse resp, BigInteger[] factors) {
}
BigInteger extractFromRequest(ServletRequest req) {
return new BigInteger("7");
}
BigInteger[] factor(BigInteger i) {
// Doesn't really factor
return new BigInteger[]{i};
}
}
3.5 安全发布
在某些情况下我们希望在多个线程间共享对象,此时必须确保安全地进行共享。然而,如果只是像下方程序那样将对象引用保存到公有域中,那么还不足以安全地发布这个对象。
public class StuffIntoPublic {
public Holder holder;
public void initialize() {
holder = new Holder(42);
}
}
3.5.1 不正确的发布:正确的对象被破坏
你不能指望一个尚未被完全创建的对象拥有完整性。某个观察该对象的线程将看到对象处于不一致的状态,然后看到对象的状态突然发生变化,即使线程在对象发布后还没有修改过它。
public class Holder {
private int n;
public Holder(int n) {
this.n = n;
}
public void assertSanity() {
if (n != n)
throw new AssertionError("This statement is false.");
}
}
由于没有使用同步来确保Holder 对象对其他线程可见,因此将Holder 称为“未被正确发布“。在未被正确发布的对象中存在两个问题。首先,除了发布对象的线程外,其他线程可以看到的Holder 域是一个失效值,因此将看到一个空引用或者之前的旧值。然而,更糟糕的情况是,线程看到Holder 引用的值是最新的,但Holder 状态的值却是失效的。情况变得更加不可预测的是,某个线程在第一次读取域时得到失效值,而再次读取这个域时会得到一个更新值,这也是 assertSainty 抛出 AssertionError 的原因。
3.5.2 不可变对象与初始化安全性
由于不可变对象是一种非常重要的对象,因此 Java 内存模型为不可变对象的共享提供种特殊的初始化安全性保证。我们已经知道,即使某个对象的引用对其他线程是可见的,也并不意味着对象状态对于使用该对象的线程来说一定是可见的。为了确保对象状态能呈现出一致的视图,就必须使用同步。
另一方面,即使在发布不可变对象的引用时没有使用同步,也仍然可以安全地访问该对象。为了维持这种初始化安全性的保证,必须满足不可变性的所有需求:状态不可修改,所有域都是final 类型,以及正确的构造过程。
任何线程都可以在不需要额外同步的情况下安全地访问不可变对象,即使在发布这些对象时没有使用同步。
这种保证还将延伸到被正确创建对象中所有final 类型的域。在没有额外同步的情况下,也可以安全地访问final 类型的域。然而,如果final 类型的域所指向的是可变对象,那么在访问这些域所指向的对象的状态时仍然需要同步。
3.5.3 安全发布的常用模式
要安全地发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方式来安全地发布:
- 在静态初始化函数中初始化一个对象引用。
- 将对象的引用保存到volatile类型的域或者AtomicReferance 对象中。
- 将对象的引用保存到某个正确构造对象的final 类型域中。
- 将对象的引用保存到一个由锁保护的域中
在线程安全容器内部的同步意味着,在将对象放入到某个容器,例如Vector 或 synchronizedList synchronizedList时,将满足上述最后一条需求。
线程安全库中的容器类提供了以下的安全发布保证:
- 通过将一个键或者值放入Hashtable 、synchronizedMap 或者ConcurrentMap 中,可以安全地将它发布给任何从这些容器中访问它的线程(无论是直接访问还是通过迭代器访问)。
- 通过将某个元素放入Vector 、CopyOnWriteArrayList、CopyOnWriteArraySet 、synchronizedList
或synchronizedSet 中,可以将该元素安全地发布到任何从这些容器中访问该元素的线程。 - 通过将某个元素放入BlockingQueue 或者ConcurrentLinkedQueue 中,可以将该元素安全地发布到任何从这些队列中访问该元素的线程。
类库中的其他数据传递机制(例如Future 和Exchanger) 同样能实现安全发布,在介绍这
些机制时将讨论它们的安全发布功能。
通常,要发布一个静态构造的对象,最简单和最安全的方式是使用静态的初始化器,静态初始化器由NM 在类的初始化阶段执行。由于在 JVM 内部存在着同步机制,因此通过这种方式初始化的任何对象都可以被安全地发布。
public static Holder holder= new Holder(42);
3.5.4 事实不可变对象
如果对象在发布后不会被修改,那么对于其他在没有额外同步的情况下安全地访问这些对象的线程来说,安全发布是足够的。所有的安全发布机制都能确保,当对象的引用对所有访问该对象的线程可见时,对象发布时的状态对于所有线程也将是可见的,井且如果对象状态不会再改变,那么就足以确保任何访问都是安全的。
如果对象从技术上来看是可变的,但其状态在发布后不会再改变,那么把这种对象称为“事实不可变对象(Effectively Immutable Object)”。在这些对象发布后,程序只需将它们视为不可变对象即可。通过使用事实不可变对象,不仅可以简化开发过程,而且还能由于减少了同步而提高性能。
在没有额外的同步的情况下,任何线程都可以安全地使用被安全发布的事实不可变对象。
3.5.5 可变对象
如果对象在构造后可以修改,那么安全发布只能确保“发布当时”状态的可见性。对于可变对象,不仅在发布对象时需要使用同步,而且在每次对象访问时同样需要使用同步来确保后续修改操作的可见性。要安全地共享可变对象,这些对象就必须被安全地发布,并且必须是线程安全的或者由某个锁保护起来。
对象的发布需求取决于它的可变性:
- 不可变对象可以通过任意机制来发布 。
- 事实不可变对象必须通过安全方式来发布 。
- 可变对象必须通过安全方弋来发布 , 并且必须是线程安全的或者由某个锁保护起来。
3.5.6 安全地共享对象
在并发程序中使用和共享对象时, 可以使用一些实用的策, 包括:
线程封闭 。 线程封闭的对象只能由一个线程拥有 , 对象被封闭在该线程中,并且只能由这个线程修改 。
只读共享。 在没有额 “ 外 “ 同步的情况下 , 共享的只读对象可以由多个线程并发访问 ,但任何线程都不能修改它。共享的只读对象包括不可变对象和事实不可变对象 。
线程安全共享。 线程安全的对在箕内部实现同步 , 囟此多个线程可以通过对象的公有接口来进行访间而不需要进一步的同步 。
保护对象。 被保护的对象只能通过特有特定的锁来访问 。 保护对象包括封装在其他线程安全对象中的对象 , 以及已发布的并且由某个特定锁保护的对象 。
第四章 对象的组合
我们并不希望对每一次内存访问都进行分析以确保程序是线程安全的,而是希望将一些现有的线程安全组件组合为更大规模的组件或程序。
4.1 设计线程安全的类
通过使用封装技术,可以使得在不对整个程序进行分析的情况下就可以判断一个类是否是线程安全的。在设计线程安全类的过程中,需要包含以下三个基本要素:
- 找出构成对象状态的所有变量。
- 找出约束状态变量的不变性条件。
- 建立对象状态的并发访问管理策略。
如果对象中所有的域都是基本类型的变量,那么这些域将构成对象的全部状态。对于含有n 个基本类型域的对象,其状态就是这些域构成的n 元组。例如,二维点的状态就是它的坐标值(x, y) 。如果在对象的域中引用了其他对象,那么该对象的状态将包含被引用对象的域。
同步策略(Synchronization Policy) 定义了如何在不违背对象不变条件或后验条件的情况下对其状态的访问操作进行协同。同步策略规定了如何将不可变性、线程封闭与加锁机制等结合起来以维护线程的安全性,并且还规定了哪些变量由哪些锁来保护。要确保开发人员可以对这个类进行分析与维护,就必须将同步策略写为正式文档。
4.1.1 收集同步需求
要确保类的线程安全性,就需要确保它的不变性条件不会在并发访问的情况下被破坏,这就需要对其状态进行推断。对象与变量都有一个状态空间,即所有可能的取值。状态空间越小,就越容易判断线程的状态。final 类型的域使用得越多,就越能简化对象可能状态的分析过程。(在极端的情况中,不可变对象只有唯一的状态。)
在许多类中都定义了一些不可变条件,用于判断状态是有效的还是无效的。如 long 类型的变量,其状态空间为从 Long.MIN_VALUE 到 Long.MAX_VALUE。
同样,在操作中还会包含一些后验条件来判断状态迁移是否是有效的。当下一个状态需要依赖当前状态时,这个操作就必须是一个复合操作。如果计数器的当前状态为17, 那么下一个有效状态只能是18 。并非所有的操作都会在状态转换上施加限制。
由于不变性条件以及后验条件在状态及状态转换上施加了各种约束,因此就需要额外的同步与封装。如果某些状态是无效的,那么必须对底层的状态变量进行封装,否则客户代码可能会使对象处于无效状态。如果在某个操作中存在无效的状态转换,那么该操作必须是原子的。另外,如果在类中没有施加这种约束,那么就可以放宽封装性或序列化等需求,以便获得更高的灵活性或性能。
在类中也可以包含同时约束多个状态变篮的不变性条件。这种包含多个变量的不变性条件将带来原子性需求:这些相关的变扯必须在单个原子操作中进行读取或更新。不能首先更新一个变量,令然后释放锁并再次获得锁,然后再更新其他的变量。因为释放锁后,可能会使对象处于无效状态。如果在一个不变性条件中包含多个变量,那么在执行任何访问相关变量的操作时,都必须持有保护这些变量的锁。
4.1.2 依赖状态的操作
在某些对象的方法中还包含一些基于状态的先验条件(Precondition) 。如果在某个操作中包含有基于状态的先验条件,那么这个操作就称为依赖状态的操作。例如,不能从空队列中移除一个元,在删除元素前,队列必须处于“非空的”状态。
在单线程程序中,如果某个操作无法满足先验条件,那么就只能失败。但在并发程序中,先验条件可能会由于其他线程执行的操作而变成真。在并发程序中要一直等到先验条件为真,然后再执行该操作。
在Java 中,等待某个条件为真的各种内置机制(包括等待和通知等机制)都与内置加锁机制紧密关联。要想实现某个等待先验条件为真时才执行的操作,一种更简单的方法是通过现有库中的类(例如阻塞队列[Blocking Queue] 或信号量[Semaphore]) 来实现依赖状态的行为。
4.1.3 状态的所有权
如果以某个对象为根节点构造一张对象图,那么该对象的状态将是对象图中所有对象包含的域的一个子集。
所有权与封装性总是相互关联的:对象封装它拥有的状态,反之也成立,即对它封装的状态拥有所有权。状态变量的所有者将决定采用何种加锁协议来维持变扯状态的完整性。所有权意味着控制权。然而,如果发布了某个可变对象的引用,那么就不再拥有独占的控制权,最多是“共享控制权”。对于从构造函数或者从方法中传递进来的对象,类通常并不拥有这些对象,除非这些方法是被专门设计为转移传递进来的对象的所有权。
容器类通常表现出一种“所有权分离”的形式,其中容器类拥有其自身的状态,而客户代码则拥有容器中各个对象的状态。
ServletContext 为Servlet 提供了类似于Map 形式的对象容器服务,在ServletContext 中可以通过名称来注册(setAttribute) 或获取(getAttribute) 应用程序对象。由Servlet 容器实现的ServletContext 对象必须是线程安全的,因为它肯定会被多个线程同时访问。
当调用setAttribute 和getAttribute 时, Servlet 不需要使用同步,但当使用保存在ServletContext 中的对象时,则可能需要使用同步。这些对象由应用程序拥有, Servlet 容器只是替应用程序保管它们。
与所有共享对象一样,它们必须安全地被共享。为了防止多个线程在并发访问同一个对象时产生的相互于扰,这些对象应该要么是线程安全的对象,要么是事实不可变的对象,或者由锁来保护的对象
4.2 实例封闭
如果某对象不是线程安全的,那么可以通过多种技术使其在多线程程序中安全地使用。你可以确保该对象只能由单个线程访问(线程封闭),或者通过一个锁来保护对该对象的所有访问。
封装简化了线程安全类的实现过程,它提供了一种实例封闭机制(Instance Confinement),通常也简称为“封闭”。当一个对象被封装到另一个对象中时,能够访问被封装对象的所有代码路径都是已知的。与对象可以由整个程序访问的情况相比,更易于对代码进行分析。通过将封闭机制与合适的加锁策略结合起来,可以确保以线程安全的方式来使用非线程安全的对象。
将数据封装在对象内部可以将数据的访问限制在对象的方法上 , 从而更容易确保线程在访问数时总能持有正确的锁 。
被封闭对象一定不能超出它们既定的作用域。对象可以封闭在类的一个实例中,或者封闭在某个作用域内,再或者封闭在线程内。当然,对象本身不会逸出一出现逸出情况的原因通常是由于开发人员在发布对象时超出了对象既定的作用域。
@ThreadSafe public class PersonSet { @GuardedBy("this") private final Set<Person> mySet = new HashSet<Person>(); public synchronized void addPerson(Person p) { mySet.add(p); } public synchronized boolean containsPerson(Person p) { return mySet.contains(p); } interface Person { } }
PersonSet 的状态由HashSet 来管理的,而HashSet 并非线程安全的。但由于mySet 是私有的并且不会逸出,因此HashSet 被封闭在PersonSet 中。唯一能访问mySet 的代码路径是addPerson 与containsPerson, 在执行它们时都要获得PersonSet 上的锁。PersonSet 的状态完全由它的内置锁保护。
注意:这个示例并未对Person 的线程安全性做任何假设
实例封闭是构建线程安全类的一个最简单方式,它还使得在锁策略的选择上拥有了更多的灵活性。实例封闭还使得不同的状态变量可以由不同的锁来保护。
一些基本的容器类井非线程安全的,例如ArrayList 和HashMap,但类库提供了包装器工厂方法(例如Collections.synchronizedList 及其类似方法),使得这些非线程安全的类可以在多线程环境中安全地使用。
这些工厂方法通过“装饰器(Decorator) "模式将容器类封装在一个同步的包装器对象中,而包装器能将接口中的每个方法都实现为同步方法,并将调用请求转发到底层的容器对象上。只要包装器对象拥有对底容器对象的唯一引用(即把底层容器对象封闭在包装器中),那么它就是线程安全的。在这些方法的Javadoc 中指出,对底层容器对象的所有访问必须通过包装器来进行。
封闭机制更易于构造线程安全的类,因为当封闭类的状态时,在分析类的线程安全性时就无须检查整个程序。
4.2.1 Java 监视器模式
从线程封闭原则及其逻辑推论可以得出Java 监视器模式。遵循Java 监视器模式的对象会把对象的所有可变状态都封装起来,并由对象自己的内置锁来保护。
在许多类中都使用了Java 监视器模式,例如Vector 和Hashtable。Java 监视器模式的主要优势就在于它的简单性。Java 监视器模式仅仅是一种编写代码的约定,对于任何一种锁对象,只要自始至终都使用该锁对象,都可以用来保护对象的状态。
// 通过一个私有锁来保护状态
public class PrivateLock {
private final Object myLock = new Object();
@GuardedBy("myLock") Widget widget;
void someMethod() {
synchronized (myLock) {
// Access or modify the state of widget
}
}
}
使用私有的锁对象而不是对象的内置锁(或任何其他可通过公有方式访问的锁)与很多的优点。私有的锁对象可以将锁封装起来,使客户代码无法得到锁,但客户代码可以通过公有方法来访问锁,以便(正确或者不正确地)参与到它的同步策略中。如果客户代码错误地获得了另一个对象的锁,那么可能会产生活跃性问题。要想验证某个公有访问的锁在程序中否被正确地使用,则需要检查整个程序,而不是单个的类。
4.3 线程安全性的委托
大多数对象都是组合对象。当从头开始构建一个类,或者将多个非线程安全的类组合为一个类时, Java 监视器模式是非常有用的。在某些情况下,通过多个线程安全类组合而成的类是线程安全的,而在某些情况下,这仅仅是一个好的开端。
4.3.1 示例:基于委托的车辆追踪器
构造一个委托给线程安全类的车辆追踪器。我们将车辆的位置保存到一个Map 对象中,因此首先要实现一个线程安全的Map 类,ConcurrentHashMap 。我们还可以用一个不可变的Point 类来代替MutablePoint 以保存位置。由于Point 类是不可变的,因而它是线程安全的。不可变的值可以被自由地共享与发布,因此在返回location 时不需要复制。
DelegatingVehicle Tracker 中没有使用任何显式的同步,所有对状态的访问都由ConcurrentHashMap 来管理,而且Map 所有的键和值都是不可变的。
@ThreadSafe
public class DelegatingVehicleTracker {
private final ConcurrentMap<String, Point> locations;
private final Map<String, Point> unmodifiableMap;
public DelegatingVehicleTracker(Map<String, Point> points) {
locations = new ConcurrentHashMap<String, Point>(points);
// Collections.unmodifiableMap 提供对内部映射的“只读”访问
unmodifiableMap = Collections.unmodifiableMap(locations);
}
public Map<String, Point> getLocations() {
return unmodifiableMap;
}
public Point getLocation(String id) {
return locations.get(id);
}
public void setLocation(String id, int x, int y) {
if (locations.replace(id, new Point(x, y)) == null)
throw new IllegalArgumentException("invalid vehicle name: " + id);
}
public Map<String, Point> getLocationsAsStatic() {
return Collections.unmodifiableMap(
new HashMap<String, Point>(locations));
}
}
在使用监视器模式的车辆追踪器中返回的是车辆位置的快照,而在使用委托的车辆追踪器中返回的是一个不可修改但却实时的车辆位置视图。这意味着,如果线程A调用getLocations, 而线程B 在随后修改了某些点的位置,那么在返回给线程A 的Map 中将反映出这些变化。在前面提到过,这可能是一种优点(更新的数据),也可能是一种缺点(可能导致不一致的车辆位置视图),具体情况取决于你的需求。
4.3.2 独立的状态变量
到目前为止,这些委托示例都仅仅委托给了单个线程安全的状态变量。我们还可以将线程安全性委托给多个状态变量,只要这些变量是彼此独立的,即组合而成的类并不会在其包含的多个状态变最上增加任何不变性条件。
4.3.3 当委托失效时
NumberRange 使用了两个Atomiclnteger 来管理状态,并且含有一个约束条件,即第一个数值要小于或等于第二个数值。
public class NumberRange {
// INVARIANT: lower <= upper
private final AtomicInteger lower = new AtomicInteger(0);
private final AtomicInteger upper = new AtomicInteger(0);
public void setLower(int i) {
// Warning -- unsafe check-then-act
if (i > upper.get())
throw new IllegalArgumentException("can't set lower to " + i + " > upper");
lower.set(i);
}
public void setUpper(int i) {
// Warning -- unsafe check-then-act
if (i < lower.get())
throw new IllegalArgumentException("can't set upper to " + i + " < lower");
upper.set(i);
}
public boolean isInRange(int i) {
return (i >= lower.get() && i <= upper.get());
}
}
NumberRange 不是线程安全的,没有维持对下界和上界进行约束的不变性条件。setLower和setUpper 等方法都尝试维持不变性条件,但却无法做到。setLower 和setUpper 都是“先检查后执行”的操作,但它们没有使用足够的加锁机制来保证这些操作的原子性。
如果某个类含有复合操作,例如NumberRange, 那么仅靠委托并不足以实现线程安全性。在这种情况下,这个类必须提供自己的加锁机制以保证这些复合操作都是原子操作,除非整个复合操作都可以委托给状态变量。
如果一个类是由多个独立且钱程安全的状态变量组成 , 并且在所有的操作中都不包含无效状态转换 , 那么可以将线程安全性委托给底层的状态变量 。
4.3.4 发布底层的状态变量
如果一个状态变量是线程安全的, 并且没有任何不变性条件来约数它的值, 在变量的操作上也不存在任何不允许的状态转换 , 那么就可以安全地发布这个变量。
4.3.5 示例:发布状态的车辆追踪器
构造车辆追踪器的另一个版本,并在这个版本中发布底层的可变状态。我们需要修改接口以适应这种变化,即使用可变且线程安全的 Point 类。
@ThreadSafe
public class SafePoint {
@GuardedBy("this") private int x, y;
private SafePoint(int[] a) {
this(a[0], a[1]);
}
public SafePoint(SafePoint p) {
this(p.get());
}
public SafePoint(int x, int y) {
this.set(x, y);
}
public synchronized int[] get() {
return new int[]{x, y};
}
public synchronized void set(int x, int y) {
this.x = x;
this.y = y;
}
}
@ThreadSafe
public class PublishingVehicleTracker {
private final Map<String, SafePoint> locations;
private final Map<String, SafePoint> unmodifiableMap;
public PublishingVehicleTracker(Map<String, SafePoint> locations) {
this.locations = new ConcurrentHashMap<String, SafePoint>(locations);
this.unmodifiableMap = Collections.unmodifiableMap(this.locations);
}
public Map<String, SafePoint> getLocations() {
return unmodifiableMap;
}
public SafePoint getLocation(String id) {
return locations.get(id);
}
public void setLocation(String id, int x, int y) {
if (!locations.containsKey(id))
throw new IllegalArgumentException("invalid vehicle name: " + id);
locations.get(id).set(x, y);
}
}
PublishingVehicleTracker 将其线程安全性委托给底层的ConcurrentHashMap, 只是Map 中的元素是线程安全的且可变的Point, 而并非不可变的。
getLocation 方法返回底层Map 对象的一个不可变副本。调用者不能增加或删除车辆,但却可以通过修改返回Map 中的SafePoint 值来改变车辆的位置。
Map 的这种“实时“特性究竟是带来好处还是坏处,仍然取决于实际的需求。PublishingVehicleTracker 是线程安全的,但如果它在车辆位置的有效值上施加了任何约束,那么就不再是线程安全的。
4.4 在现有的线程安全类中添加功能
Java 类库包含许多有用的“基础模块"类。通常,我们应该优先选择重用这些现有的类而不是创建新的类:重用能降低开发工作量、开发风险(因为现有的类都已经通过测试)以及维护成本。有时候,某个现有的线程安全类能支持我们需要的所有操作,但更多时候,现有的类只能支持大部分的操作,此时就需要在不破坏线程安全性的情况下添加一个新的操作。
要添加一个新的原子操作,最安全的方法是修改原始的类,但这通常无法做到,因为你可能无法访问或修改类的源代码。要想修改原始的类,就需要理解代码中的同步策略,这样增加的功能才能与原有的设计保持一致。如果直接将新方法添加到类中,那么意味着实现同步策略的所有代码仍然处于一个源代码文件中,从而更容易理解与维护。
另一种方法是扩展这个类,假定在设计这个类时考虑了可扩展性。Better Vector 对Vector 进行了扩展,并添加了一个新方法putlfAbsent 。扩展Vector 很简单,但并非所有的类都像Vector 那样将状态向子类公开,因此也就不适合采用这种方法。
@ThreadSafe
public class BetterVector <E> extends Vector<E> {
// When extending a serializable class, you should redefine serialVersionUID
static final long serialVersionUID = -3963416950630760754L;
public synchronized boolean putIfAbsent(E x) {
boolean absent = !contains(x);
if (absent)
add(x);
return absent;
}
}
“扩展”方法比直接将代码添加到类中更加脆弱,因为现在的同步策略实现被分布到多个单独维护的源代码文件中。如果底层的类改变了同步策略并选择了不同的锁来保护它的状态变量,那么子类会被破坏,因为在同步策略改变后它无法再使用正确的锁来控制对基类状态的并发访问。
4.4.1 客户端加锁机制
对于由Collections.synchronizedList 封装的ArrayList, 这两种方法在原始类中添加一个方法或者对类进行扩展都行不通,因为客户代码并不知道在同步封装器工厂方法中返回的List 对象的类型。
第三种策略是扩展类的功能,但并不是扩展类本身,而是将扩展代码放人一个“辅助类”中。
// 通过客户端加锁来实现“若没有则添加“
@NotThreadSafe
class BadListHelper <E> {
public List<E> list = Collections.synchronizedList(new ArrayList<E>());
public synchronized boolean putIfAbsent(E x) {
boolean absent = !list.contains(x);
if (absent)
list.add(x);
return absent;
}
}
@ThreadSafe
class GoodListHelper <E> {
public List<E> list = Collections.synchronizedList(new ArrayList<E>());
public boolean putIfAbsent(E x) {
synchronized (list) {
boolean absent = !list.contains(x);
if (absent)
list.add(x);
return absent;
}
}
}
通过添加一个原子操作来扩展类是脆弱的,因为它将类的加锁代码分布到多个类中。然而,客户端加锁却更加脆弱,因为它将类C 的加锁代码放到与C 完全无关的其他类中。当在那些并不承诺遵循加锁策略的类上使用客户端加锁时,要特别小心。
客户端加锁机制与扩展类机制有许多共同点,二者都是将派生类的行为与基类的实现耦合在一起
4.4.2 组合
当为现有的类添加一个原子操作时,有一种更好的方法:组合(Composition) 。ImprovedList 通过将List 对象的操作委托给底层的List 实例来实现List 的操作,同时还添加了一个原子的putlfAbsent 方法。(与Collections.synchronizedList 和其他容器封装器一样, ImprovedList 假设把某个链表对象传给构造函数以后,客户代码不会再直接使用这个对象,而只能通过 ImprovedList 来访问它。)
@ThreadSafe
public class ImprovedList<T> implements List<T> {
private final List<T> list;
public ImprovedList(List<T> list) { this.list = list; }
public synchronized boolean putIfAbsent(T x) {
boolean contains = list.contains(x);
if (!contains)
list.add(x);
return !contains;
}
// List方法的普通委托。
// 方法必须同步,以确保putIfAbsent的原子性。
public synchronized void clear() { list.clear(); }
// 按照类似的方式委托List 的其他方法
...
}
lmprovedList 通过自身的内置锁增加了一层额外的加锁。它并不关心底层的List 是否是线程安全的,即使List 不是线程安全的或者修改了它的加锁实现, ImprovedList 也会提供一致的加锁机制来实现线程安全性。虽然额外的同步层可能导致轻微的性能损失(底层List 上的同步不存在竞争,所以速度很快), 但与模拟另一个对象的加锁策略相比, ImprovedList 更为健壮。事实上,我们使用了Java 监视器模式来封装现有的List, 并且只要在类中拥有指向底层List 的唯一外部引用,就能确保线程安全性。
4.5 将同步策略文档化
在维护线程安全性时,文档是最强大的(同时也是最未被充分利用的)工具之一。用户可以通过查阅文档来判断某个类是否是线程安全的,而维护人员也可以通过查阅文档来理解其中的实现策略,避免在维护过程中破坏安全性。然而,通常人们从文档中获取的信息却是少之又少。
在文档中说明客户代码需要了解的线程安全性保证,以及代码维护人员需要了斛的同步策略。
synchronized 、volatile 或者任何一个线程安全类都对应于某种同步策略,用于在并发访问时确保数据的完整性。这种策略是程序设计的要素之一,因此应该将其文档化。
java.text.SimpleDateFormat 并不是线程安全的
第五章 基础构建模块
Java 平台类库包含了丰富的并发基础构建模块,例如线程安全的容器类以及各种用于协调多个相互协作的线程控制流的同步工具类(Synchronizer) 。
5.1 同步容器类
同步容器类包括Vector 和Hashtable, 二者是早期JDK 的一部分,此外还包括在JDK 1.2中添加的一些功能相似的类,这些同步的封装器类是由Collections.synchronizedXxx 等工厂方法创建的。这些类实现线程安全的方是:将它们的状态封装起来,并对每个公有方法都进行同步,使得每次只有一个线程能访问容器的状态。
5.1.1 同步容器类的问题
同步容器类都是线程安全的,但在某些情况下可能需要额外的客户端加锁来保护复合操作。容器上常见的复合操作包括:迭代(反复访问元素,直到遍历完容器中所有元素)、跳转(根据指定顺序找到当前元素的下一个元素)以及条件运算,例如“若没有则添加“(检查在Map 中是否存在键值K, 如果没有,就加入二元组(K,V) )。在同步容器类中,这些复合操作在没有客户端加锁的情况下仍然是线程安全的,但当其他线程并发地修改容器时,它们可能会表现出意料之外的行为。
由于同步容器类要遵守同步策略,即支持客户端加锁鱼因此可能会创建一些新的操作,只要我们知道应该使用哪一个锁,那么这些新操作就与容器的其他操作一样都是原子操作。步容器类通过其自身的锁来保护它的每个方法。
在调用size 和相应的get 之间, Vector 的长度可能会发生变化,这种风险在对Vector 中的元素进行迭代时仍然会出现。这种迭代操作的正确性要依赖于运气,即在调用size 和get 之间没有线程会修改Vector 。在单线程环境中,这种假设完全成立,但在有其他线程并发地修改Vector 时,则可能导致麻烦。
for(int i =0; i < vector.size(); i++){
doSomething(vector);
}
我们可以通过在客户端加锁来解决不可靠迭代的问题,但要牺牲一些伸缩性。
synchronized(vector){
for(int i =0; i < vector.size(); i++){
doSomething(vector);
}
}
5.1.2 迭代器与ConcurrentModificationException
无论在直接迭代还是在Java 5.0 引入的for-each 循环语法中,对容器类进行迭代的标准方式都是使用Iterator 。然而,如果有他线程并发地修改容器,那么即使是使用迭代器也无法避免在迭代期间对容器加锁。在设计同步容器类的迭代器时并没有考虑到并发修改的问题,并且它们表现出的行为是“及时失败”(fail-fast) 的。这意味着,当它们发现容器在迭代过程中被修改时,就会抛出一个ConcurrentModificationException 异常。
这种”及时失败"的迭代器井不是一种完备的处理机制,而只是“善意地”捕获发错误,因此只能作为并发问题的预警指示器。它们采用的实现方式是,将计数器的变化与容器关联起来:如果在迭代期间计数器被修改,那么hasNext 或next 将抛出ConcurrentModificationException 。然而,这种检查是在没有同步的情况下进行的,因此可能会看到失效的计数值,而迭代器可能并没有意识到已经发生了修改。这是一种设计上的权衡,从而降低并发修改操作的检测代码对程序性能带来的影响。
然而,有时候开发人员并不希望在迭代期间对容器加锁。如果不希望在迭代期间对容器加锁,那么一种替代方法就是“克隆”容器,并在副本上进行迭代。由于副本被封闭在线程内,因此其他线程不会在选代期间对其进行修改,这样就避免了抛出ConcurrentModificationException (在克隆过程中仍然需要对容器加锁)。在克隆容器时存在显著的性能开销。这种方式的好坏取决于多个因素,包括容器的大小,在每个元素上执行的工作,迭代操作相对于容器其他操作的调用频率,以及在响应时间和吞吐量等方面的需求。
5.1.3 隐藏迭代器
虽然加锁可以防止迭代器抛出ConcurrentModificationException, 但你必须要记住在所有对共享容器进行迭代的地方都需要加锁。实际情况要更加复杂,因为在某些情况下,迭代器会隐藏起来。
public class HiddenIterator {
@GuardedBy("this") private final Set<Integer> set = new HashSet<Integer>();
public synchronized void add(Integer i) {
set.add(i);
}
public synchronized void remove(Integer i) {
set.remove(i);
}
public void addTenThings() {
Random r = new Random();
for (int i = 0; i < 10; i++)
add(r.nextInt());
System.out.println("DEBUG: added ten elements to " + set);
}
}
在 Hiddenlterator 中没有显式的迭代操作,但在 System.out.println 这行代码中将执行迭代操作。编译器将字符串的连接操作转换为调用 StringBuilder.append(Object) ,而这个方法又会调用容器的 toString 方法,标淮容器的 toString 方法将迭代容器,并在每个元素上调用 toString 来生成容器内容的格式化表示。
如果状态与保护它的同步代码之间相际越远,那么开发人员就越容易忘记在访问状态时使用正确的同步。如果Hiddenlterator 用synchronizedSet 来包装HashSet,并且对同步代码进行封装,那么就不会发生这种错误。
正如封装对象的状态有助于维持不变性条件一样 , 封装对象的同步机制同样有助于确保实施同步策略 。
容器的 hashCode 和equals 等方法也会间接地执行迭代操作,当容器作为另一个容器的元素或键值时,就会出现这种情况。同样, containsAll 、removeAll 和 retainAll 等方法,以及把容器作为参数的构造函数,都会对容器进行迭代。所有这些间接的迭代操作都可能抛出 ConcurrentModificationException 。
5.2 并发容器
Java 5.0 提供了多种并发容器类来改进同步容器的性能。同步容器将所有对容器状态的访问都串行化,以实现它们的线程安全性。这种方法的代价是严重降低并发性,当多个线程竞争容器的锁时,吞吐批将严重减低。
另一方面,并发容器是针对多个线程并发访问设计的。在Java 5.0 中增加了ConcurrentHashMap,用来替代同步且基于散列的Map, 以及CopyOnWriteArrayList, 用于在遍历操作为主要操作的情况下代替同步的List。在新的ConcurrentMap 接口中增加了对一些常见复合操作的支持,例如“若没有则添加“、替换以及有条件删除等。
通过并发容器来代替同步容器,可以极大地提高伸缩性并降低风险。
Java 5.0 增加了两种新的容器类型: Queue 和 BlockingQueue 。Queue 用来临时保存一组等待处理的元素。它提供了几种实现,包括: ConcurrentLinkedQueue, 这是一个传统的先进先出队列,以及PriorityQueue, 这是一个(非并发的)优先队列。Queue 上的操作不会阻塞,如果队列为空,那么获取元素的操作将返回空值。虽然可以用List 来模拟Queue 的行为一一事实上,正是通过LinkedList 来实现Queue 的,但还需要一个Queue 的类,因为它能去掉List 的随机访问需求,从而实现更高效的井发。
BlockingQueue 扩展了Queue, 增加了可阻塞的插入和获取等操作。如果队列为空,那么获取元素的操作将一直阻塞,直到队列中出现一个可用的元素。如果队列已满(对于有界队列来说),那么插入元素的操作将一直阻塞,直到队列中出现可用的空间。在“生产者-消费者”这种设计模式中,阻塞队列是非常有用的。
Java 6 也引入了ConcurrentSkipListMap和ConcurrentSkipListSet, 分别作为同步的SortedMap 和SortedSet 的并发替代(例如用synchronizedMap 包装的TreeMap 或TreeSet)
5.2.1 ConcurrentHashMap
与HashMap 一样, ConcurrentHashMap 也是一个基于散列的Map, 但它使用了一种完全不同的加锁策略来提供更高的并发性和伸缩性。ConcurrentHashMap 并不是将每个方法都在同一个锁上同步并使得每次只能有一个线程访问容器,而是使用一种粒度更细的加锁机制来实现更大程度的共享,这种机制称为分段锁。在这种机制中,任意数扯的读取线程可以并发地访问Map, 执行读取操作的线程和执行写入操作的线程可以并发地访问Map, 并且一定数量的写入线程可以并发地修改Map 。ConcurrentHashMap 带来的结果是,在并发访问环境下将实现更高的吞吐址,而在单线程环境中只损失非常小的性能。
ConcurrentHashMap 与其他并发容器一起增强了同步容器类:它们提供的迭代器不会抛出ConcurrentModificationException, 因此不需要在迭代过程中对容器加锁。ConcurrentHashMap返回的迭代器具有弱一致性(Weakly Consistent) ,而并非”及时失败"。弱一致性的迭代器可以容忍井发的修改,当创建迭代器时会遍历已有的元素,并可以(但是不保证)在迭代器被构造后将修改操作反映给容器。
尽管有这些改进,但仍然有一些需要权衡的因素。对于一些需要在整个Map 上进行计算的方法,例如size 和isEmpty, 这些方法的语义被略微减弱了以反映容器的并发特性。由于size返回的结果在计算时可能已经过期了,它实际上只是一个估计值,因此允许size 返回一个近似值而不是一个精确值。虽然这看上去有些令人不安,但事实上size 和isEmpty 这样的方法在并发环境下的用处很小,因为它们的返回值总在不断变化。因此,这些操作的需求被弱化了,以换取对其他更重要操作的性能优化,包括get 、put 、containsKey 和remove 寸。
在ConcurrentHashMap 中没有实现对Map 加锁以提供独占访问。在Hashtable 和synchronizedMap中,获得Map 的锁能防止其他线程访问这个Map 。在一些不常见的情况中需要这种功能,例如通过原子方式添加一些映射,或者对Map 迭代若干次并在此期间保持元素顺序相同。然而,总体来说这种权衡还是合理的,因为并发容器的内容会持续变化。
与Hashtable 和synchronizedMap 相比, CoiicurrentHashMap 有着更多的优势以及更少的劣势,因此在大多数情况下,用ConcurrentHashMap 来代替同步Map 能进一步提高代码的可伸缩性。只有当应用程序需要加锁Map 以进行独占访问e时,才应该放弃使用ConcurrentHashMap 。
5.2.2 额外的原子Map 操作
ConcurrentHashMap 不能被加锁来执行独占访问,因此我们无法使用客户端加锁来创建新的原子操作。。但是,一些常见的复合操作,例如“若没有则添加“、“若相等则移除(Remove-If-Equal)" 和“若相等则替换(Replace-If-Equal)" 等,都已经实现为原子操作井且在ConcurrentMap 的接口中声明,
5.2.3 CopyOnWriteArrayList
CopyOnWriteArrayList 用于替代同步List, 在某些情况下它提供了更好的并发性能,在迭代期间不需要对容器进行加锁或复制。(类似地, CopyOnWriteArraySet 的作用是替代同步Set 。)
“写入时复制(Copy-On-Write)" 容器的线程安全性在于,只要正确地发布一个事实不可变的对象,那么在访问该对象时就不再需要进一步的同步。在每次修改时,都会创建并重新发布一个新的容器副本,从而实现可变性。”写入时复制”容器的迭代器保留一个指向底基础数组的引用,这个数组当前位于迭代器的起始位置,由于它不会被修改,因此在对其进行同步时只需确保数组内容的可见性。因此,多个线程可以同时对这个容器进行迭代,而不会彼此干扰或者与修改容器的线程相互干扰。“写入时复制”容器返回的迭代器不会抛出ConcurrentModificationException, 并且返回的元素与迭代器创建时的元素完全一致,而不必考虑之后修改操作所带来的影响。
显然,每当修改容器时都会复制底层数组,这需要一定的开销,特别是当容器的规模较大时。仅当迭代操作远远多于修改操作时,才应该使用“写入时复制”容器。
5.3 阻塞队列和生产者—消费者模式
阻塞队列提供了可阻塞的put 和take 方法,以及支持定时的offer 和poll 方法。如果队列已经满了,那么put 方法将阻塞直到有空间可用;如果队列为空,那么take 方法将会阻塞直到有元素可用。队列可以是有界的也可以是无界的,无界队列永远都不会充满,因此无界队列上的put 方法也永远不会阻塞。
阻塞队列支持生产者-消费者这种设计模式。该模式将“找出需要完成的工作”与“执行工作“这两个过程分离开来,并把工作项放入一个“待完成“列表中以便在随后处理,而不是找出后立即处理。生产者-消费者模式能简化开发过程,因为它消除了生产者类和消费者类之间的代码依赖性,此外,该模式还将生产数据的过程与使用数据的过程解耦开来以简化工作负载的管理,因为这两个过程在处理数据的速率上有听不同。
在基于阻塞队列构建的生产者-消费者设计中,当数据生成时,生产者把数据放人队列,而当消费者准备处理数据时,将从队列中获取数据。生产者不需要知道消费者的标识或数垦,或者它们是否是唯一的生产者,而只需将数据放入队列即可。同样,消费者也不需要知道生产者是谁,或者工作来自何处。
BlockingQueue 简化了生产者-消费者设计的实现过程,它支持任意数批的生产者和消费者。一种最常见的生产者-消费者设计模式就是线程池与工作队列的组合,在Executor 任务执行框架中就体现了这种模式。
阻塞队列简化了消费者程序的编码,因为take 操作会一直阻塞直到有可用的数据。如果生产者不能尽快地产生工作项使消费者保持忙碌,那么消费者就只能一直等待,直到有工作可做。在某些情况下,这种方式是非常合适的(例如,在服务器应用程序中,没有任何客户请求服务),而在其他一些情况下,这也表示需要调整生产者线程数量和消费者线程数量之间的比率,从而实现更高的资源利用率
如果生产者生成工作的速率比消费者处理工作的速率快,那么工作项会在队列中累积起,最终耗尽内存。同样, put 方法的阻塞特性也极大地简化了生产者的编码。如果使用有界队列,那么当队列充满时,生产者将阻塞并且不能继续生成工作,而消费者就有时间来赶上工作处理进度。
阻塞队列同样提供了一个offer 方法,如果数据项不能被添加到队列中,那么将返回一个失败状态。这样你就能够创建更多灵活的策略来处理负荷过载的情况,例如减轻负载,将多余的工作项序列化并写入磁盘,减少生产者线程的数量,或者通过某种方式来抑制生产者线程。
在构建高可靠的应用程序时,有界队列是一种强大的资源管理工具:它们能抑制并防止产生过多的工作项,使应用程序在负荷过载的情况下变得更加健壮。
在类库中包含了BlockingQueue 的多种实现,其中, LinkedBlockingQueue 和ArrayBlockingQueue是FIFO 队列,二者分别与LinkedList 和 ArrayList 类似,但比同步List 拥有更好的并发性能。PriorityBlockingQueue 是一个按优先级排序的队列,当你希望按照某种顺序而不是FIFO 来处理元素时,这个队列将非常有用。正如其他有序的容器一样, PriorityBlockingQueue 既可以根据元素的自然顺序来比较元素(如果它们实现了Comparable 方法),也可以使用Comparator来比较。
最后一个BlockingQueue 实现是SynchronousQueue, 实际上它不是一个真正的队列,因为它不会为队列中元素维护存储空间。与其他队列不同的是,它维护一组线程,这些线程在等待着把元素加入或移出队列。因为SynchronousQueue 没有储功能,因此put 和take 会一直阻塞,直到有另一个线程已经准备好参与到交付过程中。仅当有足够多的消费者,并且总是有一个消费者准备好获取交付的工作时,才适合使用同步队列。
5.3.2 串行线程封闭
在java.util.coricurrent 中实现的各种阻塞队列都包含了足够的内部同步机制,从而安全地将对象从生产者线程发布到消费者线程。
对于可变对象,生产者-消费者这种设计与阻塞队列一起,促进了串行线程封闭,从而将对象所有权从生产者交付给消费者。线程封闭对象只能由单个线程拥有,但可以通过安全地发布该对象来“转移”所有权。在转移所有权后,也只有另一个线程能获得这个对象的访问权限,并且发布对象的线程不会再访问它。
对象池利用了串行线程封闭,将对象“借给“一个请求线程。只要对象池包含足够的内部同步来安全地发布池中的对象,并且只要客户代码本身不会发布池中的对象,或者在将对象返回给对象池后就不再使用它,那么就可以安全地在线程之间传递所有权。
我们也可以使用其他发布机制来传递可变对象的所有权,但必须确保只有一个线程能接受被转移的对象。阻塞队列简化了这项工作。除此之外,还可以通过ConcurrentMap 的原子方法remove 或者AtomicReference 的原子方法compareAndSet 来完成这项工作
Java 6 增加了两种容器类型, Deque (发音为“deck”) 和B IockingDeque, 它们分别对Queue 和BlockingQueue 进行了扩展。Deque 是一个双端队列,实现了在队列头和队列尾的高效插入和移除。具体实现包括ArrayDeque 和LinkedBlockingDeque 。
正如阻塞队列适用于生产者-消费者模式,双端队列同样适用于另一种相关模式,即工作密取(Work Stealing) 。在生产者-消费者设计中,所有消费者有一个共享的工作队列,而在工作密取设计中,每个消费者都有各自的双端队列。如果一个消费者完成了自己双端队列中的全部工作,那么它可以从其他消费者双端队列末尾秘密地获取工作。密取工作模式比传统的生产者-消费者模式具有更高的可伸缩性,这是因为工作者线程不会在单个共享的任务队列上发生竞争。在大多数时候,它们都只是访问自己的双端队列,从而极大地减少了竞争。当工作者线程需要访问另一个队列时,它会从队列的尾部而不是从头部获取工作,因此进一步降低了队列上的竞争程度。
工作密取非常适用于既是消费者也是生产者问题一当执行某个工作时可能导致出现更的工作。当一个工作线程找到新的任务单元时,它会将其放到自己队列的末尾(或者在工作共享设计模式中,放入其他工作者线程的队列中)。当双端队列为空时,它会在另一个线程的队列队尾查找新的任务,从而确保每个线程都保持忙碌状态。
5.4 阻塞方法与中断方法
线程可能会阻塞或暂停执行,原因有多种:等待1/0 操作结束,等待获得一个锁,等待从Thread.sleep 方法中醒来,或是等待另一个线程的计算结果。当线程阻塞时,它通常被挂起,并处于某种阻塞状态(BLOCKED 、WAITING 或TIMED_WAITING) 。阻塞操作与执行时间很长的普通操作的差别在于,被阻塞的线程必须等待某个不受它控制的事件发生后才能继续执行,例如等待I/0 操作完成,等待某个锁变成可用,或者等待外部计算的结束。当某个外部事件发生时,线程被置回RUNNABLE 状态,并可以再次被调度执行。
BlockingQueue 的put 和take 等方法会抛出受检查异常(Checked Exception) InterruptedException,这与类库中其他一些方法的做法相同,例如Thread.sleep。当某方法抛出 InterruptedException 表示该方法是一个阻塞方法,如果这个方法被中断,那么它将努力提前结束阻塞状态。
Thread 提供了interrupt 方法,用于中断线程或者查询线程是否已经被中断。每个线程都有一个布尔类型的属性,表示线程的中断状态,当中断线程时将设置这个状态。
中断是一种协作机制。一个线程不能强制其他线程停止正在执行的操作而去执行其他的操作。当线程A 中断B 时, A 仅仅是要求B 在执行到某个可以暂停的地方停止正在执行的操作-前提是如果线程B 愿意停止下来。
当在代码中调用了一个将抛出InterruptedException 异常的方法时,你自己的方法也就变成了一个阻塞方法,并且必须要处理对中断的响应。对于库代码来说,有两种基本选择:
-
传递lnterru ptedException 。避开这个异常通常是最明智的策略—只需把InterruptedException 传递给方法的调用者。传递lnterruptedException 的方法包括,根本不捕获该异常,或者捕获该异常,然后在执行某种简单的清理工作后再次抛出这个异常。
-
恢复中断。有时候不能抛出 lnterruptedException, 例如当代码是Runnable 的一部分时。在这些情况下,必须捕获InterruptedException, 并通过调用当前线程上的interrupt 方法恢复中断状态,这样在调用栈中更高层的代码将看到引发了一个中断,如下所示:
// 恢复中断状态以避免屏蔽中断 public class TaskRunnable implements Runnable { BlockingQueue<Task> queue; public void run() { try { processTask(queue.take()); } catch (InterruptedException e) { // restore interrupted status Thread.currentThread().interrupt(); } } void processTask(Task task) { // Handle the task } interface Task { } }
还可以采用一些更复杂的中断处理方法,但上述两种方法已经可以应付大多数情况了。然而在出现InterruptedException 时不应该做的事情是,捕获它但不做出任何响应。这将使调用栈上更高层的代码无法对中断采取处理措施,因为线程被中断的证据已经丢失。只有在一种特殊的情况中才能屏蔽中断,即对Thread 进行扩展,井且能控制调用栈上所有更高层的代码。
5.5 同步工具类
同步工具类可以是任何一个对象,只要它根据其自身的状态来协调线程的控制流。阻塞队列可以作为同步工具类,其他类型的同步工具类还包括信号量(Semaphore)、栅栏(Barrier)以及闭锁(Latch) 。在平台类库中还包含其他一些同步工具类的类,如果这些类还无法满足需要,那么可以按照第14 章中给出的机制来创建自己的同步工具类。
5.5.1 闭锁
闭锁是一种同步工具类,可以延迟线程的进度直到其到达终止状态。闭锁的作用相当于一扇门:在闭锁到达结束状态之前,这扇门一直是关闭的,并且没有任何线程能通过,当到达结束状态时,这扇门会打开并允许所有的线程通过。当闭锁到达结束状态后,将不会再改变状态,因此这扇门将永远保持打开状态。
闭锁可以用来确保某些活动直到其他活动都完成后才继续执行。
-
确保某个计算在其需要的所有资源都被初始化之后才继续执行。二元闭锁(包括两个状态)可以用来表示“资源R 已经被初始化”,而所有需要R 的操作都必须先在这个闭锁上等待。
-
确保某个服务在其依赖的所有其他服务都已经启动之后才启动。每个服务都有一个相关的二元闭锁。当启动服务S 时,将首先在S 依赖的其他服务的闭锁上等待,在所有依赖的服务都启动后会释放闭锁S, 这样其他依赖S 的服务才能继续执行。
-
等待直到某个操作的所有参与者(例如,在多玩家游戏中的所有玩家)都就绪再继续执行。在这种情况中,当所有玩家都准备就绪时,闭锁将到达结束状态。
CountDownLatch 是一种灵活的闭锁实现,可以在上述各种情况中使用,它可以使一个或多个线程等待一组事件发生。闭锁状态包括一个计数器,该计数器被初始化为一个正数,表示需要等待的事件数量。countDown 方法递减计数器,表示有一个事件已经发生了,而await 方法等待计数器达到零,这表示所有需要等待的事件都已经发生。如果计数器的值非零,那么await 会一直阻塞直到计数器为零,或者等待中的线程中断,或者等待超时。
public class TestHarness {
public long timeTasks(int nThreads, final Runnable task)
throws InterruptedException {
final CountDownLatch startGate = new CountDownLatch(1);
final CountDownLatch endGate = new CountDownLatch(nThreads);
for (int i = 0; i < nThreads; i++) {
Thread t = new Thread() {
public void run() {
try {
startGate.await();
try {
task.run();
} finally {
endGate.countDown();
}
} catch (InterruptedException ignored) {
}
}
};
t.start();
}
long start = System.nanoTime();
startGate.countDown();
endGate.await();
long end = System.nanoTime();
return end - start;
}
}
TestHarness 创建一定数量的线程,利用它们井发地执行指定的任务。它使用两个闭锁,分别表示“起始门(StartingGate)" 和“结束门(Ending Gate)”。起始门计数器的初始值为1, 而结束门计数器的初始值为工作线程的数量。每个工作线程首先要做的值就是在启动门上等待,从而确保所有线程都就绪后才开始执行。而每个线程要做的最后一件事情是将调用结束门的countDown 方法减1, 这能使主线程高效地等待直到所有工作线程都执行完成,因此可以统计所消耗的时间。
我们希望测试n 个线程并发执行某个任务时需要的时间。如果在创建线程后立即启动它们,那么先启动的线程将“领先”后启动的线程,并且活跃线程数量会随着时间的推移而增加或减少,竞争程度也在不断发生变化。启动门将使得主线程能够词时释放所有工作线程,而结束门则使主线程能够等待最后一个线程执行完成,而不是顺序地等待每个线程执行完成。
5.5.2 FutureTask
Future Task 也可以用做闭锁。(FutureTask 实现了Future 语义,表示一种抽象的可生成结果的计算。FutureTask 表示的计算是通过Callable 来实现的,相当于一种可生成结果的Runnable, 并且可以处于以下3 种状态:等待运行(Waiting to run) ,正在运行(Running) 和运行完成(Completed) 。“执行完成”表示计算的所有可能结束方式,包括正常结束、由于取消而结束和由于异常而结束等。当FutureTask 进人完成状态后,它会永远停止在这个状态上。
Future.get 的行为取决于任务的状态。如果任务已经完成,那么get 会立即返回结果,否则get 将阻塞直到任务进入完成状态,然后返回结果或者抛出异常。FutureTask 将计算结果从执行计算的线程传递到获取这个结果的线程,而 FutureTask 的规范确保了这种传递过程能实现结果的安全发布。
Future Task 在Executor 框架中表示异步任务,此外还可以用来表示一些时间较长的计算,这些计算可以在使用计算结果之前启动。
public class Preloader {
ProductInfo loadProductInfo() throws DataLoadException {
return null;
}
private final FutureTask<ProductInfo> future = new FutureTask<ProductInfo>(() -> {
return loadProductInfo();
});
private final Thread thread = new Thread(future);
public void start() {
thread.start();
}
public ProductInfo get()
throws DataLoadException, InterruptedException {
try {
return future.get();
} catch (ExecutionException e) {
Throwable cause = e.getCause();
if (cause instanceof DataLoadException)
throw (DataLoadException) cause;
else
throw LaunderThrowable.launderThrowable(cause);
}
}
class ProductInfo{
}
}
class DataLoadException extends Exception {
}
Callable 表示的任务可以抛出受检查的或未受检查的异常,并且任何代码都可能抛出一个Error 。无论任务代码抛出什么异常,都会被封装到一个ExecutionException 中,并在Future.get 中被重新抛出。这将使调用get 的代码变得复杂,因为它不仅需要处理可能出现的Execution-Exception (以及未检查的CancellationException) ,而且还由于ExecutionException 是作为一个Throwable 类返回的,因此处理起来并不容易。
5.5.3 信号量
计数信号量(Counting Semaphore) 用来控制同时访问某个特定资源的操作数量,或者同时执行某个指定操作的数量。计数信号量还可以用来实现某种资源池,或者对容器施加边界。
Semaphore 中管理着一组虚拟的许可(permit) ,许可的初始数量可通过构造函数来指定。在执行操作时可以首先获得许可(只要还有剩余的许可),并在使用以后释放许可。如果没有许可,那么acquire 将阻塞直到有许可(或者直到被中断或者操作超时)。release 方法将返回一个许可给信号量。计算信号量的一种简化形式是二值信号晟,即初始值为1 的Semaphore 。二值信号量可以用做互斥体(mutex) ,并具备不可重入的加锁语义:谁拥有这个唯一的许可,谁就拥有了互斥锁。
Semaphore 可以用于实现资源池,例如数据库连接池。(在构造阻塞对象池时,一种更简单的方法是使用BlockingQueue 来保存池的资源。)
可以使用Semaphore 将任何一种容器变成有界阻塞容器。
public class BoundedHashSet <T> {
private final Set<T> set;
private final Semaphore sem;
public BoundedHashSet(int bound) {
this.set = Collections.synchronizedSet(new HashSet<T>());
sem = new Semaphore(bound);
}
public boolean add(T o) throws InterruptedException {
sem.acquire();
boolean wasAdded = false;
try {
wasAdded = set.add(o);
return wasAdded;
} finally {
if (!wasAdded)
sem.release();
}
}
public boolean remove(Object o) {
boolean wasRemoved = set.remove(o);
if (wasRemoved)
sem.release();
return wasRemoved;
}
}
信号量的计数值会初始化为容器容批的最大值。add 操作在向底层容器中添加一个元素之前,首先要获取一个许可。如果add 操作没有添加任何元素,那么会立刻释放许可。同样, remove 操作释放一个许可,使更多的元素能够添加到容器中。底层的Set 实现并不知道关于边界的任何信息,这是由BoundedHashSet 来处理的。
5.5.4 栅栏
我们已经看到通过闭锁来启动一组相关的操作,或者等待一组相关的操作结束。闭锁是一次性对象,一且进入终止状态,就不能被重置。
栅栏(Barrier) 类似于闭锁,它能阻塞一组线程直到某个事件发生。栅栏与闭锁的关键区别在于,所有线程必须同时到达栅栏位置,才能继续执行。闭锁用于等待事件,而栅栏用于等待其他线程。栅栏用于实现一些协议。
CyclicBarrier 可以使一定数量的参与方反复地在栅栏位置汇集,它在井行迭代算法中非常有用:这种算法通常将一个问题拆分成一系列相互独立的子问题D 当线程到达栅栏位置时将调用await 方法,这个方法将阻塞直到所有线程都到达棚栏位置。
- 如果所有线程都到达了栅栏位置,那么栅栏将打开,此时所有线程都被释放,而栅栏将被重置以便下次使用。
- 如果对await的调用超时,或者await 阻塞的线程被中断,那么栅栏就被认为是打破了,所有阻塞的await 调用都将终止并抛出BrokenBarrierException。
- 如果成功地通过栅栏,那么await 将为每个线程返回一个唯一的到达索引号,我们可以利用这些索引来”选举”产生一个领导线程,并在下一次迭代中由该领导线程执行一些特殊的工作。
CyclicBarrier 还可以使你将一个栅栏操作传递给构造函数,这是一个Runnable, 当成功通过棚栏时会(在一个子任务线程中)执行它,但在阻塞线程被释放之前是不能执行的。
在模拟程序中通常需要使用栅栏,例如某个步骤中的计算可以并行执行,但必须等到该步骤中的所有计算都执行完毕才能进人下一个步骤。
另一种形式的栅栏是Exchanger, 它是一种两方(Two-Party) 栅栏,各方在栅栏位置上交换数据。当两方执行不对称的操作时, Exchanger 会非常有用,例如当一个线程向缓冲区写入数据,而另一个线程从缓冲区中读取数据。这些线程可以使用Exchanger 来汇合,并将满的缓冲区与空的缓冲区交换。当两个线程通过Exchanger 交换对象时,这种交换就把这两个对象安全地发布给另一方。
数据交换的时机取决于应用程序的响应需求。最简总的方案是,当缓冲区被填满时,由填充任务进行交换,当缓冲区为空时,由清空任务进行交换。这样会把需要交换的次数降至最低,但如果新数据的到达率不可预测,那么一些数据的处理过程就将延迟。另一个方法是,不仅当缓冲被填满时进行交换,并且当缓冲被填充到一定程度并保持一定时间后,也进行交换。
5.6 构建高效且可伸缩的结果缓存
几乎所有的服务器应用程序都会使用某种形式的缓存。重用之前的计算结果能降低延迟,提高吞吐量,但却需要消耗更多的内存。
像许多“重复发明的轮子”一样,缓存看上去都非常简单。然而,简单的缓存可能会将性能瓶颈转变成可伸缩性瓶颈,即使缓存是用于提升单线程的性能。
下方Computable<A, V> 接口中声明了一个函数Computable, 其输入类型为A, 输出类型为V 。在ExpensiveFunction 中实现的Computable, 需要很长的时间来计算结我们将创建一个Computable 包装器,帮助记住之前的计算结果,并将缓存过程封装起来。(这项技术被称为“记忆[Memoization]” )
interface Computable <A, V> {
V compute(A arg) throws InterruptedException;
}
// 不好,不会缓存结果
class ExpensiveFunction implements Computable<String, BigInteger> {
public BigInteger compute(String arg) {
// after deep thought...
return new BigInteger(arg);
}
}
// 使用HashMap 和同步机制来初始化缓存
public class Memoizer1 <A, V> implements Computable<A, V> {
@GuardedBy("this") private final Map<A, V> cache = new HashMap<A, V>();
private final Computable<A, V> c;
public Memoizer1(Computable<A, V> c) {
this.c = c;
}
public synchronized V compute(A arg) throws InterruptedException {
V result = cache.get(arg);
if (result == null) {
result = c.compute(arg);
cache.put(arg, result);
}
return result;
}
}
使用HashMap 来保存之前计算的结果。compute 方法将首先检查需要的结果是否巳经在缓存中,如果存在则返回之前计算的值。则,将把计算结果缓存在HashMap 中,然后再返回。
HashMap 不是线程安全的,因此要确保两个线程不会同时访问HashMap, Memoizerl 采用了一种保守的方法,即对整个compute 方法进行同步。这种方法能确保线程安全性,但会带来一个明显的可伸缩性问题:每次只有一个线程能够执行compute 。下图给出了当多个线程使用这种方法中的“记忆“操作时发生的情况,这显然不是我们希望通过缓存获得的性能提升结果。
下方代码Memoizer2 用ConcurrentHashMap 代替HashMap 来改进Memoizerl 中糟糕的并发行为。由于ConcurrentHashMap 是线程安全的,因此在访问底层Map 时就不需要进行同步,因而避免了在对Memoizerl 中的compute 方法进行同步时带来的串行性。
public class Memoizer2 <A, V> implements Computable<A, V> {
private final Map<A, V> cache = new ConcurrentHashMap<A, V>();
private final Computable<A, V> c;
public Memoizer2(Computable<A, V> c) {
this.c = c;
}
public V compute(A arg) throws InterruptedException {
V result = cache.get(arg);
if (result == null) {
result = c.compute(arg);
cache.put(arg, result);
}
return result;
}
}
Memoizer2 比Memoizerl 有着更好的并发行为:多线程可以并发地使用它。但它在作为缓存时仍然存在一些不足—当两个线程同时调用compute 时存在一个漏洞,可能会导致计算得到相同的值。在使用memoization 的情况下,这只会带来低效,因为缓存的作用是避免相同的数据被计算多次。但对于更通用的缓存机制来说,这种情况将更为糟糕。对于只提供单次初始化的对象缓存来说,这个涌洞就会带来安全风险。
Memoizer2 的问题在于,如果某个线程启动了一个开销很大的计算,而其他线程并不知道这个计算正在进行,那么很可能会重复这个计算,如下图所示。
下方代码Memoizer3 将用于缓存值的Map 重新定义为ConcurrentHashMap< A,Future< V>>,替换原来的ConcurrentHashMap<A, V> 。Memoizer3 首先检查某个相应的计算是否已经开始(Memoizer2 与之相反,它首先判断某个计算是否已经完成)。如果还没有启动,那么就创建一个FutureTask, 并注册到Map 中,然后启动计算:如果已经启动,那么等待现有计算的结果。结果可能很快会得到,也可能还在运算过程中,但这对Future.get 的调用者来说是透明的。
public class Memoizer3 <A, V> implements Computable<A, V> {
private final Map<A, Future<V>> cache
= new ConcurrentHashMap<A, Future<V>>();
private final Computable<A, V> c;
public Memoizer3(Computable<A, V> c) {
this.c = c;
}
public V compute(final A arg) throws InterruptedException {
Future<V> f = cache.get(arg);
if (f == null) {
Callable<V> eval = new Callable<V>() {
public V call() throws InterruptedException {
return c.compute(arg);
}
};
f = new FutureTask<V>(eval);
cache.put(arg, f);
f.run(); // call to c.compute happens here
}
try {
return f.get();
} catch (ExecutionException e) {
throw LaunderThrowable.launderThrowable(e.getCause());
}
}
}
Memoizer3 的实现几乎是完美的:它表现出了非常好的并发性(基本上是源于ConcurrentHashMap 高效的并发性),若结果已经计算出来,那么将立即返回。如果其他线程正在计算该结果,那么新到的线程将一直等待这个结果被计算出来。它只有一个缺陷,即仍然存在两个线程计算出相同值的漏洞。
这个漏洞的发生概率要远小于Memoizer2 中发生的概率,但由于compute 方法中的if 代码块仍然是非原子(nonatomic)的“先检查再执行“操作,因此两个线程仍有可能在同一时间内调用compute 来计算相同的值,即二者都没有在缓存中找到期望的值,因此都开始计算。这个错误的执行时序如下所示。
Memoizer3 中存在这个问题的原因是,复合操作(“若没有则添加“)是在底层的Map 对象上执行的,而这个对象无法通过加锁来确保原子性。
下方代码Memoizer 使用了ConcurrentMap 中的原子方法putifAbsent, 避免了Memoizer3 的漏洞。
public class Memoizer<A, V> implements Computable<A, V> {
private final ConcurrentMap<A, Future<V>> cache = new ConcurrentHashMap<A, Future<V>>();
private final Computable<A, V> c;
public Memoizer(Computable<A, V> c) {
this.c = c;
}
public V compute(final A arg) throws InterruptedException {
while (true) {
Future<V> f = cache.get(arg);
if (f == null) {
Callable<V> eval = new Callable<V>() {
public V call() throws InterruptedException {
return c.compute(arg);
}
};
FutureTask<V> ft = new FutureTask<V>(()-> c.compute(arg));
f = cache.putIfAbsent(arg, ft);
if (f == null) {
f = ft;
ft.run();
}
}
try {
return f.get();
} catch (CancellationException e) {
cache.remove(arg, f);
} catch (ExecutionException e) {
throw LaunderThrowable.launderThrowable(e.getCause());
}
}
}
}
当缓存的是Future 而不是值时,将导致缓存污染(Cache Pollution) 问题:如果某个计算被取消或者失败,那么在计算这个结果时将指明计算过程被取消或者失败。为了避免这种情况,如果Memoizer 发现计算被取消,那么将把Future 从缓存中移除。
如果检测到RuntimeException,那么也会移除Future, 这样将来的计算才可能成功。
Memoizer 同样没有解决缓存逾期的问题,但它可以通过使用FutureTask 的子类来解决,在子类中为每个结果指定一个逾期时间,并定期扫描缓存中逾期的元素。(同样,它也没有解决缓存清理的问题,即移除旧的计算结果以便为新的计算结果腾出空间,从而使缓存不会消耗过多的内存。)
第一部分小结
并发技巧清单:
-
可变状态是至关重要的(It’s the mutable state, stupid)
所有的并发问题都可以归结为如何协调对并发状态的访问。可变状态越少,就越容易确保线程安全性。
-
尽量将域声明为 final 类型,除非需要它们是可变的。
-
不可变对象一定是线程安全的。
不可变对象能极大地降低并发编程的复杂性。它们更为简单而且安全,可以任意共享而无须使用加锁或保护性复制等机制。
-
封装有助于管理复杂性。
在编写线程安全的程序时,虽然可以将所有数据都保存在全局变量中,什么要这样做?将数据封裴在对象中,更易于维持不变性条件:将同步机制封装在对象中,史易于遵循同步策略。
-
用锁来保护每个可变变量。
-
当保护同一个不变性条件中的所有变量时,要使用同一个锁。
-
在执行复合操作期间,要持有锁。
-
如果从多个线程中访问同一个可变变量时没有同步机制,那么程序会出现问题。
-
不要故作聪明地推断出不需要使用同步。
-
在设计过程中考虑线程安全,或者在文档中明确地指出它不是线程安全的。
-
将同步策略文档化。
评论区