线程池的使用
第6章介绍了任务执行框架,它不仅能简化任务与线程的生命周期管理,而且还提供一种简单灵活的方式将任务的提交与任务的执行策略解耦开来。第7章介绍了在实际应用程序中 使用任务执行框架时出现的一些与服务生命周期相关的细节问题。本章将介绍对线程池进行配 置与调优的一些髙级选项,并分析在使用任务执行框架时需要注意的各种危险,以及一些使用 Executor的髙级示例。
8.1在任务与执行策略之间的隐性耦合
我们已经知道,Executor框架可以将任务的提交与任务的执行策略解耦开来。就像许多对复杂过程的解耦操作那样,这种论断多少有些言过其实了。虽然Executor框架为制定和修改执 行策略都提供了相当大的灵活性,但并非所有的任务都能适用所有的执行策略。有些类型的任 务需要明确地指定执行策略,包括:
依赖性任务。大多数行为正确的任务都是独立的:它们不依赖于其他任务的执行时序、执行结果或其他效果。当在线程池中执行独立的任务时,可以随意地改变线程池的大小和配置, 这些修改只会对执行性能产生影响。然而,如果提交给线程池的任务需要依赖其他的任务,那 么就隐含地给执行策略带来了约束,此时必须小心地维持这些执行策略以避免产生活跃性问题 (请参见8.1.1节)。
使用线程封闭机制的任务。与线程池相比,单线程的Executor能够对并发性做出更强的承 诺。它们能确保任务不会并发地执行,使你能够放宽代码对线程安全的要求。对象可以封闭在 任务线程中,使得在该线程中执行的任务在访问该对象时不需要同步,即使这些资源不是线程 安全的也没有问题。这种情形将在任务与执行策略之间形成隐式的耦合——任务要求其执行所 在的Executor是单线程的㊀。如果将Executor从单线程环境改为线程池环境,那么将会失去线 程安全性。
对响应时间敏感的任务。GUI应用程序对于响应时间是敏感的:如果用户在点击按钮后需 要很长延迟才能得到可见的反馈,那么他们会感到不满。如果将一个运行时间较长的任务提交 到单线程的Executor中,或者将多个运行时间较长的任务提交到一个只包含少量线程的线程池 中,那么将降低由该Executor管理的服务的响应性。
使用ThreadLocal的任务。ThreadLocal使每个线程都可以拥有某个变量的一个私有“版 本”。然而,只要条件允许,Executor可以自由地重用这些线程。在标准的Executor实现中, 当执行需求较低时将回收空闲线程,而当需求增加时将添加新的线程,并且如果从任务中拋出 了一个未检查异常,那么将用一个新的工作者线程来替代抛出异常的线程。只有当线程本地值 的生命周期受限于任务的生命周期时,在线程池的线程中使用ThreadLocal才有意义,而在线 程池的线程中不应该使用ThreadLocal在任务之间传递值。
只有当任务都是同类型的并且相互独立时,线程池的性能才能达到最佳。如果将运行时 间较长的与运行时间较短的任务混合在一起,那么除非线程池很大,否则将可能造成“拥塞”。 如果提交的任务依赖于其他任务,那么除非线程池无限大,否则将可能造成死锁。幸运的是, 在基于网络的典型服务器应用程序中——网页服务器、邮件服务器以及文件服务器等,它们的 请求通常都是同类型的并且相互独立的。
8.1.1线程饥饿死锁
在线程池中,如果任务依赖于其他任务,那么可能产生死锁。在单线程的Executor中,如 果一个任务将另一个任务提交到同一个Executor,并且等待这个被提交任务的结果,那么通常 会引发死锁。第二个任务停留在工作队列中,并等待第一个任务完成,而第一个任务又无法完 成,因为它在等待第二个任务的完成。在更大的线程池中,如果所有正在执行任务的线程都由 于等待其他仍处于工作队列中的任务而阻塞,那么会发生同样的问题。这种现象被称为线程饥 饿死锁(Thread Starvation Deadlock),只要线程池中的任务需要无限期地等待一些必须由池中 其他任务才能提供的资源或条件,例如某个任务等待另一个任务的返回值或执行结果,那么除 非线程池足够大,否则将发生线程饥饿死锁。
在程序清单8-1的ThreadDeadlock中给出了线程饥饿死锁的示例。RenderPageTask向 Executor提交了两个任务来获取网页的页眉和页脚,绘制页面,等待获取页眉和页脚任务的结 果,然后将页眉、页面主体和页脚组合起来并形成最终的页面。如果使用单线程的Executor, 那么ThreadDeadlock会经常发生死锁。同样,如杲线程池不够大,那么当多个任务通过栅栏 (Barrier)机制来彼此协调时,将导致线程饥饿死锁。
_______________ 程序清单8-1在单线程Executor中任务发生死锁(不要这么做)
public class ThreadDeadlock {
ExecutorService exec = Executors.newSingleThreadExecutor();
public class RenderPageTask implements Callable<String> {
public String call () throws Exception {
Future<String> header, footer;
header = exec.submit(new LoadFileTask("header.html"));
footer = exec . submit (new LoadFileTask (" footer .html11));
String page = renderBody();
//将发生死锁——由于任务在等待子任务的结果
return header.get() + page + footer.get();
}
}
}
除了在线程池大小上的显式限制外,还可能由于其他资源上的约束而存在一些隐式限制。 如果应用程序使用一个包含10个连接的JDBC连接池,并且每个任务需要一个数据库连接, 那么线程池就好像只有10个线程,因为当超过10个任务时,新的任务需要等待其他任务释放连接。
8.1.2 运行时间较长的任务
如果任务阻塞的时间过长,那么即使不出现死锁,线程池的响应性也会变得糟糕。执行时 间较长的任务不仅会造成线程池堵塞,甚至还会增加执行时间较短任务的服务时间。如果线程 池中线程的数量远小于在稳定状态下执行时间较长任务的数量,那么到最后可能所有的线程都 会运行这些执行时间较长的任务,从而影响整体的响应性。
有一项技术可以缓解执行时间较长任务造成的影响,即限定任务等待资源的时间,而不要 无限制地等待。在平台类库的大多数可阻塞方法中,都同时定义了限时版本和无限时版本,例 如 Thread.join、BlockingQueue.put、CountDownLatch.await 以及 Selector.select 等。如果等待 超时,那么可以把任务标识为失败,然后中止任务或者将任务重新放回队列以便随后执行。这 样,无论任务的最终结果是否成功,这种办法都能确保任务总能继续执行下去,并将线程释放 出来以执行一些能更快完成的任务。如果在线程池中总是充满了被阻塞的任务,那么也可能表 明线程池的规模过小。
8.2设置线程池的大小
线程池的理想大小取决于被提交任务的类型以及所部署系统的特性。在代码中通常不会固 定线程池的大小,而应该通过某种配置机制来提供,或者根据Runtime.availableProcessors来动 态计算。
幸运的是,要设置线程池的大小也并不困难,只需要避免“过大”和“过小”这两种极端 情况。如果线程池过大,那么大量的线程将在相对很少的CPU和内存资源上发生竞争,这不仅 会导致更髙的内存使用量,而且还可能耗尽资源。如果线程池过小,那么将导致许多空闲的处理器无法执行工作,从而降低吞吐率。
要想正确地设置线程池的大小,必须分析计算环境、资源预算和任务的特性。在部署的系 统中有多少个CPU?多大的内存?任务是计算密集型、I/O密集型还是二者皆可?它们是否需 要像JDBC连接这样的稀缺资源?如果需要执行不同类别的任务,并且它们之间的行为相差才艮 大,那么应该考虑使用多个线程池,从而使每个一线程池可以根据各自的工作负载来调整。
对于计算密集型的任务,在拥有个处理器的系统上,当线程池的大小为时,通 常能实现最优的利用率。(即使当计算密集型的线程偶尔由于页缺失故障或者其他原因而暂停 时,这个“额外”的线程也能确保CPU的时钟周期不会被浪费。)对于包含I/O操作或者其他 阻塞操作的任务,由于线程并不舍一直执行,因此线程池的规模应该更大。要正确地设置线程 池的大小,你必须估算出任务的等待时间与计算时间的比值。这种估算不需要很精确,并且可 以通过一些分析或监控工具来获得。你还可以通过另一种方法来调节线程池的大小:在某个基准负载下,分别设置不同大小的线程池来运行应用程序,并观察CPU利用率的水平。
当然,CPU周期并不是唯一影响线程池大小的资源,还包括内存、文件句柄、套接字句柄 和数据库连接等。计算这些资源对线程池的约束条件是更容易的:计算每个任务对该资源的需求 量,然后用该资源的可用总量除以每个任务的需求量,所得结果就是线程池大小的上限。
当任务需要某种通过资源池来管理的资源时,例如数据库连接,那么线程池和资源池的大 小将会相互影响。如果每个任务都需要一个数据库连接,那么连接池的大小就限制了线程池的 大小。同样,当线程池中的任务是数据库连接的唯一使用者时,那么线程池的大小又将限制连 接池的大小。
8.3 配置 Thread PooExecutor
ThreadPoolExecutor 为一些 Executor 提供了基本的实现,这些 Executor 是由 Executors 中的 newCachedThreadPool、newFixedThreadPool 和 newScheduledThreadExecutor 等工厂方法返回的。ThreadPoolExecutor是一个灵活的、稳定的线程池,允许进行各种定制。
如果默认的执行策略不能满足需求,那么可以通过ThreadPoolExecutor的构造函数来实例 化一个对象,并根据自己的需求来定制,并且可以参考Executors的源代码来了解默认配置下 的执行策略,然后再以这些执行策略为基础进行修改。ThreadPoolExecutor定义了很多构造函 数,在程序清单8-2中给出了最常见的形式。
程序清单8-2 ThreadPooExecutor的通用构造函数
public ThreadPoolExecutor{
int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) { ... }
8.3.1线程的创建与销毁
线程池的基本大小(CorePoolSize)、最大大小(Maximum Pool Size)以及存活时间等因 素共同负责线程的创建与销毁。基本大小也就是线程池的目标大小,即在没有任务执行时0线 程池的大小,并且只有在工作队列满了的情况下才会创建超出这个数量的线程@。线程池的最 大大小表示可同时活动的线程数量的上限。如果某个线程的空闲时间超过了存活时间,那么将 被标记为可回收的,并且当线程池的当前大小超过了基本大小时,这个线程将被终止。
通过调节线程池的基本大小和存活时间,可以帮助线程池回收空闲线程占有的资源,从而 使得这些资源可以用于执行其他工作。(显然,这是一种折衷:回收空闲线程会产生额外的延 迟,因为当需求增加时,必须创建新的线程来满足需求。)
newFixedThreadPool工厂方法将线程池的基本大小和最大大小设置为参数中指定的值,而 且创建的线程池不会超时。newCachedThreadPool工厂方法将线程池的最大大小设置为Integer. MAX_VALUE,而将基本大小设置为零,并将超时设置为1分钟,这种方法创建出来的线 程池可以被无限扩展,并且当需求降低时会自动收缩。其他形式的线程池可以通过显式的 ThreadPoolExecutor构造函数来构造。
8.3.2管理队列任务
在有限的线程池中会限制可并发执行的任务数量。(单线程的Executor是一种值得注意的特例:它们能确保不会有任务并发执行,因为它们通过线程封闭来实现线程安全性。)
在6.1.2节中曾介绍,如果无限制地创建线程,那么将导致不稳定性,并通过采用固定大 小的线程池(而不是每收到一个请求就创建一个新线程)来解决这个问题。然而,这个方案并 不完整。在髙负载情况下,应用程序仍可能耗尽资源,只是出现问题的概率较小。如果新请求 的到达速率超过了线程池的处理速率,那么新到来的请求将累积起来。在线程池中,这些请求 会在一个由Executor管理的Runnable队列中等待,而不会像线程那样去竞争CPU资源。通过 一个Runnable和一个链表节点来表现一个等待中的任务,当然比使用线程来表示的开销低很 多,但如果客户提交给服务器请求的速率超过了服务器的处理速率,那么仍可能会耗尽资源。
即使请求的平均到达速率很稳定,也仍然会出现请求突增的情况。尽管队列有助于缓解任 务的突增问题,但如果任务持续髙速地到来,那么最终还是会抑制请求的到达率以避免耗尽内 存。0甚至在耗尽内存之前,响应性能也将随着任务队列的增长而变得越来越糟。
ThreadPoolExecutor允许提供一个BlockingQueue来保存等待执行的任务。基本的任务排 队方法有3种:无界队列、有界队列和同步移交(Synchronous Handoff)。队列的选择与其他的 配置参数有关,例如线程池的大小等。
newFixedThreadPool和newSingleThreadExecutor在默认情况下将使用一个无界的 LinkedBlockingQueue0如果所有工作者线程都处于忙碌状态,那么任务将在队列中等候。如果 任务持续快速地到达,并且超过了线程池处理它们的速度,那么队列将无限制地增加。
一种更稳妥的资源管理策略是使用有界队列,例如ArrayBlockingQueue、有界的 LinkedBlockingQueue、PriorityBlockingQueue。有界队列有助于避免资源耗尽的情况发生, 但它又带来了新的问题:当队列填满后,新的任务该怎么办?(有许多饱和策略[Saturation Policy]可以解决这个问题。请参见8.3.3节。)在使用有界的工作队列时,队列的大小与线程池 的大小必须一起调节。如果线程池较小而队列较大,那么有助于减少内存使用量,降低CPU的 使用率,同时还可以减少上下文切换,但付出的代价是可能会限制吞吐量。
对于非常大的或者无界的线程池,可以通过使用SynchronousQueue来避免任务排队,以 及直接将任务从生产者移交给工作者线程。SynchronousQueue不是一个真正的队列,而是一 种在线程之间进行移交的机制。要将一个元素放入SynchronousQueue中,必须有另一个线程 正在等待接受这个元素。如果没有线程正在等待,并且线程池的当前大小小于最大值,那么 ThreadPoolExecutor将创建一个新的线程,否则根据饱和策略,这个任务将被拒绝。使用直接 移交将更高效,因为任务会直接移交给执行它的线程,而不是被首先放在队列中,然后由工作 者线程从队列中提取该任务。只有当线程池是无界的或者可以拒绝任务时,SynchronousQueue 才有实际价值。在newCachedThreadPool工厂方法中就使用了 SynchronousQueue。
当使用像 LinkedBlockingQueue 或 ArrayBlockingQueue 这样的 FIFO(先进先出)队列 时,任务的执行顺序与它们的到达顺序相同。如果想进一步控制任务执行顺序,还可以使用 PriorityBlockingQueue,这个队列将根据优先级来安排任务。任务的优先级是通过自然顺序或Comparator (如果任务实现了 Comparable)来定义的。
只有当任务相互独立时,为线程池或工作队列设置界限才是合理的。如果任务之间存在依 赖性,那么有界的线程池或队列就可能导致线程“饥饿”死锁问题。此时应该使用无界的线程 池,例如 newCachedThreadPool
8.3.3饱和策略 ,
当有界队列被填满后,饱和策略开始发挥作用。ThreadPoolExecutor的饱和策略可以通过 调用setRejectedExecutionHandler来修改。(如果某个任务被提交到一个已被关闭的Executor 时,也会用到饱和策略。)JDK提供了几种不同的RejectedExecutionHandlei•实现,每种实现都 包含有不同的饱和策略:AbortPolicy、CallerRunsPolicy、DiscardPolicy 和 DiscardOldestPolicy。
“中止(Abort)”策略是默认的饱和策略,该策略将抛出未检查的RejectedExecution- Exception。 调用者可以捕获这个异常,然后根据需求编写自己的处理代码。当新提交的任务 无法保存到队列中等待执行时,“抛弃(Discard)”策略会悄悄抛弃该任务。“抛弃最旧的( Discard-Oldest)”策略则会抛弃下一个将被执行的任务,然后尝试重新提交新的任务。(如果工 作队列是一个优先队列,那么“抛弃最旧的”策略将导致抛弃优先级最高的任务,因此最好不 要将“抛弃最旧的”饱和策略和优先级队列放在一起使用。)
“调用者运行(Caller-Rims)”策略实现了一种调节机制,该策略既不会抛弃任务,也不 会拋出异常,而是将某些任务回退到调用者,从而降低新任务的流量。它不会在线程池的某 个线程中执行新提交的任务,而是在一个调用了 execute的线程中执行该任务。我们可以将 Webserver示例修改为使用有界队列和“调用者运行”饱和策略,当线程池中的所有线程都被 占用,并且工作队列被填满后,下一个任务会在调用execute时在主线程中执行。由于执行任 务需要一定的时间,因此主线程至少在一段时间内不能提交任何任务,从而使得工作者线程有 时间来处理完正在执行的任务。在这期间,主线程不会调用accept,因此到达的请求将被保存 在TCP层的队列中而不是在应用程序的队列中。如持续过载,那么TCP层将最终发现它的 请求队列被填满,因此同样会开始抛弃请求。当服务器过载时,这种过载情况会逐渐向外蔓延 开来——从线程池到工作队列到应用程序再到TCP层,最终达到客户端,导致服务器在高负载下实现一种平缓的性能降低。
当创建Executor时,可以选择饱和策略或者对执行策略进行修改。程序清单8-3给出了如 何创建一个固定大小的线程池,同时使用“调用者运行”饱和策略。
当工作队列被填满后,没有预定义的饱和策略来阻塞execute。然而,通过使用Semaphore (信号量)来限制任务的到达率,就可以实现这个功能。在程序清单8-4的BcmndedExecutw中 给出了这种方法。该方法使用了一个无界队列(因为不能限制队列的大小和任务的到达率), 并设置信号量的上界设置为线程池的大小加上可排队任务的数量,这是因为信号量需要控制正 在执行的和等待执行的任务数量。
8.3.4线程工厂
每当线程池需要创建一个线程时,都是通过线程工厂方法(请参见程序清单8-5)来完成 的。默认的线程工厂方法将创建一个新的、非守护的线程,并且不包含特殊的配置信息。通过 指定一个线程工厂方法,可以定制线程池的配置信息。在ThreadFactory中只定义了一个方法 newThread,每当线程池需要创建一个新线程时都会调用这个方法。
然而,在许多情况下都需要使用定制的线程工厂方法。例如,你希望为线程池中的线程指 定一个UncaughtExceptionHandler,.或者实例化一个定制的Thread类用于执行调试信息的记 录。你还可能希望修改线程的优先级(这通常并不是一个好主意。请参见10.3.1节)或者守护 状态(同样,这也不是一个好主意。请参见7.4.2节)。或许你只是希望给线程取一个更有意义 的名称,用来解释线程的转储信息和错误日志。
程序清单 8-5 ThreadFactory 接口
public interface ThreadFactory {
Thread newThread(Runnable r);
}
在程序清单8-6的MyThreadFactory中给出了一个自定义的线程工厂。它创建了一个新的 MyAppThread实例,并将一个特定于线程池的名字传递给MyAppThread的构造函数,从而可 以在线程转储和错误日志信息中区分籴自不同线程池的线程。在应用程序的其他地方也可以使 用MyAppThread,以便所有线程都能使用它的调试功能。
___________________________ 程序清单8-6自定义的线程工厂__________________________
public class MyThreadFactory implements ThreadFactory { private final String poolName ;
public MyThreadFactory(String poolName) {
this.poolName - poolName;
}
public Thread newThread(Runnable runnable) {
return new MyAppThread(runnable, poolName);
}
}
如果在应用程序中需要利用安全策略来控制对某些特殊代码库的访问权限,那么可以通过 Executor中的privilegedThreadFactory工厂来定制自己的线程工厂。通过这种方式创建出来的 线程,将与创建privilegedThreadFactory的线程拥有相同的访问权限、AccessControlContext和 contextClassLoader。如果不使用privilegedThreadFactory,线程池创建的线程将从在需要新 线程时调用execute或submit的客户程序中继承访问权限,从而导致令人困惑的安全性异 常。
8.3.5 在调用构造函数后再定制ThreadPoo旧xecutor
在调用完ThreadPoolExecutor的构造函数后,仍然可以通过设置函数(Setter)来修改 大多数传递给它的构造函数的参数(例如线程池的基本大小、最大大小、存活时间、线程工 厂以及拒绝执行处理器(Rejected Execution Handler))。如果Executor是通过Executors中 的某个(newSingleThreadExecutor除外)工厂方法创建的,那么可以将结果的类型转换为 ThreadPoolExecutor以访问设置器,如程序清单8-8所示。
____________ 程序请单8-8对通过标准工厂方法创建的Executor进行修改
ExecutorService exec = Executors.newCachedThreadPool ();
if (exec instanceof ThreadPoolExecutor)
((ThreadPoolExecutor) exec).setCorePoolSize(10);
else
throw new AssertionError ("Oops, bad assumption11);
在Executors中包含一个unconflgurableExecutorService工厂方法,该方法对一个现有 的ExecutorService进行包装,使其只暴露出ExecutorService的方法,因此不能对它进行 配置。newSingleThreadExecutor返回按这种方式封装的ExecutorService,而不是最初的 ThreadPoolExecutoro虽然单线程的Executor实际上被实现为一个只包含唯一线程的线程池, 但它同样确保了不会并发地执行任务。如果在代码中增加单线程Executor的线程池大小,那么 将破坏它的执行语义。
你可以在自己的Executor中使用这项技术以防止执行策略被修改。如果将ExecutorService 暴露给不信任的代码,又不希望对其进行修改,就可以通过imconfigurableExecutorService来
包装它。
8.4 扩展ThreadPoolExecutor
ThreadPoolExecutor是可扩展的,它提供了几个可以在子类化中改写的方法:beforeExecute、afterExecute和terminated,这些方法可以用于扩展ThreadPoolExecutor的行为。
在执行任务的线程中将调用beforeExecute和afterExecute等方法,在这些方法中还可以 添加日志、计时、监视或统计信息收集的功能。无论任务是从nm中正常返回,还是抛出一个 异常而返回,afterExecute都会被调用。(如果任务在完成后带有一个Error,那么就不会调用 afterExecute 0)如果beforeExecute拋出一个RuntimeException,那么任务将不被执行,并且 afterExecute也不会被调用。
在线程池完成关闭操作时调用terminated,也就是在所有任务都已经完成并且所有工作者 线程也已经关闭后。terminated可以用来释放Executor在其生命周期里分配的各种资源,此外 还可以执行发送通知、记录日志或者收集fmaUze统计信息等操作。
示例:给线程池添加统计信息
在程序清单8-9的TimingThreadPool中给出了一个自定义的线程池,它通过 beforeExecute、afterExecute和terminated等方法来添加日志记录和统计信息收集。为了测 量任务的运行时间,beforeExecute必须记录开始时间并把它保存到•^个afterExecute可以访 问的地方。因为这些方法将在执行任务的线程中调用,因此beforeExecute可以把值保存到 一个ThreadLocal变量中,然后由afterExecute来读取。在TimingThreadPool中使用了两个 AtomicLong变量,分别用于记录已处理的任务数和总的处理时间,并通过terminated来输出 包含平均任务时间的日志消息。
避免活跃性危险
在安全性与活跃性之间通常存在着某种制衡。我们使用加锁机制来确保线程安全,但 如果过度地使用加锁,则可能导致锁顺序死锁(Lock-Ordering Deadlock)。同样,我们使用 线程池和信号量来限制对资源的使用,但这些被限制的行为可能会导致资源死锁(Resource Deadlock)0 Java应用程序无法从死锁中恢复过来,因此在设计时一定要排除那些可能导致死锁 出现的条件。本章将介绍一些导致活跃性故障的原因,以及如何避免它们。
10.1死锁,
经典的“哲学家进餐”问题很好地描述了死锁状况。5个哲学家去吃中餐,坐在一张圆桌 旁。他们有5根筷子(而不是5双),并且每两个人中间放一根筷子。哲学家们时而思考,时 而进餐。每个人都需要一双筷子才能吃到东西,并在吃完后将筷子放回原处继续思考。有些筷 子管理算法能够使每个人都能相对及时地吃到东西(例如一个饥饿的哲学家会尝试获得两根邻 近的筷子,但如果其中一根正在被另一个哲学家使用,那么他将放弃已经得到的那根筷子,并 等待几分钟之后再次尝试),但有些算法却可能导致一些或者所有哲学家都“饿死”(每个人 都立即抓住自己左边的筷子,然后等待自己右边的筷子空出来,但同时又不放下已经拿到的筷 子)。后一种情况将产生死锁:每个人都拥有其他人需要的资源,同时又等待其他人已经拥有的 资源,并且每个人在获得所有需要的资源之前都不会放弃已经拥有的资源。
当一个线程永远地持有一个锁,并且其他线程都尝试获得这个锁时,那么它们将永远被 阻塞。在线程A持有锁L并想获得锁M的同时,线程B持有锁M并尝试获得锁L,那么 这两个线程将永远地等待下去。这种情况就是最简单的死锁形式(或者称为“抱死[Deadly Embrace]”),其中多个线程由于存在环路的锁依赖关系而永远地等待下去。(把每个线程丨段想 为有向图中的一个节点,图中每条边表示的关系是:“线程A等待线程B所占有的资源”。果 在图中形成了一条环路,那么就存在一个死锁。)
在数据库系统的设计中考虑了监测死锁以及从死锁中恢复。在执行一个事务(Transaction) 时可能需要获取多个锁,并一直持有这些锁直到事务提交。因此在两个事务之间很可能发生死 锁,但事实上这种情况并不多见。如果没有外部干涉,那么这些事务将永远等待下去(在某个 事务中持有的锁可能在其他事务中也需要)。但数据库服务器不会让这种情况发生。当它检测 到一组事务发生了死锁时(通过在表示等待关系的有向图中搜索循环),将选择一个牺牲者并放 弃这个事务。作为牺牲者的事务会释放它所持有的资源,从而使其他事务继续进行。应用程序 可以重新执行被强行中止的事务,而这个事务现在可以成功完成,因为所有跟它竞争资源的事 务都已经完成了。
JVM在解决死锁问题方面并没有数据库服务那样强大。当一组Java线程发生死锁时,“游 戏”将到此结束——这些线程永远不能再使用了。根据线程完成工作的不同,可能造成应用程 序完全停止,或者某个特定的子系统停止,或者是性能降低。恢复应用程序的唯一方式就是中 止并重启它,并希望不要再发生同样的事情。
与许多其他的并发危险一样,死锁造成的影响很少会立即显现出来。如果一个类可能发生 死锁,那么并不意味着每次都会发生死锁,而只是表示有可能。当死锁出现时,往往是在最糟 糕的时候——在高负载情况下。
10.1.1锁顺序死锁
程序清单10-1中的LeftRightDeadlock存在死锁风险。leftRight和rightLeft这两个方法分 别获得left锁和right锁。如果一个线程调用了 leftRight,而另一个线程调用了 nghtLeft,并且 这两个线程的操作是交错执行,如图10-1所示,那么它们会发生死锁。
![](https://i-blog.csdnimg.cn/blog_migrate/181b7f744852384dbeb8895f53c7aec6.png)
在LeftRightDeadlock中发生死锁的原因是:两个线程试图以不同的顺序来获得相同的锁。 如果按照相同的顺序来请求锁,那么就不会出现循环的加锁依赖性,因此也就不会产生死锁。 如果每个需要锁L和锁M的线程都以相同的顺序来获取L和M,那么就不会发生死锁了。
要想验证锁顺序的一致性,需要对程序中的加锁行为进行全局分析。如果只是单独地分析 每条获取多个锁昨代码路径,那是不够的:leftRight和rightLeft都采用了“合理的”方式来获 得锁,它们只是不能相互兼容。当需要加锁时,它们需要知道彼此正在执行什么操作。
程序清单10-1简单的锁顺序死锁(不要这么做)
//注意:容易发生死锁!
10.1.2动态的锁顺序死锁
有时候,并不能清楚地知道是否在锁顺序上有足够的控制权来避免死锁的发生。考虑程序 清单10-2中看似无害的代码,它将资金从一个账户转入另一个账户。在开始转账之前,首先要 获得这两个Account对象的锁,以确保通过原子方式来更新两个账户中的余额,同时又不破坏 一些不变性条件,例如“账户的余额不能为负数”。
在transferMoney中如何发生死锁?所有的线程似乎都是按照相同的顺序来获得锁,但事实 上锁的顺序取决于传递给transferMoney的参数顺序,而这些参数顺序又取决于外部输入。女口果 两个线程同时调用transferMoney,其中一个线程从X向Y转账,另一个线程从Y向X转账,那 么就会发生死锁:
A: transferMoney(myAccount, yourAccount, 10);
B: transferMoney(yourAccount, myAccount, 20);
如果执行时序不当,那么A可能获得myAccount的锁并等待your Account的锁,然而B 此时持有yourAccount的锁,并正在等待myAccount的锁。
这种死锁可以采用程序清单10-1中的方法来检查——査看是否存在嵌套的锁获取操作。由 于我们无法控制参数的顺序,因此要解决这个问题,必须定义锁的顺序,并在整个应用程序中 都按照这个顺序来获取锁。
在制定锁的顺序时,可以使用System.identityHashCode方法,该方法将返回由Object. hashCode返回的值。程序清单10-3给出了另一个版本的transferMoney,在该版本中使用了 System.identityHashCode来定义锁的顺序。虽然增加了一些新的代码,但却消除了发生死锁的 可能性。
在极少数情况下,两个对象可能拥有相同的散列值,此时必须通过某种任意的方法来 决定锁的顺序,而这可能又会重新引入死锁。为了避免这种情况,可以使用“加时赛(Tie- Breaking)” 锁。在获得两个Account锁之前,首先获得这个“加时赛”锁,从而保证每次只有 一个线程以未知的顺序获得这两个锁,从而消除了死锁发生的可能性(只要一致地使用这种机 制)。如果经常会出现散列冲突的情况,那么这种技术可能会成为并发性的一个瓶颈(这类《以 于在整个程序中只有一个锁的情况),但由于System.identityHashCode中出现散列冲突的频率 非常低,因此这项技术以最小的代价,换来了最大的安全性。
如果在Account中包含一个唯一的、不可变的,并且具备可比性的键值,例如账号,那么 要制定锁的顺序就更加容易了:通过键值对对象进行排序,因而不需要使用“加时赛”锁。
你或许认为我有些夸大了死锁的风险,因为锁被持有的时间通常很短暂,然而在真实系 统中,死锁往往都是很严重的问题。作为商业产品的应用程序每天可能要执行数十亿次获取 锁-释放锁的操作。只要在这数十亿次操作中有一次发生了错误,就可能导致程序发生死锁, 并且即使应用程序通过了压力测试也不可能找出所有潜在的死锁e。在程序清单10-4©中的 DemonstrateDeadlock在大多数系统下都会很快发生死锁。
10.1.3 在协作对象之间发生的死锁
某些获取多个锁的操作并不像在LeftRightDeadlock或transferMoney中那么明显,这两个 锁并不一定必须在同一个方法中被获取。考虑程序清单10-5中两个相互协作的类,在出租车调 度系统中可能会用到它们。Taxi代表一个出租车对象,包含位置和目的地两个属性,Dispatcher 代表一个出租车车队。
尽管没有任何方法会显式地获取两个锁,但setLocation和getlmage等方法的调用者都会 获得两个锁。如果一个线程在收到GPS接收器的更新事件时调用setLocation,那么它将首先 更新出租车的位置,然后判断它是否到达了目的地。如果已经到达,它会通知Dispatcher:它 需要一个新的目的地。因为setLocation和notifyAvailable都是同步方法,因此调用setLocation 的线程将首先获取Taxi的锁,然后获取Dispatcher的锁。同样,调用getlmage的线程将首先 获取Dispatcher锁,然后再获取每一个Taxi的锁(每次获取一个)。这与LeftRightDeadlock中 的情况相同,两个线程按照不同的顺序来获取两个锁,因此就可能产生死锁。
在LeftRightDeadlock或transferMoney中,要查找死锁是比较简单的,只需要找出那些需 要获取两个锁的方法。然而要在Taxi和Dispatcher中査找死锁则比较困难:如果在持有锁的情 况下调用某个外部方法,那么就需要警惕死锁。
10.1.4开放调用
当然,Taxi和Dispatcher并不知道它们将要陷入死锁,况且它们本来就不应该知道。方法 调用相当于一种抽象屏障,因而你无须了解在被调用方法中所执行的操作。但也正是由于不知 道在被调用方法中执行的操作,因此在持有锁的时候对调用某个外部方法将难以进行分析,从 而可能出现死锁。
如果在调用某个方法时不需要持有锁,那么这种调用被称为开放调用(Open Call) [CPJ 2.4.1.3]。依赖于开放调用的类通常能表现出更好的行为,并且与那些在调用方法时需要持有锁 的类相比,也更易于编写。这种通过开放调用来避免死锁的方法,类似于采用封装机制来提供 线程安全的方法:虽然在没有封装的情况下也能确保构建线程安全的程序,但对一个使用了封 装的程序进行线程安全分析,要比分析没有使用封装的程序容易得多。同理,分析一个完全依 赖于开放调用的程序的活跃性,要比分析那些不依赖开放调用的程序的活跃性简单。通过尽可 能地使用开放调用,将更易于找出那些需要获取多个锁的代码路径,因此也就更容易确保采用 一致的顺序来获得锁。e
可以很容易地将程序清单10-5中的Taxi和Dispatcher修改为使用开放调用,从而消除 发生死锁的风险。这需要使同步代码块仅被用于保护那些涉及共享状态的操作,如程序清 单10-6所示。通常,如果只是为了语法紧凑或简单性(而不是因为整个方法必须通过一个锁 来保护)而使用同步方法(而不是同步代码块),那么就会导致程序清单10-5中的问题。(此 外,收缩同步代码块的保护范围还可以提髙可伸缩性,在11.4.1节中给出了如何确定同步代码 块大小的方法。)
有时候,在重新编写同步代码块以使用开放调用时会产生意想不到的结果,因为这会使得 某个原子操作变为非原子操作。在许多情况下,使某个操作失去原子性是可以接受的。例女口, 对于两个操作:更新出租车位置以及通知调度程序这辆出租车已准备好出发去一个新的目的地, 这两个操作并不需要实现为一个原子操作。在其他情况中,虽然去掉原子性可能会出现一些值 得注意的结果,但这种语义变化仍然是可以接受的。在容易产生死锁的版本中,getlmage会生 成某个时刻下的整个车队位置的完整快照,而在重新改写的版本中,getlmage将获得每辆出租 车不同时刻的位置。
然而,在某些情况下,丢失原子性会引发错误,此时需要通过另一种技术来实现原子十生。 例如,在构造一个并发对象时,使得每次只有单个线程执行使用了开放调用的代码路径。例 如,在关闭某个服务时,你可能希望所有正在运行的操作执行完成以后,再释放这些服务占用 的资源。如果在等待操作完成的同时持有该服务的锁,那么将很容易导致死锁,但如果在服务 关闭之前就释放服务的锁,则可能导致其他线程开始新的操作。这个问题的解决方法是,在将 服务的状态更新为“关闭”之前一直持有锁,这样其他想要开始新操作的线程,包括想关闭 该服务的其他线程,会发现服务已经不可用,因此也就不会试图开始新的操作。然后,你可 以等待关闭操作结束,并且知道当开放调用完成后,只有执行关闭操作的线程才能访问服务 的状态。因此,这项技术依赖于构造一些协议(而不是通过加锁)来防止其他线程进入代码 的临界区。
10.1.5资源死锁
正如当多个线程相互持有彼此正在等待的锁而又不释放自己已持有的锁时会发生死锁,当 它们在相同的资源集合上等待时,也会发生死锁。
假设有两个资源池,例如两个不同数据库的连接池。资源池通常采用信号量来实现(请参 见5.5.3节)当资源池为空时的阻塞行为。如果一个任务需要连接两个数据库,并且在请求这 两个资源时不会始终遵循相同的顺序,那么线程A可能持有与数据库0,的连接,并等待与数 据库D2的连接,而线程B则持有与口2的连接并等待与0,的连接。(资源池越大,出现这种情 况的可能性就越小。如果每个资源池都有N个连接,那么在发生死锁时不仅需要N个循环等待 的线程,而且还需要大量不恰当的执行时序。)
另一种基于资源的死锁形式就是线程饥饿死锁(Thread-Starvation Deadlock)。8.1.1节给出 ,了这种危害的一个示例:一个任务提交另一个任务,并等待被提交任务在单线程的Executor中 执行完成。这种情况下,第一个任务将永远等待下去,并使得另一个任务以及在这个Executor 中执行的所有其他任务都停止执行。如果某些任务需要等待其他任务的结果,那么这些任务往
往是产生线程饥饿死锁的主要来源,有界线程池/资源池与相互依赖的任务不能一起使用。
10.2死锁的避免与诊断
如果一个程序每次至多只能获得一个锁,那么就不会产生锁顺序死锁。当然,这种情况通 常并不现实,但如果能够避免这种情况,那么就能省去很多工作。如果必须获取多个锁,SP么 在设计时必须考虑锁的顺序:尽量减少潜在的加锁交互数量,将获取锁时需要遵循的协议写入 正式文档并始终遵循这些协议。
在使用细粒度锁的程序中,可以通过使用一种两阶段策略(Two-Part Strategy)来检査代 码中的死锁:首先,找出在什么地方将获取多个锁(使这个集合尽量小),然后对所有这些实 例进行全局分析,从而确保它们在整个程序中获取锁的顺序都保持一致。尽可能地使用开放调 用,这能极大地简化分析过程。如果所有的调用都是开放调用,那么要发现获取多个锁的实例 是非常简单的,可以通过代码审查,或者借助自动化的源代码分析工具。
10.2.1支持定时的锁
还有一项技术可以检测死锁和从死锁中恢复过来,即显式使用Lock类中的定时tryLock功 能(参见第13章)来代替内置锁机制。当使用内置锁时,只要没有获得锁,就会永远等待下 去,而显式锁则可以指定一个超时时限(Timeout),在等待超过该时间后tryLock会返回一个 失败信息。如果超时时限比获取锁的时间要长很多,那么就可以在发生某个意外情况后重新获 得控制权。(在程序清单13-3中给出了 transferMoney的另一种实现,其中使用了一种轮询的 tryLock消除了死锁发生的可能性。)
当定时锁失败时,你并不需要知道失败的原因。或许是因为发生了死锁,或许某个线程在 持有锁时错误地进入了无限循环,还可能是某个操作的执行时间远远超过了你的预期。然而, 至少你能记录所发生的失败,以及关于这次操作的其他有用佶息,并通过一种更平缓的方式来 重新启动计算,而不是关闭整个进程。
即使在整个系统中没有始终使用定时锁,使用定时锁来获取多个锁也能有效地应对死锁问 题。如果在获取锁时超时,那么可以释放这个锁,然后后退并在一段时间后再次尝试,从而消 除了死锁发生的条件,使程序恢复过来。(这项技术只有在同时获取两个锁时才有效,如果在 嵌套的方法调用中请求多个锁,那么即使你知道已经持有了外层的锁,也无法释放它。)
10.2.2通过线程转储信息来分析死锁
虽然防止死锁的主要责任在于你自己,但JVM仍然通过线程转储(Thread Dump)来帮助识 别死锁的发生。线程转储包括各个运行中的线程的栈追踪信息,这类似于发生异常时的栈追踪 信息。线程转储还包含加锁信息,例如每个线程持有了哪些锁,在哪些栈帧中获得这些锁,以 及被阻塞的线程正在等待获取哪一个锁。0在生成线程转储之前,JVM将在等待关系图中通过
㊀即使没有死锁,这些信息对干调试来说也是有用的。通过定期触发线程转储,可以观察程序的加锁行为。 搜索循环来找出死锁。如果发现了一个死锁,则获取相应的死锁信息,例如在死锁中涉及哪些 锁和线程,以及这个锁的获取操作位于程序的哪些位置。
要在UNIX平台上触发线程转储操作,可以通过向JVM的进程发送SIGQUIT信号(kill-3), 或者在UNIX平台中按下Ctrl-\键,在Windows平台中按下Ctrl-Break键。在许多IDE (集成 开发环境)中都可以请求线程转储。
如果使用显式的Lock类而不是内部锁,那么Java 5.0并不支持与Lock相关的转储信息, 在线程转储中不会出现显式的Lock。虽然Java 6中包含对显式Lock的线程转储和死锁检测等 的支持,但在这些锁上获得的信息比在内置锁上获得的信息精确度低。内置锁与获得它们所在 的线程栈帧是相关联的,而显式的Lock只与获得它的线程相关联。
程序清单10-7给出了一个J2EE应用程序中获取的部分线程转储信息。在导致死锁的故障中 包括3个组件:一个J2EE应用程序,一个J2EE容器,以及一个JDBC驱动程序,分别由不同 的生产商提供。这3个组件都是商业产品,并经过了大量的测试,但每一个组件中都存在一个错 误,并且这个错误只有当它们进行交互时才会显现出来,并导致服务器出现一个严重的故障。
我们只给出了与查找死锁相关的部分线程转储信息。当诊断死锁时,jvm可以帮我们做 许多工作——哪些锁导致了这个问题,涉及哪些线程,它们持有哪些其他的锁,以及是否间接 地给其他线程带来了不利影响。其中一个线程持有MumbleDBCorniection上的锁,并等待获得 MumbleDBCallableStatement 上的锁,而另一个线程则持有 MumbleDBCallableStatement 上的 锁,并等待MumbleDBConnection上的锁。
在这里使用的JDBC驱动程序中明显存在一个锁顺序问题:不同的调用链通过JDBC驱动程 序以不同的顺序获取多个锁。如果不是由于另一个错误,这个问题永远不会显现出来:多个线程 试图同时使用同一个JDPC连接。这并不是应用程序的设计初衷——开发人员惊讶地发现同一个 Connection被两个线程并发使用。在JDBC规范中并没有要求Connection必须是线程安全的,以 及Connection通常被封闭在单个线程中使用,而在这里就采用了这种假设。这个生产商试图提 供一个线程安全的JDBC驱动,因此在驱动程序代码内部对多个JDBC对象施加了同步机制。然 而,生产商却没有考虑锁的顺序,因而驱动程序很容易发生死锁,而正是由于这个存在死锁风险 的驱动程序与错误共享Connection的应用程序发生了交互,才使得这个问题暴露出来。因为单个 错误并不会产生死锁,只有这两个错误同时发生时才会产生,即使它们分别进行了大量测试。
10.3其他活跃性危险
尽管死锁是最常见的活跃性危险,但在并发程序中还存在一些其他的活跃性危险,包括: 饥饿、丢失信号和活锁等。(“丢失信号”这种活跃性危险将在14.2.3节中介绍。)
10.3.1饥饿
当线程由于无法访问它所需要的资源而不能继续执行时,就发生了 “饥饿(Starvation)”。 引发饥饿的最常见资源就是CPU时钟周期。如果在Java应用程序中对线程的优先级使用不当, 或者在持有锁时执行一些无法结束的结构(例如无限循环,或者无限制地等待某个资源),那 么也可能导致饥饿,因为其他需要这个锁的线程将无法得到它。
在Thread API中定义的线程优先级只是作为线程调度的参考。在Thread API中定义了 10个优先级,JVM根据需要将它们映射到操作系统的调度优先级。这种映射是与特定平台相关 的,因此在某个操作系统中两个不同的Java优先级可能被映射到同一个优先级,而在另一个操 作系统中则可能被映射到另一个不同的优先级。在某些操作系统中,如果优先S的数量少于10 个,那么有多个Java优先级会被映射到同一个优先级。
操作系统的线程调度器会尽力提供公平的、活跃性良好的调度,甚至超出Java语言规 范的需求范围。在大多数Java应用程序中,所有线程都具有相同的优先级Thread.NORM_ PRIORITY。线程优先级并不是一种直观的机制,而通过修改线程优先级所带来的效果通常也 不明显。当提高某个线程的优先级时,可能不会起到任何作用,或者也可能使得某个线程的调 度优先级髙于其他线程,从而导致饥饿。
通常,我们尽量不要改变线程的优先级。只要改变了线程的优先级,程序的行为就将与 平台相关,并且会导致发生饥饿问题的风险。你经常能发现某个程序会在一些奇怪的地方调用 Thread.sleep或Thread.yield,这是因为该程序试图克服优先级调整问题或响应性问题,并试图 让低优先级的线程执行更多的时间。
Thread.yield(以及Thread.sleep (0))的语义都是未定义的[JLS 17.9]。JVM既可以将它们实现为空操作,也 可以将它们视为线程调度的参考。尤其是,在UNIX系统中并不要求它们拥有sleep (0)的语义一将当前 线程放在与该优先级对应的运行队列末尾,并将执行权交给拥有相同优先级的其他线程,尽管有些JVM是 按照这种方式来实现yield方法的。
要避免使用线程优先级,因为这会增加平台依赖性,并可能导致活跃性问题。在大 多数并发应用程序中,都可以使用默认的线程优先级。
10.3.2糟糕的响应性
除饥饿以外的另一个问题是糟糕的响应性,如果在GUI应用程序中使用了后台线程,那么 这种问题是很常见的。_在第9章中开发了一个框架,并把运行时间较长的任务放到后台线程中 运行,从而不会使用户界面失去响应。但CPU密集型的后台任务仍然可能对响应性造成影响, 因为它们会与事件线程共同竞争CPU的时钟周期。在这种情况下就可以发挥线程优先级的作 用,此时计算密集型的后台任务将对响应性造成影响。如果由其他线程完成的工作都是后台任 务,那么应该降低它们的优先级,从而提髙前台程序的响应性。
不良的锁管理也可能导致糟糕的响应性。如果某个线程长时间占有一个锁(或许正在对一 个大容器进行迭代,并且对每个元素进行计算密集的处理),而其他想要访问这个容器的线程 就必须等待很长时间。
10.3.3活锁
活锁(Livelock)是另一种形式的活跃性问题,该问题尽管不会阻塞线程,但也不能继续执 行,因为线程将不断重复执行相同的操作,而且总会失败。活锁通常发生在处理事务消息的应 用程序中:如果不能成功地处理某个消息,那么消息处理机制将回滚整个事务,并将它重新放 到队列的开头。如果消息处理器在处理某种特定类型的消息时存在错误并导致它失败,那么每 当这个消息从队列中取出并传递到存在错误的处理器时,都会发生事务回滚。由于这条消息又 被放回到队列开头,因此处理器将被反复调用,并返回相同的结果。(有时候也被称为毒药消 息,Poison Message。)虽然处理消息的线程并没有阳塞,但也无法继续执行下去。这种形式 的活锁通常是由过度的错误恢复代码造成的,因为它错误地将不可修复的错误作为可修复的 错误。
当多个相互协作的线程都对彼此进行响应从而修改各自的状态,并使得任何一个线程都无 法继续执行时,就发生了活锁。这就像两个过于礼貌的人在半路上面对面地相遇:他们彼此都 让出对方的路,然而又在另一条路上相遇了。因此他们就这样反复地避让下去。
要解决这种活锁问题,需要在重试机制中引入随机性。例如,在网络上,如果两台机器 尝试使用相同的载波来发送数据包,那么这些数据包就会发生冲突。这两台机器都检查到了冲 突,并都在稍后再次重发。如果二者都选择了在1秒钟后重试,那么它们又会发生冲突,并且 不断地冲突下去,因而即使有大量闲置的带宽,也无法使数据包发送出去。为了避免这种情况 发生,需要让它们分别等待一段随机的时间。(以太协议定义了在重复发生冲突时采用指数方 式回退机制,从而降低在多台存在冲突的机器之间发生拥塞和反复失败的风险。)在并发应用 程序中,通过等待随机长度的时间和回退可以有效地避免活锁的发生。
小结
活跃性故障是一个非常严重的问题,因为当出现活跃性故障时,除了中止应用程序之外没 有其他任何机制可以帮助从这种故障时恢复过来。最常见的活跃性故障就是锁顺序死锁。在 设计时应该避免产生锁顺序死锁:确保线程在获取多个锁时采用一致的顺序。最好的解决方 法是在程序中始终使用开放调用。这将大大减少需要同时持有多个锁的地方,也更容易发现 这些地方。
性能与可伸缩性
11.3线程引入的开销
单线程程序既不存在线程调度,也不存在同步开销,而且不需要使用锁来保证数据结构的 一致性。在多个线程的调度和协调过程中都需要一定的性能开销:对于为了提升性能而引入的 线程来说,并行带来的性能提升必须超过并发导致的开销。
11-3.1上下文切换
如果主线程是唯一的线程,那么它基本上不会被调度出去。另一方面,如果可运行的线程 数大于CPU的数量,那么操作系统最终会将某个正在运行的线程调度出来,从而使其他线程能够使用CPU。这将导致一次上下文切换,在这个过程中将保存当前运行线程的执行上下文,并 将新调度进来的线程的执行上下文设置为当前上下文。
切换上下文需要一定的开销,而在线程调度过程中需要访问由操作系统和JVM共享的数 据结构。应用程序、操作系统以及JVM都使用一组相同的CPU。在JVM和操作系统的代码中 消耗越多的CPU时钟周期,应用程序的可用CPU时钟周期就越少。但上下文切换的开销并不 只是包含JVM和操作系统的开销。当一个新的线程被切换进来时,它所需要的数据可能不在 当前处理器的本地缓存中,因此上下文切换将导致一些缓存缺失,因而线程在首次调度运行时 会更加缓慢。这就是为什么调度器会为每个可运行的线程分配一个最小执行时间,即使有许多 其他的线程正在等待执行:它将上下文切换的开销分摊到更多不会中断的执行时间上,从而提 高整体的吞吐量(以损失响应性为代价)。
当线程由于等待某个发生竞争的锁而被阻塞时,JVM通常会将这个线程挂起,并允许它被 交换出去。如果线程频繁地发生阻塞,那么它们将无法使用完整的调度时间片。在程序中发生 越多的阻塞(包括阻塞1/0,等待获取发生竞争的锁,或者在条件变量上等待),与CPU密集 型的程序就会发生越多的上下文切换,从而增加调度开销,并因此而降低吞吐量。(无阻塞算 法同样有助于减小上下文切换。请参见第15章。)
上下文切换的实际开销会随着平台的不同而变化,然而按照经验来看:在大多数通用的处 理器中,上下文切换的开销相当于5 000~10000个时钟周期,也就是几微秒。
UNIX系统的vmstat命令和Windows系统的perfmon工具都能报告上下文切换次数以及在 内核中执行时间所占比例等信息。如果内核占用率较高(超过10%),那么通常表示调度活动发 生得很频繁,这很可能是由I/O或竞争锁导致的阻塞引起的。
11.3.2内存同步
同步操作的性能开销包括多个方面。在synchronized和volatile提供的可见性保证中可能 会使用一些特殊指令,即内存栅栏(Memory Barrier)。内存栅栏可以刷新缓存,使缓存无效, 刷新硬件的写缓冲,以及停止执行管道。内存栅栏可能同样会对性能带来间接的影响,因为它 们将抑制一些编译器优化操作。在内存栅栏中,大多数操作都是不能被重排序的。
在评估同步操作带来的性能影响时,区分有竞争的同步和无竞争的同步非常重要。 synchronized机制针对无竞争的同步进行了优化(volatile通常是非竞争的),而在编写本书时, 一个“快速通道(Fast-Path)”的非竞争同步将消耗20〜250个时钟周期。虽然无竞争同步的 开销不为零,但它对应用程序整体性能的影响微乎其微,而另一种方法不仅会破坏安全性,而 且还会使你(或者后续开发人员)经历非常痛苦的除错过程。
现代的JVM能通过优化来去掉一些不会发生竞争的锁,从而减少不必要的同步开销。如 果一个锁对象只能由当前线程访问,那么JVM就可以通过优化来去掉这个锁获取操作,因为
另一个线程无法与当前线程在这个锁上发生同步。例如,JVM通常都会去掉程序清单11-2中 的锁获取操作。
_____________________ 程序清单11-2没有作用的同步(不要这么做)
synchronized (new Object") {
II执行一些操作……
}
一些更完备的JVM能通过逸出分析(Escape Analysis)来找出不会发布到堆的本地对象 引用(因此这个引用是线程本地的)。在程序清单11-3的getStoogeNames中,对List的唯 一引用就是局部变量stooges,并且所有封闭在栈中的变量都会自动成为线程本地变量。在 getStoogeNames的执行过程中,至少会将Vector上的锁获取/释放4次,每次调用add或 toString时都会执行1次。然而,一个智能的运行时编译器通常会分析这些调用,从而使 stooges及其内部状态不会逸出,因此可以去掉这4次对锁获取操作。
___________________ 程序清单11-3可通过锁消除优化去掉的锁获取操作__________________
public String getStoogeNames() {
List<String> stooges = new Vector<String>();
stooges.add("Moe");
stooges.add("Larry");
stooges.add("Curly");
return stooges.toString();
}
即使不进行逸出分析,编译器也可以执行锁粒度粗化(Lock Coarsening)操作,即将邻近的 同步代码块用同一个锁合并起来。在getStoogeNames中,如果JVM进行锁粒度粗化,那么可 能会把3个add与1个toString调用合并为单个锁获取/释放摄作,并采用启发式方法来评估 同步代码块中采用同步操作以及指令之间的相对开销。©这不仅减少了同步的开销,同时还能 使优化器处理更大的代码块,从而可能实现进一步的优化。
某个线程中的同步可能会影响其他线程的性能。同步会增加共享内存总线上的通信量,总线的带宽是有限的,并且所有的处理器都将共享这条总线。如果有多个线程竞争同步带宽,那么所有使用了同步的线程都会受到影响。
11.3.3阻塞
非竞争的同步可以完全在JVM中进行处理(Bacon等,1998),而竞争的同步可能需要操 作系统的介入,从而增加开销。当在锁上发生竞争时,竞争失败的线程肯定会阻塞。JVM在实 现阻塞行为时,可以采用自旋等待(Spin-Waiting,指通过循环不断地会试获取锁,直到成功) 或者通过操作系统挂起被阻塞的线程。这两种方式的效率髙低,要取决于上下文切换的开销以 及在成功获取锁之前需要等待的时间。如果等待时间较短,则适合采用自旋等待方式,而如果 等待时间较长,则适合采用线程挂起方式。有些JVM将根据对历史等待时间的分析数据在这 两者之间进行选择,但是大多数JVM在等待锁时都R是将线程挂起。
当线程无法获取某个锁或者由于在某个条件等待或在I/O操作上阻塞时,需要被挂起,在 这个过程中将包含两次额外的上下文切换,以及所有必要的操作系统操作和缓存操作:被阻塞 的线程在其执行时间片还未用完之前就被交换出去,而在随后当要获取的锁或者其他资源可用 时,又再次被切换回来。(由于锁竞争而导致阻塞时,线程在持有锁时将存在一定的开销:当它 释放锁时,必须告诉操作系统恢复运行阻塞的线程。)
11.4减少锁的竞争
我们已经看到,串行操作会降低可伸缩性,并且上下文切换也会降低性能。在锁上发生竞争时将同时导致这两种问题,因此减少锁的竞争能够提髙性能和可伸缩性。
在对由某个独占锁保护的资源进行访问时,将采用串行方式——每次只有一个线程能访问 它。当然,我们有很好的理由来使用锁,例如避免数据被破坏,但获得这种安全性是需要付出 代价的。如果在锁上持续发生竞争,那么将限制代码的可伸缩性。
在并发程序中,对可伸縮性的最主要威胁就是独占方式的资源锁。 -
有两个因素将影响在锁上发生竞争的可能性:锁的请求频率,以及每次持有该锁的时间。 如果二者的乘积很小,那么大多数获取锁的操作都不会发生竞争,因此在该锁上的竞争不会对 可伸缩性造成严重影响。然而,如果在锁上的请求量很高,那么需要获取该锁的线程将被阻塞 并等待。在极端情况下,即使仍有大量工作等待完成,处理器也会被闲置。
有3种方式可以降低锁的竞争程度:
•减少锁的持有时间。
•降低锁的请求频率。
•使用带有协调机制的独占锁。
11.4.1缩小锁的范围(“快进快出”)
降低发生竞争可能性的一种有效方式就是尽可能缩短锁的持有时间。例如,可以将一些与锁无关的代码移出同步代码块,尤其是那些开销较大的操作,以及可能被阻塞的操作,例如i/ 0操作。
我们都知道,如果将一个“高度竞争”的锁持有过长的时间,那么会限制可伸缩性,例如 在第2章中介绍的SynchronizedFactorizer的示例。如果某个操作持有锁的时间超过2毫秒并 且所有操作都需要这个锁,那么无论拥有多少个空闲处理器,吞吐量也不会超过每秒500个操 作。如果将这个锁的持有时间降为1毫秒,那么能够将这个锁对应的吞吐量提高到每秒1000 个操作。
程序清单11-4给出了一个示例,其中锁被持有过长的时间。userLocationMatches方法在一 个Map对象中查找用户的位置,并使用正则表达式进行匹配以判断结果值是否匹配所提供的模 式。整个userLocationMatches方法都使用了 synchronized来修饰,但只有Map.get这个方法才 真正需要锁。
在程序清单11-5的BetterAttributeStore中重新编写了 AttributeStore,从而大大减少了锁 的持有时间。第一个步骤是构建Map中与用户位置相关联的键值,这是一个字符串,形式为
©事实上,这里的计算仅考虑了锁的持有时间过长而导致的开销,而并没有考虑在锁的竞争中导致切换上下文 而导致的开销。
users.name.location。这个步骤包括实例化一个StringBuilder对象,向其添.加几个字符串,并将 结果实例化为一个String类型对象。在获得了位置后,就可以将正则表达式与位置字符串进行 匹配。由于在构建键值字符串以及处理正则表达式等过程中都不需要访问共享状态,因此在执 行时不需要持有'锁。通过在BetterAttributeStore中将这些步骤提取出来并放到同步代码块之夕卜, 从而减少了锁被持有的时间。
通过缩小userLocationMatches方法中锁的作用范围,能极大地减少在持有锁时需要执行的 指令数量。根据Amdahl定律,这样消除了限制可伸缩性的一个因素,因为串行代码的总量减 少了。
由于在AttributeStore中只有一个状态变量attributes,因此可以通过将线程安全性委 托给其他的类来进一步提升它的性能(参见4.3节)。通过用线程安全的Map (Hashtable、 synchronizedMap 或 ConcurrentHashMap)来代替 attributes, AttributeStore 可以将确保线程安 全性的任务委托给顶层的线程安全容器来实现。这样就无须在AttributeStore中采用显式的同 步,缩小在访问Map期间锁的范围,并降低了将来的代码维护者无意破坏线程安全性的风险 (例如在访问attributes之前忘记获得相应的锁)。
尽管缩小同步代码块能提髙可伸缩性,但同步代码块也不能过小 --- 一些需要采用原子方式执行的操作(例如对某个不变性条件中的多个变量进行更新)必须包含在一个同步块中。此 外,同步需要一定的开销,当把一个同步代码块分解为多个同步代码块时(在确保正确性的情 况下),反而会对性能提升产生负面影响。在分解同步代码块时,理想的平衡点将与平台相 关,但在实际情况中,仅当可以将一些“大量”的计算或阻塞操作从同步代码块中移出时,才 应该考虑同步代码块的大小。
11.4.2减小锁的粒度
另一种减小锁的持有时间的方式是降低线程请求锁的频率(从而减小发生竞争的可能性)。 这可以通过锁分解和锁分段等技术来实现,在这些技术中将采用多个相互独立的锁来保护独立 的状态变量,从而改变这些变量在之前由单个锁来保护的情况。这些技术能减小锁操作的粒 度,并能实现更高的可伸缩性,然而,使用的锁越多,那么发生死锁的风险也就越高。
设想一下,如果在整个应用程序中只有一个锁,而不是为每个对象分配一个独立的锁,那 么,所有同步代码块的执行就会变成串行化执行,而不考虑各个同步块中的锁。由于很多线程 将竞争同一个全局锁,因此两个线程同时请求这个锁的概率将剧增,从而导致更严重的竞争。 所以如果将这些锁请求分布到更多的锁上,那么能有效地降低竞争程度。由于等待锁而被阻塞 的线程将更少,因此可伸缩性将提髙。
如果一个锁需要保护多个相互独立的状态变量,那么可以将这个锁分解为多个锁,并且每 个锁只保护一个变量,从而提高可伸缩性,并最终降低每个锁被请求的频率。
在程序清单11-6的ServerStatus中给出了某个数据库服务器的部分监视接口,该数据库 维护了当前已登录的用户以及正在执行的请求。当一个用户登录、注销、开始查询或结束查询 时,都会调用相应的add和remove等方法来更新ServerStatus对象。这两种类型的信息是完全 独立的,ServerStatus甚至可以被分解为两个类,同时确保不会丢失功能。
在代码中不是用ServerStatus锁来保护用户状态和查询状态,而是每个状态都通过一个锁 来保护,如程序清单11-7所示。在对锁进行分解后,每个新的细粒度锁上的访问量将比最初的 访问量少。(通过将用户状态和査询状态委托给一个线程安全的Set,而不是使用显式的同步, 能隐含地对锁进行分解,因为每个Set都会使用一个不同的锁来保护其状态。)
_________________ 程序清单11-7将ServerStatus重新改写为使用锁分解技术______________
©ThreadSafe
public class ServerStatus {
@GuardedBy ( "users'1) public final Set<String> users ;
©GuardedBy("queries") public final Set<String> queries;
public void addUser(String u) {
synchronized (users)
{ users.add(u);}
public void addQuery(String q) {
synchronized (queries) { queries.add(q)};
}
}
//去掉同样被改写为使用被分解锁的方法
}
如果在锁上存在适中而不是激烈的竞争时,通过将一个锁分解为两个锁,能最大限度地提 升性能。如果对竞争并不激烈的锁进行分解,那么在性能和吞吐量等方面带来的提升将非常有 限,但是也会提高性能随着竞争提高而下降的拐点值。对竞争适中的锁进行分解时,实际上是 把这些锁转变为非竞争的锁,从而有效地提高性能和可伸缩性。
11.4.3锁分段
把一个竞争激烈的锁分解为两个锁时,这两个锁可能都存在激烈的竞争。虽然采用两个线 程并发执行能提高一部分可伸缩性,但在一个拥有多个处理器的系统中,仍然无法给可伸缩性 带来极大的提高。在ServerStatus类的锁分解示例中,并不能进一步对锁进行分解。
在某些情况下,可以将锁分解技术进一步扩展为对一组独立对象上的锁进行分解,这种情 况被称为锁分段。例如,在ConcurrentHashMap的实现中使用了一个包含16个锁的数组,每 个锁保护所有散列桶的1/16,其中第7V个散列桶由第(Nmod 16)个锁来保护。假设散列函数 具有合理的分布性,并且关键字能够实现均匀分布,那么这大约能把对于锁的请求减少到原来 的1/16。正是这项技术使ConcurrentHashMap能够支持多达16个并发的写入器。(要使得拥 有大量处理器的系统在髙访问量的情况下实现更高的并发性,还可以进一步增加锁的数量,但 仅当你能证明并发写入线程的竞争足够激烈并需要突破这个限制时,才能将锁分段的数量超过 默认的16个。)
锁分段的--个劣势在于:与采用单个锁来实现独占访问相比,要获取多个锁来实现独占访 问将更加困难并且开销更高。通常,在执行一个操作时最多只需获取一个锁,但在某些情况下 需要加锁整个容器,例如当ConcurrentHashMap需要扩展映射范围,以及重新计算键值的散列 值要分布到更大的桶集合中时,就需要获取分段所集合中所有的锁。
11.4.4避免热点域
锁分解和锁分段技术都能提高可伸缩性,因为它们都能使不同的线程在不同的数据(或者同一个数据的不同部分)上操作,而不会相互干扰。如果程序采用锁分段技术,那么一定要表 现出在锁上的竞争频率高于在锁保护的数据上发生竞争的频率。如果一个锁保护两个独立变量 .X和Y,并且线程A想要访问X,而线程B想要访问Y(这类似于在ServerStatus中,一个线 程调用addUser,而另一个线程调用addQuery),那么这两个线程不会在任何数据上发生竞争, 即使它们会在同一个锁上发生竞争。
当每个操作都请求多个变量时,锁的粒度将很难降低。这是在性能与可伸缩性之间相互制 衡的另一个方面,一些常见的优化措施,例如将一些反复计算的结果缓存起来,都会引人一些 “热点域(Hot Field)”,而这些热点域往往会限制可伸缩性。
当实现HashMap时,你需要考虑如何在size方法中计算Map中的元素数量。最简单的方 法就是,在每次调用时都统计一次元素的数量。一种常见的优化措施是,在插人和移除元素时 更新一个计数器,虽然这在put和remove等方法中略微增加了一些开销,以确保计数器是晕新 的值,但这将把size方法的开销从0⑻降低到0(1)。
在单线程或者采用完全同步的实现中,使用一个独立的计数能很好地提高类似size和 isEmpty这些方法的执行速度,但却导致更难以提升实现的可伸缩性,因为每个修改map的操 作都需要更新这个共享的计数器。即使使用锁分段技术来实现散列链,那么在对计数器的访问进行同步时,也会重新导致在使用独占锁时存在的可伸缩性问题。一个看似性能优化的措 施——缓存size操作的结果,已经变成了一个可伸缩性问题。在这种情况下,计数器也被称 为热点域,因为每个导致元素数量发生变化的操作都需要访问它。
为了避免这个问题,ConcurrentHashMap中的size将对每个分段进行枚举并将每个分段中 的元素数量相加,而不是维护一个全局计数。为了避免枚举每个元素,ConcurrentHashMap为 每个分段都维护了一个独立的计数,并通过每个分段的锁来维护这个值。
11.4.5 —些替代独占锁的方法
第三种降低竞争锁的影响的技术就是放弃使用独占锁,从而有助于使用一种友好并发的方 式来管理共享状态。例如,使用并发容器、读-写锁、不可变对象以及原子变量。
ReadWriteLock (请参见第13章)实现了一种在多个读取操作以及单个写入操作情况下 的加锁规则:如果多个读取操作都不会修改共享资源,那么这些读取操作可以同时访问该共 享资源,但在执行写入操作时必须以独占方式来获取锁。对于读取操作占多数的数据结构, ReadWriteLock能提供比独占锁更高的并发性。而对于只读的数据结构,其中包含的不变性可 以完全不需要加锁操作。
原子变量(请参见第15章)提供了一种方式来降低更新“热点域”时的开销,例如静 态计数器、序列发生器、或者对链表数据结构中头节点的引用。(在第2章的示例中使用了 AtomicLong来维护Servlet的计数器。)原子变量类提供了在整数或者对象引用上的细粒度原子操作(因此可伸缩性更高),并使用了现代处理器中提供的底层并发原语(例如比较并 交换[compare-and-swap])。如果在类中只包含少量的热点域,并且这些域不会与其他变量 参与到不变性条件中,那么用原子变量来替代它们能提高可伸缩性。(通过减少算法中的 热点域,可以提髙可伸缩性——虽然原子变量能降低热点域的更新开销,但并不能完全消 除。)
synchronized和Lock
在Java 5.0之前,在协调对共享对象的访问时可以使用的机制只有synchronized和volatile。 Java 5.0增加了一种新的机制:ReentrantLock。与之前提到过的机制相反,ReentrantLock并不是 一种替代内置加锁的方法,而是当内置加锁机制不适用时,作为一种可选择的高级功能。
13.1 Lock 与 ReentrantLock
在程序清单13-1给出的Lock接口中定义了一组抽象的加锁操作。与内置加锁机制不同 的是,Lock提供了一种无条件的、可轮询的、定时的以及可中断的锁获取操作,所有加锁和 解锁的方法都是显式的。在Lock的实现中必须提供与内部锁相同的内存可见性语义,但在 加锁语义、调度算法、顺序保证以及性能特性等方面可以有所不同。(第14章将介绍Lock. newCondition。)
_____________________________ 程序清单13-1 Lock接口_____________________________
public interface Lock {
void lock();
void locklnterruptibly{} throws InterruptedException;
boolean tryLock();
boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
}
ReentrantLock实现了 Lock接口,并提供了与synchronized相同的互斥性和内存可见 性。在获取ReentrantLock时,有着与进入同步代码块相同的内存语义,在释放ReentrantLock 时,同样有着与退出同步代码块相同的内存语义。(3.1节以及第16章介绍内存可见性。)此 外,与synchronized 一样,ReentrantLock‘还提供了可重入的加锁语义(请参见2.3.2节)。 ReentfantLock支持在Lock接口中定义的所有获取锁模式,并且与synchronized相比,它还为 处理锁的不可用性问题提供了更高的灵活性。
为什么要创建一种与内置锁如此相似的新加锁机制?在大多数情况下,内置锁都能很好地 工作,但在功能上存在一些局限性,例如,无法中断一个正在等待获取锁的线程,或者无法在请求获取一个锁时无限地等待下去。内置锁必须在获取该锁的代码块中释放,这就简化了编码 工作,并且与异常处理操作实现了很好的交互,但却无法实现非阻塞结构的加锁规则。这些都 是使用synchronized的原因,但在某些情况下,一种更灵活的加锁机制通常能提供更好的活跃 性或性能。
程序清单13-2给出了 Lock接口的标准使用形式。这种形式比使用内置锁复杂一些:必须 在finally块中释放锁。否则,如果在被保护的代码中抛出了异常,那么这个锁永远都无法释 放。当使用加锁时,还必须考虑在try块中抛出异常的情况,如果可能使对象处于某种不一致 的状态,那么就需要更多的try-catch或try-fmally代码块。(当使用某种形式的加锁时,包括内置锁,都应该考虑在出现异常时的情况。)
___________________ 程序清单13-2使用ReentrantLock来保护对象状态________________________________
Lock lock = new ReentrantLock();
lock.lock();
try {
//更新对象状态
// 捕获异常,并在必要时恢复不变性条件
} finally {
lock.unlock();
}
如果没有使用finally来释放Lock,那么相当于启动了一个定时炸弹。当“炸弹爆炸” 时,将很难追踪到最初发生错误的位置,因为没有记录应该释放锁的位置和时间。这就是 ReentrantLock不能完全替代synchronized的原因:它更加“危险”,因为当程序的执行控制离开 被保护的代码块时,不会自动清除锁。虽然在finally块中释放锁并不困难,但也可能忘记。
13.1.1轮询锁与定时锁
可定时的与可轮询的锁获取模式是由tryLock方法实现的,与无条件的锁获取模式相比, 它具有更完善的错误恢复机制。在内置锁中,死锁是一个严重的问题,恢复程序的唯一方法是重新启动程序,而防止死锁的唯一方法就是在构造程序时避免出现不一致的锁顺序。可定时的 与可轮询的锁提供了另一种选择:避免死锁的发生。
如果不能获得所有需要的锁,那么可以使用可定时的或可轮询的锁获取方式,从而使你 重新获得控制权,它会释放已经获得的锁,然后重新尝试获取所有锁(或者至少会将这个失败记录到日志,并采取其他措施)。程序清单13-3给出了另一种方法来解决10.1.2节中动态顺序 死锁的问题:使用tryLock来获取两个锁,如果不能同时获得,那么就回退并重新尝试。在休 眠时间中包括固定部分和随机部分,从而降低发生活锁的可能性。如果在指定时间内不能获得 所有需要的锁,那么transferMoney将返回一个失败状态,从而使该操作平缓地失败。
在实现具有时间限制的操作时,定时锁同样非常有用(请参见6.3.7节)。当在带有时间限 制的操作中调用了一个阻塞方法时,它能根据剩余时间来提供一个时限。如果操作不能在指定的时间内给出结果> 那么就会使程序提前结束。当使用内置锁时,在开始请求锁后,这个操作将无法取消,因此内置锁很难实现带有时间限制的操作。
在程序清单6-17的旅游门户网站示例中,为询价的每个汽车租赁公司都创建了一个独立的任务。询价操作包含某种基于网络的请求机制,例如Web服务请求。但在询价操作中同样可能 需要实现对紧缺资源的独占访问,例如通向公司的直连通信线路。
9.5节介绍了确保对资源进行串行访问的方法:一个单线程的Executor。另一种方法是使 用一个独占锁来保护对资源的访问。程序清单13-4试图在Lock保护的共享通信线路上发送一 条消息,如果不能在指定时间内完成,代码就会失败。定时的tryLock能够在这种带有时间限 制的操作中实现独占加锁行为。
13.1.2可中断的锁获取操作
正如定时的锁获取操作能在带有时间限制的操作中使用独占锁,可中断的锁获取操作同样 能在可取消的操作中使用加锁。7.1.6节给出了几种不能响应中断的机制,例如请求内置锁。这 些不可中断的阻塞机制将使得实现可取消的任务变得复杂。locklnterruptibly方法能够在获得锁 的同时保持对中断的响应,并且由于它包含在Lock中,因此无须创建其他类型扣不可中断阻 塞机制。
可中断的锁获取操作的标准结构比普通的锁获取操作略微复杂一些,因为需要两个try 块。(如果在可中断的锁获取操作中抛出了 InterruptedException,那么可以使用标准的try- finally加锁模式。)在程序清单13-5中使用了 locklnterruptibly来实现程序清单13-4中的 sendOnSharedLine,以便在一个可取消的任务中调用它。定时的tryLock同样能响应中断,因 此当需要实现一个定时的和可中断的锁获取操作时,可以使用tryLock方法。
__________________________ 程序清单13-5可中断的锁获取操作_________________________
public boolean sendOnSharedLine(String message) throws InterruptedException {
lock.locklnterruptibly();
try {
return cancellableSendOnSharedLine(message);
} finally {
lock.unlock();
}
private boolean cancellableSendOnSharedLine(String message) throws InterruptedException { ... }
13.1.3非块结构的加锁
在内置锁中,锁的获取和释放等操作都是基于代码块的■~释放锁的操作总是与获取锁的 操作处于同一个代码块,而不考虑控制权如何退出该代码块。自动的锁释放操作简化了对程序 的分析,避免了可能的编码错误,但有时侯需要更灵活的加锁规则。
在第11章中,我们看到了通过降低锁的粒度可以提高代码的可伸缩性。锁分段技术在基 于散列的容器中实现了不同的散列链,以便使用不同的锁。我们可以通过采用类似的原则来降 低链表中锁的粒度,即为每个链表节点使用一个独立的锁,使不同的线程能独立地对链表的不 同部分进行操作。每个节点的锁将保护链接指针以及在该节点中存储的数据,因此当遍历或修 改链表时,我们必须持有该节点上的这个锁,直到获得了下一个节点的锁,只有这样,才能释 放前一个节点上的锁。在[CPJ 2.5.1.4]中介绍了使用这项技术的一个示例,并称之为连锁式力口 锁(Hand-Over-Hand Locking)或者锁锅合(Lock Coupling)。
13.2性能考虑因素
当把ReentrantLock添加到Java 5.0时,它能比内置锁提供更好的竞争性能。对于同步原 语来说,竞争性能是可伸缩性的关键要素:如果有越多的资源被耗费在锁的管理和调度上,那 么应用程序得到的资源就越少。锁的实现方式越好,将需要越少的系统调用和上下文切换, 并且在共享内存总线上的内存同步通信量也越少,而一些耗时的操作将占用应用程序的计算 资源。
在Java 5.0中,当从单线程(无竞争)变化到多线程时,内置锁的性能将急剧下降,而 ReentrantLock的性能下降则更为平缓,因而它具有更好的可伸缩性。但在Java 6中,情况就完 全不同了,内置锁的性能不会由于竞争而急剧下降,并且两者的可伸缩性也基本相当。
13.3公平性
在ReentrantLock的构造函数中提供了两种公平性选择:创建一个非公平的锁(默认)或 者一个公平的锁。在公平的锁上,线程将按照它们发出请求的顺序来获得锁,但在非公平的锁 上,则允许“插队”:当一个线程请求非公平的锁时,如果在发出请求的同时该锁的状态变为可 用,那么这个线程将跳过队列中所有的等待线程并获得这个锁。(在Semaphore中同样可以选 择采用公平的或非公平的获取顺序。)非公平的ReentrantLock并不提倡“插队”行为,但无法 防止某个线程在合适的时候进行“插队”。在公平的锁中,如果有另一个线程持有这个锁或者 有其他线程在队列中等待这个锁,那么新发出请求的线程将被放入队列中。在非公平的锁中,只有当锁被某个线程持有时,新发出请求的线程才会被放入队列中。
©当开始写作本书时,ReentrantLock似乎是解决锁的可伸缩性的最终手段。但不到一年的时间,内置锁在可伸缩性上已经获得了极大的提升。性能不仅是一个在不断变化的指标,而且变化得非常快。
我们为什么不希望所有的锁都是公平的?毕竟,公平是一种好的行为,而不公平则是一 种不好的行为,对不对?当执行加锁操作时,公平性将由于在挂起线程和恢复线程时存在的开 销而极大地降低性能。在实际情况中,统计上的公平性保证——确保被阻塞的线程能最终获得 锁,通常已经够用了,并且实际开销也小得多。有些算法依赖于公平的排队算法以确保它们的 正确性,但这些算法并不常见。在大多数情况下,非公平锁的性能要高于公平锁的性能。
图13-2给出了 Map的性能测试,并比较由公平的以及非公平的ReentrantLock包装的 HashMap的性能,测试程序在一个4路的Opteron系统上运行,操作系统为Solaris,在绘制结 果曲线时采用了对数缩放比例@。从图中可以看出,公平性把性能降低了约两个数量级。不必 要的话,不要为公平性付出代价。
图13-2公平锁与非公平锁的性能比较
在激烈竞争的情况下,非公平锁的性能髙于公平锁的性能的一个原因是:在恢复一个被挂起的线程与该线程真正开始运行之间存在着严重的延迟。假设线程A持有一个锁,并且线程B 请求这个锁。由于这个锁已被线程A持有,因此B将被挂起。当A释放锁时,B将被唤醒,因 此会再次尝试获取锁。与此同时,如果C也请求这个锁,那么C很可能会在B被完全唤醒之前获得、使用以及释放这个锁。这样的情况是一种“双赢”的局面:B获得锁的时刻并没有推迟, C更早地获得了锁,并且吞吐量也获得了提高。
当持有锁的时间相对较长,或者请求锁的平均时间间隔较长,那么应该使用公平锁。在这些情况下,“插队”带来的吞吐量提升(当锁处于可用状态时,线程却还处于被唤醒的过程中) 则可能不会出现。
与默认的ReentrantLock—样,内置加锁并不会提供确定的公平性保证,但在大多数情况 下,在锁实现上实现统计上的公平性保证已经足够了。Java语言规范并没有要求JVM以公平 的方式来实现内置锁,而在各种JVM中也没有这样做。ReentrantLock并没有进一步降低锁的 公平性,而只是使一些已经存在的内容更明显。
13.4 在 synchronized 和 ReentrantLock 之间进行选择
ReentrantLock在加锁和内存上提供的语义与与内置锁相同,此外它还提供了一些其他功 能,包括定时的锁等待、可中断的锁等待、公平性,以及实现非块结构的加锁。ReentrantLock 在性能上似乎优于内置锁,其中在Java 6中略有胜出,而在Java 5.0中则是远远胜出。那么为 什么不放弃synchronized,并在所有新的并发代码中都使用ReentrantLock ?事实上有些作者已 经建议这么做,将synchronized作为一种“遗留”结构,但这会将好事情变坏。
与显式锁相比,内置锁仍然具有很大的优势。内置锁为许多开发人员所熟悉,并且简洁紧 凑,而且在许多现有的程序中都已经使用了内置锁——如果将这两种机制混合使用,那么不仅 容易令人困惑,也容易发生错误。ReentrantLock的危险性比同步机制要高,如果忘记在finally 块中调用unlock,那么虽然代码表面上能正常运行,但实际上已经埋下了一颗定时炸弹,并很 有可能伤及其他代码。仅当内置锁不能满足需求时,才可以考虑使用ReentrantLock。
在Java 5.0中,内置锁与ReentrantLock相比还有另一个优点:在线程转储中能给出在 哪些调用帧中获得了哪些锁,并能够检测和识别发生死锁的线程。JVM并不知道哪些线程 持有ReentrantLock,因此在调试使用ReentrantLock的线程的问题时,将起不到帮助作用。 Java 6解决了这个问题,它提供了一个管理和调试接口,锁可以通过该接口进行注册,从而与 ReentrantLocks相关的加锁信息就能出现在线程转储中,并通过其他的管理接口和调试接口来 访问。与synchronized相比,这些调试消息是一种重要的优势,即便它们大部分都是临时性消 息,线程转储中的加锁能给很多程序员带来帮助。ReentrantLock的非块结构特性仍然意味着, 获取锁的操作不能与特定的栈顿关联起来,而内置锁却可以。
未来更可能会提升synchronized而不是ReentrantLock的性能。因为synchronized是JVM 的内置属性,它能执行一些优化,例如对线程封闭的锁对象的锁消除优化,通过增加锁的粒度来消除内置锁的同步(请参见11.3.2节),而如果通过基于类库的锁来实现这些功能,则可能 性不大。除非将来需要在Java 5.0上部署应用程序,并且在该平台上确实需要ReentrantLock包 含的可伸缩性,否则就性能方面来说,应该选择synchronized而不是ReentrantLock。
13.5读-写锁
ReentrantLock实观了一种标准的互斥锁:每次最多只有一个线程能持有ReentrantLock。 但对于维护数据的完整性来说,互斥通常是一种过于强硬的加锁规则,因此也就不必要地限制了并发性。互斥是一种保守的加锁策略,虽然可以避免“写/写”冲突和“写/读”冲突,但 同样也避免了 “读/读”冲突。在许多情况下,数据结构上的操作都是“读操作”——虽然它 们也是可变的并且在某些情况下被修改,但其中大多数访问操作都是读操作。此时,如果能够 放宽加锁需求,允许多个执行读操作的线程同时访问数据结构,那么将提升程序的性能。只要 每个线程都能确保读取到最新的数据,并且在读取数据时不会有其他的线程修改数据,那么就 不会发生问题。在这种情况下就可以使用读/写锁:一个资源可以被多个读操作访问,或者被 一个写操作访问,但两者不能同时进行。
在程序清单13-6的ReadWriteLock中暴露了两个Lock对象,其中一个用于读操作,而 另一个用于写操作。要读取由ReadWriteLock保护的数据,必须首先获得读取锁,当需要修改 ReadWriteLock保护的数据时,必须首先获得写入锁。尽管这两个锁看上去是彼此独立的,但 读取锁和写入锁只是读写锁对象的不同视图。
程序清单 13-6 ReadWriteLock 接 口
public interface ReadWriteLock {
Lock readLock();
Lock writeLock();
}
在读-写锁实现的加锁策略中,允许多个读操作同时进行,但每次只允许一个写操作。与 Lock—样,ReadWriteLock可以采用多种不同的实现方式,这些方式在性能、调度保证、获取 优先性、公平性以及加锁语义等方面可能有所不同。
读-写锁是一种性能优化措施,在一些特定的情况下能实现更高的并发性。在实际情况 中,对于在多处理器系统上被频繁读取的数据结构,读-写锁能够提高性能。而在其他情况下, 读-写锁的性能比独占锁的性能要略差一些:这是因为它们的复杂性更高。如果要判断在某 种情况下使用读-写锁是否会带来性能提升,最好对程序进行分析。由于ReadWriteLock使用 Lock来实现锁的读-写部分,因此如果分析结果表明读-写锁没有提高性能,那么可以很容易地将读-写锁换为独占锁。
在读取锁和写入锁之间的交互可以采用多种实现方式。ReadWriteLock中的一些可选实现 包括:
释放优先。当一个写入操作释放写入锁时,并且队列中同时存在读线程和写线程,那 么应该优先选择读线程,写线程,还是最先发出请求的线程? '
读线程插队。如果锁是由读线程持有,但有写线程正在等待,那么新到达的读线程能 否立即获得访问权,还是应该在写线程后面等待?如果允许读线程插队到写线程之前,那 么将提高并发性,但却可能造成写线程发生饥饿问题。
重入性。读取锁和写入锁是否是可重入的?
降级。如果一个线程持有写入锁,那么它能否在不释放该锁的情况下获得读取锁?这 可能会使得写入锁被“降级”为读取锁,同时不允许其他写线程修改被保护的资源。
升级。读取锁能否优先于其他正在等待的读线程和写线程而升级为一个写入锁?在大 多数的读-写锁实现中并不支持升级,因为如果没有显式的升级操作,那么很容易造成死 锁。(如果两个读线程试图同时升级为写入锁,那么二者都不会释放读取锁。) ReentrantReadWriteLock为这两种锁都提供了可重入的加锁语义。与ReentrantLock类似, ReentrantReadWriteLock在构造时也可以选择是一个非公平的锁(默认)还是一个公平的锁。 在公平的锁中,等待时间最长的线程将优先获得锁。如果这个锁由读线程持有,而另一个线程 请求写入锁,那么其他读线程都不能获得读取锁,直到写线程使用完并且释放了写入锁。在非 公平的锁中,线程获得访问许可的顺序是不确定的。写线程降级为读线程是可以的,但从读线 程升级为写线程则是不可以的(这样做会导致死锁)。
与ReentrantLock类似的是,ReentrantReadWriteLock中的写入锁只能有唯一的所有者,并 且只能由获得该锁的线程来释放。在Java 5.0中,读取锁的行为更类似于一个Semaphore而不 是锁,它只维护活跃的读线程的数量,而不考虑它们的标识。在Java 6中修改了这个行为:记 录哪些线程已经获得了读者锁。Q
当锁的持有时间较长并且大部分操作都不会修改被守护的资源时,那么读-写锁能提高并 发性。在程序清单13-7的ReadWriteMap中使用了 ReentrantReadWriteLock来包装Map,从而 使它能在多个读线程之间被安全地共享,并且仍然能避免“读-写”或“写-写”冲突@。在 现实中,ConcurrentHashMap的性能已经很好了,因此如果只需要一个并发的基于散列的映射, 那么就可以使用ConcurrentHashMap来代替这种方法,但如果需要对另一种Map实现(例如 LinkedHashMap)提供并发性更髙的访问,那么可以使用这项技术。
图13-3给出了分别用ReentrantLock和ReadWriteLock来封装ArrayList的吞吐量比较, 测试程序在4路的Opteron系统上运行,操作系统为Solaris。这里使用的测试程序与本书使用 的Map性能测试基本类似——每个操作随机地选择一个值并在容器中查找这个值,并且只有少 量的操作会修改这个容器中的内容。
![](https://i-blog.csdnimg.cn/blog_migrate/6d589cacdf174ede653dc94b978027ff.png)
小结
与内置锁相比,显式的Lock提供了一些扩展功能,在处理锁的不可用性方面有着更髙的灵活性,并且对队列行有着更好的控制。但ReentrantLock不能完全替代synchronized,只有在 synchronized无法满足需求时,才应该使用它。读-写锁允许多个读线程并发地访问被保护的对象,当访问以读取操作为主的数据结构 时,它能提髙程序的可伸缩性。