性能与可伸缩性

我们虽然希望提升性能,但始终要把安全性放在第一位。首先要保证程序能正确运行,然后仅当程序的性能需求和测试结果要求程序执行得更快时,才应该设法提高他的运行速度。

对性能的思考

多线程会引入一些额外的开销:

  • 线程之间的协调(锁、触发信号、内存同步等)
  • 增加的上下文切换
  • 线程的创建与销毁
  • 线程的调度

想要通过并发来提升性能,需要做好2件事:更高效地利用现有处理资源,以及出现新的处理资源时使程序尽可能利用这些资源。CPU需要尽可能保持忙碌。

性能与可伸缩性

程序的性能指标,一些(服务时间、等待时间)用于衡量程序的运行速度,即某个指定任务单元需要“多块”才能完成;另外一些指标(生产量、吞吐量)用于衡量“处理能力”,即在计算资源一定的情况下,能完成“多少”工作。

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

可伸缩性设计与传统的性能调优方法截然不同:传统的性能调优通常是用更小的代价完成相同的工作,例如缓存重用计算结果,采用时间复杂度更优化的算法;在可伸缩性调优时,其目的是设法将计算并行化,从而利用更多的计算资源来完成更多的工作。

性能的两个方面————“多快”和“多少”,是完全独立的,有时候甚至是相互矛盾的。我们熟悉的三层模型(表现层、逻辑层、持久层)就很好地说明了提高可伸缩性会造成性能损失的原因。

对于服务器程序来说,“多少”这个问题————可伸缩性、吞吐量、生产量,往往比“多快”更受重视。

评估各种性能权衡因素

很多性能优化措施通常是以牺牲可读性或可维护性为代价————代码越“聪明”越“晦涩”,就越难以理解和维护。

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

Amdahl定律

在增加计算资源的情况下,程序在理论上能够实现最高加速比,取决于程序中并行组件与串行组件所占的比重。假定F为必须串行的部分,在N个处理器的机器中,最高加速比为:

Speedup <= 1 / (F + (1 - F) / N)

当N趋近无穷大时,最大的加速比趋近于1/F。

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

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

举了一个从队列中取出任务并发处理的例子,来说明串行部分Queue取元素对可伸缩性的影响。

Amdahl定律的应用

一些在4处理器系统中看似具有可伸缩性的算法,却可能含有一些隐藏的可伸缩性瓶颈,只是还没有遇到。

在评估一个算法时,要考虑算数百上千个处理器时的性能表现,从而对可能出现的可伸缩性局限性有一定程度的认识。

线程引入的开销

上下文切换

内存同步

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

阻塞

当线程无法获取某个锁或者由于在某个条件等待或者I/O操作上阻塞时,需要被挂起,在这个过程中将包含2次额外的上下文切换,以及所有必要的操作系统操作和缓存操作。

减少锁的竞争

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

有三种方式可以降低锁的竞争程度:

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

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

将一些与锁无关的代码移出同步代码块,尤其是那些开销较大的操作,以及可能被阻塞的操作,如I/O操作。

尽管缩小同步代码块能提高可伸缩性,但同步代码块不能过小————一些需要采用原子方式执行的操作,必须包含在一个同步块中。

此外,同步需要一定的开销,当把一个同步代码分解为多个时,反而可能会对性能提升带来负面影响。

在实际情况下,仅当可以将“大量”的计算,或阻塞操作从同步代码中移出,才应该考虑同步代码块的大小。

减小锁的粒度

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

使用的锁越多,发生死锁的风险就越大。

锁分段

在某些情况下,可以将锁分解技术进一步扩展为对一组独立对象上的锁进行分解,成为锁分段。

参考jdk中ConcurrentHashMap的实现原理。

避免热点域

一些常见的优化措施,如将反复计算的结果缓存起来,都会引入“热点域”,热点域往往会限制可伸缩性。

如HashMap中size计数器,在多线程情况下,这个热点域就导致了难以提升的可伸缩性。ConcurrentHashMap为了避免size热点域,为每个分段维护了一个独立的技术,并通过每个分段的锁来维护这个值。

一些替代独占锁的方法

第三种降低竞争锁的影响的技术就是放弃使用独占锁,从而有助于使用一种友好并发的方式,如:

  • 并发容器
  • 读写锁:实现了在多个线程读取、单个写入情况下的加锁规则。
  • 不可变对象
  • 原子变量

检测CPU的利用率

Unix系统命令:vmstat/mpstat

如果CPU没有充分利用,通常原因有以下几种:

  • 负载不充分
  • I/O密集。可以通过iostat来判断应用是否是I/O密集型,或者检测网络的通信流量级别来判断它是否需要高带宽。
  • 外部限制。如数据库、Web服务等。
  • 锁竞争

如果CPU保持忙碌状态,那么可以使用监测工具来判断能否通过增加CPU来提升性能。在vmstat的输出中,有一栏信息是当前处于可运行状态但并没有运行(由于没有足够CPU)的线程数量,如果CPU利用率很高,并且总会有可运行线程在等待CPU,那么增加更多的处理器时,性能可能会得到提升。

向对象池说不

在单线程程序中,尽管对象池能降低GC开销,但对于高开销对象之外的其他对象来说,仍然存在性能缺失。

在并发程序中,对象池的表现更糟。如果多线程在对象池中请求对象,那么通常需要同步对象池的访问,从而使某个线程阻塞。阻塞的开销是内存分配操作的数百倍,因此对象池很可能带来可伸缩性瓶颈。

示例:比较Map的性能

减少上下文切换的开销

举例说明:日志输出时,将写日志的I/O操作转移到了另一个用户感知不到开销的线程,消除了用户线程被I/O阻塞的机会,进而使用户线程快进快出,减少其他被持有的锁上发生竞争的机会。