5.4 阻塞方法和中断方法(Blocking and Interruptible Methods)
线程可能会阻塞或暂停执行,原因有多种:等待I/O操作结束,等待获得一个锁,等待从Thread.sleep方法中醒来,或是等待另一个线程的计算结果。
当线程阻塞时,它通常被挂起,并处于某个阻塞状态(BLOCKED,WAITING,或TIMED_WAITING)。阻塞操作与执行时间很长的普通操作的差别在于,被阻塞的线程必须等待某个不受它控制的事件发生后才能继续执行,例如等到I/O操作,等待某个锁变成可用,或者等待外部计算的结束。当某个外部事件发生时,线程被置回RUNNABLE状态,并可以再次被调度执行。
BlockingQueue的put和take等方法会抛出受检查异常(Checked Excecption)InterruptedException。这与类库中的Thread.sleep做法相同。当某方法抛出InterruptedException时,表示该方法是一个阻塞方法,如果该方法被中断,那么它将努力提前结束阻塞状态。
InterruptedException实质上是一个检测异常,它表明又一个阻塞的被中断了,它尝试进行解除阻塞操作并返回地更早一些。中断阻塞方法的操作线程并不是自身线程干的,而是其它线程。而中断操作发生之后,随后会抛出一个InterruptedException,伴随着这个异常抛出的同时,当前线程的中断状态重新被置为false。
Thread提供了interrupt方法,用于中断线程或者查询线程是否已经被中断,每个线程都有一个布尔类型的属性,表示线程的中断状态,当中断线程时将设置这个状态。
interrupt方法本质上不会进行线程的终止操作的,它不过是改变了线程的中断状态。而改变了此状态带来的影响是,部分可中断的线程方法(比如Object.wait, Thread.sleep)会定期执行isInterrupted方法,检测到此变化,随后会停止阻塞并抛出InterruptedException异常。
中断是一种协作机制,一个线程不能强制其他线程停止正在执行的操作而去执行其他的操作。当线程A中断B时,A仅仅是要求在执行到某个可以暂停的地方停止正在执行的操作——前提是如果线程B愿意停止下来。
最常使用中断的情况就是取消某个操作。方法对中断请求的响应度越高,就越容易及时取消那些执行时间长的操作。
当在代码中调用了一个将抛出InterruptedException异常的方法时,你自己的方法也就变成一个阻塞方法,并且必须要处理中断的响应。对库代码来说,有两种基本选择:
①传递(Propagate)InterruptedException。
避开这个异常通常是明智的策略——只需把InterruptedException传递给方法的调用者,传递InterruptedException的方法包括,根本不捕获该异常,或者捕获该异常,然后执行某种简单的清理工作后再次抛出这个异常。
②恢复(restore)中断。
有时候不能抛出InterruptedException,例如当代码是Runnable的一部分时,在这些情况下,必须捕获InterruptedException,并通过调用当前线程上的interrupt方法恢复中断状态,这样在调用栈中更高层的代码将看到引发了一个中断。
// 5-10 恢复中断状态以避免屏蔽中断
public class TaskRunnable implements Runnable {
BlockingQueue<Task> queue;
public void run() {
try {
processTask(queue.take());
} catch (InterruptedException e) {
// 恢复被中断的状态,终结被阻塞状态。
Thread.currentThread().interrupt();
}
}
void processTask(Task task) {
// 处理任务
}
interface Task {
}
}
Thread.interrupt()方法不会中断一个正在运行的线程。这一方法实际上完成的是,在线程受到阻塞时抛出一个中断信号,这样线程就得以退出阻塞的状态。更确切的说,如果线程被Object.wait, Thread.join和Thread.sleep三种方法之一阻塞,那么,它将接收到一个中断异常(InterruptedException),从而提早地终结被阻塞状态。
在出现InterruptedException时不应该捕获它却不做出任何响应。这将使调用栈上更高层代码无法对中断采取处理措施,因为线程被中断的证据已经丢失。只有在一种特殊的情况下才能屏蔽中断,即对Thread进行继承(extend),并且能控制调用栈上所有更高层的代码。
5.5同步工具类(Synchronizers)
在容器类中,阻塞队列是一种独特的类,它们不仅能作为保存对象的容器,还能协调生产者和消费者等线程之间的控制流,因为take和put等方法将阻塞,直到队列到达期望的状态(队列非空也非满)。
同步工具类可以是任何一个对象,只要它根据自身的状态来协调线程的控制流。阻塞队列可以作为同步工具类,其他类型的同步工具类还包括信号量(Semaphore),栅栏(Barrier),以及闭锁(Latch)。
所有的同步工具类都包含一些特定的结构化属性:它们封装了一些状态,这些状态将决定执行同步工具类的线程是继续执行还是等待,此外还提供了一些方法对状态进行操作,以及另一些方法用于高效地等待同步工具类进入到预期状态。
5.5.1 闭锁(latch)
闭锁是一种同步工具类,可以延迟线程的进度知道其达到终止状态。
闭锁的作用相当于一扇门:在闭锁到达结束状态之前,这扇门一直是关闭的,并且没有任何线程能通过,当到达结束状态时,这扇门会打开并允许所有的线程通过。当闭锁到达结束状态后,将不会再改变状态,因此这扇门将永远保持打开状态。
闭锁可以用来确保某些活动直到其他活动都完成后才继续执行,例如:
①确保某个计算在其需要的所有资源都被初始化之后才继续执行。二元闭锁(包括这两个状态)【binary (two-state) latch】可以用来表示“资源R已经被初始化”,而所有需要R的操作都必须在这个闭锁上等待。
②确保某个服务在其依赖的所有其他服务都已经启动之后才启动。每个服务都有一个相关的二元闭锁当启动服务S时,将首先在S依赖的其他服务上等待,在所有依赖的服务都启动后会释放闭锁S,这样其他依赖S的服务才能继续执行。
③等到直到某个操作的所有参与者(例如,lol中的所有玩家)都就绪再继续执行。在这种情况中,当所有玩家都准备就绪时,闭锁将到达结束状态。
CountDownLatch(倒计时闭锁)是一种灵活的闭锁实现。它可以使一个或多个线程在等待一组事件发生。闭锁状态包括一个计数器,该计数器被初始化为一个正数,表示需要等待的事件数量。
countDown方法递减计数器,表示有一个事件已经发生了,而await方法等待计数器达到零,这表示所有需要等待的事件都已经发生。如果计数器的值非0,那么await会一直阻塞直到计数器为零,或这等待中的线程中断,或者等待超时。
TestHarness创建一定数量的线程,利用它们并发地执行指定的任务。它使用两个闭锁,分别为
startingGate和endingGate,起始门的初始值为1,关闭门的初始值为工作线程数量。
每个线程首先要做的就是在启动门上等待,从而确保所有线程都就绪后才开始执行。
每个线程要做的最后一件事就是讲调用结束门的countDown方法减1,这能使主线程高效地等到所有工作都执行完成,因此可以统计所消耗的时间
// 5-11 在计时测试中使用CountDownLatch来启动和停止线程
public class TestHarness {
public long timeTasks(int nThreads, final Runnable task)
throws InterruptedException {
//有两个闭锁
final CountDownLatch startGate = new CountDownLatch(1); //startGate计数器初始值为1
final CountDownLatch endGate = new CountDownLatch(nThreads); //endGate计数器初始值为工作线程的数量
for (int i = 0; i < nThreads; i++) {
Thread t = new Thread() { //创建一定数量的线程
public void run() {
try {
startGate.await();//每个线程首先要做的就是在启动门上等待,从而确保所有线程都就绪后才开始执行,等待startGate值为0才能执行,之前一直阻塞
try {
task.run(); //允许任务
} finally {
endGate.countDown(); //每个线程要做的最后一件事就是讲调用结束门的countDown方法减1,这能使主线程高效地等到所有工作都执行完成,因此可以统计所消耗的时间
}
} catch (InterruptedException ignored) {
}
}
};
t.start();
}
//计算执行任务的时间
long start = System.nanoTime();
startGate.countDown(); //countDown方法递减计数器,当为0时所有线程已准备好
endGate.await(); //等待工作都执行完,endGate为0才能执行,之前一直阻塞
long end = System.nanoTime();
return end - start;
}
}
为什么要在TestHarness中使用闭锁,而不是在线程创建后就立即启动?
或许我们希望测试n个线程并发执行某个任务时需要的事件,如果在创建线程后立即启动它们,那么先启动的线程将“领先”后启动的线程,并且活跃线程数量会随着时间的推移而增或减,竞争程度也在不断变化。
启动门startGate将使得主线程能能够同时释放所有工作线程,而结束门endGate则使主线程能够等待最后一个线程执行完成,而不是顺序地等待每个线程执行完成。
5.5.2 FutureTask
FutureTask也可以用作闭锁(Future实现了Future语义,表现一种抽象的可生成结果的计算)。
FutureTask表示的计算是通过Callable来实现的,相当于一种可生成结果的Runnable,并且可以处于以下3种状态:等待状态(Waiting to run),正在运行(Running)和运行完成(Completed)。“执行完成”表示计算的所有可能结束方式,包括正常结束,由于取消而结束和由于异常而结束等。当FutureTask进入完成状态后,它会永远停止在这个状态上。
Future.get的行为取决与任务的状态。如果任务已经完成,那么get会立即返回结果,否则get将阻塞直到任务进入完成状态,然后返回结果或者抛出异常。FutrueTask将计算结果从执行计算的线程传递到获取这个结果的线程,而FutureTask的规范确保了这种传递过程能实现结果的安全发布。
FutureTask在Executor框架中表示异步任务。此外还可以用来表示一些时间较长的计算,这些计算可以在使用计算结果之前启动。
// 5-12 使用FutureTask来提前加载稍后需要的数据
public class Preloader {
ProductInfo loadProductInfo() throws DataLoadException {
return null;
}
//新建了一个FutureTask,其中包含从数据库加载产品信息的任务,以及一个执行运算的线程
private final FutureTask<ProductInfo> future =
new FutureTask<ProductInfo>(new Callable<ProductInfo>() { //Callable表示的任务可以抛出受检查或未受检查的异常,并且任何代码都可能抛出一个Error
public ProductInfo call() throws DataLoadException { //Callable可能抛出受检查异常
return loadProductInfo();
}
});
private final Thread thread = new Thread(future); //创建private,final线程
public void start() { thread.start(); }//由于在构造函数或者静态初始化方法中启动线程并不是一种好方法,因此提供了start方法来启动线程
public ProductInfo get()
throws DataLoadException, InterruptedException { //当线程需要ProductInfo时,调用get,如果数据已经加载,返回数据,否则将等待加载完成后再返回
try {
return future.get();
} catch (ExecutionException e) { //当get方法抛出ExecutionException(执行异常),可能是下面三种情况之一:Callable抛出的受检查异常,RuntimeException以及Error。
Throwable cause = e.getCause();
//getCause来获得被封装的初始异常
//在调用LaunderThrowable之前,Preloader会首先检查已知的受检查异常,并重新抛出它们。
if (cause instanceof DataLoadException)
throw (DataLoadException) cause;
else
throw LaunderThrowable.launderThrowable(cause);
}
}
interface ProductInfo { //产品信息接口
}
}
class DataLoadException extends Exception { } //自定义数据加载异常
在实例化FutureTask的时候,new FutureTask(new Callable),参数是一个Callable
Callable接口类似于Runnable,但是Runnable***不会返回结果,并且无法抛出返回结果的异常,而Callable功能更强大一些,被线程执行后,可以返回值*,这个返回值可以被Future拿到,也就是说,Future可以拿到异步执行任务的返回值。对应Runnable中的run方法,Callable中是call方法。
其中的future.get()会等待call()中的操作执行完毕得到其结果。
Callable表示的任务可以抛出受检查或未检查的异常,并且任何代码都可能抛出一个Error。无论任务代码抛出上面异常,都会被封装到一个ExecutionException中,并在Future.get中被重新抛出。
在Preloader中,当get方法抛出ExecutionException(执行异常),可能是下面三种情况之一:
Callable抛出的受检查异常,RuntimeException以及Error。
我们可以使用LaunderThrowable辅助方法来封装一些复杂的异常处理逻辑。在调用LaunderThrowable之前,Preloader会首先检查已知的受检查异常,并重新抛出它们。剩下的是未检查异常,PreLoader将调用launderThrowable并抛出结果。
// 5-13 强制将未检查的Throwable转换为RuntimeException
public class LaunderThrowable {
//如果Throwable是Error,那么抛出它,如果是RuntimeException,那么返回它,否则抛出IllegalStateException
public static RuntimeException launderThrowable(Throwable t) {
if (t instanceof RuntimeException)
return (RuntimeException) t;
else if (t instanceof Error)
throw (Error) t;
else
throw new IllegalStateException("Not unchecked", t);
}
}
如果Throwable传递给launderThrowable的是一个Error,那么LaunderThrowable将直接再次抛出它;如果是RuntimeException,LaunderThrowable将把它们返回给调用者,而调用者通常会重新抛出它们;都不是的话将抛出一个IllegalStateException表示这是一个逻辑错误。
5.5.3 信号量(Semaphore)
计数信号量(Counting Semaphore)是用来控制同时访问某个特定资源的操作数量,或者同时执行某个执行操作的数量。计数信号量还可以用来实现某种资源池,或者对容器施加边界。
Semaphore中管理着一组虚拟的许可(permit),许可的初始数量可通过构造函数来指定。在执行操作时可以首先获得许可(只要还有剩余的许可),并且使用以后释放许可。如果没有许可,那么acquire将阻塞直到有许可(或者直到被中断或请求超时)。release方法将返回一个许可给信号量。计算洗好量的一种简化形式是二值信号量(binary semaphore),即初始值为1的Semaphore。二值信号量可以用做互斥体(mutex),并具备不可重入的加锁语义:谁拥有这个唯一的许可,谁就拥有了互斥锁。
Semaphore可以用于实现资源池,例如数据库连接池。我们可以构造一个固定长度的资源池,当池会空时,请求资源就会阻塞,非空时解除阻塞。如果将Semaphore的技术支持初始化为池的大小,并且从池中获取一个资源之前首先调用acquire(获得)方法获取一个许可,在资源返回给池之后调用release释放许可,那么acquire将一直阻塞直到资源池不为空。
还可以使用Semaphore将任何一种容器编程有界阻塞容器。
如BoundedHashSet,信号量的计数值会初始化为容器容量的最大值
// 5-14 使用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); //创建一个信号量,大小为bound,信号量的计数值会初始化为容器容量的最大值
}
public boolean add(T o) throws InterruptedException{
sem.acquire(); //获得许可
boolean wasAdded=false;
try{ //因为已经抛出了异常,所以不用catch做处理
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;
}
}
底层的Set实现并不知道关于边界的任何信息,这是由BoundedHashSet来处理的。
5.5.4 栅栏(Barrier)
通过闭锁来启动一组相关的操作,或者等待一组相关的操作结束。闭锁是一次性对象,一旦进入终止状态,就不能被重置。
栅栏(Barrier)类似于闭锁,它能阻塞一组线程直到某个事件发生。
闭锁是一次性对象,一旦进入终止状态,就不能被重置。而栅栏打开后可以重置。
闭锁是调用countDown()方法时,闭锁的计数器将减1,当闭锁计数器为0时,闭锁将打开,所有线程将通过闭锁开始执行。而栅栏当线程在CyclicBarrier对象上调用await()方法时,栅栏的计数器将增加1,当计数器为parties时,栅栏将打开。
两者的关键区别在于,所有线程必须同时到达栅栏位置,才能继续执行。闭锁用于等待事件,而栅栏用于等待其他线程。
区别:闭锁用于所有线程等待一个外部事件的发生;栅栏则是所有线程相互等待,直到所有线程都到达某一点时才打开栅栏,然后线程可以继续执行。
闭锁用来等待的事件就是countDown事件,只有该countDown事件执行后所有之前在等待的线程才有可能继续执行;而栅栏没有类似countDown事件控制线程的执行,只有线程的await方法能控制等待的线程执行.
栅栏用于实现一些协议,例如几个家庭决定在某个地方集合:“所有人6:00在KFC碰头,到了以后要等其他人,之后再讨论下一步做的事情”。
CyclicBarrier可以使一定数量的参与方反复地在栅栏位置汇集,它在并行迭代算法中非常有用:这种算法通常将一个问题拆分成一系列相互独立的子问题。
当线程到达栅栏位置时将调用await方法,这个方法将阻塞直到所有线程都到达栅栏位置。如果所有线程都到达了栅栏位置,那么栅栏将打开,此时所有线程都将释放,而栅栏将被重置(闭锁一旦进入终止状态不能重置)以便下次使用。如果对await的调用超时,或者await阻塞的线程被终端,那么栅栏就被认为是打破了,所有阻塞的await调用都将终止并抛出BrokenBarrierException。如果成功地通过栅栏,那么await将为每个线程返回一个唯一的达到索引号,我们可以利用这些索引来“选举”产生一个领导线程,并在下一次迭代中由该领导线程执行一些特殊的工作。
CyclicBarrier还可以使你将一个栅栏操作传递给构造函数,这是一个Runnable,当成功通过栅栏时会(在一个子任务线程中)执行它,但在阻塞线程被释放之前是不能执行的。
在模拟程序中通常需要使用栅栏,例如某个步骤中的计算可以并行执行,但必须等到该步骤的所有计算都执行完毕才能进入下一个步骤。
// 5-15 通过CyclicBarrier协调细胞自动衍生系统中的计算
public class CellularAutomata {
private final Board mainBoard;
private final CyclicBarrier barrier;
private final Worker[] workers;
public CellularAutomata(Board board){
this.mainBoard=board;
int count=Runtime.getRuntime().availableProcessors(); //获得可用的处理器个数
//有count数量的线程,并在线程全部到达栅栏处执行Runnable
this.barrier=new CyclicBarrier(count,new Runnable(){
public void run(){
mainBoard.commitNewValues();
}
});
this.workers=new Worker[count];
//
for(int i=0;i<count;i++) //将问题分为count个子问题
workers[i]=new Worker(mainBoard.getSubBoard(count, i));
}
public class Worker implements Runnable{
private final Board board;
public Worker(Board board){
this.board=board;
}
public void run(){ //工作线程为各自自问中的所有细胞计算新值
while(!board.hasConverged()){
for(int x =0;x<board.getMaxX();x++)
for(int y=0;y<board.getMaxY();y++)
board.setNewValue(x, y, computeValue(x,y));
try{
barrier.await();//等待所有工作线程都到达栅栏处
}catch (InterruptedException e) {
// TODO: handle exception
return ;
}catch (BrokenBarrierException e) {
// TODO: handle exception
return ;
}
}
}
private int computeValue(int x, int y) {
// 计算在 (x,y)中的新值
return 0;
}
}
public void start(){
for(int i=0;i<workers.length;i++)
new Thread(workers[i]).start(); //为每个子问题分配一个线程
mainBoard.waitForConvergence();//进行下一步
}
interface Board {
int getMaxX();
int getMaxY();
int getValue(int x, int y);
int setNewValue(int x, int y, int value);
void commitNewValues();
boolean hasConverged();
void waitForConvergence();
Board getSubBoard(int numPartitions, int index);
}
}
另一种形式的栅栏是Exchanger,它是一种两方(Two-Party)栅栏,各方在栅栏位置上交换数据。当两方执行不对称的操作时,Exchanger会非常有用,例如当一个线程向缓冲区写入数据,而另一个线程从缓冲区中读取数据。这些线程可以使用Exchanger来汇合,并将满的缓冲区与空的缓冲区交换。当两个线程通过Exchanger交换对象时,这种交换就把这两个对象安全地发布给另一方。
数据交换的时机取决与应用程序的响应需求。最简单的方案是,当缓冲区被填满时,由填充任务进行交换 。当缓冲区为空时,由清空任务进行交换。这样会把需要交换的次数降至最低。但如果新数据的到达率不可预测,那么一些数据的处理过程就将延迟。另一个方法是,不仅当缓冲被填满时进行交换,并且当缓冲被填充到一定程度并保持一定时间后,也进行交换。
5.6 构建高效且可伸缩的结果缓存(Building an Efficient, Scalable Result Cache)
几乎所有的服务器应用程序都会使用某种形式的缓存。重用之前的计算结果能降低延迟,提高吞吐量,但却需要消耗更多内存。
本节我们尝试开发一个高效且可伸缩的缓存,用于改进一个高计算开销的函数。我们首先从简单的HashMap开始,然后分析它的并发性缺陷,并讨论如何修复它们。
我们将创建一个Computable包装器,帮助记住之前的计算结果,并将缓存过程封装起来(这项技术被称为“记忆【Memoization】”)
输入类型为A,输出类型为V
// 5-16 使用HashMap和同步机制来初始化缓冲(并不好,需改进)
public class Memoizer1<A,V> implements Computable<A,V>{
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; //返回结果
}
}
interface Computable<A,V>{ //输入类型为A,输出类型为V
V compute(A rag) throws InterruptedException;
}
class ExpensiveFunction implements Computable<String,BigInteger>{
public BigInteger compute(String arg){
//在长时间的计算后
return new BigInteger(arg);
}
}
Memoizer1给出了第一种尝试:使用HashMap来保存之前计算的结果。compute方法将首先检查需要的结果是否在缓存中,如果存在则返回之前计算的值。否则,将计算结果缓存在HashMap中,然后再返回。
HashMap不是线程安全的,因此要确保两个线程不会同时访问HashMap,Memoizer1对整个compute方法进行同步。这种方法能确保线程安全性,但会带来一种明显的可伸缩性:每次只有一个线程能执行compute。如果另一个线程正在计算结果,那么其他调用compute的线程可能被阻塞很长时间。如果有多个线程爱排队等待还未计算出的结果,那么compute方法的计算方法可能比没有“记忆”操作的时间更长。下面是多个线程调用这种方法中的“记忆”操作的情况,显然不是我们希望的。
对Memoizer1中的compute方法进行同步时带来的串行性
下面的Memoizer2用ConcurrentHashMap代替HashMap来改进。由于ConcurrentHashMap是线程安全的,因此在访问底层Map时就不需要进行同步,因此避免了对Memoizer1中的compute方法进行同步时带来的串行性。
Memoizer2比Memoizer1有着更好的并发性:多线程可以并发地使用它。
但它在作为缓存时仍然存在不足:当两个线程同时调用compute时存在一个漏洞,可能会导致计算得到两个相同的值(而我们只需要缓存一个)。在使用memoization的情况下,这只会带来低效,因为缓存的作用是避免相同的结果被计算多次。但对于更通用的缓存机制来说,这种情况将更为糟糕。对于只提供单次初始化的对象缓存来说,这个漏洞就会带来安全风险。
// 5-17 用ConcurrentHashMap替换HashMap(还是不好)
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);
result=cache.put(arg, result);
}
return result;
}
}
Memoizer2的问题在于,如果某个线程启动了一个开销很大的计算,而其他线程并不知道这个计算正在计算,那么可能会重复这个计算。
我们希望通过某种方法来表达“线程X正在计算f(27)”这种情况,这样当另一个线程查找f(27)时它能够知道最高效的方法是等待线程X计算结束,然后再去查询缓存“f(27)的结果是多少”。
我们可以使用:FutureTask来实现这个功能。FutureTask表示一个计算的过程,这个过程可能已经计算完成,也可能正在进行。如果有结果可用,那么FutureTask.get将立即返回结果,否则它会一直等待,知道结果计算出来再将其返回。
Memoizer3 用ConcurrentHashMap
// 5-18 基于FutureTask的Memoizing封装器(还不够好)
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 { //为什么是final?
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>(eval);
f = ft;
cache.put(arg, ft);//注册到Map中
ft.run(); // 调用call启动计算
}
try {
return f.get(); //若结果已经计算出来,那么将立刻返回。如果其他线程正在计算该结果,那么信道的线程将一直等待这个结果被计算出来。
} catch (ExecutionException e) {//当get方法抛出ExecutionException(执行异常)及Error。
throw LaunderThrowable.launderThrowable(e.getCause());//e.getCasue获得被封装的初始异常
}
}
}
Memoizer3变现出了很好的并发性(基本上是源于ConcurrentHashMap高效的并发性)。若结果已经计算出来,那么将立刻返回。如果其他线程正在计算该结果,那么信道的线程将一直等待这个结果被计算出来。
但仍然存在两个线程计算出相同值的漏洞,这个概率比Memoizer2要小,但由于compute方法中的if代码块仍然是非原子的(nonatomic)的“先检查在执行”操作,因此两个线程仍有可能在同一个时间内调用compute来计算相同的值,即两者都没有在缓存中找到期望的值,因此都开始计算,这个错误的执行时序如下:
Memoizer3存在这个问题的原因是,复合操作(“若没有则添加”)是在底层的Map对象上执行,而这个对象无法通过加锁来确保原子性。
下面的Memoizer使用ConcurrentMap中的原子方法putIfAbsent,避免了Memoizer3的漏洞。
// 5-19 最终实现
public class Memoizer <A, V> implements Computable<A, V> {//继承Computable,其中有compute方法
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 {//尽量将域声明为final类型,除非需要它们是可变的。不可变对象一定是线程安全的。
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>(eval);
//putIfAbsent操作:仅当K没有相应的映射值时才插入,absent(不在场的)
f = cache.putIfAbsent(arg, ft);//底层的Map中的put是复合操作(“若没有则添加”),属于非原子性的“先检查再执行”操作
/*putIfAbsent相当于
* if (!map.containsKey(key))
return map.put(key, value);
else
return map.get(key);
*/
if (f == null) { //所以要对f进行判断
f = ft;
ft.run();
}
}
try {
return f.get();//若结果已经计算出来,那么将立刻返回。如果其他线程正在计算该结果,那么信道的线程将一直等待这个结果被计算出来。
} catch (CancellationException e) {
cache.remove(arg, f); //如果发现计算被取消或失败,将Future从缓存中移出
} catch (ExecutionException e) {
//getCause来获得被封装的初始异常
throw LaunderThrowable.launderThrowable(e.getCause());
}
}
}
}
Future是一个接口,Future就是对于具体的Runnable或者Callable任务的执行结果进行取消、查询是否完成、获取结果。必要时可以通过get方法获取执行结果,该方法会阻塞直到任务返回结果。
FutureTask是Future接口的一个唯一实现类。
当缓存的是Future而不是值时,将导致缓存污染(Cache Pollution)问题:如果某个计算被取消或失败,那么future在计算这个结果时将指明计算过程被取消或失败。为了避免这种情况,如果Memoizer发现计算被取消,那么将Future从缓存中移除。如果检查到RuntimeException,也会移除Future,这样将来的计算才可能成功。
Memoizer同样没有解决缓存逾期的问题,但它可以通过使用FutureTask的子类来解决,在子类中为每个结果制定一个逾期时间,并定期扫描缓存中逾期的元素。
同样,它也没有解决缓存清理的问题,即移除旧的计算结果以为新的计算结果腾出空间,从而使缓存不会消耗过多的内存。
我们可以类似地为第二章因数分解servlet添加结果缓存。
// 5-20 在因式分解servlet中使用Memoizer来缓存结果
@ThreadSafe
public class Factorizer extends GenericServlet implements Servlet {
//Memoizer中的Computable并没有初始化方法,这里添加compute放
private final Computable<BigInteger, BigInteger[]> c =
new Computable<BigInteger, BigInteger[]>() {
public BigInteger[] compute(BigInteger arg) {
return factor(arg);
}
};
private final Computable<BigInteger, BigInteger[]> cache
= new Memoizer<BigInteger, BigInteger[]>(c);//使用Memoizer来缓存之前的计算结果
public void service(ServletRequest req,
ServletResponse resp) {
try {
BigInteger i = extractFromRequest(req);//得到要计算的值
encodeIntoResponse(resp, cache.compute(i));//计算结果缓存并返回给客户端
} catch (InterruptedException e) {
encodeError(resp, "factorization interrupted");
}
}
void encodeIntoResponse(ServletResponse resp, BigInteger[] factors) {
}
void encodeError(ServletResponse resp, String errorString) {
}
BigInteger extractFromRequest(ServletRequest req) {
return new BigInteger("7");
}
BigInteger[] factor(BigInteger i) {
// Doesn't really factor
return new BigInteger[]{i};
}
}