Java并发编程实战读书笔记——第十一章 性能与可伸缩性

第11章 性能与可伸缩性

线程的最主要目的是提高程序的运行性能。线程可以使线程更加充分地发挥系统的可用处理能力,从而提高系统的资源利用率。此外,线程还可以使程序在运行现有任务的情况下立即开始处理新的任务,从而提高系统的响应性。

本章将介绍各种分析、监测以及提升并发程序性能的技术。然而,它们同样会增加复杂性,因此也就增加了在安全性和活跃性上发生失败的风险。更糟糕的是,它们也可能带来其他的新的性能问题。

在开发多线程应用程序时,首始终要把安全性放在第一位。首先要保证程序能正确运行,然后仅当程序的性能需求和测试结果要求程序执行得更快时,才应该设法提高它的运行速度。

11.1 对性能的思考

提升性能意味着用更小的资源做更多的事情。资源包含CPU时钟周期、内存、网络带宽、I/O带宽、数据库请求、磁盘空间以及其他资源。当操作性能由于某种特定的资源而受限制时,我们通常将该操作称为资源密集型操作,例如CPU密集型与数据库密集型。

使用多线程的开销:线程之间的协调(加锁、触发信号以及内存同步),上下文切换,线程的创建和销毁,以及线程的调度等。

线程的性能提升:吞吐量、响应性、计算能力带来的性能提升。

更有效地利用现有处理资源,以及在出现新的处理资源时使程序尽可能地利用这些新资源。

11.1.1 性能与可伸缩性

多快:服务时间、等待时间

多少:生产量、吞吐率

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

提高可伸缩性通常会造成性能损失:把一个应用程序分为多层,增加了层次之间传递任务时存在的网络延迟等开销。

对于服务器应用程序来说,多少:可伸缩性、吞吐量和生产量,往往比多快这个方面更受重视,除了交互式应用程序。

11.1.2 评估各种性能权衡因素

快速排序处理大规模数据集上的执行效率非常高,但对于小规模数据集来说,冒泡d排序实际上更高效。

避免不成熟的优化。首先使程序下确,然后再提高运行速度——如果它还运行得不够快。

在某个方案更快之前,首先问自己一些问题:

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

对性能的提升可能是并发错误的最大来源。

性能调优一定要有明确的性能需求, 此外还需要一个测试程序以及真实的配置和负载等环境。

以测试为基准,不要猜测。

分析工具perfbar应用程序可以给出CPU的忙碌程序的信息。

11.2 Amdahl定律

在增加计算资源的情况下,程序在理论上能够实现最高的加速比,这个值取决于程序中可并行组件与串行组件所占的比重。

假定F是必须被串行执行的部分,那么根据Amdahl定律,在包含N个处理器的机器中,最高的加速比为:

Speedup<=1/(F+(1F)/n)

当N趋近于无穷大时,最大的加速比趋近于1/F。因此有50%的计算需要串行执行,那么最高的加速比只能是2。

利用率:加速比除以处理器的数量。

随着处理器数量的增加,可以很明显地看到,即使串行部分所占的百分比很小,也会极大地限制当增加计算资源时能够提升的吞吐率。

Java并发编程实战读书笔记——第十一章 性能与可伸缩性

在所有的并发程序中都包含一些串行部分。如果你认为在你的程序中不存在串行部分,那么可以再仔细检查一遍。

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

Java并发编程实战读书笔记——第十一章 性能与可伸缩性

ConcurrentLinkedQueue的吞吐量不断提升,直到到达了处理器数量的上限,之后基本不变。Synchronized.LinkedList的吞吐量先上升,但之后会由于同步开销,上下文切换而下跌。

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

11.2.2 Amdahl定律的应用

如果能准确估计出执行过程中串行部分所占的比例,那么Amdahl定律就能量化当有更多的计算资源可用时的加速比。

评估一个算法时,要考虑算法在数百个或数千个处理器的情况下的性能表现,从而对可能出现 的可伸缩性局限有一定程序的认识。例如11.4.2与11.4.3中介绍了两种降低锁粒度的技术:锁分解(将一个锁分解为两个锁),锁分段(把一个锁分解为多个锁)。锁分段似乎更有前途,因为分段可以随着处理器数量的增加而增加。

11.3 线程引入的开销

并行带来的性能提升必须 超过并发导致的开销。

11.3.1 上下文切换

如果可运行的线程数存在于CPU的数量,那么操作系统最终会将某个正在运行的线程调度出来,从而使其他线程能够使用CPU。这将导致一次上下文切换,在这个过程中将保存当前运行线程的执行上下文,并将新调度进来的线程的执行上下文设置为当前上下文。

切换上下文需要一定的开销,而在线程调度过程中需要访问由操作系统和JVM共享的数据结构。应用程序、操作系统以及JVM都使用同一组相同的CPU。在JVM和操作系统代码中消耗越多的CPU时钟周期,应用程序的可用CPU时钟周期就越少。但上下文切换的开销并不只是包含JVM和操作系统的开销。当一个新的线程被切换进来时,它所需要的数据可能不在当前处理器的本地缓存中,因此上下文切换将导致一些缓存缺失,因而线程在首次调度运行时会更加缓慢。这就是为什么调度器会为每个可运行的线程分配一个最小执行时间,即使有许多其他的线程正在等待执行:它将上下文切换的开销分摊到更多不会中断的执行时间上,从而提高整体的吞吐量(以损失响应性为代价)。

当线程由于等待某个发生竞争的锁而被阻塞时,JVM通常会将这个线程挂起,并允许它被交换出去。如果线程频繁地发生阻塞,那么它们将无法使用完整的调度时间片。在程序中发生越多的阻塞,与CPU密集型的程序就会发生越多的上下文切换,从而增加调度开销,并因此而降低吞吐量。(无阻塞算法同样有助于减小上下文切换)

上下文切换的开销相当于5000-10000个时钟周期,也就是几微秒。

vmstat命令和perfmon工具能报告上下文切换次数以及在内核中执行时间所占比例等信息。如果内核占用率较高(超过10%),那么通常表示调度活动发生得很频繁,这很可能是由于I/O或竞争锁导致的阻塞引起的。

11.3.2 内存同步

在synchronized和volatile提供的可见性保证中可能会使用一些特殊指令,即内存栅栏(Memory Barrier)。内存栅栏可以刷新缓存,使缓存无效,刷新硬件的写缓冲,以及保证上执行管道,这会抵制一些编译器优化操作,操作不能被重排序。

在评估同步操作带来的性能影响时,区分有竞争的同步和无竞争的同步非常重要。synchronized机制针对无竞争的同步进行了优化(volatile通常是非竞争的),一个快速通道的非竞争同步将消耗20-250个时钟周期,对程序的性能影响微乎其微。

现代的JVM能通过优化来去掉一些不会发生竞争的锁,从而减少不必要的同步开销。如果一个锁对象只能由当前线程访问,那么JVM就可以通过优化来去年这个锁获取操作。一些更完备的JVM能通过逸出分析(Escape Analysis)来找出不会发布到堆的本地对象引用。即使不进行逸出分析,编译器也可以执行锁粒度粗化(Lock Coarsening)操作,即将邻近的同步代码块用同一个锁合并起来。

不要过度担心非竞争同步带来的开销。这个基本的机制已经非常快了,并且JVM还能进行额外的优化以进一步降低或消除开销。因此,我们应该将优化重点放在那些发生锁竞争的地方。

某个线程中的同步可能会影响其他线程的性能。同步会增加共享内存总线上的通信量,总线的带宽是有限的,并且所有的处理器都将共享这条总线。如果有多个线程竞争同步带宽,那么所有使用了同步的线程都会受到影响。

11.3.3 阻塞

非竞争的同步可以完全在JVM中进行处理,而竞争的同步可能需要操作系统的介入,从而增加开销。当在锁上发生竞争时,竞争失败的线程肯定会阻塞JVM在实现阻塞行为时,可以采用自旋等待(Spin-Waiting,指通过不断地尝试获取锁,直到成功),或者通过操作系统挂起被阻塞的线程。这两种试的效率高低,要取决于上下文切换的开销以及在成功获取锁之前需要等待的时间。如果等待时间较短,则适合采用自旋等待方式,而如果等待时间较长,则适合采用线程挂起的方式。有些JVM将根据对历史等待时间的分析数据在这两者之间选择,但是大多数JVM在等待锁时都只是将线程挂起。

当线程无法获取某个锁或者由于在某个条件等待或在I/O操作上阻塞时,需要被挂起,在这个过程中将包含两次额外的上下文切换,以及所有必要的操作系统操作和缓存 操作:被阻塞的线程在其执行时间片还未用完之前就被交换出去,而在随后当要获取的锁或者其他资源可用时,又再次被切换回来。

11.4 减少锁的竞争

在并发程序中,对可伸缩性的最主要威胁就是独占的方式的资源锁。

影响锁上竞争可能性的两个因素:锁的请求频率,以及每次持有该锁的时间。如果二者乘积很小,那么大多数操作不会发生竞争。

Little定律:在一个稳定的系统中,顾客的平均数量等于他们的平均到达率乘以在系统中的平均停留时间。

有三种试法可以降低锁的竞争程度:

  • 减少锁的持有时间。
  • 降低锁的请求频率
  • 使用带有协调机制的独占锁,这些机制允许更高的并发性。

11.4.1 减小锁的范围

尽可能缩短锁的持有时间,例如可以将一些与锁无关的代码移出同步代码块,尤其是那些开销较大的操作,以及可能被阻塞的操作。

如果持有锁的时间超过2毫秒,那么无论拥有多少个空闲处理器,吞吐量也不会超过500个操作。如果这个锁的持有时间降为1毫秒,那么能够将这个锁对应的吞吐量提高到每秒1000个操作。

通过缩小锁的作用范围,能极大减少在持有锁时需要执行的指令数量。根据Amdahl定律,这样消除了限制可伸缩性的一个因素,因为串行代码的总量减少了。

同步代码块,不能破坏原子操作,理想的平衡点将与平台相关。仅当可以将一些大量的计算或阻塞操作从同步代码块中移出时,才应该考虑使用同步代码块的大小。

11.4.2 减小锁的粒度

通过锁分解与锁分段技术来降低线程请求锁的频率。但是使用的锁越多,发生死锁的风险也就越高。

将锁请求分布到更多的锁上,那么能有效地降低竞争程度。由于等待锁而被阻塞的线程将更少,因此可以伸缩性将提高。

如果一个锁需要保护多个相互独立的状态变量,那么可以将这个锁分解为多个锁,并且每个锁只保护一个变量,从而提高可伸缩性,并最终降低每个锁被请求的频率。

对竞争适中的锁进行分解时,实际上是把这些锁转变为非竞争的锁,从而有效地提高性能和可伸缩性。

11.4.3 锁分段

可以将锁分解技术进一步扩展为对一组独立对象上的锁进行分解。

ConcurrentHashMap实现了一个包含16个锁的数组,每个锁保护所有散列桶的1/16。这样大约能把对锁的请求减少到原来的1/16,使得它能支持多达16个并发的写入。除非你能证明并发写入线程的竞争足够激烈并需要突破这个限制时,才能将锁分段的数量超过默认的16个。

锁分段的一个劣势在于:与采用单个锁来实现独占访问相比,要获取多个锁来实现独占访问将更加困难并且开销更高。通在执行一个操作操作时最多只需要获取一个锁,但在某些情况下需要加锁整个容器,例如当ConcurrentHashMap需要扩展映射 范围时,以及重新计算键值的散列值要分布到更大的桶集合中时,就需要递归获取分段所集合中的所有锁。

Java并发编程实战读书笔记——第十一章 性能与可伸缩性

11.4.4 避免热点域

如果程序采用锁分段技术,那么一定要表现出锁上的竞争频率高于在锁保护数据上发生竞争的频率。一个锁访问两个独立变量X和Y,那么这两个线程不会在任何数据上发生竞争,即使它们会在同一个锁上发生竞争。

将一些反复计算的结果缓存起来,会引入一些热点域,而这些热点域往往会限制可伸缩性。

为了避免计数器热点问题,ConcurrentHashMap中的size将对每个分段进行枚举产将每个分段中的元素数量相加,而不是维护一个全局计数。为了避免枚举每个元素,ConcurrentHashMap为每个分段都维护一个独立的计数,并通过每个分段的锁来维护这个值。

11.4.5 一些替代独占锁的方法

并发容器、读写锁,不可变对象以及原子变量。

ReadWriteLock实现了一种在多个读取操作以及单个写入操作情况下的加锁规则:如果多个读取操作都不会修改共享资源,那么这些读取操作可以同时访问该共享资源,但在执行写入操作时必须以独占的方式来获取锁。对于读取操作点多数的数据结构,ReadWriteLock能提供比独占锁更高的并发性。而对于只读的数据结构,其中包含的不变性可以完全不需要加锁操作。

原子变量提供了一种方式来降低更新热点域时的开销,例如静态计数器、序列发生器、或者对链表数据结构中头节点的引用。原子变量类提供了在整数或者对象引用上的细粒度原子操作(因此可伸缩性更高),并使用了现代处理器中提供的底层并发原语(例如比较并交换(compare-and-swap))。如果类中只包含少量的热点域,并且这些域不会与其他变量参与到不变性条件中,那么用原子变量来替代它们能提高可伸缩性,降低更新开销。

11.4.6 监测CPU的利用率

Vmstat,mpstat以及perfmon

CPU没有得到充分利用:

  • 负载不充足
  • I/O密集:通过监测网络的通信流量级别来判断它是否需要高带宽
  • 外部限制:数据库或Web服务
  • 锁竞争:使用分析工具可以知道在程序中存在何种程序的锁竞争以及在哪些锁上存在激烈的竞争。利用线程转储查找在锁上发生竞争的线程,如果线程由于等待某个锁而被阻塞,那么在线程转储信息中将存在相应的栈帧,包含waiting to lock monitor。非竞争的锁很少会出现在线程转储中。

如果CPU保持忙碌,那么可以使用监视工具来判断是否能通过增加额外的CPU来提升程序的性能。

11.4.7 向对象池说不

事实上,现在Java分配操作已经比C语言的malloc调用更快,new object大约只包含10条机器指令。通常,对象分配操作的开销比同步的开销更低。

在并发应用程序中,对象池的表现很糟糕。线程分配新的对象时,基本上不需要在线程之间进行协调,因为对象分配器通常会使用线程本地的内存块,所以不需要在堆数据结构上进行同步。如果某个线程由于锁竞争而被阻塞,那么这种阻塞的开销将是内存分配操作开销的数百倍,因此即使对象池带来的竞争很小,也可能形成一个可伸缩性瓶颈。即使一个非竞争的同步,所导致的开销也会比分配一个对象的开销大。

对象池缺点:损失CPU指令周期,正确设置对象池大小,对象重用时状态是否正确,归还对象后仍然使用该对象的问题。

11.5 示例:比较Map的性能

Java并发编程实战读书笔记——第十一章 性能与可伸缩性

ConcurrentHashMap和ConcurrentSkipListMap的数据显示,它们在线程数量增加时能表现出很好的可伸缩性,并且吞吐量会随着线程数量的增加而增加。

同步容器的数量并非越多越好。单线程环境下,差不多,但当负载情况由非竞争性转变成竞争性时,同步容器的性能将变得糟糕。当竞争变得激烈时,每个操作消耗的时间大部分都用于上下文切换和调度延迟,而再加入更多的线程也不会提高太多的吞吐量。

11.6 减少上下文切换的开销

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

多个线程同时记录日志:在输出流的锁上发生竞争。

I/O操作阻塞:操作系统将这个被阻塞的线程从调度队列中移走并直到I/O操作结束。

请求服务的时间不应该过长:服务时间越长,意味着越多的锁竞争。

通过将I/O操作从处理请求的线程中分离出来,可以缩短处理请求的平均服务时间。调用LOG方法的线程将不会再因为等待输出流的锁或者I.O完成而被阻塞,它们只需要要将消息放入队列,虽然在消息队列上可能会发生竞争,但put操作相对于记录日志的I/O操作(可能需要调用系统调用),是一种更为轻量级的操作,因此在实际上阻塞的概率更小,只要队列没填满。由于发出日志请求的线程被阻塞的概率降低,因此该线程在处理请求时被竞争的出去的概率也会降低。

通过将I/O操作移动了另一个用户感知不到开销的线程上,通过把所有记录日志的I/O转移到一个线程,还消除了输出流上的竞争,因此又去掉一个竞争来源。这将提升整体的吞吐量。

小结

由于使用线程池常常是为了充分利用多个处理器的计算能力,因此在并发程序性能的讨论中,通常更多地将侧重点放在吞吐量和可伸缩性上,而不是服务时间。

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