线程的最主要目的是提高程序的运行性能。 线程可以使程序更加充分地发挥系统的可用处理能力, 从而提高系统的资源利用率。 此外, 线程还可以使程序在运行现有任务的情况下立即开始处理新的任务, 从而提高系统的响应性。
本章将介绍各种分析、 监测以及提升并发程序性能的技术。 然而,许多提升性能的技术同样会增加复杂性, 因此也就增加了在安全性和活跃性上发生失败的风险。 更糟糕的是,
某些技术的初衷是提升性能, 但事实上却与最初的目标背道而驰, 或者又带来了其他新的性能问题。 虽然我们希望获得更好的性能一提升性能总会令人满意, 但始终要把安全性放在第一位。首先要保证程序能正确运行, 然后仅当程序的性能需求和测试结果要求程序执行得更快时,才应该设法提高它的运行速度。 在设计并发的应用程序时, 最重要的考虑因素通常并不是 将程序的性能提升至极限。
提升性能意味着用更少的资源做更多的事情。“资源” 的含义很广。 对于一个给定的操作, 通常会缺乏某种特定的资源, 例如CPU时钟周期、 内存、 网络带宽、I/O带宽、数据库请求、磁盘空间以及其他资源。 当操作性能由于某种特定的资源而受到限制时,我们通常将该操作称为资源密集型的操作, 例如, CPU密集型、数据库密集型等。
尽管使用多个线程的目标是提升整体性能,但与单线程的方法相比, 使用多个线程总会引人一些额外的性能开销。造成这些开销的操作包括:线程之间的协调(例如加锁、触发信号以及内存同步等), 增加的上下文切换, 线程的创建和销毁, 以及线程的调度等。如果过度地使用线程, 那么这些开销甚至会超过由千提高吞吐量、响应性或者计算能力所带来的性能提升。另一方面, 一个并发设计很糟糕的应用程序, 其性能甚至比实现相同功能的串行程序的性能还要差。
要想通过并发来获得更好的性能, 需要努力做好两件事情: 更有效地利用现有处理资源,以及在出现新的处理资源时使程序尽可能地利用这些新资源。从性能监视的视角来看, CPU需要尽可能保持忙碌状态。(当然, 这并不意味着将CPU时钟周期浪费在一些无用的计算上, 而 是执行一些有用的工作。)如果程序是计算密集型的,那么可以通过增加处理器来提高性能。 因为如果程序无 法使现有的处理器保持忙碌状态,那么增加再多的处理器也无济千事。通过将 应用程序分解到多个线程上执行, 使得每个处理器都执行一些工作, 从而使所有 CPU都保持忙 碌状态。
性能与可伸缩性
应用程序的性能可以采用多个指标来衡量, 例如服务时间、延迟时间、吞吐率、效率、可 伸缩性以及容量等。 其中一些指标(服务时间、等待时间)用于衡量程序的 ”运行速度 ”,即某个指定的任务单元需要 “多快 ” 才能处理完成。 另一些指标(生产量、吞吐量)用于程序的 “处理能力 ”,即在计算资源一定的情况下, 能完成 “多少 ” 工作。
可伸缩性指的是:当增加计算资源时(例如CPU、内存、存储容量或I/O带宽),程序的吞吐量或者处理能力能相应的增加。
在并发应用程序中针对可伸缩性进行设计和调整时所采用的方法与传统的性能调优方法截然不同。 当进行性能调优时, 其目的通常是用更小的代价完成相同的工作, 例如通过缓存来重用之前计算的结果, 或者采用时间复杂度为O(n2)算法来代替复杂度为O(n log n)的算法。 在进行可伸缩性调优时,其目的是设法将问题的计算并行化,从而能利用更多的计算资源来完成更 多的工作。
性能的这两个方面—— “多快 ” 和 “ 多少 ”,是完全独立的, 有时候甚至是相互矛盾的。要实现更高的可伸缩性或硬件利用率, 通常会增加各个任务所要处理的工作量, 例如把任务分解为多个 “流水线” 子任务时。具有讽刺意味的是, 大多数提高单线程程序性能的技术, 往往都会破坏可伸缩性。
我们熟悉的三层程序模型, 即在模型中的表现层、业务逻辑层和持久化层是彼此独立的,并且可能由不同的系统来处理, 这很好地说明了提高可伸缩性通常会造成性能损失的原因。 如 果把表现层、 业务逻辑层和持久化层都融合到单个应用程序中,那么在处理第一个工作单元时, 其性能肯定要高于将应用程序分为多层并将不同层次分布到多个系统时的性能。 这种单一 的应用程序避免了在不同层次之间传递任务时存在的网络延迟,同时也不需要将计算过程分解到不同的抽象层次, 因此能减少许多开销(例如在任务排队、线程协调以及数据复制时存在的开销)。
然而,当这种单一的系统到达自身处理能力的极限时, 会遇到一个严重的问题:要进一步提升它的处理能力将非常困难。 因此, 我们通常会接受每个工作单元执行更长的时间或消耗更多的计算资源,以换取应用程序在增加更多资源的情况下处理更高的负载。
对于服务器应用程序来说, “ 多少 ” 这个方面—可伸缩性、 吞吐量和生产量, 往往比 “多快 ” 这个方面更受重视。(在交互式应用程序中,延迟或许更加重要, 这样用户就不用等待进度条的指定, 并奇怪程序究竟在执行哪些操作。)本章将重点介绍可伸缩性而不是单线程程序的性能。
评估各种性能权衡因素
在几乎所有的工程决策中都会涉及某些形式的权衡。在建设桥梁时, 使用更粗的钢筋可以提高桥的负载能力和安全性, 但同时也会提高建造成本。尽管在软件工程的决策中通常不会涉 及资金以及人身安全, 但在做出正确的权衡时通常会缺少相应的信息。例如,“快速排序” 算法在大规模数据集上的执行效率非常高, 但对于小规模的数据集来说, “ 冒泡排序” 实际上更高效。如果要实现一个高效的排序算法, 那么需要知道被处理数据集的大小, 还有衡最优化的指标, 包括:平均计算时间、最差时间、 可预知性。然而,编写某个库中排序算法的开发人员通常无法知道这些需求信息。这就是为什么大多数优化措施都不成熟的原因之一 :它们通常无法获得一组明确的需求。
避免不成熟的优化。首先使程序正确,然后再提高运行速度——如果它还运行得不够快。
当进行决策时, 有时候会通过增加某种形式的成本来降低另一种形式的开销 (例如, 增加内存使用益以降低服务时间),也会通过增加开销来换取安全性。安全性并不一定就是指对人 身安全的威胁, 例如桥梁设计的示例。很多性能优化措施通常都是以牺牲可读性或可维护性为 代价一代码越 “聪明” 或越 “ 晦涩”,就越难以理解和维护。 有时候, 优化措施会破坏面向对象的设计原则, 例如需要打破封装, 有时候, 它们又会带来更高的错误风险, 因为通常越快 的算法就越复杂。(如果你无法找出其中的代价或风险, 那么或许还没有对这些优化措施进行彻底的思考和分析。)
在大多数性能决策中都包含有多个变量, 并且非常依赖于运行环境。在使某个方案比其他方案 “更快” 之前, 首先问自己一些问题:
a."更快” 的含义是什么?
b.该方法在什么条件下运行得更快?在低负载还是高负载的情况下?大数据集还是小数据集?能否通过测试结果来验证你的答案?
c.这些条件在运行环境中的发生频率?能否通过测试结果来验证你的答案?
d.在其他不同条件的环境中能否使用这里的代码?
e.在实现这种性能提升时需要付出哪些隐含的代价, 例如增加开发风险或维护开销?这种权衡是否合适?
在进行任何与性能相关的决策时, 都应该考虑这些问题, 本书只介绍并发性方面的内容。我们为什么要推荐这种保守的优化方法?对性能的提升可能是并发错误的最大来源。有人认为同步机制“ 太慢”, 因而采用一些看似聪明实则危险的方法来减少同步的使用, 这也通常作为不遵守同步规则的一个常见借口。然而, 由于并发错误是最难追踪和消除的错误, 因此对于任何可能会引入这类错误的措施, 都需要谨慎实施。
更糟的是, 虽然你的初衷可能是用安全性来换取性能, 但最终可能什么都得不到。特别 是, 当提到并发时, 许多开发人员对于哪些地方存在性能问题, 哪种方法的运行速度更快, 以 及哪种方法的可伸缩性更高,往往会存在错误的直觉。因此, 在对性能的调优时, 一定要有明确的性能需求(这样才能知道什么时候需要调优, 以及什么时候应该停止), 此外还需要一个 测试程序以及真实的配置和负载等环境。在对性能调优后, 你需要再次测量以验证是否到达了 预期的性能提升目标。在许多优化措施中带来的安全性和可维护性等风险非常高。如果不是必须的话, 你通常不想付出这样的代价, 如果无法从这些措施中获得性能提升, 那么你肯定不希望付出这种代价。
已测试为基准,不要猜测
在市场上有一些成熟的分析工具可以用于·评估性能以及找出性能瓶颈, 但你不需要花太多 的资金来找出程序的功能。例如, 免费的perfbar应用程序可以给出CPU的忙碌程度信息, 而我们通常的目标就是使CPU保持忙碌状态, 因此这个功能可以有效地评估是否需要进行性能调优或者已实现的调优效果如何。
在有些问题中, 如果可用资源越多, 那么问题的解决速度就越快。 例如, 如果参与收割庄稼的工人越多, 那么就能越快地完成收割工作。而有些任务本质上是串行的, 例如, 即使增加再多的工人也不可能增加作物的生长速度。如果使用线程主要是为了发挥多个处理器的处理能 力, 那么就必须对问题进行合理的井行分解, 井使得程序能有效地使用这种潜在的并行能力。
大多数并发程序都与农业耕作有着许多相似之处, 它们都是由一系列的并行工作和串行工作组成的。Amdahl定律描述的是:在增加计算资源的情况下, 程序在理论上能够实现最高加速比, 这个值取决于程序中可并行组件与串行组件所占的比重。假定F是必须被串行执行的部 分, 那么根据Amdahl定律, 在包含N个处理器的机器中, 最高的加速比为:
当N趋近无穷大时, 最大的加速比趋近于1/F。 因此, 如果程序有50%的计算需要串行执行, 那么最高的加速比只能是 2 (而不管有多少个线程可用);如果在程序中有10%的计算需要串行执行, 那么最高的加速比将接近10。Amdahl定律还量化了串行化的效率开销。在拥有10个处理器的系统中, 如果程序中有10%的部分需要串行执行, 那么最高的加速比为5.3 (53% 的使用率), 在拥有 100 个处理器的系统中, 加速比可以达到 9.2 (9% 的使用率)。即使拥有无限多的CPU, 加速比也不可能为10。
图11-1 给出了处理器利用率在不同串行比例以及处理器数量情况下的变化曲线。(利用率的定义为:加速比除以处理器的数量。)随着处理器数量的增加, 可以很明显地看到, 即使串行 部分所占的百分比很小, 也会极大地限制当增加计算资源时能够提升的吞吐率。
第6章介绍了如何识别任务的逻辑边界并将应用程序分解为多个子任务。 然而, 要预测应用程序在某个多处理器系统中将实现多大的加速比, 还需要找出任务中的串行部分。
假设应用程序中N个线程正在执行程序消单11-1中的doWork, 这些线程从一个共享的工作队列中取出任务进行处理,而且这里的任务都不依赖于其他任务的执行结果或影响。暂时先不考虑任务是如何进入这个队列的, 如果增加处理器, 那么应用程序的性能是否会相应地发生变化?初看上去, 这个程序似乎能完全并行化:各个任务之间不会相互等待, 因此处理器越多,能够并发处理的任务也就越多。然而, 在这个过程中包含了一个串行部分——从队列中获取任务。所有工作者线程都共享同一个工作队列, 因此在对该队列进行并发访问时需要采用某种同步机制来维持队列的完整性。如果通过加锁来保护队列的状态, 那么当一个线程从队列中取出任务时, 其他需要获取下一个任务的线程就必须等待, 这就是任务处理过程中的串行部分。
单个任务的处理时间不仅包括执行任务Runnable的时间,也包括从共享队列中取出任务的时间。如果使用LinkedBlockingQueue作为工作队列,那么出列操作被阻塞的可能性将小于使用同步LinkedList时发生阻塞的可能性,因为LinkedBlockingQueue使用了一种可伸缩性更高的算法。然而,无论访问何种共享数据结构,基本上都会在程序中引人一个串行部分。
这个示例还忽略了另一种常见的串行操作:对结果进行处理。所有有用的计算都会生成某种结果或者产生某种效应一如果不会,那么可以将它们作为“ 死亡代码” 删除掉。由于Runnable没有提供明确的结果处理过程,因此这些任务一定会产生某种效果,例如将它们的结果写人到日志或者保存到某个数据结构。通常,日志文件和结果容器都会由多个工仵芍旨线程共享,并且这也是一个串行部分。如果所有线程都将各自的计算结果保存到自行维扩喽妇居结构中,并且在所有任务都执行完成后再合并所有的结果,那么这种合并操作也是一个串行部分。
在所有并发程序中都包含一些串行部分。如果你认为在你程序中不存在串行部分,那么可以再仔细检查一遍。
示例:在各种框架中隐裁的串行部分
要想知道串行部分是如何隐藏在应用程序的架构中,可以比较当增加线程时吞吐量的变化,并根据观察到的可伸缩性变化来推断串行部分中的差异。图11-2给出了一个简单的应用程序,其中多个线程反复地从一个共享Queue中取出元素进行处理,这与程序清单11-1很相似。处理步骤只需执行线程本地的计算。如果某个线程发现队列为空,那么它将把一组新元素放人队列,因而其他线程在下一次访间时不会没有元素可供处理。在访问共享队列的过程中显然存在着一定程度的串行操作,但处理步骤完全可以并行执行,因为它不会访问共享数据。
图11-2的曲线对两个线程安全的Queue的吞吐率进行了比较:其中一个是采用synchronizedList封装的LinkedList; 另一个是ConcurrentLinkedQueue。这些测试在8路Spare V880系统上运行,操作系统为 Solaris。尽管每次运行都表示相同的 “ 工作量”,但我们可以看到, 只需改变队列的实现方式, 就能对可伸缩性产生明显的影响。
ConcurrentLinkedQueue 的吞吐量不断提升,直到到达了处理器数量上限, 之后将基本保持不变。 另一方面, 当线程数量小于 3 时,同步 LinkedList 的吞吐量也会有某种程度的提升,但是之后会由于同步开销的增加而下跌。 当线程数最达到 4 个或 5 个时,竞争将非常激烈,至每次访问队列都会在锁上发生竞争,此时的吞吐量主要受到上下文切换的限制。
吞吐量的差异来源于两个队列中不同比例的串行部分。 同步的 LinkedList 采用单个锁来保护整个队列的状态, 井且在 offer 和 remove 等方法的调用期间都将持有这个锁。 ConcurrentLinkedQueue 使用了一种更复杂的非阻塞队列算法(请参见 15.4.2 节),该算法使用 原子引用来更新各个链接指针。 在第一个队列中,整个的插入或删除操作都将串行执行, 而在 第二个队列中, 只有对指针的更新操作需要串行执行。
Amdahl 定律的应用
如果能准确估计出执行过程中串行部分所占的比例,那么 Amdahl 定律就能量化当有更多计算资源可用时的加速比。 虽然要直接测量串行部分的比例非常困难,但即使在不进行测试的情况下 Amdahl 定律仍然是有用的。
因为我们的思维通常会受到周围环境的影响, 因此很多人都会习惯性地认为在多处理器系统中会包含2个或4个处理器,甚至更多(如果得到足够大的预算批准), 因为这种技术在近年来被广泛使用。 但随着多核 CPU逐渐成为主流,系统可能拥有数百个甚至数千个处理器。 一些在 4 路系统中看似具有可伸缩性的算法,却可能含有一些隐藏的可伸缩性瓶颈, 只是还没有遇到而巳。
在评估一个算法时,要考虑算法在数百个或数千个处理器的情况下的性能表现,从而对可能出现的可伸缩性局限有一定程度的认识。 例如,在 11.4.2 节和 11.4.3 节中介绍了两种降低锁粒度的技术: 锁分解(将一个锁分解为两个锁) 和锁分段(把一个锁分解为多个锁)。 当通过 Amdahl 定律来分析这两项技术时,我们会发现, 如果将一个锁分解为两个锁,似乎并不能充分利用多处理器的能力。 锁分段技术似乎更有前途, 因为分段的数量可随着处理器数量的增加而增加。(当然,性能优化应该考虑实际的性能需求,在某些情况下,将一个锁分解为两个就够了。)
单线程程序既不存在线程调度,也不存在同步开销,而且不需要使用锁来保证数据结构的一致性。 在多个线程的调度和协调过程中都需要一定的性能开销: 对于为了提升性能而引入的线程来说,并行带来的性能提升必须超过并发导致的开销。
上下文切换
如果主线程是唯一的线程, 那么它基本上不会被调度出去。另一方面, 如果可运行的线程数大于CPU 的数量,那么操作系统最终会将某个正在运行的线程调度出来,从而使其他线程能 够使用CPU。这将导致一次上下文切换,在这个过程中将保存当前运行线程的执行上下文,并 将新调度进来的线程的执行上下文设置为当前上下文。
切换上下文需要一定的开销,而在线程调度过程中需要访问由操作系统和JVM共享的数据结构。应用程序、操作系统以及JVM都使用一组相同的CPU。在JVM和操作系统的代码中消耗越多的CPU时钟周期,应用程序的可用CPU时钟周期就越少。但上下文切换的开销并不只是包含JVM和操作系统的开销。当一个新的线程被切换进来时,它所需要的数据可能不在当前处理器的本地缓存中,因此上下文切换将导致一些缓存缺失,因而线程在首次调度运行时会更加缓慢。这就是为什么调度器会为每个可运行的线程分配一个最小执行时间,即使有许多 其他的线程正在等待执行: 它将上下文切换的开销分摊到更多不会中断的执行时间上,从而提嵩整体的吞吐量 (以损失响应性为代价)。
当线程由于等待某个发生竞争的锁而被阻塞时,JVM通常会将这个线程挂起, 并允许它被交换出去。如果线程频繁地发生阻塞,那么它们将无法使用完整的调度时间片。在程序中发生越多的阻塞 (包括阻塞I/0, 等待获取发生竞争的锁,或者在条件变晕上等待),与CPU密集型的程序就会发生越多的上下文切换,从而增加调度开销,并因此而降低吞吐量。(无阻塞算法同样有助于减小上下文切换。请参见第15章。)
上下文切换的实际开销会随着平台的不同而变化,然而按照经验来看:在大多数通用的处理器中,上下文切换的开销相当于 5000-10000个时钟周期,也就是几微秒。
UNIX系统的vmstat命令和Windows系统的perfmon工具都能报告上下文切换次数以及在内核中执行时间所占比例等信息。如果内核占用率较高(超过10%), 那么通常表示调度活动发生得很频繁,这很可能是由I/0或竞争锁导致的阻塞引起的。
内存同步
同步操作的性能开销包括多个方面。在 synchronized和volatile提供的可见性保证中可能会使用一些特殊指令, 即内存栅栏(MemoryBarrier)。内存栅栏可以刷新缓存, 使缓存无效, 刷新硬件的写缓冲,以及停止执行管道。内存栅栏可能同样会对性能带来间接的影响, 因为它们将抑制一些编译器优化操作。在内存栅栏中,大多数操作都是不能被重排序的。
在评估同步操作带来的性能影响时, 区分有竞争的同步和无竞争的同步非常重要。synchronized机制针对无竞争的同步进行了优化(volatile通常是非竞争的 ),而在编写本书时, 一个 “快速通道(Fast-Path)"的非竞争同步将消耗20-250个时钟周期。虽然无竞争同步的开销不为零, 但它对应用程序整体性能的影响微乎其微,而另一种方法不仅会破坏安全性,而且还会使你(或者后续开发人员)经历非常痛苦的除错过程。
现代的JVM能通过优化来去掉一些不会发生竞争的锁, 从而减少不必要的同步开销。如果一个锁对象只能由当前线程访问,那么JVM就可以通过优化来去掉这个锁获取操作,因为另一个线程无法与当前线程在这个锁上发生同步。例如,JVM通常都会去掉程序清单11-2中的锁获取操作。
一些更完备的JVM能通过逸出分析(Escape Analysis)来找出不会发布到堆的本地对象引用(因此这个引用是线程本地的)。在程序清单11-3的getStoogeNames中, 对List的唯一引用就是局部变量stooges, 并且所有封闭在栈中的变量都会自动成为线程本地变量。在getStoogeNames的执行过程中,至少会将Vector上的锁获取释放4次, 每次调用add或toString时都会执行1次。然而, 一个智能的运行时编译器通常会分析这些调用,从而使stooges及其内部状态不会逸出, 因此可以去掉这4次对锁获取操作。
即使不进行逸出分析,编译器也可以执行锁粒度粗化(Lock Coarsening)操作,即将邻近的同步代码块用同一个锁合并起来。在getStoogeNames中, 如果JVM进行锁粒度粗化,那么可能会把3个add与1个toString调用合并为单个锁获取I释放燥作,并采用启发式方法来评估同步代码块中采用同步操作以及指令之间的相对开销。这不仅减少了同步的开销, 同时还能使优化器处理更大的代码块, 从而可能实现进一步的优化。
不要过度担心非竞争同步带来的开销。这个基本的机制已经非常快了,并且JVM还能进行额外的优化以进一步降低或消除开销。因此,我们应该将优化重点放在那些发生锁竞争的地方。
某个线程中的同步可能会影响其他线程的性能。同步会增加共享内存总线上的通信量,总线的带宽是有限的,并且所有的处理器都将共享这条总线。如果有多个线程竞争同步带宽,那么所有使用了同步的线程都会受到影响。
阻塞
非竞争的同步可以完全在 JVM 中进行处理 (Bacon 等,1998), 而竞争的同步可能需要操作系统的介入,从而增加开销。当在锁上发生竞争时,竞争失败的线程肯定会阻塞。JVM 在实现阻塞行为时,可以采用自旋等待 (Spin-Waiting, 指通过循环不断地尝试获取锁,直到成功) 或者通过操作系统挂起被阻塞的线程。这两种方式的效率高低,要取决于上下文切换的开销以及在成功获取锁之前需要等待的时间。如果等待时间较短,则适合采用自旋等待方式,而如果等待时间较长,则适合采用线程挂起方式。有些 JVM 将根据对历史等待时间的分析数据在这两者之间进行选择,但是大多数 JVM 在等待锁时都只是将线程挂起。
当线程无法获取某个锁或者由于在某个条件等待或在I/0操作上阻塞时,需要被挂起,这个过程中将包含两次额外的上下文切换,以及所有必要的操作系统操作和缓存操作:被阻塞 的线程在其执行时间片还未用完之前就被交换出去,而在随后当要获取的锁或者其他资源可用 时,又再次被切换回来。(由于锁竞争而导致阻塞时,线程在持有锁时将存在一定的开销:当它释放锁时,必须告诉操作系统恢复运行阻塞的线程。)
我们已经看到,串行操作会降低可伸缩性,并且上下文切换也会降低性能。在锁上发生竞争时将同时导致这两种问题,因此减少锁的竞争能够提高性能和可伸缩性。
在对由某个独占锁保护的资源进行访问时,将采用串行方式——每次只有一个线程能访问它。当然,我们有很好的理由来使用锁,例如避免数据被破坏,但获得这种安全性是需要付出代价的。如里在锁上持续发生竞争,那么将限制代码的可伸缩性。
在并发程序中,对可仲缩性的最主要威胁就是独占方式的资源锁。
有两个因素将影响在锁上发生竞争的可能性:锁的请求频率,以及每次持有该锁的时间。如果二者的乘积很小,那么大多数获取锁的操作都不会发生竞争,因此在该锁上的竞争不会对可伸缩性造成严重影响。然而,如果在锁上的请求量很高,那么需要获取该锁的线程将被阻塞 并等待。在极端情况下,即使仍有大量工作等待完成,处理器也会被闲置。
有3种方式可以降低锁的竞争程度:
a.减少锁的持有时间
b.降低锁的请求频率
c.持有带有协调机制的独占锁,这些机制允许更高的并发性
缩小锁的范围("快进快出")
降低发生竞争可能性的一种有效方式就是尽可能缩短锁的持有时间。 例如, 可以将一些与 锁无关的代码移出同步代码块, 尤其是那些开销较大的操作, 以及可能被阻塞的操作, 例如 I/ 0操作。
我们都知道, 如果将一个 “高度竞争” 的锁持有过长的时间, 那么会限制可伸缩性, 例如 在第2章中介绍的 SynchronizedF actorizer的示例。 如果某个操作持有锁的时间超过2亳秒并且所有操作都需要这个锁, 那么无论拥有多少个空闲处理器, 吞吐量也不会超过每秒500个操 作。 如果将这个锁的持有时间降为1毫秒, 那么能够将这个锁对应的吞吐量提高到每秒1000 个操作。
程序清单11-4给出了一个示例,其中锁被持有过长的时间。userLocationMatches方法在一 个Map对象中查找用户的位置,并使用正则表达式进行匹配以判断结果值是否匹配所提供的模式。整个user查找用户的位置,并使用正则表达式进行匹配以判断结果值是否匹配所提供的模式。整个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-6的ServerStatus中给出了某个数据库服务器的部分监视接口, 该数据库维护了当前已登录的用户以及正在执行的请求。当一个用户登录、注销、开始查询或结束查询时, 都会调用相应的add 和remove等方法来更新ServerStatus对象。这两种类型的信息是完全独立的,ServerStatus甚至可以被分解为两个类, 同时确保不会丢失功能。
在代码中不是用ServerStatus锁来保护用户状态和查询状态, 而是每个状态都通过一个锁来保护, 如程序清单11-7所示。在对锁进行分解后,每个新的细粒度锁上的访问量将比最初的访问量少。(通过将用户状态和查询状态委托给一个线程安全的Set, 而不是使用显式的同步,能隐含地对锁进行分解, 因为每个Set都会使用一个不同的锁来保护其状态。)
如果在锁上存在适中而不是激烈的竞争时,通过将一个锁分解为两个锁,能最 大限度地提升性能。如果对竞争井不激烈的锁进行分解,那么在性能和吞吐批等方面带来的提升将非常有 限,但是也会提高性能随着竞争提高而下降的拐点值。对竞争适中的锁进行分解时,实际上是把这些锁转变为非竞争的锁,从而有效地提高性能和可伸缩性。
锁分段
把一个竞争激烈的锁分解为两个锁时,这两个锁可能都存在激烈的竞争。虽然采用两个线程并发执行能提高一部分可伸缩性,但在一个拥有多个处理器的系统中,仍然无法给可伸缩性带来极大的提高。在ServerStatus类的锁分解示例中,并不能进一步对锁进行分解。
在某些情况下,可以将锁分解技术进一步扩展为对一组独立对象上的锁进行分解,这种情况被称为锁分段。例如,在ConcurrentHashMap的实现中使用了一个包含16 个锁的数组,每个锁保护所有散列桶的1/1 6, 其中第N个散列桶由第(Nmod 16)个锁来保护。假设散列函数具有合理的分布性,并且关键字能够实现均匀分布,那么这大约能把对于锁的请求减少到原来的l/16。正是这项技术使得ConcurrentHashMap能够支持多达16 个并发的写入器。(要使得拥有大最处理器的系统在高访问量的情况下实现更高的并发性,还可以进一步增加锁的数量,但仅当你能证明并发写入线程的竞争足够激烈并需要突破这个限制时,才能将锁分段的数量超过默认的16 个。)
锁分段的一个劣势在于:与采用单个锁来实现独占访问相比,要获取多个锁来实现独占访问将更加困难并且开销更高。通常,在执行一个操作时最多只需获取一个锁,但在某些情况下需要加锁整个容器, 例如当ConcurrentHashMap需要扩展映射范围,以及重新计算键值的散列值要分布到更大的桶集合中时,就需要获取分段所集合中所有的锁。
在程序清单11-8 的StripedMap中给出了基于散列的Map实现,其中使用了锁分段技术。它拥有N_LOCKS 个锁,并且每个锁保护散列桶的一个子集。大多数方法, 例如get,都只需要获得一个锁,而有些方法则需要获得所有的锁,但并不要求同时获得, 例如clear方法的实现。
避免热点域
锁分解和锁分段技术都能提高可伸缩性, 因为它们都能使不同的线程在不同的数据(或者同一个数据的不同部分)上操作, 而不会相互干扰。如果程序采用锁分段技术, 那么一定要表现出在锁上的竞争频率高于在锁保护的数据上发生竞争的频率。如果一个锁保护两个独立变量X和Y, 并且线程A 想要访问X, 而线程B 想要访问Y( 这类似于在ServerStatus 中, 一个线程调用addUser, 而另一个线程调用addQuery), 那么这两个线程不会在任何数据上发生竞争,即使它们会在同一个锁上发生竞争。
当每个操作都请求多个变量时, 锁的粒度将很难降低。这是在性能与可伸缩性之间相互制衡的另一个方面, 一些常见的优化措施, 例如将一些反复计算的结果缓存起来, 都会引入一些“热点域(Hot Field)", 而这些热点域往往会限制可伸缩性。
当实现HashMap 时, 你需要考虑如何在size 方法中计算Map 中的元素数量。最简单的方法就是, 在每次调用时都统计一次元素的数量。一种常见的优化措施是, 在插入和移除元素时更新一个计数器, 虽然这在put 和remove 等方法中略微增加了一些开销, 以确保计数器是最新的值, 但这将把size 方法的开销从O(n) 降低到0(1)。
在单线程或者采用完全同步的实现中, 使用一个独立的计数能很好地提高类似size 和isEmpty 这些方法的执行速度, 但却导致更难以提升实现的可伸缩性, 因为每个修改map 的操作都需要更新这个共享的计数器。即使使用锁分段技术来实现散列链, 那么在对计数器的访问进行同步时, 也会重新导致在使用独占锁时存在的可伸缩性问题。一个看似性能优化的措施-缓存size 操作的结果, 已经变成了一个可伸缩性问题。在这种情况下, 计数器也被称为热点域, 因为每个导致元素数量发生变化的操作都需要访问巳0
为了避免这个问题, ConcurrentHashMap 中的size 将对每个分段进行枚举并将每个分段中的元素数量相加, 而不是维护一个全局计数。为了避免枚举每个元素, ConcurrentHashMap 为每个分段都维护了一个独立的计数, 并通过每个分段的锁来维护这个值。
一些替代独占锁的方法
第三种降低竞争锁的影响的技术就是放弃使用独占锁, 从而有助于使用一种友好并发的方式来管理共享状态。例如, 使用并发容器、读-写锁、不可变对象以及原子变量。
ReadWriteLock (请参见第13 章)实现了一种在多个读取操作以及单个写入操作情况下的加锁规则: 如果多个读取操作都不会修改共享资掠, 那么这些读取操作可以同时访问该共享资源, 但在执行写入操作时必须以独占方式来获取锁。对于读取操作占多数的数据结构,ReadWriteLock 能提供比独占锁更高的并发性。而对于只读的数据结构, 其中包含的不变性可以完全不需要加锁操作。
原子变量(请参见第15章)提供了一种方式来降低更新“ 热点域” 时的开销, 例如静态计数器、序列发生器、或者对链表数据结构中头节点的引用。(在第2章的示例中使用了AtomicLong 来维护Serviet 的计数器。)原子变量类提供了在整数或者对象引用上的细粒度原子操作(因此可伸缩性更高), 并使用了现代处理器中提供的底层并发原语(例如比较并 交换[compare-and-swap])。如果在类中只包含少量的热点域,并且这些域不会与其他变量参与到不变性条件中,那么用原子变量来替代它们能提高可伸缩性。(通过减少算法中的 热点域,可以提高可伸缩性一一虽然原子变量能降低热点域的更新开销, 但并不能完全消除。)
监测CPU的利用率
当测试可伸缩性时, 通常要确保处理器得到充分利用。 例如UNIX系统上的 vmstat和mpstat, 或者Windows 系统的perfmon , 都能给出处理器的 “忙碌” 状态。
如果所有CPU的利用率并不均匀(有些CPU在忙碌地运行, 而其他CPU却并非如此), 那么你的首要目标就是进一步找出程序中的并行性。不均匀的利用率表明大多数计算都是由小组线程完成的,并且应用程序没有利用其他的处理器。
如果CPU没有得到充分利用, 那么需要找出其中的原因。通常有以下几种原因:
a.负载不充足。测试的程序中可能没有足够多的负载,因而可以在桐试时增加负载, 并检查利用率、响应时间和服务时间等指标的变化。如果产生足够多的负载使应用程序达到饱和, 那么可能需要大量的计算机能耗, 并且问题可能在于客户端系统是否具有足够的能力,而不是被测试系统。
b.I/O密集。可以通过iostat或perfmon来判断某个应用程序是否是磁盘I/O密集型的,或者
通过监测网络的通信流量级别来判断它是否需要高带宽。外部限制。如果应用程序依赖于外部服务,例如数据库或Web服务,那么性能瓶颈可能并不在你自己的代码中。可以使用某个分析工具或数据库管理工具来判断在等待外部服务的结果时需要多少时间。
c.锁竞争。使用分析工具可以知道在程序中存在何种程度的锁竞争,以及在哪些锁上存在“激烈的竞争”。然而, 也可以通过其他一些方式来获得相同的信息, 例如随机取样,触发一些线程转储并在其中查找在锁上发生竞争的线程。如果线程由千等待某个锁而被阻塞,那么在线程转储信息中将存在相应的栈帧, 其中包含的信息形如"waiting to lock monitor ... "。非竞争的锁很少会出现在线程转储中,而对于竞争激烈的锁, 通常至少会有一个线程在等待获取它,因此将在线程转储中频繁出现。
如果应用程序正在使CPU保持忙碌状态, 那么可以使用监视工具来判断是否能通过增加额外的CPU来提升程序的性能。如果一个程序只有4个线程,那么可以充分利用一个4路系统的计算能力,但当移植到8路系统上时, 却未必能获得性能提升,因为可能需要更多的线程才会有效利用剩余的处理器。(可以通过重新配置程序将工作负载分配给更多的线程,例如调整线程池的大小。) 在vmstat命令的输出中, 有一栏信息是当前处于可运行状态但并没有运行(由于没有足够的CPU) 的线程数址。如果CPU的利用率很高,并且总会有可运行的线程在等待CPU, 那么当增加更多的处理器时,程序的性能可能会得到提升。
向对象池说 “不 ”
在JVM的早期版本中, 对象分配和垃圾回收等操作的执行速度非常慢, 但在后续的版本 中, 这些操作的性能得到了极大提高。 事实上, 现在 Java的分配操作已经比 C 语言的 malloc调用更快:在 Hotspot 1.4.x 和 5.0 中, "new Object" 的代码大约只包含 10条机器指令。
为了解决 “缓慢的” 对象生命周期问题,许多开发人员都选择使用对象池技术, 在对象 池中, 对象能被循环使用, 而不是由垃圾收集器回收井在需要时重新分配。 在单线程程序中 (Click, 2005), 尽管对象池技术能降低垃圾收集操作的开销, 但对于高开销对象以外的其他对 象来说, 仍然存在性能缺失@(对于轻量级和中量级的对象来说, 这种损失将更为严重)。
在并发应用程序中, 对象池的表现更加糟糕。 当线程分配新的对象时, 基本上 不需要在线 程之间进行协调, 因为对象分配器通常会使用线程本地的内存块, 所以不需要在堆数据结构上进行同步。 然而, 如果这些线程从对象池中请求一个对象, 那么就需要通过某种同步来协调对 对象池数据结构的访问, 从而可能使某个线程被阻塞。 如果某个线程由千锁竞争而被阻塞,那 么这种阻塞的开销将是内存分配操作开销的数百倍, 因此即使对象池带来的竞争很小, 也可能 形成一个可伸缩性瓶颈。(即使是一个非竞争的同步, 所导致的开销也会比分配一个对象的开 销大。)虽然这看似是一种性能优化技术, 但实际上却会导致可伸缩性问题。 对象池有其特定 的用途, 但对于性能优化来说, 用途是有限的。
通常,对象分配操作的开销比同步的开销更低。
在单线程环境下, ConcurrentHashMap 的性能比同步的HashMap 的性能略好一些, 但在并发环境中则要好得多。在ConcurrentHashMap的实现中假设, 大多数常用的操作邹是获取某个已经存在的值, 因此它对各种get 操作进行了优化从而提供最高的性能和并发性。
在同步Map 的实现中, 可伸缩性的最主要阻碍在于整个Map 中只有一个锁, 因此每次只有一个线程能够访问这个Map。不同的是, ConcurrentHashMap 对于大多数读操作并不会加锁,并且在写入操作以及其他一些需要锁的读操作中使用了锁分段技术。因此,多个线程能并发地访问这个Map 而不会发生阻塞。
图11-3 给出了几种Map 实现在可伸缩上的差异: ConcurrentHashMap 、ConcurrentSkipListMap,以及通过synchronizedMap 来包装的HashMap 和TreeMap。前两种Map 是线程安全的, 而后两个 Map 则通过同步封装器来确保线程安全性。 每次运行时, 将有 N个线程并发地执行一个 紧凑的循坏:选择一个随机的键值, 井尝试获取与这个键值相对应的值。如果不存在相应的 值, 那么将这个值增加到 Map 的概率为p = 0.6, 如果存在相应的值, 那么删除这个值的概率 为p == 0.02。这个测试在 8 路 Spare V880 系统上运行, 基于 Java 6环境, 井且在图中给出了将 ConcurrentHashMap 归一化为单个线程时的吞吐址。(并发容器与同步容器在可伸缩性上的差异比在 Java 5.0 中更加明显。)
ConcurrentHashMap 和ConcurrentSkipListMap 的数据显示, 它们在线程数量增加时能表现出很好的可伸缩性, 并且吞吐量会随着线程数最的增加而增加。虽然图11-3 中的线程数最并不大, 但与普通的应用程序相比,这个测试程序在每个线程上生成了更多的竞争, 因为它除了向Map 施加压力外几乎没有执行任何其他操作, 而实际的应用程序通常会在每次迭代中进行一些线程本地工作。
同步容器的数量并非越多越好。单线程情况下的性能与ConcurrentHashMap 的性能基本相当,但当负载情况由非竞争性转变成竞争性时一一这里是两个线程, 同步容器的性能将变得糟糕。在伸缩性受到锁竞争限制的代码中, 这是一种常见的行为。只要竞争程度不高, 那么每个操作消耗的时间基本上就是实际执行工作的时间, 并且吞吐量会因为线程数的增加而增加。当竞争变得激烈时, 每个操作消耗的时间大部分都用于上下文切换和调度延迟, 而再加入更多的线程也不会提高太多的吞吐量。
在许多任务中都包含一些可能被阻塞的操作。当任务在运行和阻塞这两个状态之间转换时,就相当于一次于下文切换。在服务器应用程序中, 发生阻塞的原因之一就是在处理请求时产生各种日志消息。为了说明如何通过减少上下文切换的次数来提高吞吐量, 我们将对两种日志方法的调度行为进行分析。
在大多数日志框架中都是简单地对println 进行包装, 当需要记录某个消息时, 只需将其写入日志文件中。在第7 章的LogWriter 中给出了另一种方法:记录日志的工作由一个专门的后台线程完成,而不是由发出请求的线程完成。从开发人员的角度来看,这两种方法基本上是相同的。 但二者在性能上可能存在一些差异,这取决于日志操作的工作量,即有多少线程正在记录日志,以及其他一些因素, 例如上下文切换的开销等。
日志操作的服务时间包括与I/0流类相关的计算时间,如果I/0操作被阻塞,那么还会包括线程被阻塞的时间。操作系统将这个被阻塞的线程从调度队列中移走并直到I/0操作结束, 这将比实际阻塞的时间更长。当I/0操作结束时,可能有其他线程正在执行它们的调度时间片, 并且在调度队列中有些线程位于被阻塞线程之前,从而进一步增加服务时间。如果有多个线程 在同时记录日志,那么还可能在输出流的锁上发生竞争,这种情况的结果与阻塞I/0的情况一 样——线程被阻塞并等待锁,然后被线程调度器交换出去。在这种日志操作中包含了I/0操作和加锁操作,从而导致上下文切换次数的增多,以及服务时间的增加。
请求服务的时间不应该过长,主要有以下原因。 首先, 服务时间将影响服务质量:服务时间越长,就意味着有程序在获得结果时需要等待更长的时间。但更重要的是,服务时间越长,也就意味着存在越多的锁竞争。11.4.1节中的 “快进快出” 原则告诉我们,锁被持有的时间应该尽可能地短,因为锁的持有时间越长,那么在这个锁上发生竞争的可能性就越大。 如果一个线程由于等待I/0操作完成而被阻塞,同时它还持有一个锁,那么在这期间很可能会有另一个 线程想要获得这个锁。如果在大多数的锁获取操作上不存在竞争, 那么并发系统就能执行得更好,因为在锁获取操作上发生竞争时将导致更多的上下文切换。在代码中造成的上下文切换次数越多,吞吐最就越低。
通过将I/0操作从处理请求的线程中分离出来,可以缩短处理请求的平均服务时间。调用log方法的线程将不会再因为等待输出流的锁或者I/O完成而被阻塞,它们只需将消息放人队 列,然后就返回到各自的任务中。 另一方面,虽然在消息队列上可能会发生竞争,但put操作相对于记录日志的I/O操作(可能需要执行系统调用) 是一种更为轻量级的操作,因此在实际使用中发生阻塞的概率更小(只要队列没有填满)。由于发出日志请求的线程现在被阻塞的概 率降低, 因此该线程在处理请求时被交换出去的概率也会降低。我们所做的工作就是把一条包含I/O操作和锁竞争的复杂且不确定的代码路径变成一条简单的代码路径。
从某种意义上讲,我们只是将工作分散开来, 并将I/O操作移到了另一个用户感知不到开销的线程上(这本身已经获得了成功)。通过把所有记录日志的I/O转移到一个线程,还消除了输出流上的竞争,因此又去掉了一个竞争来源。这将提升整体的吞吐量,因为在调度中消耗的资源更少,上下文切换次数更少,并且锁的管理也更简单。
通过把I/O操作从处理请求的线程转移到一个专门的线程,类似于两种不同救火方案之间的差异:第一种方案是所有人排成一队, 通过传递水桶来救火;第二种方案是每个人都拿着一个水桶去救火。在第二种方案中, 每个人都可能在水源和着火点上存在更大的竞争(结果导致了只能将更少的水传递到着火点), 此外救火的效率也更低, 因为每个人都在不停的切换模式(装水、跑步、倒水、跑步……)。在第一种解决方案中, 水不断地从水源传递到燃烧的建筑物, 人们付出更少的体力却传递了更多的水, 并且每个人从头至尾只需做一项工作。正如中断会干扰人们的工作并降低效率, 阻塞和上下文切换同样会干扰线程的正常执行。
由于使用线程常常是为了充分利用多个处理器的计算能力, 因此在并发程序性能的讨论中, 通常更多地将侧重点放在吞吐量和可伸缩性上, 而不是服务时间。Amdahl 定律告诉我们,程序的可伸缩性取决于在所有代码中必须被串行执行的代码比例。因为Java 程序中串行操作的主要来源是独占方式的资源锁, 因此通常可以通过以下方式来提升可伸缩性:减少锁的持有时间, 降低锁的粒度, 以及采用非独占的锁或非阻塞锁来代替独占锁。