性能与可伸缩性
线程的最主要目的是提高程序的运行性能。线程可以使程序更加充分地发挥系统的可用处理能力,从而提高系统的资源利用率。此外,线程还可以使程序在运行现有任务的情况下立即开始处理新的任务,从而提高系统的响应性。
首先要保证程序能正确运行,然后仅当程序的性能需求和测试结果要求程序执行得更快时,才应该设法提高它的运行速度。
11.1 对性能的思考
提升性能意味着用更少的资源做更多地事情。“资源”的含义很广。对于一个给定的操作,通常会缺乏某种特定的资源,例如CPU时钟周期、内存、网络带宽、I/O带宽、数据库请求、磁盘空间以及其他资源。当操作性能由于某种特定的资源而受限制时,我们通常将该操作称为资源密集型的操作,例如,CPU密集型、数据库密集型等。
尽管使用多个线程的目标是提升整体性能,但与单线程的方法相比,使用多个线程总会引入一些额外的性能开销。造成这些开销的操作包括:线程之间的协调(例如加锁、触发信号以及内存同步等),增加的上下文切换,线程的创建和销毁,以及线程的调度等。如果过度地使用线程,那么这些开销甚至会超过由于提高吞吐量、响应性或者计算能力所带来的性能提升。
要想通过并发来获得更好的性能,需要努力做好两件事情:更有效地利用现有处理资源,以及在出现新的处理资源时使程序尽可能地利用这些新资源。从性能监视的视角来看,CPU需要尽可能保持忙碌状态。(当然,这并不意味着将CPU时钟周期浪费在一些无用的计算上,而是执行一些有用的工作。)如果程序是计算密集型的,那么可以通过增加处理器来提高性能。因为如果程序无法使现有的处理器保持忙碌状态,那么增加再多的处理器也无济于事。通过将应用程序分解到多个线程上执行,使得每个处理器都执行一些工作,从而使得所有CPU都保持忙碌状态。
11.1.1 性能与可伸缩性
应用程序的性能可以采用多个指标来衡量,例如服务时间、延迟时间、吞吐率、效率、可伸缩性以及容量等。其中一些指标(服务时间、等待时间)用于衡量程序的“运行速度”,即某个特定的任务单元需要“多快“才能处理完成。另一些指标(生产量、吞吐量)用于程序的”处理能力“,即在计算资源一定的情况下,能完成”多少“工作。
可伸缩性指的是:当增加计算资源时(例如CPU、内存、存储容量或I/O带宽),程序的吞吐量或者处理能力响应地增加。
在并发应用程序中针对可伸缩性进行设计和调整时所采用的方法与传统的性能调优方法截然不同。当进行性能调优时,其目的通常是用更小的代价完成相同的工作,例如通过缓存来重用之前计算的结果,或者采用时间复杂度O(n2)算法来代替复杂度为O(nlogn)的算法。在进行可伸缩性调优时,其目的是设法将问题的计算并行化,从而能利用更多地计算资源来完成更多的工作。
性能的这两个方面——”多块“和”多少“,是完全独立的,有时候甚至是相互矛盾的。要实现更高的可伸缩性或硬件利用率,通常会增加各个任务所要处理的工作量,例如把任务分解为多个”流水线“子任务时。但是,大多数提高单线程程序性能的技术,往往都会破坏可伸缩性。
我们熟悉的三层程序模型,即在模型中的表现层、业务逻辑层和持久层是彼此独立的,并且可能由不同的系统来处理,这很好地说明了提高可伸缩性通常会造成性能损失的原因。如果把表现层、业务逻辑层和持久层都融合到单个应用程序中,那么在处理第一个工作单元时,其性能肯定要高于将应用程序分为多层并将不同层次分布到多个系统时的性能。这种单一的应用程序避免了在不同层次之间传递任务时存在的网络延迟,同时也不需要将计算过程分解到不同的抽象层次,因此能减少许多开销(例如在任务排队、线程协调以及数据复制时存在的开销)。
然而,这种单一的系统到达自身的处理能力的极限时,会遇到一个严重的问题:要进一步提升它的处理能力将非常困难。因此,我们通常会接受每个工作单元执行更长的时间或消耗更多的计算资源,以换取应用程序在增加更多资源的情况下处理更高的负载。
对于服务器应用程序来说,”多少“这个方面——可伸缩性、吞吐量和生产量,往往比”多块“这个方面更受重视。(在交互式应用程序中,延迟或许更加重要,这样用户就不用等待进度条的指示,并奇怪程序究竟在执行哪些操作。)
11.1.2 评估各种性能权衡因素
在几乎所有的工程决策中都会设计某些形式的权衡。例如”快速排序“算法在大规模数据集上的执行效率非常高,但对于小规模的数据集来说,“冒泡排序“实际上更高效。如果要实现一个高效的排序算法,那么需要知道被处理数据集的大小,还有权衡优化的指标,包括:平均计算时间、最差时间、可预知性。然而,编写某个库中排序算法的开发人员通常无法知道这些需求信息。这就是为什么大多数优化措施都不成熟的原因之一:它们通常无法获得一组明确地需求。
避免不成熟的优化。首先使程序正确,然后再提高运行速度——如果它还运行得不够快。
当进行决策时,有时候会通过增加某种形式的成本来降低另一种形式的开销(例如,增加内存使用量以降低服务时间)。如果你无法找出其中的代价或风险,那么或许还没有对这些优化措施进行彻底的思考和分析。
在大多数性能决策中都包含有多个变量,并且非常依赖于运行环境。在使某个方案比其他方案”更快“之前,首先问自己一些问题:
- ”更快“的含义是什么?
- 该方法在什么条件下运行得更快?在低负载还是高负载的情况下?大数据集还是小数据集?能否通过测试结果来验证你的答案?
- 这些条件在运行环境中的发生频率?能否通过测试结果来验证你的答案?
- 在其他不同条件的环境中能否使用这里的代码?
- 在实现这种性能提升时需要付出哪些隐含地代价,例如增加开发风险或维护开销?这种权衡是否合适?
在进行任何与性能相关的决策时,都应该考虑这些问题。
在对性能的调优时,一定要有明确的性能需求(这样才能知道什么时候需要调优,以及什么时候应该停止),此外还需要一个测试程序以及真实地配置和负载等环境。在对性能调优后,你需要再次测量以验证是否到达了预期的性能提升目标。在许多优化措施中带来的安全安全性和可维护性等很风险非常高。如果不是必须的话,你通常不想付出这样的代价,如果无法从这些措施中获得性能提升,那么你肯定不希望付出这种代价。
以测试为基准,不要猜测。
在市场上有一些成熟的分析工具可以用于评估性能以及找出性能瓶颈,但你不需要花太多的资金来找出程序的功能。例如,免费得perfbar应用程序可以给出CPU的忙碌程度信息,而我们通常的目标就是使CPU保持忙碌状态,因此这个功能可以有效地评估是否需要进行性能调优或者已实现的调优效果如何。
11.2 Amdahl定律
在有些问题中,如果可用资源越多,那么问题的解决速度就越快。例如,如果参与收割庄稼的工人越多,那么就能越快地完成收割工作。而有些任务本质上是串行的,例如,即使增加再多的工人也不能增加作物的生长速度。如果使用线程主要是为了发挥多个处理器的处理能力,那么就必须对问题进行合理的并行分解,并使得程序能有效地使用这种潜在的并行能力。
大多数并发程序都与农业耕作有着许多相似之处,它们都是由一系列的并行工作和串行工作组成的。Amdahl定律描述的是:在增加计算资源的情况下,程序在理论上能够实现最高加速比,这个值取决于程序中可并行组件与串行组件所占的比重。假定F是必须被串行执行的部分,那么根据Amdahl定律,在包含N个处理器的机器中,最高的加速比为:
Speedup <= 1 / (F + (1 - F) / N)
当N趋近无穷大时,最大的加速比趋近于1/F。因此,如果程序有50%的计算需要串行执行,那么最高的加速比只能是2(而不管有多少个线程可用):如果在程序中有10%的计算需要串行执行,那么最高的加速比将接近10。Amdahl定律还量化了串行化的效率开销。在拥有19个处理器的系统中,如果程序中有10%的部分需要串行执行,那么最高的加速比为5.3%(53%的使用率),在拥有100个处理器的系统中,加速比可以达到9.2(9%的使用率)。即使拥有无限多的CPU,加速比也不可能为10.
下图给出了处理器利用率在不同串行比例以及处理器数量情况下的变化曲线。(利用率的定义为:加速比除以处理器的数量。)随着处理器数量的增加,可以很明显地看到,即使串行部分所占的百分比很小,也会极大地限制当增加计算资源时能够提升的吞吐率。
要预测应用程序在某个多处理器系统中将实现多大的加速比,还需要找出任务中的串行部分。
public class WorkerThread extends Thread {
private final BlockingQueue<Runnable> queue;
public WorkerThread(BlockingQueue<Runnable> queue) {
this.queue = queue;
}
public void run() {
while (true) {
try {
Runnable task = queue.take();
task.run();
} catch (InterruptedException e) {
break; /* Allow thread to exit */
}
}
}
}
在这个过程中包含了一个串行部分——从队列中获取任务。所有工作者线程都共享同一个工作队列,因此在对该队列进行并发访问时需要采用某种同步机制来维护队列的完整性。如果通过加锁来保护队列的状态,那么当一个线程从队列中取出任务时,其他需要获取下一个任务的线程就必须等待,这就是任务处理过程中的串行部分。
这个示例还忽略了另一种常见的串行操作:对结果进行处理。所有有用的计算都会生成某种结果或者产生某种效应——如果不会,那么可以将它们作用”死亡代码“删除掉。由于Runnable没有提供明的结果处理过程,因此这些任务一定会产生某种效果,例如将它们的结果写入到日志或者保存到某个数据结构。通常,日志文件和结果容器都会由多个工作者线程共享,并且这也是一个串行部分。如果所有线程都将各自的计算结果保存到自行维护数据结构中,并且在所有任务都执行完后再合并所有的结果,那么这种合并操作也是一个串行部分。
在所有并发程序中都包含一些串行部分。如果你认为你的程序中不存在串行部分,那么可以再仔细检查一遍。
11.2.1 示例:在各种框架中隐藏的串行部分
要想知道串行部分是如何隐藏在应用程序的架构中,可以比较当增加线程时吞吐量的变化,并根据观察到的可伸缩性变化来推断串行部分中的差异。下图给出了一个简单的应用程序,其中多个线程反复地从一个共享Queue中取出元素进行处理,处理步骤只需执行线程本地的计算。如果某个线程发现队列为空,那么它将把一组新元素放入队列,因而其他线程在下一次访问时不会没有元素可供处理。在访问共享队列的过程中显然存在着一定程度的串行操作,但处理步骤完全可以并行执行,因为它不会访问共享数据。
虽然每次运行都表现相同的”工作量“,但我们可以看到,只需改变队列的实现方式,就能对可伸缩性产生明显的影响。
11.2.2 Amdahl定律的应用
如果能准确估计出执行过程中串行部分所占的比例,那么Amdahl定律就能量化当有更多计算资源可用时的加速比。虽然要直接测量串行部分的比例非常困难,但即使在不进行测试的情况下Amdahl定律仍然是有用的。
在评估一个算法时,要考虑算法在数百个或数千个处理器的情况下的性能表现,从而对可能出现的可伸缩性局限有一定程度的认识。例如,降低锁粒度的两种技术:锁分解(将一个锁分解为两个锁)和锁分段(把一个锁分解为多个锁)。当通过Amdahl定律来分析这两项技术时,我们会发现,如果将一个锁分解为两个锁,似乎并不能充分利用多处理器的能力。锁分段技术似乎更有前途,因为分段的数量可随着处理器数量的增加而增加。(当然,性能优化应该考虑实际的性能需求,在某些情况下,将一个锁分解为两个就够了。)
11.3 线程引入的开销
单线程程序既不存在线程调度,也不存在同步开销,而且不需要使用锁来保护数据结构的一致性。在多个线程的调度和协调过程总都需要一定的性能开销:对于为了提升性能而引入的线程来说,并行带来的性能提升必须超过并发导致的开销。
11.3.1 上下文切换
如果主线程是唯一的线程,那么它基本上不会被调度出去。另一方面,如果可运行的线程数大于CPU的数量,那么操作系统最终会将某个正在运行的线程调度出来,从而使其他线程能够使用CPU。这将导致一次上下文切换,在这个过程中将保存当前运行线程的执行上下文,并将新调度进来的线程的上下文设置为当前上下文。
切换上下文需要一定的开销,而在线程调度过程中需要访问由操作系统和JVM共享的数据结构。应用程序、操作系统以及JVM都使用一组相同的CPU。在JVM和操作系统的代码中消耗越多的CPU时钟周期,应用程序的可用CPU时钟周期就越少。当上下文切换的开销并不只是包含JVM和操作系统的开销,当一个新的线程被切换进来时,它所需要的数据可能不在当前处理器的本地缓存中,因此上下文切换将导致一些缓存缺失,因而线程在首次调度运行时会更加缓慢。这就是为什么调度器会为每个可运行的线程分配一个最小执行时间,即使有许多其他的线程正在等待执行:它将上下文切换的开销分摊到更多不会中断的执行时间上,从而提高整体的吞吐量(以损失响应性为代价)。
当线程由于等待某个发生竞争的锁而被阻塞时,JVM通常会将这个线程挂起,并允许它被交换出去。如果线程频繁发生阻塞, 那么它们将无法使用完整的调度时间片。在程序中发生越多的阻塞(包括阻塞I/O,等待获取发生竞争的锁,或者在条件变量上等待),与CPU密集型的程序就会发生越多的上下文交换,从而增加调度开销,并因此而降低吞吐量。
上下文切换的实际开销会随着平台的不同而变化,然而按照经验来看:在大多数通用的处理器中 ,上下文切换的开销相当于5000~10000个时钟周期,也就是几微秒。
UNIX系统的vmstat命令和Windows系统的perfmon工具都能报告上下文切换次数以及在内核中执行时间所占比例等信息。如果内核占用率较高(超过10%),那么通常表示调度活动发生得很频繁,这很可能是由I/O或竞争锁导致的阻塞引起的。
11.3.2 内存同步
同步操作的性能开销包括多个方面。在synchronized和volatile提供的可见性保证中可能会使用一些特殊指令,即内存栅栏(Memory Barrier)。内存栅栏可以刷新缓存,使缓存无效,刷新硬件的写缓存,以及停止执行管道。内存栅栏可能同样会对性能带来间接的影响,因为它们将抑制一些编译器优化操作。在内存栅栏中,大多数操作都是不能被重排序的。
在评估同步操作带来的性能影响时,区分有竞争的同步和无竞争的同步非常重要。synchronized机制针对无竞争的同步进行了优化(volatile通常是非竞争的),一个”快速通道“的非竞争同步将消耗20~250个时钟周期。虽然无竞争同步的开销不为零,但它对应用程序整体性能的影响微乎其微,而另一种方法不仅会破坏安全性,而且还会使你(或者后续开发人员)经历非常痛苦的除错过程。
现代的JVM能够通过优化去掉一些不会发生竞争的锁,从而减少不必要的同步开销。如果一个锁对象只能由当前线程访问,那么JVM就可以通过优化来去掉这个锁获取操作,因为另一个线程无法与当前线程在这个锁上发生同步。例如,JVM通常都会去掉如下程序的锁获取操作:
synchronized (new Object())
{
// 执行一些操作...
}
一些更完备的JVM能通过逸出分析来找出不会发布到堆的本地变量引用(因此这个引用是线程本地的)。即使不进行逸出分析,编译器也可以执行锁粒度粗化,操作,即将邻近的同步代码块用同一个锁合并起来。如下代码:
public String getStoogeNames()
{
List<String> stooges = new Vector<String>();
stooges.add("Moe");
stooges.add("Larry");
stooges.add("Curly");
return stooges.toString();
}
不要过度担心非竞争同步带来的开销。这个基本的机制已经非常快了,并且JVM还能进行额外的优化以进一步降低或消除开销。因此,我们应该将优化重点放在那些发生锁竞争的地方。
某个线程的同步可能会影响其他线程的性能。同步会增加共享总线上的通信量,总线的带宽是有限的,并且所有的处理器都将共享这条总线。如果有多个线程竞争同步带宽,那么所有使用了同步的线程都会受到影响。
11.3.3 阻塞
非竞争的同步可以完全在JVM中进行处理,而竞争的同步可能需要操作系统的介入,从而增加开销。当在锁上发生竞争时,竞争失败的线程肯定会阻塞。JVM在实现阻塞行为时,可以采用自旋等待(Spin-Waiting,指通过循环不断地尝试获取锁,知道成功)或者通过操作系统挂起被阻塞的线程。这两种方式的效率高低,要取决于上下文切换的开销以及在成功获取锁之前需要等待的时间。如果等待的时间较短,则适合采用自旋等待方式,而如果等待的时间较长,则适合采用线程挂起方式。有些JVM将根据对历史等待时间的分析数据在这两者之间进行选择,但是大多数JVM在等待锁时都只是将线程挂起。
当线程无法获取某个锁或者由于在某个条件等待或在I/O操作阻塞时,需要被挂起,在这个过程中将包含两次额外的上下文切换,以及所有必要的操作系统和缓存操作:被阻塞的线程在其执行时间片还未用完之前就被交换出去,而在随后弹药获取的锁或者其他资源可用时,又再次被切换回来。(由于锁竞争而导致阻塞时,线程在持有锁时将存在一定的开销:当它释放锁时,必须告诉操作系统恢复运行阻塞的线程。)
11.4 减少锁的竞争
串行操作会降低可伸缩性,并且上下文切换也会降低性能。在锁上发生竞争时将同时导致这两种问题,因此减少锁的竞争能够提高性能和可伸缩性。
在对由某个独占锁保护的资源进行访问时,将采用串行方式——每次只有一个线程能访问它。当然,我们有很好的理由来使用锁,例如避免数据被破坏,但获得这种安全性是需要付出代价的。如果在锁上持续发生竞争,那么将限制代码的可伸缩性。
在并发程序中,对可伸缩性的最主要威胁就是独占方式的资源锁。
有两个因素将影响在锁上发生竞争的可能性:锁的请求频率,以及每次持有该锁的时间。如果二者的乘积很小,那么大多数获取锁的操作都不会发生竞争,因此在该锁上的竞争不会对可伸缩性造成严重的影响。然而,如果在锁上的请求量很高,那么需要获取该锁的线程被阻塞等等待。在极端的情况下,即使仍有大量工作等待完成,处理器也会被闲置。
有3种方式可以降低锁的竞争程度:
- 减少锁的持有时间。
- 降低锁的请求频率。
- 使用带有协调机制的独占锁,这些机制允许更高的并发性。
11.4.1 缩小锁的范围(“快进快出“)
降低发生竞争可能性的一种有效方式就是尽可能缩短锁的持有时间。例如,可以将一些与锁无关的代码移除同步代码块,尤其是那些开销比较大的曹组,以及可能被阻塞的操作,例如I/O操作。
例如如下程序,其中锁被持有过长的时间。userLocationMatches方法在一个Map对象中查找用户的位置,并使用正则表达式进行匹配以判断结果值时候匹配所提供的模式。整个userLocationMatches方法使用了synchronized来修饰,但只有Map.get这个方法才真正需要锁。
@ThreadSafe
public class AttributeStore {
@GuardedBy("this") private final Map<String, String>
attributes = new HashMap<String, String>();
public synchronized boolean userLocationMatches(String name,
String regexp) {
String key = "users." + name + ".location";
String location = attributes.get(key);
if (location == null)
return false;
else
return Pattern.matches(regexp, location);
}
}
修改后的代码:
@ThreadSafe
public class BetterAttributeStore {
@GuardedBy("this") private final Map<String, String>
attributes = new HashMap<String, String>();
public boolean userLocationMatches(String name, String regexp) {
String key = "users." + name + ".location";
String location;
synchronized (this) {
location = attributes.get(key);
}
if (location == null)
return false;
else
return Pattern.matches(regexp, location);
}
}
根据Amdahl定律,这样消除了限制可伸缩性的一个因素,因为串行代码的总量减少了。
由于在AttributeStore中只有一个状态变量attributes,因此可以通过将线程安全性委托给其他的类来进一步提升它的性能。通过用线程安全的Map(Hashtable、synchronizedMap或ConcurrentHashMap)来代替attributes,AttributeStore可以将确保线程安全性的任务委托给顶层的线程安全容器来实现。这样就无需在AttributeStore中采用显式地同步,缩小在访问Map期间锁的范围,并降低了将来的代码维护者无意破坏线程安全性的风险(例如在访问attribute之前忘记获得相应的锁)。
尽管缩小同步代码块能提高可伸缩性,但同步代码快也不能过小——一些需要采用原子方式执行的操作(例如对某个不变性条件中的多个变量进行更新)必须包含在一个同步块中。此外,同步需要一定的开销,当把一个同步代码块分解为多个同步代码块时(在确保正确性的情况下),反而会对性能提升产生负面影响。
在分解同步代码块时,理想的平衡点将与平台相关,但在实际情况中,仅当可以将一些”大量“的计算或阻塞操作从同步代码块中移出时,才应该考虑同步代码块的大小。
11.4.2 减小锁的粒度
另一种减小锁的持有时间的方式是降低线程请求锁的频率(从而减小发生竞争的可能性)。这可以通过锁分解和锁分段技术来实现,在这些技术中将采用多个相互独立的锁来保护独立的状态变量,从而改变这些变量在之前由单个锁来保护的情况。这些技术能减小锁操作的粒度,并能实现更高的可伸缩性,然而,使用的锁越多,那么发生死锁的风险也越高。
设想一下,如果在整个应用程序中只有一个锁,而不是为每个对象分配一个独立的锁,那么,所有同步代码块的执行就会变成串行化执行,而不考虑各个同步块中的锁。由于很多线程将竞争同一个全局锁,因此两个相册欢乐谷同时请求这个锁的概念将剧增,从而导致更严重的竞争。所以如果将这些锁请求分布到更多地锁上,那么能有效地降低竞争程度。由于等待锁而被阻塞的线程将减少,因此可伸缩性将提高。
如果一个锁需要保护多个相互独立的状态变量,那么可以将这个锁分解为多个锁,并且每个锁只保护一个变量,从而提高可伸缩性,并最终降低每个锁被请求的频率。
下面程序给出了某个数据库服务器的部分监视接口,该数据库维护了当前已登录的用户以及正在执行的请求。当一个用户登录、注销、开始查询或结束查询时,都会调用相应的add和remove等方法更新ServerStatus。这两种类型的信息是完全独立的,ServerStatus甚至可以被分解为两个类,同时确保不会丢失功能。
@ThreadSafe
public class ServerStatusBeforeSplit {
@GuardedBy("this") public final Set<String> users;
@GuardedBy("this") public final Set<String> queries;
public ServerStatusBeforeSplit() {
users = new HashSet<String>();
queries = new HashSet<String>();
}
public synchronized void addUser(String u) {
users.add(u);
}
public synchronized void addQuery(String q) {
queries.add(q);
}
public synchronized void removeUser(String u) {
users.remove(u);
}
public synchronized void removeQuery(String q) {
queries.remove(q);
}
}
分解后的程序如下,也可以委托给线程安全的Set,而不是使用显式地同步,能隐含地对所进行分解,因为每个Set都会使用一个不同的锁来保护其状态:
@ThreadSafe
public class ServerStatusAfterSplit {
@GuardedBy("users") public final Set<String> users;
@GuardedBy("queries") public final Set<String> queries;
public ServerStatusAfterSplit() {
users = new HashSet<String>();
queries = new HashSet<String>();
}
public void addUser(String u) {
synchronized (users) {
users.add(u);
}
}
public void addQuery(String q) {
synchronized (queries) {
queries.add(q);
}
}
public void removeUser(String u) {
synchronized (users) {
users.remove(u);
}
}
public void removeQuery(String q) {
synchronized (users) {
queries.remove(q);
}
}
}
如果在锁上存在适中而不是激烈的竞争,通过将一个锁分解为两个锁,能最大限度地提升性能。如果对竞争并不激烈的锁进行分解,那么在性能和吞吐量等方面带来的提升将非常有限,但是也会提高性能随着竞争提高而下降的拐点值。对竞争适中的锁进行分解时,实际上是把这些锁转变为非竞争的锁,从而有效地提高性能和可伸缩性。
11.4.3 锁分段
在某些情况下,可以将锁分解技术进一步扩展为对一组独立对象上的锁进行分解,这种情况被称为锁分段。例如,在ConcurrentHashMap的实现中使用了一个包含16个锁的数组,每个锁保护所有散列桶的1/16,其中第N个散列桶由第(N mod 16)个锁来保护。假设散列函数具有合理的分布性,并且关键字能够均匀分布,那么这大约能把对于锁的请求减少到原来的1/16,正是这项技术使得ConcurrentHashMap能够支持多达16个并发的写入器。(要使得拥有大量处理器的系统在高访问量的情况下实现更高的并发性,还可以进一步增加锁的数量,但仅当你能证明并发写入线程的竞争足够激烈并需要突破这个限制时,才能将锁分段的数量超过默认的16个。)
锁分段的一个劣势在于:与采用单个锁来实现独占访问相比,要获取多个锁来实现独占访问将更加困难并且开销更高。通常,在执行一个操作时最多只需获取一个锁,但在某些情况下需要加锁整个容器,例如当ConcurrentHashMap需要扩展映射范围,以及重新计算键值的散列值要分布到更大的桶集合中时,就需要获取分段锁集合中的所有锁。
如下程序给出了基于散列的Map实现,其中使用了锁分段技术。它拥有N_LOCKS个锁,并且每个锁保护散列桶的一个子集。大多数方法,例如get,都只需要获得一个锁,而有些方法则需要获得所有的锁,但并不要求同时获得,例如clear方法的实现。
@ThreadSafe
public class StripedMap {
// Synchronization policy: buckets[n] guarded by locks[n%N_LOCKS]
private static final int N_LOCKS = 16;
private final Node[] buckets;
private final Object[] locks;
private static class Node {
Node next;
Object key;
Object value;
}
public StripedMap(int numBuckets) {
buckets = new Node[numBuckets];
locks = new Object[N_LOCKS];
for (int i = 0; i < N_LOCKS; i++)
locks[i] = new Object();
}
private final int hash(Object key) {
return Math.abs(key.hashCode() % buckets.length);
}
public Object get(Object key) {
int hash = hash(key);
synchronized (locks[hash % N_LOCKS]) {
for (Node m = buckets[hash]; m != null; m = m.next)
if (m.key.equals(key))
return m.value;
}
return null;
}
public void clear() {
for (int i = 0; i < buckets.length; i++) {
synchronized (locks[i % N_LOCKS]) {
buckets[i] = null;
}
}
}
}
11.4.4 避免热点域
锁分解和锁分段技术都能提高可伸缩性,因为它们都能使不同的线程在不同的数据(或者同一个数据的不同部分)上操作,而不会相互干扰。如果程序采用锁分段或分解技术,那么一定要表现出在锁上的竞争频率高于在锁保护的数据上发生竞争的频率。如果一个锁保护两个独立变量X和Y,并且线程A想要访问X,而线程B想要访问Y(这类似于在ServerStatus中,一个线程调用addUser,而另一个线程调用addQuery),那么这两个线程不会在任何数据上发生竞争,即使它们会在同一个锁上发生竞争。
当每个操作都请求多个变量时,锁的粒度将很难降低。这是在性能与可伸缩性之间相互制衡的另一个方面,一些常见的优化措施,例如将一些反复计算的结果缓存起来,都会引入一些”热点域“,而这些热点域往往会限制可伸缩性。
当实现HashMap时,你需要考虑如何在size方法中计算Map中的元素数量。最简单的方法就是,在每次调用时都统计一次元素的数量。一种常见的优化措施是,在插入和移除元素时更新一个计数器,虽然这在put和remove等方法中略微增加了一些开销,以确保计数器是最新的值,但这把size方法的开销从O(n)降低到O(1)。
在单线程或者采用完全同步的实现中,使用一个独立的计算器能很好地提高类似size和isEmpty这些方法的执行速度,但却导致更难以提升实现的可伸缩性,因为每个修改map的操作都需要更新这个共享的计数器。即使使用锁分段技术来实现散列链,那么在对计数器的访问进行同步时,也会重新导致在使用独占锁时存在的可伸缩性问题。一个看似性能优化的措施——缓存size操作的结果,已经变成了一个可伸缩性问题。在这种情况下,计数器也被称为热点域,因为每个导致元素数量发生变化的操作都需要访问它。
为了避免这个问题,ConcurrentHashMap中的size将对每个分段进行枚举并将每个分段中的元素数量相加,而不是维护一个全局计数。为了避免枚举每个元素,ConcurrentHashMap为每个分段都维护一个独立的计数,并通过每个分段的锁来维护这个值。
11.4.5 一些替代独占锁的方法
第三种降低竞争锁的影响的技术就是放弃使用独占锁,从而有助于使用一种友好并发的方式来管理共享状态。例如,使用并发容器、读-写锁、不可变对象以及原子变量。
ReadWriteLock实现了一种在多个读取操作以及单个写入操作情况下的加锁规则:如果多个读取操作都不会修改共享资源,那么这些读取操作可以同时访问该共享资源,但在执行写入操作时必须以独占方式来获取锁。对于读取操作占多数的数据结构,ReadWriteLock能够提供比独占锁更高的并发性。而对于只读的数据结构,其中包含的不变性可以完全不需要加锁操作。
原子变量提供了一种方式来降低更新“热点域”时的开销,例如竞态计数器、序列发生器、或者对链表数据结构中头节点的引用。原子变量类提供了在整数或者对象引用上的细粒度原子操作(因此可伸缩性更高),并使用了现代处理器中提供的底层并发原语(例如比较并交换)。如果在类中只包含少量的热点域,并且这些域不会与其他变量参与到不变性条件中,那么用原子变量来替代他们能提高可伸缩性。(通过减少算法中的热点域,可以提高可伸缩性——虽然原子变量能降低热点域的更新开销,但并不能完全消除。)
11.4.6 监测CPU的利用率
当测试可伸缩性时,通常要确保处理器得到充分利用。一些工具,例如UNIX系统上的vmstat和mpstat,或者Windows系统上的perfmon,都能给出处理器的“忙碌“状态。
如果所有CPU的利用率并不均匀(有些CPU在忙碌地运行,而其他CPU却并非如此),那么你的首要目标就是进一步找出程序中的并行性。不均匀的利用率表明大多数计算都是由一小组线程完成的,并且应用程序没有利用其他的处理器。
如果CPU没有得到充分利用,那么需要找出其中的原因。通常由以下几种原因:
- 负载不充足。测试的程序中可能没有足够多的负载,因而还可以在测试时增加负载,并检查利用率、响应时间和服务时间等指标的变化。如果产生足够多的负载使应用程序达到饱和,那么可能需要大量的计算机能耗,并且问题可能在于客户端系统是否具有足够的能力,而不是被测试系统。
- I/O密集。可以通过iostat或perfmon来判断某个应用程序是否是磁盘I/O密集型的,或者通过监测网络的通信流量级别来判断它是否需要高带宽。
- 外部限制。如果应用程序依赖于外部服务,例如数据库或Web服务,那么性能瓶颈可能并不在你自己的代码中。可以使用某个分析工具或数据库管理工具来判断在等待外部服务的结果时需要多少时间。
- 锁竞争。使用分析工具可以知道在程序中存在何种程度的锁竞争,以及在哪些锁上存在”激烈的竞争“。然而,也可以通过其他一些方式来获得相同的信息,例如随机取样,触发一些线程转储并在其中查找在锁上发生竞争的线程。如果线程由于等待某个锁而被阻塞,那么在线程转储信息中将存在相应的栈帧,其中包含的信息形如”waiting to lock monitor…“。非竞争的锁很少会出现在线程转储中,而对于竞争激烈的锁,通常至少会有一个线程在等待获取它,因此在线程转储中频繁出现。
如果应用程序正在使CPU保持忙碌状态,那么可以使用监视工具来判断是否能通过增加额外的CPU来提升程序的性能。如果一个程序只有4个线程,那么可以充分利用一个4路系统的计算能力,但当移植到8路系统上时,却未必能获得性能提升,因为可能需要更多的线程才会有效利用剩余的剩余的处理器。(可以通过重新配置程序将工作负载分配给更多的线程,例如调整线程池的大小。)在vmstat命令的输出中,有一栏信息是当前处于可运行状态但并没有运行(由于没有足够的CPU)的线程数量。如果CPU的利用率很高,并且总会有可运行的线程在等待CPU,那么当增加更多地处理器时,程序的性能可能会得到提升。
11.4.7 向对象池说”不“
在JVM的早期版本中,对象分配和垃圾回收等操作的执行速度非常慢,但在后续的版本中,这些操作的性能得到了极大提高。事实上,现在Java的分配操作已经比C语言的malloc调用更快:在HotSpot 1.4.x和5.0中,”new Object“的代码大约只包含10条机器指令。
在并发应用程序中,对象池的表现更加糟糕。当线程分配新的对象时,基本上不需要在线程之间进行协调,因为对象分配器通常会使用线程本地的内存块,所以不需要在堆数据结构上进行同步。然而,如果这些线程从对象池中请求一个对象,那么就需要通过某种同步来协调对象池数据结构的访问,从而使某个线程被阻塞。如果某个线程由于锁竞争而被阻塞,那么这种阻塞的开销将是内存分配操作开销的数百倍,因此即使对象池带来的竞争很小,也可能形成一个可伸缩性瓶颈。(即使是一个非竞争的同步,所导致的开销也会比分配一个对象的开销大。)虽然这看似是一种性能优化技术,但实际上却会导致可伸缩性问题。对象池有其特定的用途,但对于性能优化来说,用途是有限的。
通常,对象分配操作的开销比同步的开销更低。
11.5 示例:比较Map的性能
在单线程环境中,ConcurrentHashMap的性能比同步的HashMap的性能略好一些,但在并发环境中则要好得多。在ConcurrentHashMap的实现中假设,大多数常用的操作都是获取某个已经存在的值,因此它对各种get操作进行了优化从而提供了最高的性能和并发性。
在同步Map的实现中,可伸缩性的最主要阻碍在于整个Map中只有一个锁,因此每次只有一个线程能够访问这个Map。不同的是,ConcurrentHashMap对于大多数读操作并不会加锁,并且在写入操作以及其他一些需要锁的读取操作中使用了锁分段技术。因此,多个线程能并发地访问这个Map而不会发生阻塞。
ConcurrentHashMap和ConcurrentSkipListMap在线程数量增加时能表现出很好地可伸缩性,并且吞吐量会随着线程数量的增加而增加。
同步容器的数量并非越多越好。单线程情况下的性能与ConcurrentHashMap的性能基本相当,但当负载情况由非竞争性转为竞争性时——这里是两个线程,同步容器的性能将变得糟糕。
在伸缩性受到锁竞争限制地代码中,这是一种常见的行为。只要竞争程度不高,那么每个竞争操作消耗的时间基本上就是实际执行工作的时间,并且吞吐量会因为线程数的增加而增加。当竞争程度变得激烈时,每个操作消耗的时间大部分都用于上下文切换和调度延迟,而再加入更多的线程也不会提高太多的吞吐量。
11.6 减少上下文切换的开销
在许多任务中都包含一些可能被阻塞的操作。当任务在运行和阻塞这两个状态之间转换时,就相当于一次上下文切换。
两种写日志方式:一种简单对println进行包装,另一种专门由后台线程完成。这两种写日志方式的比较来分析说明如何通过减少上下文切换的次数来提高吞吐量。略。
小结
由于使用线程常常是为了充分利用多个处理器的计算能力,因此在并发程序性能讨论中,通常更多地将侧重点放在吞吐量和可伸缩性上,而不是服务时间。Amdahl定律告诉我们,程序的可伸缩性取决于所有代码中必须被串行执行的代码比例。因为Java程序中串行操作的主要来源是独占方式的资源锁,因此通常可以通过以下方式来提升可伸缩性:减少锁的持有时间,降低锁的粒度,以及采用非独占的锁或非阻塞锁来代替独占锁。
参考:Java并发编程
性能和可伸缩性
使用线程最主要的目的是提高性能,充分利用空闲的资源,同时也能提高系统的响应性。
尽管多线程的目的是提高性能,与间线程方法相比,使用多线程总会引入一些性能上的开销:与协调线程相关的开销(加锁、信号、内存同步),增加的上下文切换,线程的创建与消亡,以及调度的开销。所以如果过度使用可能适得其反。
如果可运行的线程数大于CPU的数量,那么OS最终会强行换出正在执行的线程,从而使其他线程能够使用CPU,这会引起上下文切换,它会保存当前运行线程的执行上下文,并重建新调入线程的执行上下文。切换上下文是要付出代价的,线程的调度需要操控OS与JVM中共享的数据结构,你的程序与OS、JVM使用相同的CPU,CPU在JVM和OS的代码花费越多时间,意味着用于你的程序的时间就越少。
当线程因为竞争一个锁而阻塞时,JVM通常会将这个线程挂起,允许它被换出。如果线程频繁发生阻塞,那线程就不能将分给它的时钟用完。一个程序发生越多的阻塞(阻塞I/O、等待竞争锁、或者等待条件变量),与受限于CPU的程序相比,就会造成越多的上下文切换,这增加了调度的开锁,并减少了吞量。
切换上下文真正的开锁根据不同的平台而不同,但在大多数处理器中,开销相当于5000到10000个时钟周期,或者几微秒。Unix系统的vmstat命令与Windows系统的perfmon工具都能报告上下文切换次数和内核战用的时间等信息,高内核占用率(超过10%)通常表示频繁的调度活动,这很可能是由I/O阻塞或竞争锁引起的。
串行化会损害可伸缩性,上下文切换会损害性能。竞争锁会同时导致这两种损失,所以减少锁的竞争能够改进性能和可伸缩性。减少锁的竞争3种方式:
1、 减少持有锁的时间;
2、 减少请求锁的频率;
3、 或者用协调机制取代独占锁,从而允许更强的并发性。
减少锁竞争的有效方法是尽可能缩短把持锁的时间。这可以通过把锁无关的代码移出到synchronized块来实现,尤其是那些花费时间长的操作,以前阻塞操作。
减少锁的粒度,可以通过分拆锁(一个分成两个,适用于中等竞争强度的锁)和分离锁(一个分成多个,适用于竞争激烈的锁)来实现,这样就会减少对同一锁的调用频度,可伸缩性得以提高,这比使用一个锁来锁住整个对象具有高并发性。
ConcurrentHashMap就采用了锁分离技术,它使用了一个包含16个锁的Array,每个锁都守护Hash Bucket的1/16;Bucket N 由第 N mod 16个锁来守护,这会把对于锁的请求减少到约为原来的1/16。这项技术能够支持16个并发的Writer。锁分离的一个负作用就是对整容器进行操作时,进行独占访问更加的困难,并且可能更加的昂贵。
减少上下文切换的开销关键在于将阻塞方法的调用放入另一线程中进行调用。
java程序中串行化首要的来源是独占的资源锁,所以可伸缩性通常可能通过以下这些方法提升:减少用于获取锁的时间、减少锁的粒度、减少锁的占用时间、或者用非独占锁(ReadWriteLock)或非阻塞锁来取代独占锁。