第11章 性能与可伸缩性

本章讲介绍各种分析、检测以及提升并发程序性能的技术。

11.1.1 性能与可伸缩性

可伸缩性指的是:当增加计算资源(例如CPU、内存、存储容量或I/O带宽)时,程序的吞吐量或者处理能力能相应地增加。

11.1.2 评估各种性能权衡因素

避免不成熟的优化。首先使程序正确,然后再提高运行速度 — 如果它还运行得不够快。
通过增加某种形式的成本来降低另一种形式的开销(例:增加内存使用量以降低服务时间),或增加开销换取安全性。
在使某个方案更快之前,应有一些思考:

  • 更快的含义是什么?
  • 该方法在什么条件下运行得更快?在低负载还是高负载的情况下?大数据量还是小数据集?能否通过测试结果来验证你的答案?
  • 这些条件在运行环境中的发生频率?能否通过测试结果来验证你的答案?
  • 在其他不同条件的环境中能否使用这里的代码?
  • 在实现这种性能提升是需要付出哪些隐含的代价,例如增加开发风险或维护开销?这种权衡是否合适?

优化结果以测试为基准,不要猜测。
在所有并发程序中都包含一些串行部分。如果你认为在你的程序中不存在串行部分,那么可以再仔细检查一遍。
11.4.2节和11.4.3节介绍了降低锁粒度的技术

  • 锁分解(将一个锁分解为两个锁)
  • 锁分段(将一个锁分解为多个锁)

11.2 Amdahl定律 - WorkerThread

11.2.1 示例:在各种框架中隐藏的串行部分

吞吐量的差异来源于两个队列中不同比例的串行部分。
同步的LinkedList采用单个锁来保护整个队列的状态,并且在offer和remove等方法的调用期间都将持有这个锁。该队列的整个插入或删除操作都将串行执行。
ConcurrentLinkedQueue 使用了一种更复杂的非阻塞队列算法(参见15.5.2 节),该算法使用原子引用来更新各个链接指针。该队列只有对指针的更新操作需要串行执行

11.2.2 Amdahl定律的应用

11.4.2节和11.4.3节介绍了降低锁粒度的技术
锁分解(将一个锁分解为两个锁)
锁分段(将一个锁分解为多个锁)

11.3 线程引入的开销

单线程不存在线程调度,也不存在同步开销。
多个线程的调度和协调过程都需要一定的性能开销:对于为了提升性能而引入的线程来说,并行带来的性能提升必须超过并发导致的开销。

11.3.1 上下文切换

  1. 如果可运行的线程数大于CPU的数量,则操作系统最终会将某个正在运行的线程调度出来,从而使其他线程能够使用CPU。这将导致一次上下文切换,在这个过程中将保存当前运行线程的执行上下文,并将新调度进来的线程的执行上下文设置为当前上下文。<br /> 如果线程频繁地发生阻塞,那么它们将无法使用完整的调度时间片。<br /> 在程序中发生越多的阻塞,与CPU密集型的程序就会发生越多的上下文切换,从而增加调度开销,并因此而降低吞吐量(无阻塞算法同样有助于减小上下文切换-15章)。<br /> 上下文切换的开销相当于 5000~10000个时钟周期数,也就是几微秒。

11.3.2 内存同步

在Synchronized和volatile提供的可见性保证中可能会使用一些特殊指令,即内存栅栏 (Memory Barrier)。
内存栅栏可以刷新缓存,使缓存无效,刷新硬件的写缓冲,以及停止执行管道。内存栅栏可能同样对性能带来间接影响,因为它们将抑制一些编译器优化操作。在内存栅栏中,大多数操作都是不能被重排序的。
有竞争的同步和无竞争的同步是区分同步操作性能影响的重要因素

  • 无竞争同步的队伍 - 虽然无竞争同步的开销不为零,但它对应用程序整体性能影响微乎其微

    1. Synchronized 对无竞争同步进行了优化<br /> Volatile 通常是非竞争的
  • 有竞争同步

    1. 该同步方式不仅会破坏安全性,而且还会使开发人员经历痛苦的除错过程<br />现代JVM能通过优化来去掉一些不会发生竞争的锁,从而减少不必要的同步开销。<br />如果一个所对象只能由当前线程访问,那么JVM就可以通过优化来去掉这个锁获取操作,如程序清单 11-2。<br />
    1. synchronized (new Object()) {
    2. // 执行一些操作 ....
    3. }

    11-2 没有作用的同步 (不要这么做)
    一些更完备的JVM能通过逸出分析(Escape Analysis)来找出不会发布到堆的本地对象引用(因为这个引用是线程本地的),如程序清单 11-3 ,在getStoogeNames执行过程中,至少会将Vector上的锁获取/释放4次,每次调用add或toString时都会执行1次,然而,一个智能的运行时编译器通常会分析这些调用,从而使Stooges及其内部不会溢出,因此可以去掉这四次对锁获取操作,这个编译器优化也被称为锁消除优化(Lock Elision)。

  1. public String getStoogeNames() {
  2. List<String> stooges = new Vector<String>();
  3. Stooges.add("Moe");
  4. Stooges.add("Larry");
  5. Stooges.add("Curly");
  6. return stooges.toString();
  7. }

程序清单 11-3 可通过锁消除优化去掉的锁获取操作
某个线程中的同步可能会影响其他线程的性能。同步会增加共享内存总线上的通信量,总线的带宽是有限的,并且所有的处理器都将共享这条总线。如果有多个线程竞争同步带宽,那么所有使用了同步的线程都会受到影响。

11.3.3 阻塞

  1. 非竞争的同步可以在JVM中进行处理,而竞争的同步可能需要操作系统的介入,从而增加开销。<br /> JVM实现阻塞行为的两种方式:<br /> 自旋等待(Spin-Wating):通过循环不断地尝试获取锁,直到成功,适合等待时间较短的方式<br /> 通过操作系统挂起被阻塞的线程:适合等待时间长的方式。

11.4 减少锁竞争

  1. 在并发程序中,对可伸缩性的最主要威胁就是独占方式的资源锁。<br /> 影响锁发生竞争可能性的两个因素: - Little定律<br /> 锁的请求频率<br /> 每次持有锁的时间<br /> 有三种方式可以降低锁的竞争程度:<br /> * 减少锁的持有时间<br /> * 降低锁的请求频率<br /> * 使用带有协调机制的独占锁,这些机制允许更高的并发性

11.4.1 缩小锁的范围(“快进快出”) AttributeStoreBetterAttributeStore

11.4.2 减小锁的粒度 - ServerStatusBeforeSplit, ServerStatusAfterSplit

如果在锁上存在适中而不是激烈的竞争时,通过将一个锁分解为两个锁,能最大限度地提升性能。对竞争适中的锁进行分解时,实际上是把这些锁转变为非竞争的锁,从而有效地提高性能和可伸缩性。

11.4.3 锁分段 - StripedMap

在某些情况下,可以将锁分解技术进一步扩展为对一组独立对象上的锁进行分解,这种情况被称为锁分段。
在ConcurrentHashMap的实现使用了一个包含16个锁的数组,每个锁保护所有散列桶的1/16,其中第N个散列桶有第(N mod 16)个锁来保护。-意味着支持16个并发器。
要是的想拥有更高的并发性,还可进一步增加锁的数量,但仅当能证明并发写入线程的竞争足够激烈并需要突破这个限制时,才能将锁分段的数量超过默认的16个。
锁分段的劣势:

  • 与单个锁来实现独占访问相比,要获取多个锁来实现独占访问将更加困难并且开销更高。
  • 当需要重新计算键值的散列值要分布到更大的桶集合时,就需要获取分段所集合中的所有锁。

要获取内置锁的一个集合,只能递归。

11.4.4 避免热点域

将一些反复计算的结果缓存起来,都会引入一些热点域(Hot Field),而这些热点域往往会限制可伸缩性。
在单线程或采用完全同步的实现中,使用独立的计数器能很好地提高类似size和isEmpty这些方法的执行速度,但却导致难以提升实现的可伸缩性。在对计数器访问进行同步时,会重新导致在使用独占锁时存在的可伸缩性问题。每个导致元素数量发送变化的操作都需要访问它。- 热点域
concurrentHashMap解决方案:
将每个分段进行枚举并将每个分段中的元素相加,而不是维护一个全局计数器。concurrentHashMap为每个分段都维护了一个独立的计数器,并通过每个分段的锁来维护这个值。

11.4.5 一些代替独占锁的方法

可以放弃独占锁,使用友好并发的方式啦管理共享状态。例:

  • 使用并发容器
  • 读-写锁 ReadWriteLock (13章):实现了在多个读取操作以及单个写入操作情况下的加锁规则。
  • 不可变对象
  • 原子变量 原子变量类提供了对整数或者对象引用上的细粒度原子操作(可伸缩性更高)

11.4.6 检测CPU的利用率

UNIX:

  • vmstat
  • mpstat

windows:

  • perfmon

CPU利用不均匀(有些CPU在忙碌地运行,而其他CPU并非如此)

  • 负载不充足 程序可能没有足够多的负载,在测试时增加负载,并检查利用与,响应时间等指标的变化
  • I/O 密集 使用vmstat,mpstat判断程序是否是磁盘I/O密集型
  • 外部限制 可能是程序等待依赖的外部服务时间过长
  • 锁竞争 使用线程转储检测

11.4.7 向对象池说“不”

通常,对象分配操作的开销比同步的开销更低。

11.5 比较Map的性能

11.6 减少上下文切换的开销

  1. 在任务运行和阻塞这两个状态之间转换时,就相当于一次上下文切换。

小节

  1. Amdahl定理告诉我们,程序的可伸缩性取决于在所有代码中必须被串行执行的代码比例。Java程序中串行操作的主要来源是独占方式的资源锁。<br /> 可通过以下方式提升可伸缩性:<br /> * 减少锁的持有时间<br /> * 降低锁的粒度<br /> * 采用非独占的锁或非阻塞锁来代替独占锁