性能与可伸缩性
我们虽然希望提升性能,但始终要把安全性放在第一位。首先要保证程序能正确运行,然后仅当程序的性能需求和测试结果要求程序执行得更快时,才应该设法提高他的运行速度。
对性能的思考
多线程会引入一些额外的开销:
- 线程之间的协调(锁、触发信号、内存同步等)
- 增加的上下文切换
- 线程的创建与销毁
- 线程的调度
想要通过并发来提升性能,需要做好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阻塞的机会,进而使用户线程快进快出,减少其他被持有的锁上发生竞争的机会。