并发编程学习笔记之可伸缩性(九)
很多改进性能的技术增加了复杂度,因此增加了安全和活跃度失败的可能性.
更糟糕的是,有些技术的目的是改善性能,事实上产生了相反的作用,带来了其他的性能问题.
数据的正确性永远是第一位的,保证程序是正确的,然后再让它更快.只有当你的性能需求和评估标准需要程序运行得更快时,才去进行改进.
在设计并发应用程序的时候,最大可能地改进性能,通常并不是最重要的事情.
性能的思考
当活动的运行因某个特定资源受阻时,我们称之为受限于该资源:受限于CPU,受限于数据库.
使用线程的目的是希望全面提升性能,但是与单线程相比,使用多线程会引入一些额外的开销.
如:
- 协调线程相关的开销(加锁、信号、内存同步)
- 增加的上下文切换
- 线程的创建和消亡,以及调度的开销
当线程被过度使用后,这些开销会超过提高后的吞吐量响应性和计算能力带来的补偿.
一个没能经过良好并发设计的应用程序,甚至比相同功能的顺序的程序性能更差.
性能"遭遇"可伸缩性
可伸缩性指的是:当增加计算资源的时候(比如增加额外CPU数量、内存、存储器、I/O带宽),吞吐量和生产量能够相应地得以改进.
对性能的权衡进行评估
避免不成熟的优化,首先使程序正确,然后再加快----如果它运行得还不够快.
很多性能的优化会损害可读性或可维护性--代码越"聪明",越"晦涩",就越难理解和维护.
在多个方案之间进行选择的时候,先问自己一些问题:
- 你所谓的更"快"指的是什么
- 在什么样的条件下你的方案能够真正运行得更快?在轻负载还是重负载下?大数据集还是小数据集?是否支持你的测量标准答案?
- 这些条件在你的环境中发生的频率?是否支持你的测量标准的答案?
- 这些代码在其他环境的不同条件下被用到的可能性?
- 你用什么样隐含的代价,比如增加的开发风险或维护性,换取了性能的提高?这个权衡的决定是否正确?
做出任何与性能相关的工程决定时,都应该考虑这些问题.
最好选择保守的优化方案,因为对性能的追求很可能是并发bug唯一最大的来源.通过减少同步来提高响应性,成了不遵守同步规定的常用的借口,但是因为并发bug是最难追踪和消除的,所以任何引入这类bug的行动风险都需要慎重进行.
优化改进后的代码,一定要进行压力测试.主观认为会提高性能的代码,在实际生产环境可能会出现问题.
测评,不要臆测
Amdahl 定律
Amdahl定律描述了在一个系统中,基于可并行化和串行化的组件各自所占的比重,程序通过获得额外的计算资源,理论上能够加速多少.
如果F是必须串行化执行的比重,那么Amdahl定律告诉我们,在一个N处理器的机器中,我们最多可以加速:
串行执行的比率越大,处理器越多,处理器的利用率越低:
线程引入的开销
调度和线程内部的协调都要付出性能的开销: 对于改进性能的线程来说,并行带来的性能优势必须超过并发所引入的开销.
切换上下文
如果可运行的线程大于CPU的数量,那么操作系统最终会强行换出正在执行的线程,从而使其他线程能够使用CPU,这回引起上下文切换,他会保存当前运行线程的执行上下文,并重建新调入线程的执行上下文.
切换上下文会有资源的损耗.
一个程序发生越多的阻塞(阻塞I/O,等待竞争锁,或者等待条件变量),与受限于CPU的程序相比,就会造成越多的上下文切换,这增加了调度的开销,并减少了吞吐量(无阻塞的算法可以减少上下文切换).
Unix系统的vmstat命令和Windows系统的perfmon工具都能报告上下文切换次数和内核占用的时间等信息.
阻塞
多个线程竞争加锁的方法的时候,失败的线程必然发生阻塞.
JVM在阻塞的时候有两种处理方式:
- 自旋等待(spin-waiting,不断尝试获取锁,直到成功).
- 挂起(suspending)这个阻塞的线程.
自旋等待适合短期的等待.挂起适合长期间等待.,有一些JVM基于过去等待时间的数据剖析来在这两者之间选择,但是大多数等待锁的线程都是被挂起的.
减少锁的竞争
串行化会损害可伸缩性,上下文切换会损害性能.竞争性的锁会同时导致这两种损失,所以减少锁的竞争能够改进性能和可伸缩性.
访问独占锁守护的资源是串行的--一次只能有一个线程访问它.使用锁可以避免过期数据,但是安全性是用很大的代价换来的,对锁长期的竞争会限制可伸缩性.
并发程序中,对可伸缩性首要的威胁是独占的资源锁.
有两个原因影响着锁的竞争性:
- 锁被请求的频率
- 每次持有锁的时间
如果这两者的乘积足够小,那么大多数请求锁的尝试都是非竞争的,这样竞争性的锁将不会成为可伸缩性巨大的障碍.
但是,如果这个锁的请求量很大,线程将会阻塞以等待锁.在极端的情况下,处理器将会闲置,即使仍有大量工作等待着完成.
有三种方式来减少锁的竞争:
- 减少持有锁的时间;
- 减少请求锁的频率;
- 或者用协调机制取代独占锁,从而允许更强的并发性.
缩小锁的范围("快进快出")
减少竞争发生可能性的有效方式是尽可能缩短把持锁的时间.尽量缩小synchronized代码块,尤其是那些耗时的操作,以及那些潜在的阻塞操作(I/O).
减少锁的粒度
减少持有锁的时间比例的另一种方式是让线程减少调用它的频率(因此减少发生竞争的可能性).
可以通过使用分拆锁(lock splitting)和分离锁(lock striping)来实现,也就是采用相互独立的锁,守卫多个独立的状态变量,在改变之前,它们都是由一个锁守护的.这些技术减少了锁发生时的粒度,潜在实现了更好的可伸缩性---但是使用更多的锁同样会增加死锁的风险.
如果一个锁 守卫数量大于一、且相互独立的状态变量,你可能能通过分拆锁,使每一个锁守护不同的变量,从而改进可伸缩性.结果是每个锁被请求的频率都减少 了.
使用相同的锁:
public class NewLock {
//对象A
private final Object objA = new Object();
//队相比
private final Object objB = new Object();
public synchronized Object getObjA(){
return objA;
}
public synchronized Object getObjB(){
return objB;
}
}
使用不同的锁(分拆锁),减少了锁的请求频率:
public class NewLock {
//对象A
private final Object objA = new Object();
//队相比
private final Object objB = new Object();
public Object getObjA(){
synchronized (objA){
return objA;
}
}
public Object getObjB(){
synchronized (objB){
return objB;
}
}
}
分拆锁对于竞争并不激烈的锁,能够在性能和吞吐量方面产生一些纯粹的改进,尽管这可能会在性能开始因为竞争而退化时增加负载的极限.
分拆锁对于中等竞争强度的锁,能够切实地把它们大部分转化成非竞争的锁,这个结果是性能和可伸缩性都期望得到的.
分离锁
分拆锁对性能的改进有一些局限性,不能大幅地提高多个处理器在同一系统中并发性的能力.
分拆锁有时候可以被扩展,分成可大可小加锁块的集合,并且它们归属于相互独立的对象,这样的情况就是分离锁.
分离锁的一个负面作用是:对容器加锁,进行独占访问更加困难,并且更加昂贵了.
分拆锁和分离锁能够改进可伸缩性,因为它们能够使不同的线程操作不同的数据(或者相同数据结构的不同部分),而不会发生相互干扰.
能够从分拆锁收益的程序,通常是那些对锁的竞争普遍大于对锁守护数据竞争的程序.
例如: 一个锁守护两个独立变量X和Y,线程A想要访问X,而线程B想要访问Y,这两个线程没有竞争任何数据,然而它们竞争相同的锁.
独占锁的替代方法
用于减轻竞争锁带来的影响的第三种技术是提前使用独占锁,这有助于使用更友好的并发方式进行共享状态的管理.
这包括:
- 使用并发容器
- 读-写锁
- 不可变对象
- 原子变量
读写锁
读写锁实行了一个多读者-单写者(multiple-reader,single-write)加锁规则:只要没有改变,多个读者可以并发访问共享资源,但是写者必须独占获得锁.
对于多数操作都为读操作的数据结构,ReadWriteLock与独占的锁相比,可以提供更好的并发性.
对于只读的数据结构,不变性可以完全消除加锁的必要.
原子变量
原子变量类提供了针对整数或对象引用的非常精妙的原子操作,因此更具可伸缩性.
如果你的类只有少量热点域(例如:多个方法都在调用的计数操作,就是一个热点域),并且该类不参与其它变量的不变约束,那么使用原子变量替代它可能会提高可伸缩性.
检测CPU利用率
当我们测试可伸缩性的时候,我们的目标通常是保持处理器的充分利用.
Unix系统的vmstat和mpstat,或者Windows系统的perfmon都能够告诉你处理器有多忙碌.
如果所有的CPU都没有被均匀地利用(有时CPU很忙碌地运行,有时很清闲),那么你的首要目标应该是增强你程序的并行性.
不均匀的利用率表名,大多数计算都有很小的线程集完成,你的应用程序将不能够利用额外的处理器资源.
如果你的CPU没有完全利用,你需要找出原因.有以下几种:
- 不充足的负载. 数据量不够多
- I/O限制
- 外部限制.可能你的应用程序取决于外部服务,比如数据库或者Web Service 那么瓶颈可能不在于你自己的代码.
- 锁竞争. 使用Profiling工具能够告诉你,程序中存在多少个锁的竞争,哪些锁很"抢手".或者使用线程转储,如果线程因等待锁被阻塞,与线程转储的栈框架会声明"waiting to lock monitor...".非竞争的锁几乎不会出现在线程转储中:竞争激烈的锁几乎总会只要有一个线程在等待获得它,所以会频繁出现在线程转储中.
向"对象池"说"不"
不要使用对象池,对象池跟线程池差不多,为了减少创建和销毁对象的开销,能够重复使用对象,创建了一个对象池,但是现代的JVM对象的分配和垃圾回收已经非常快了.
如果使用对象池,那么线程从池中请求对象,协调访问池的数据结构的同步就成为必然了,这便产生了线程阻塞的可能性.
又因为由锁的竞争产生的阻塞,其代价比直接分配的代价多几百倍,即使是很小的池竞争都会造成可伸缩性的瓶颈(甚至是非竞争的同步,其代价也会比分配一个对象大很多).
所以使用对象池有点得不偿失了,反而效率更低.
比较Map的性能
单线程的时候ConcurrentHashMap的性能要比同步的HashMap的性能稍好一点,但是在并发应用中,这种作用就十分明显了.
ConcurrentHashMap对get操作做了一些优化,提供最好的性能和并发性.
同步的Map对所用的操作用的都是一个锁,所以同一时刻只有一个线程能够访问map.
而ConcurrentHashMap并没有对成功的读操作加锁,只对写操作和真正需要锁的读操作使用了分离锁的方法.因此多线程能够并发地访问Map而不被阻塞.
随着线程数的增加,并发的map吞吐量得到增长.看ConcurrentHashMap在线程数到达16的时候,它的吞吐量不在提高,因为它的内部使用的是16个分离锁的数组,可以支持16个线程同时写,当线程多余这个数量的时候,就得不到提升了(可以增加锁的数量,提高并行性)
再看同步容器,线程数越多,反而吞吐量降低.
在对锁的竞争小的境况下,每个操作花费的时间取决于真正工作的时间,吞吐量会因为线程数的增加而增加.
一旦竞争变得激烈,每个操作花费的时间就由上下文切换和调度延迟决定了,并且加入更多的线程不会对吞吐量有什么帮助.
总结
- Amdahl定律告诉我们,程序的可伸缩性是由必须连续执行的代码比例决定的.
- Java程序中串行化首要的来源是独占的资源锁,所以可伸缩性通常可以通过以下这些方式提升:
- 减少获取锁的时间
- 减少锁的粒度
- 减少锁的占用时间
- 用非独占或非阻塞锁来取代独占锁
喜欢我的博客就请点赞+【关注】一波