引:线程最主要的目的是提高程序的运行性能,虽然我们希望获得更好的性能,但始终要把安全性放在第一位。首先要保证程序的正常运行,然后仅当程序的性能需求和测试结果要求程序执行得更快时,才应该设法提高它的运行速度
性能和可伸缩性
提升性能意味着用更少的资源做更多的事。这些资源包括CPU时钟周期、内存、网络带宽、I/O带宽、数据库请求、磁盘空间以及其他资源。
尽管就是用多个线程的目标是提升整体性能,但与单线程相比,使用多个线程会引入一些额外的开销。造成这些开销的操作包括:线程之间的协调(例如加锁、触发信号以及内存同步等),增加上下文切换,线程的创建和销毁、以及线程的调度等。
为了通过并发获得更好的性能,需要:
- 更有效地利用现有处理资源
- 在出现新的处理资源时使程序尽可能地利用这些新资源
- 从性能监视角度来看,CPU需要尽可能保持忙绿状态
应用程序性能的衡量指标
- 服务时间、等待时间用于衡量程序的“运行速度”,即某个指定的任务单元需要“多快”才能处理完成。
- 生产量、吞吐量用于衡量程序的“处理能力”,即在给定计算机资源的情况下,能完成“多少”工作。
性能的提高就是使应用程序,1)对任务单元的处理速度更快,2)资源一定的情况下,完成更多的工作
可伸缩性定义
当增加计算资源时(例如CPU、内存、存储容量或I/O带宽),程序的吞吐量或者处理能力能相应地增加。
评估各种性能权衡因素
服务器应用程序的指标是可伸缩性、吞吐量和生成量;交互式应用程序指标是多快。
避免不成熟的优化(由于需求不明确),首先使程序正确,然后再提高运行速度——如果它还运行得不够快。
Amdahl定律
Amdahl定律: 在增加计算资源的情况下,程序在理论上能够实现最高加速比,这个值取决于可并行组件与串行组件所占的比重。假定F是必须被串行执行的部分,那么根据Amdahl定律,在包含N个处理器的机器中,最高的加速比为:1
Speedup <= 1 / ( F + (1-F) / N )
当N趋近于无穷大时,最大的加速比趋近于1/F。因此,如果程序有50%的计算需要串行执行,那么最高的加速比只能是2(而不管有多少个线程可用)
注意:在所有并发程序中都包含一些串行部分。
我们评估一个算法时,要考虑算法在数百个或数千个处理器的情况下的性能表现,从而对可能出现的可伸缩性局限有一定程度的认识。
线程引入的开销
在多个线程的调度和协调过程中都需要一定的性能开销:对于为了提升性能而引入的线程来说,并行带来的性能提升必须超过并发导致的开销。
上下文切换
如果可运行的线程数大于CPU数量,那么操作系统最终会将某个运行的线程调度出来,从而使其他线程能够使用CPU,这将导致一次上下文切换,在这个过程中将保存当前运行线程的执行上下文,并将新调度进来的线程的执行上下文设置为当前上下文。
当线程由于等待某个发生竞争的锁而被阻塞时,JVM通常会将这个线程挂起,并允许它被交换出去。如果线程频繁地发生阻塞,那么他们将无法使用完整的调度时间片。在程序中发生越多的阻塞,CPU密集型的程序就会发生越多的上下文切换,从而增加调度开销,并因此而降低吞吐量。
内存同步
同步操作的性能开销包括多个方面。在synchronized和volatile提供的可见性保证中可能会使用一些特殊的指令,即内存栅栏。内存栅栏可以刷新缓存,使缓存无效,刷新硬件的写缓冲,以及停止执行管道。内存栅栏可能同样会对性能带来间接地影响,应为它会抑制一些编译器的优化。在内存栅栏中,大多数操作是不能被重排序的。
不要过度担心非竞争同步带来的开销。这个基本的机制已经非常快了,并且JVM还能进行额外的优化以进一步降低或开销。因此,我们应该将优化的重点放在那些发生锁竞争的地方。
阻塞
当在锁上发生竞争时,竞争失败的线程肯定会阻塞,JVM在实现阻塞行为时,可以采用自旋等待(指通过循环不断尝试获取锁,直到成功)或者通过操作系统挂起被阻塞的线程。这两种方式的效率高低,要取决于上下文切换的开销以及在成功获取锁之前需要等待的时间。
- 等待时间较短:适合采用自旋等待方式
- 等待时间较长:适合采用线程挂起方式
减小锁的竞争
串行操作会降低可伸缩性,并且上下文切换也会降低性能。在锁上发生竞争时将同时导致这两种问题,因此减少锁竞争会提高性能和可伸缩性。
在并发程序中,对可伸缩性的最主要威胁就是独占方式的资源锁。
有两个因素将影响在锁上发生竞争的可能性:
- 锁的请求频率
- 每次持有锁的时间
如果两者的乘积很小,那么大多数获取锁的操作都不会发生竞争。
有三种方式可以降低锁的竞争程度:
- 减少锁的持有时间
- 降低锁的请求频率
- 使用带有协调机制的独占锁,这些机制允许更高的并发性
缩小锁的范围(“快进快出”)
其实质是减少锁的持有时间。,同时根据Amdahl定律,这样消除了限制可伸缩性的一个因素,因为串行代码的总量减少了。
注意: 在实际情况中,仅当可以将一些“大量”的计算或阻塞操作从同步代码块移出时,才应该考虑同步代码块的大小。
减小锁的粒度
另一种减小锁的持有时间的方式是降低线程请求的频率(从而减小发生竞争的可能性)。这可以通过锁分解和锁分段等技术来实现。
锁分解
如果一个需要保护多个相互独立的状态变量,那么可以将这个锁分解为多个锁,并且每个锁只保护一个变量,从而提高可伸缩性,并最终降低每个锁的请求频率。看到下面的代码变化:1
2
3
4
5
6
7
8
9// 原代码:通过内置锁保护了users和queries两个状态变量
public class ServerStatus {
public final Set<String> users;
public final Set<String> queries;
public synchronized void addUser(String u) { users.add(u); }
public synchronized void addQuery(String q) { queries.add(q); }
}
1 | // 修改代码:通过锁分解分开保护了users和queries两个状态变量 |
对竞争适中的锁进行分解时,实际上是把这些转变为非竞争的锁,从而有效地提高性能和可伸缩性。
锁分段
由于在一个拥有多个处理器的系统中,锁分解仍然无法给可伸缩性带来极大的提高,这个时候就出来锁分段技术。
锁分段: 将锁分解技术进一步扩展为对一组对象上的锁进行分解。
例子:在ConcurentHashMap的实现中使用了一个包含16个锁的数组,每个锁保护所有散列桶的1/16,其中第N个散列桶有第(N mod 16)个锁来保护。正是这项技术使得CouncurentHashMap能够支持多达16个并发的写入器。
锁分段的劣势:与采用单个锁来实现独占访问相比,要获得多个锁来实现独占访问将更困难整个容器,例如当ConcurrentHashMap需要扩展映射范围等。
避免热点域
如果将一些反复计算的结果缓存起来,那么将会引入一些“热点域”。而这些热点域往往会限制可伸缩性。
例子:参考ConcurrentHashMap里边将热点域size分成多个值,当我们需要获取全局size的时候,就临时把这些值加起来就是,虽然可能得不到一个准确的值,但大大提高了并发性,是划算的。
一些代替独占锁的方法
放弃使用独占锁,从而有助于使用一种友好并发的方式来管理共享状态。例如,使用并发容器、读-写锁、不可变对象以及原子变量。
监测CPU的利用率
当测试可伸缩性时,通常要确保处理器得到充分利用。
如果CPU没有得到充分利用,那么需要找出其中的原因(vmstat,mpstat查询CPU使用情况)。可能的原因如下:
- 负载不充足。可以在测试时增加负载,并检查利用率,响应时间和服务时间等指标的变化。如果产生足够多的负载使应用程序达到饱和,那么可能需要大量的计算机能耗,并且问题可能在于客户端系统是否具有足够的能力,而不是被测试系统。
- IO密集。可以通过iostat或者perfmon来判断某个应用程序是否是磁盘I/O密集型的,或者通过监测应用的通信流量来判断它是否需要高带宽。
- 外部限制。如果应用程序依赖于外部服务,比如数据库或web服务,那么性能瓶颈可能并不在你自己的代码中。
- 锁竞争。使用分析工具可以知道在程序中存在何种程度的锁竞争。比如进行线程栈帧转储,来观察是不是有“waiting to lock monitor”之类的关键字。
在CPU保持忙碌状态之后,我们试试增加CPU的数量,比如从4核换到8核,看是否能增加处理能力,如此就可以得出结论:增加CPU可以提高程序的处理能力,类似的其它资源验证过程也是类似的。
向对象池说“不”
早期垃圾回收机制很慢,效率很低,很多程序通过对象池来降低垃圾回收的压力。但现在的垃圾回收机制已经很快了。在并发程序中,对象池的表现更加糟糕。
减小上下文切换的开销
传统网络模式下,同步阻塞IO将导致上下文切换,同时,一个连接一个线程将导致更多的上下文切换,改进方法如下:
- 将阻塞IO操作从处理请求的线程分离出来,放到专门的线程中去处理。
- 使用nio,多路复用机制,实现可以由有限线程池来处理所有的连接请求。
总结
由于使用线程通常是为了充分利用多个处理器的计算能力,因此在并发程序性能的套路那种,通常更多地将重点放在吞吐量和可伸缩性上,而不是服务时间。Amdahl定律告诉我们,程序的可伸缩性取决于在所有代码中必须被串行化执行的代码比例。因为Java程序中串行操作的主要来源是独占方式的资源锁,因此通常可以通过以下方式来提高可伸缩性:减少锁的持有时间,降低锁的粒度,以及采用非独占的锁或非阻塞锁来代替独占锁。
参考
- 《Java并发编程实战》
- 并发编程实战学习笔记(八)——性能与可伸缩性