对性能的思考
提升性能意味着用更少的资源做更多的事情。 “资源”的含义很广。对于给定的操作,通常会缺乏某种特定的资源,例如CPU时钟周期、内存、网路带宽、IO带宽、数据库请求、磁盘空间以及其它资源。当操作性能由于某种特定资源而受到限制时,我们通常将该操作称为资源密集型的操作,例如CPU密集型、数据库密集型等。
使用多线程的目的是提升整体性能,但与单线程的方法比,使用多线程总会引起一些额外的开销。照成这些开销的操作包括:线程之间的协调(例如加锁、触发信号以及内存同步等),增加的上下文切换,线程的创建和销毁,以及线程的调度等。
想要通过并发获得更好的性能,需要努力做好两件事情:更有效地利用现有处理资源;以及在出现新的处理资源时使程序尽可能地利用这些新资源。
性能与可伸缩性
可伸缩性:当增加计算资源时(CPU、内存、储存容量、IO带宽),程序的吞吐量或者处理能力能相应的增加
避免不成熟的优化。首先使程序正确,然后再提高运行速度(如果它还运行的不够快)
大多数性能决策中都包含多个变量,并且非常依赖于运行环境。在使某个方案比其他方案“更快”之前,请先搞清楚:
- “更快”的含义是什么?
- 该方法在什么条件下运行得更快?低负载还是高负载?大数据集还是小数据集?能否测试验证?
- 在实现这种性能提升时需要付出哪些隐含的代价,增加开发风险或维护开销?这种权衡是否合适?
Amdahl 定律
在增加计算资源的情况下,程序在理论上能够实现最高的加速比,这个值取决于程序中可并行组件与串行组件所占的比重。
假定F是必须被串行执行的部分,那么根据Amdahl定律,在包含N个处理器的机器中,最高的加速比为:
Speedup = 1/(F+(1-F)/N)
当N趋近于无穷大时,最大的加速比趋近于1/F。
在所有并发程序中都包含一些串行部分。如果你认为你的程序中不存在串行部分,那么可以再仔细检查一遍。
线程引入的开销
单线程程序既不存在线程调度,也不存在同步开销,而且不需要使用锁来保证数据结构的一致性。在多个线程的调度和协调过程中都需要一定的性能开销:对于为了提升性能而引入的线程来说,并行带来的性能提升必须超过并发导致的开销。
上下文切换
如果可运行的线程数大于CPU的数量,那么操作系统最终会将某个正在运行的线程调度出来,从而使其他线程能够使用CPU。这将导致一次上下文切换。这个过程中将保存当前运行线程的执行上下文,并将新调度近来的线程的执行上下文设置为当前上下文
上下文切换的实际开销会随着平台的不同而变化:在大多数通用的处理器中,上下文切换的开销相当于5000~10000个时钟周期,也就是几微秒。
内存同步
同步操作的性能开销包括多个方面。再
synchronized
和volatile
提供的可见性保证中可能会用到一些特殊指令,即内存栅栏。内存栅栏可以刷新缓存,使缓存无效,刷新硬件的写缓冲,以及停止执行管道。内存栅栏可能会对性能带来间接的影响,因为它们将抑制一些编译器的优化操作。在内存栅栏种,大多数操作是不能被重排序的。
不要过度担心非竞争同步带来的开销。JVM基本的机制已经非常快了,并且JVM还能进行额外的优化以进一步降低或消除开销。因此我们应该将优化的重点放在那些发生锁竞争的地方。
阻塞
非竞争的同步完全可以在JVM中进行处理,而竞争的同步可能需要操作系统的介入,从而增加开销。当在锁上发生竞争时,竞争失败的线程肯定会阻塞。JVM在实现阻塞行为时,可以采用自旋等待(Spin-waiting,指通过循环不断地尝试获取锁,知道成功)或者通过操作系统挂起被阻塞的线程。这两种方式的效率高低,要取决于上下文切换的开销以及在成功获取锁之前需要等待的时间。
如果等待时间短,则适合采用自旋方式等待,如果时间较长,则适合采用线程挂起方式。
当线程被挂起的过程中将包含两次额外的上下文切换,以及所有必要的操作系统操作和缓存操作。
减少锁的竞争
串行会降低可伸缩性,上下文切换也会降低性能。在锁上发生竞争将同时导致这两种问题。在并发程序中,对可伸缩性的主要威胁就是独占方式的资源锁。
有两个因素将影响在锁上发生竞争的可能性:锁的请求频率,以及每次持有该锁的时间。
有三种方式可以降低锁的竞争程度:
* 减少锁的持有时间
* 降低锁的请求频率
* 使用带有协调机制的独占锁,这些机制允许更高的并发性。(乐观锁????)
缩小锁的范围
降低发生竞争的可能性的一种有效方式就是尽可能缩短锁的持有时间。例如:移除同步代码块中与锁无关的代码。
尽管缩小同步代码块能提高伸缩性,单同步代码块不宜过小----一些需要原子操作的多个操作必须包含在一个同步块中。
在分解同步代码块时,理想的平衡点于平台相关。
此外,如果JVM执行粗粒度化操作,那么可能会将分解的同步代码块又重新合并起来。
减小锁的粒度
另一种减小锁的持有时间的方式是降低线程请求所的频率(从而减小发生竞争的可能性)。这可以通过分解锁和锁分段技术来实现,采用多个相互独立的锁来保护独立的状态变量,从而改变这些变量在之前由单个锁来保护的情况。这些技术能减少锁操作的粒度,并能实现更到的可伸缩性,然而使用的锁越多,那么发生死锁的风险也就越高。
由于使用线程常常是为了充分利用多个处理器的计算能力,因此在并发程序中关注的更多的是吞吐量和可伸缩性,而不是服务时间。Amdahl定律告诉我们,程序的可伸缩性取决于代码中必须串行执行的代码比例。因为Java程序中串行操作的主要来源是独占方式的资源锁,因此我们可以通过以下方式来提升可伸缩性:减少锁的持有时间、降低锁的粒度、采用非独占的锁或非阻塞的锁来替代独占锁。