阻塞队列提供了可阻塞的put 和take方法, 以及支持定时的offer和poll方法。如果队列已经满了, 那么put方法将阻塞直到有空间可用;如果队列为空, 那么take方法将会阻塞直到有元素可用。队列可以是有界的也可以是无界的, 无界队列永远都不会充满, 因此无界队列上的put方法也永远不会阻塞。
阻塞队列支持生产者-消费者这种设计模式。该模式将“找出需要完成的工作” 与“执行工作” 这两个过程分离开来, 并把工作项放入一个“ 待完成” 列表中以便在随后处理, 而不是找出后立即处理。生产者- 消费者模式能简化开发过程, 因为它消除了生产者类和消费者类之间的代码依赖性, 此外, 该模式还将生产数据的过程与使用数据的过程解耦开来以简化工作负载的管理, 因为这两个过程在处理数据的速率上有听不同。
在基于阻塞队列构建的生产者-消费者设计中, 当数据生成时, 生产者把数据放人队列,而当消费者准备处理数据时, 将从队列中获取数据。生产者不需要知道消费者的标识或数量,或者它们是否是唯一的生产者, 而只需将数据放入队列即可。同样, 消费者也不需要知道生产者是谁, 或者工作来自何处。BlockingQueue 简化了生产者- 消费者设计的实现过程, 它支持任意数批的生产者和消费者。一种最常见的生产者- 消费者设计模式就是线程池与工作队列的组合, 在Executor任务执行框架中就体现了这种模式。
以两个人洗盘子为例, 二者的劳动分工也是一种生产者一消费者模式:其中一个人把洗好的盘子放在盘架上, 而另一个人从盘架上取出盘子并把它们烘干。在这个示例中, 盘架相当于阻塞队列。如果盘架上没有盘子, 那么消费者会一直等待, 直到有盘子需要烘干。如果盘架放满了, 那么生产者会停止清洗直到盘架上有更多的空间。我们可以将这种类比扩展为多个生产(虽然可能存在对水槽的竞争)和多个消费者, 每个工人只需与盘架打交道。人们不需要知道究竞有多少生产者或消费者, 或者谁生产了某个指定的工作项。
“生产者” 和“ 消费者” 的角色是相对的, 某种环境中的消费者在另一种不同的环境中可能会成为生产者。烘干盘子的工人将“ 消费” 洗干净的湿盘子, 而产生烘干的盘子。第三个人把洗干净的盘子整理好,在这种情况中, 烘干盘子的工人既是消费者,也是生产者, 从而就有了两个共享的工作队列(每个队列都可能阻塞烘干工作的运行)。
阻塞队列简化了消费者程序的编码, 因为 take操作会一直阻塞直到有可用的数据。如果 生产者不能尽快地产生工作项使消费者保持忙碌, 那么消费者就只能一直等待, 直到有工作可做。在某些情况下, 这种方式是非常合适的(例如, 在服务器应用程序中,没有任何客户请求 服务), 而在其他一些情况下, 这也表示需要调整生产者线程数量和消费者线程数量之间的比率,从而实现更高的资源利用率(例如,在 “ 网页爬虫[Web Crawler]"或其他应用程序中,有无穷的工作需要完成)。
如果生产者生成工作的速率比消费者处理工作的速率快, 那么工作项会在队列中累积起 , 最终耗尽内存。 同样, put方法的阻塞特性也极大地简化了生产者的编码。 如果使用有界 队列, 那么当队列充满时, 生产者将阻塞并且不能继续生成工作, 而消费者就有时间来赶上工 作处理进度。
阻塞队列同样提供了一个 offer方法,如果数据项不能被添加到队列中, 那么将返回一个失败状态。 这样你就能够创建更多灵活的策略来处理负荷过载的情况,例如减轻负载, 将多余 的工作项序列化并写入磁盘,减少生产者线程的数量,或者通过某种方式来抑制生产者线程。
在构建高可靠的应用程序时,有界队列是一种强大的资源管理工具:它们能抑制并防止产生过多的工作项,使应用程序在负荷过载的情况下
虽然生产者-消费者模式能够将生产者和消费者的代码彼此解耦开来, 但它们的行为仍然会通过共享工作队列间接地耦合在一起。 开发人员总会假设消费者处理工作的速率能赶上生产者生成工作项的速率, 因此通常不会为工作队列的大小设置边界, 但这将导致在之后需要重新设计系统架构。因此,应该尽早地通过阻塞队列在设计中构建资源管理机制-这件事清做得越早, 就越容易。在许多情况下, 阻塞队列能使这项工作更加简单,如果阻塞队列并不完全符合设计需求, 那么还可以通过信号址(Semaphore)来创建其他的阻塞数据结构。
在类库中包含了BlockingQueue的多种实现,其中,LinkedBlockingQueue和ArrayBlockingQueue是FIFO队列,二者分别与LinkedList和ArrayList类似,但比同步List拥有更好的并发性能。PriorityBlockingQueue是一个按优先级排序的队列,当你希望按照某种顺序而不是FIFO来处理元素时,这个队列将非常有用。正如其他有序的容器一样,PriorityBlockingQueue既可以根据元素的自然顺序来比较元素(如果它们实现了Comparable方法),也可以使用Comparator来比较。
最后一个BlockingQueue实现是SynchronousQueue, 实际上它不是一个真正的队列, 因为它不会为队列中元素维护存储空间。与其他队列不同的是, 它维护一组线程, 这些线程在等待着把元素加入或移出队列。如果以洗盘子的比喻为例, 那么这就相当千没有盘架, 而是将洗好的盘子直接放入下一个空闲的烘干机中。这种实现队列的方式看似很奇怪, 但由于可以直接交付工作,从而降低了将数据从生产者移动到消费者的延迟。(在传统的队列中, 在一个工作单元可以交付之前, 必须通过串行方式首先完成入列[Enqueue]或者出列[Dequeue]等操作。) 直接交付方式还会将更多关于任务状态的信息反馈给生产者。当交付被接受时, 它就知道消费者已经得到了任务, 而不是简单地把任务放入一个队列一一这种区别就好比将文件直接交给同事, 还是将文件放到她的邮箱中并希望她能尽快拿到文件。因为SynchronousQueue 没有存储功能,因此put 和take 会一直阻塞,直到有另一个线程已经准备好参与到交付过程中。仅当有足够多的消费者,并且总是有一个消费者准备好获取交付的工作时, 才适合使用同步队列。
串行线程封闭
在java.util.coricurrent中实现的各种阻塞队列都包含了足够的内部同步机制, 从而安全地将对象从生产者线程发布到消费者线程。
对于可变对象, 生产者 - 消费者这种设计与阻塞队列一起, 促进了串行线程封闭, 从而将对象所有权从生产者交付给消费者。 线程封闭对象只能由单个线程拥有, 但可以通过安全地发布该对象来 “转移 ” 所有权。在转移所有权后, 也只有另一个线程能获得这个对象的访问权限,并且发布对象的线程不会再访问它。这种安全的发布确保了对象状态对于新的所有者来说是可见的, 并且由于最初的所有者不会再访问它, 因此对象将被封闭在新的线程中。 新的所有者线程可以对该对象做任意修改, 因为它具有独占的访问权。
对象池利用了串行线程封闭, 将对象 “借给“一个请求线程。 只要对象池包含足够的内部同步来安全地发布池中的对象, 并且只要客户代码本身不会发布池中的对象, 或者在将对象返回给对象池后就不再使用它, 那么就可以安全地在线程之间传递所有权。
我们也可以使用其他发布机制来传递可变对象的所有权, 但必须确保只有一个线程能接受被转移的对象。 阻塞队列简化了这项工作。 除此之外, 还可以通过 ConcurrentMap 的原子方法remove 或者 AtomicReference 的原子方法 compareAndSet 来完成这项工作。
双端队列与工作密取
Java 6 增加了两种容器类型, Deque (发音为 "deck") 和 BIockingDeque, 它们分别对 Queue 和 BlockingQueue 进行了扩展。 Deque 是一个双端队列, 实现了在队列头和队列尾的高效插入和移除。 具体实现包括 ArrayDeque 和 LinkedBlockingDeque。
正如阻塞队列适用于生产者 - 消费者模式, 双端队列同样适用于另一种相关模式, 即工作密取 (Work Stealing)。 在生产者-消费者设计中,所有消费者有一个共享的工作队列, 而在 工作密取设计中, 每个消费者都有各自的双端队列。 如果一个消费者完成了自己双端队列中的 全部工作, 那么它可以从其他消费者双端队列末尾秘密地获取工作。 密取工作模式比传统的生产者-消费者模式具有更高的可伸缩性, 这是因为工作者线程不会在单个共享的任务队列上发 生竞争。 在大多数时候, 它们都只是访问自己的双端队列, 从而极大地减少了竞争。 当工作者线程需要访问另一个队列时, 它会从队列的尾部而不是从头部获取工作, 因此进一步降低了队列上的竞争程度。
工作密取非常适用于既是消费者也是生产者问题——当执行某个工作时可能导致出现更多的工作。 例如, 在网页爬虫程序中处理一个页面时, 通常会发现有更多的页面需要处理。 类似的还有许多搜索图的算法, 例如在垃圾回收阶段对堆进行标记, 都可以通过工作密取机制来实 现高效并行。 当一个工作线程找到新的任务单元时, 它会将其放到自己队列的末尾(或者在工作共享设计模式中, 放入其他工作者线程的队列中)。 当双端队列为空时, 它会在另一个线程的队列队尾查找新的任务, 从而确保每个线程都保持忙碌状态。
阻塞方法与中断方法
线程可能会阻塞或暂停执行, 原因有多种:等待I/O操作结束, 等待获得一个锁, 等待从Thread.sleep 方法中醒来, 或是等待另一个线程的计算结果。 当线程阻塞时, 它通常被挂起,并处于某种阻塞状态 (BLOCKED、 WAITING或 TIMED_WAITING)。阻塞操作与执行时间很长的普通操作的差别在于,被阻塞的线程必须等待某个不受它控制的事件发生后才能继续执行, 例如等待 I/0 操作完成, 等待某个锁变成可用, 或者等待外部计算的结束。 当某个外部事件发生时, 线程被置回 RUNNABLE状态,并可以再次被调度执行。
BlockingQueue 的 put 和 take 等方法会抛出受检查异常 (Checked Exception) InterruptedException, 这与类库中其他一些方法的做法相同,例如 Thread.sleep。当某方法抛出 Interrupted-Exception 时, 表示该方法是一个阻塞方法, 如果这个方法被中断, 那么它将努力提前结束阻塞状态。
Thread提供了interrupt方法,用于中断线程或者查询线程是否已经被中断。每个线程都有一个布尔类型的属性, 表示线程的中断状态, 当中断线程时将设置这个状态。
中断是一种协作机制。一个线程不能强制其他线程停止正在执行的操作而去执行其他的操作。当线程A中断B时, A仅仅是要求B在执行到某个可以暂停的地方停止正在执行的操作-前提是如果线程B愿意停止下来。虽然在API或者语言规范中并没有为中断定义任何特定应用级别的语义, 但最常使用中断的情况就是取消某个操作。方法对中断请求的响应度越高, 就越容易及时取消那些执行时间很长的操作。
当在代码中调用了一个将抛出InterruptedException 异常的方法时, 你自己的方法也就变成了一个阻塞方法, 并且必须要处理对中断的响应。对于库代码来说, 有两种基本选择:
传递lnterruptedException。避开这个异常通常是最明智的策略——只需把InterruptedException 传递给方法的调用者。传递lnterruptedException 的方法包括, 根本不捕获该异常, 或者捕获该异常, 然后在执行某种简单的清理工作后再次抛出这个异常。
恢复中断有时候不能抛出lnterruptedException, 例如当代码是Runnable 的一部分时。在这些情况下, 必须捕获InterruptedException, 并通过调用当前线程上的interrupt 方法恢复中断状态, 这样在调用栈中更高层的代码将看到引发了一个中断。
还可以采用一些更复杂的中断处理方法, 但上述两种方法已经可以应付大多数情况了。然而在出现InterruptedException 时不应该做的事情是, 捕获它但不做出任何响应。这将使调用栈上更高层的代码无法对中断采取处理措施, 因为线程被中断的证据已经丢失。只有在一种特殊的情况中才能屏蔽中断, 即对Thread 进行扩展, 井且能控制调用栈上所有更高层的代码。
同步工具类
在容器类中,阻塞队列是一种独特的类:它们不仅能作为保存对象的容器, 还能协调生产者和消费者等线程之间的控制流, 因为take 和put 等方法将阻塞, 直到队列达到期望的状态(队列既非空, 也非满)。
同步工具类可以是任何一个对象,只要它根据其自身的状态来协调线程的控制流。 阻塞队列可以作为同步工具类,其他类型的同步工具类还包括信号量(Semaphore)、 栅栏(Barrier) 以及闭锁(Latch)。在平台类库中还包含其他一些同步工具类的类, 如果这些类还无法满足需要,那么可以按照第14章中给出的机制来创建自己的同步工具类。
所有的同步工具类都包含一些特定的结构化属性:它们封装了一些状态, 这些状态将决定执行同步工具类的线程是继续执行还是等待,此外还提供了一些方法对状态进行操作,以及另 一些方法用于高效地等待同步工具类进入到预期状态。
闭锁
闭锁是一种同步工具类, 可以延迟线程的进度直到其到达终止状态[CPJ 3.4.2]。 闭锁的作用相当于一扇门:在闭锁到达结束状态之前, 这扇门 一直是关闭的,并且没有任何线程能通过,当到达结束状态时,这扇门会打开并允许所有的线程通过。 当闭锁到达结束状态后 将不会再 改变状态,因此这扇门将永远保持打开状态。闭锁可以用来确保某些活动直到其他活动都完成后才继续执行,例如:
· 确保某个计算在其需要的所有资源都被初始化之后才继续执行。 二元闭锁(包括两个状态)可以用来表示 “资源R已经被初始化 ”,而所有需要R的操作都必须先在这个闭锁 上等待。
· 确保某个服务在其依赖的所有其他服务都已经启动之后才启动。 每个服务都有一个相关的二元闭锁。 当启动服务S时, 将首先在S依赖的其他服务的闭锁上等待, 在所有依赖的服务都启动后会释放闭锁S, 这样其他依赖S的服务才能继续执行。
· 等待直到某个操作的所有参与者(例如,在多玩家游戏中的所有玩家)都就绪再继续执 行。在这种情况中, 当所有玩家都准备就绪时, 闭锁将到达结束状态。
CountDownLatch是一种灵活的闭锁实现,可以在上述各种情况中使用, 它可以使一个或多个线程等待一组事件发生。闭锁状态包括一个计数器,该计数器被初始化为一个正数, 表示需要等待的事件数量。countDown方法递减计数器, 表示有一个事件已经发生了,而 await 方 法等待计数器达到零,这表示所有需要等待的事件都已经发生。 如果计数器的值非零, 那么 await会一直阻塞直到计数器为零, 或者等待中的线程中断, 或者等待超时。
在程序清单5-11的TestHamess中给出了闭锁的两种常见用法。TestHarness创建一定数量的线程, 利用它们井发地执行指定的任务。 它使用两个闭锁, 分别表示 “起始门(Starting Gate)"和 “结束门(Ending Gate) "。 起始门计数器的初始值为 1, 而结束门计数器的初始值为工作线程的数量。 每个工作线程首先要做的值就是在启动门上等待, 从而确保所有线程都就绪 后才开始执行。 而每个线程要做的最后一件事情是将调用结束门的countDown方法减 1, 这能 使主线程高效地等待直到所有工作线程都执行完成, 因此可以统计所消耗的时间。
为什么要在TestHarness 中使用闭锁, 而不是在线程创建后就立即启动?或许, 我们希望测试n 个线程并发执行某个任务时需要的时间。如果在创建线程后立即启动它们, 那么先启动的线程将“ 领先” 后启动的线程, 并且活跃线程数量会随着时间的推移而增加或减少, 竞争程度也在不断发生变化。启动门将使得主线程能够词时释放所有工作线程, 而结束门则使主线程能够等待最后一个线程执行完成, 而不是顺序地等待每个线程执行完成。
FutureTask
FutureTask 也可以用做闭锁。(FutureTask 实现了Future 语义,表示一种抽象的可生成结果的计算[CPJ 4.3.3])。FutureTask表示的计算是通过Callable 来实现的, 相当于一种可生成结果的Runnable, 并且可以处于以下3 种状态: 等待运行(Waiting to run), 正在运行(Running) 和运行完成(Completed)。“执行完成” 表示计算的所有可能结束方式, 包括正常结束、由于取消而结束和由千异常而结束等。当FutureTask 进人完成状态后, 它会永远停止在这个状态上。
Future.get 的行为取决于任务的状态。如果任务已经完成, 那么get 会立即返回结果, 否则get 将阻塞直到任务进入完成状态, 然后返回结果或者抛出异常。FutureTask 将计算结果从执行计算的线程传递到获取这个结果的线程, 而FutureTask 的规范确保了这种传递过程能实现结果的安全发布。
FutureTask在Executor框架中表示异步任务, 此外还可以用来表示一些时间较长的计算,这些计算可以在使用计算结果之前启动。程序清单5-12中的Preloader就使用了FutureTask来执行一个高开销的计算, 并且计算结果将在稍后使用。通过提前启动计算,可以减少在等待结果时需要的时间。
Preloader创建了一个FutureTask, 其中包含从数据库加载产品信息的任务,以及一个执行运算的线程。由于在构造函数或静态初始化方法中启动线程并不是一种好方法, 因此提供了一个start方法来启动线程。当程序随后需要Productlnfo时, 可以调用get方法, 如果数据巳经加载,那么将返回这些数据, 否则将等待加载完成后再返回。
Callable表示的任务可以抛出受检查的或未受检查的异常, 并且任何代码都可能抛出一个Error。无论任务代码抛出什么异常, 都会被封装到一个ExecutionException中, 并在Future.get中被重新抛出。这将使调用get的代码变得复杂, 因为它不仅需要处理可能出现的Execution-Exception (以及未检查的CancellationException), 而且还由于ExecutionException 是作为一个Throwable类返回的, 因此处理起来并不容易。
在Preloader中, 当get方法抛出ExecutionException 时,可能是以下三种情况之一: Callable抛出的受检查异常,RuntimeException, 以及Error。我们必须对每种情况进行单独处理,但我们将使用程序清单5-13中的launderThrowable辅助方法来封装一些复杂的异常处理逻辑。在调用launderThrowable之前,Preloader会首先检查巳知的受检查异常, 并重新抛出它们。剩下的是未检查异常,Preloader将调用launderThrowable 并抛出结果。如果Throwable传递给launderThrowable 的是一个 Error, 那么 launderThrowable 将直接再次抛出它; 如果不 RuntimeException, 那么将抛出一个 IllegalStateException 表示这是一个逻辑错误。 剩下的RuntimeException, launderThrowable 将把它们返回给调用者, 而调用者通常会重新抛出它们。
信号量
计数信号量(Counting Semaphore) 用来控制同时访问某个特定资源的操作数量, 或者同时执行某个指定操作的数量[CPJ 3.4.1]。计数信号量还可以用来实现某种资源池, 或者对容器施加边界。
Semaphore 中管理着一组虚拟的许可(permit), 许可的初始数量可通过构造函数来指定。在执行操作时可以首先获得许可(只要还有剩余的许可), 并在使用以后释放许可。如果没有许可, 那么acquire 将阻塞直到有许可(或者直到被中断或者操作超时)。release 方法将返回一个许可给信号量。计算信号量的一种简化形式是二值信号量, 即初始值为1 的Semaphore。二值信号量可以用做互斥体(mutex), 并具备不可重入的加锁语义: 谁拥有这个唯一的许可, 谁就拥有了互斥锁。
Semaphore 可以用于实现资源池, 例如数据库连接池。我们可以构造一个固定长度的资源池, 当池为空时, 请求资源将会失败, 但你真正希望看到的行为是阻塞而不是失败并且当池非空时解除阻塞。如果将Semaphore 的计数值初始化为池的大小, 并在从池中获取一个资源之前首先调用acquire 方法获取一个许可, 在将资源返回给池之后调用release 释放许可, 那么acquire 将一直阻塞直到资源池不为空。在第12 章的有界缓冲类中将使用这项技术。(在构造阻塞对象池时, 一种更简单的方法是使用BlockingQueue 来保存池的资源。)
同样, 你也可以使用Semaphore 将任何一种容器变成有界阻塞容器, 如程序清单5-14 中的BoundedHashSet 所示。信号量的计数值会初始化为容器容批的最大值。add 操作在向底层容器中添加一个元素之前, 首先要获取一个许可。如果add 操作没有添加任何元素, 那么会立刻释放许可。 同样, remove操作释放一个许可,使更多的元素能够添加到容器中。底层的 Set 实现并不知道关于边界的任何信息,这是由 BoundedHashSet 来处理的。
栅栏
我们已经看到通过闭锁来启动一组相关的操作, 或者等待一组相关的操作结束。闭锁是一次性对象, 一且进入终止状态, 就不能被重置。
栅栏(Barrier) 类似于闭锁, 它能阻塞一组线程直到某个事件发生[CPJ 4,4.3]。栅栏与闭锁的关键区别在于, 所有线程必须同时到达栅栏位置, 才能继续执行。闭锁用于等待事件, 而栅栏用于等待其他线程。栅栏用于实现一些协议, 例如几个家庭决定在某个地方集合: “所有人6:00 在麦当劳碰头, 到了以后要等其他人, 之后再讨论下一步要做的事情。”
CyclicBarrier 可以使一定数量的参与方反复地在栅栏位置汇集, 它在井行迭代算法中非常有用:这种算法通常将一个问题拆分成一系列相互独立的子问题。 当线程到达栅栏位置时将调用await 方法, 这个方法将阻塞直到所有线程都到达棚栏位置。如果所有线程都到达了栅栏位,那么栅栏将打开, 此时所有线程都被释放, 而栅栏将被重置以便下次使用。如果对await的调用超时, 或者await 阻塞的线程被中断, 那么栅栏就被认为是打破了, 所有阻塞的await调用都将终止并抛出 BrokenBarrierException。如果成功地通过栅栏,那么await将为每个线程返回一个唯一的到达索引号, 我们可以利用这些索引来 “ 选举 ” 产生一个领导线程, 并在下一次迭代中由该领导线程执行一些特殊的工作。 CyclicBarrier 还可以使你将一个栅栏操作传递给构造函数, 这是一个Runnable, 当成功通过棚栏时会(在一个子任务线程中)执行它, 但在阻塞线程被释放之前是不能执行的。
在模拟程序中通常需要使用栅栏, 例如某个步骤中的计算可以并行执行, 但必须等到该步骤中的所有计算都执行完毕才能进人下一个步骤。 例如, 在 n-body 粒子模拟系统中, 每个步骤都根据其他粒子的位置和属性来计算各个粒子的新位置。 通过在每两次更新之间等待栅栏, 能够确保在第 k 步中的所有更新操作都已经计算完毕, 才进入第 k+1步。
在程序清单5-15的CellularAutomata中给出了如何通过栅栏来计算细胞的自动化模拟,例如Conway的生命游戏(Gardner,1970)。在把模拟过程并行化时,为每个元素(在这个示例 中相当于一个细胞)分配一个独立的线程是不现实的,因为这将产生过多的线程,而在协调这 些线程上导致的开销将降低计算性能。合理的做法是,将问题分解成一定数簸的子问题,为每 个子问题分配一个线程来进行求解,之后再将所有的结果合并起来。CellularAutomata将间题 分解为Ncpu个子问题,其中Ncpu等千可用CPU的数量,并将每个子问题分配给一个线程。在每个步骤中,工作线程都为各自子问题中的所有细胞计算新值。当所有工作线程都到达栅栏 时,栅栏会把这些新值提交给数据模型。在栅栏的操作执行完以后,工作线程将开始下一步的计算,包括调用isDone方法来判断是否需要进行下一次迭代。
另一种形式的栅栏是Exchanger, 它是一种两方(Two-Party)栅栏,各方在栅栏位置上交换数据[CPJ 3.4.3]。当两方执行不对称的操作时,Exchanger会非常有用,例如当一个线程向缓冲区写入数据,而另一个线程从缓冲区中读取数据。这些线程可以使用Exchanger来汇合,并将满的缓冲区与空的缓冲区交换。当两个线程通过Exchanger交换对象时,这种交换就把这两个对象安全地发布给另一方。
数据交换的时机取决于应用程序的响应需求。最简单的方案是, 当缓冲区被填满时,由填充任务进行交换,当缓冲区为空时,由清空任务进行交换。这样会把需要交换的次数降至最低,但如果新数据的到达率不可预测,那么一些数据的处理过程就将延迟。另一个方法是,不仅当缓冲被填满时进行交换,并且当缓冲被填充到一定程度并保持一定时间后,也进行交换。
构建高效且可伸缩的结果缓存
几乎所有的服务器应用程序都会使用某种形式的缓存。重用之前的计算结果能降低延迟, 提高吞吐量,但却需要消耗更多的内存。
像许多 “重复发明的轮子”一样,缓存看上去都非常简单。然而,简单的缓存可能会将性能瓶颈转变成可伸缩性瓶颈,即使缓存是用于提升单线程的性能。本节我们将开发一个高效且 可伸缩的缓存,用于改进一个高计算开销的函数。我们首先从简单的HashMap开始,然后分析它的并发性缺陷,并讨论如何修复它们。
在程序清单5-16的Computable接口中声明了一个函数Computable, 其输入类型为A, 输出类型为V。 在ExpensiveFunction中实现的Computable, 需要很长的时间来计算结果,我们将创建一个Computable包装器, 帮助记住之前的计算结果, 并将缓存过程封装起来。 (这项技术被称为 “记忆[Memoization]“。
在程序清单5-16中的Memoizerl给出了第一种尝试:使用HashMap来保存之前计算的结果。compute方法将首先检查需要的结果是否巳经在缓存中, 如果存在则返回之前计算的值。否则, 将把计算结果缓存在HashMap中, 然后再返回。
HashMap不是线程安全的, 因此要确保两个线程不会同时访问HashMap, Memoizerl采用了一种保守的方法, 即对整个compute方法进行同步。这种方法能确保线程安全性, 但会带来一个明显的可伸缩性问题:每次只有一个线程能够执行compute。如果另一个线程正在计算结果, 那么其他调用compute的线程可能被阻塞很长时间。如果有多个线程在排队等待还未计算出的结果, 那么compute方法的计算时间可能比没有“ 记忆” 操作的计算时间更长。在图5-2中给出了当多个线程使用这种方法中的“ 记忆” 操作时发生的情况, 这显然不是我们希望通过缓存获得的性能提升结果。
程序清单5-17中的Memoizer2用ConcurrentHashMap代替HashMap来改进Memoizerl中糟糕的并发行为。由于ConcurrentHashMap是线程安全的,因此在访问底层Map时就不需要进行同步,因而避免了在对Memoizerl中的compute方法进行同步时带来的串行性。
Memoizer2比Memoizerl有着更好的并发行为:多线程可以并发地使用它。但它在作为缓存时仍然存在一些不足——当两个线程同时调用compute时存在一个涌洞,可能会导致计算得到相同的值。在使用memoization的情况下,这只会带来低效,因为缓存的作用是避免相同的 数据被计算多次。但对千更通用的缓存机制来说,这种情况将更为糟糕。对于只提供单次初始化的对象缓存来说,这个漏洞就会带来安全风险。
Memoizer2 的问题在于, 如果某个线程启动了一个开销很大的计算, 而其他线程并不知道这个计算正在进行, 那么很可能会重复这个计算, 如图5-3 所示。我们希望通过某种方法来表达“线程X 正在计算f(27)" 这种情况, 这样当另一个线程查找f(27) 时, 它能够知道最高效的方法是等待线程X 计算结束, 然后再去查询缓存"f(27) 的结果是多少? ”。
我们已经知道有一个类能基本实现这个功能: Future Task。FutureTask 表示一个计算的过程,这个过程可能已经计算完成, 也可能正在进行。如果有结果可用,那么FutureTask.get 将立即返回结果, 否则它会一直阻塞, 直到结果计算出来再将其返回。
当缓存的是Future而不是值时, 将导致缓存污染(Cache Pollution)问题: 如果某个计算被取消或者失败,那么在计算这个结果时将指明计算过程被取消或者失败。为了避免 这种情况,如果 Memoizer 发现计算被取消,那么将把 Future 从缓存中移除。如果检测到 RuntimeException, 那么也会移除 Future, 这样将来的计算才可能成功。Memoizer 同样没有解 决缓存逾期的问题,但它可以通过使用 FutureTask 的子类来解决,在子类中为每个结果指定一 个逾期时间,并定期扫描缓存中逾期的元素。(同样,它也没有解决缓存清理的问题,即移除旧的计算结果以便为新的计算结果腾出空间,从而使缓存不会消耗过多的内存。)
在完成并发缓存的实现后,就可以为第2 章中因式分解 servlet 添加结果缓存。程序清单5-20 中的 Factorizer 使用 Memoizer来缓存之前的计算结果,这种方式不仅高效,而且可扩展性 也更高。
小结
a.可变状态至关重要:所有的并发问题都可以归结为如何协调对并发状态的访问,可变状态越少,就越容易确保线程安全性。
b.尽量将域声明为final类型,除非需要它们是可变的。
c.不可变对象一定是线程安全地:不可变对象能极大地降低并发编程的复杂性。它们更为简单而且安全,可以任意共享而无须使用加锁或保护性复制等机制。
d.封装有助于管理复杂性:在编写线程安全的程序时,虽然可以将所有数据都保存在全局变量中,但为什么要这么做?将数据封装在对象中,更易于维持不变形条件:将同步机制封装在对象中,更易于遵循同步策略。
e.用锁来保护每个可变变量。
f.当保护同一个不变性条件中的所有变量时,要使用同一个锁。
g.在执行复合操作期间,要持有锁。
h.如果从多个线程中访问同一个可变变量时没有同步机制,那么程序会出现问题。
i.不要故作聪明地推断出不需要使用同步。
j.在设计程序过程中考虑线程安全,或者在文档中明确指出它不是线程安全地。
k.将同步策略文档化。