并发编程学习笔记之可伸缩性(九)

很多改进性能的技术增加了复杂度,因此增加了安全和活跃度失败的可能性. 更糟糕的是,有些技术的目的是改善性能,事实上产生了相反的作用,带来了其他的性能问题. 数据的正确性永远是第一位的,保证程序是正确的,然后再让它更快.只有当你的性能需求和评估标准需要程序运行得更快时,才去进行改进. 在设计并发应用程序的时候,最大可能地改进性能,通常并不是最重要的事情. 性能的思考 当活动的运行因某个特定资源受阻时,我们称之为受限于该资源:受限于CPU,受限于数据库. 使用线程的目的是希望全面提升性能,但是与单线程相比,使用多线程会引入一些额外的开销. 如: 协调线程相关的开销(加锁、信号、内存同步) 增加的上下文切换 线程的创建和消亡,以及调度的开销 当线程被过度使用后,这些开销会超过提高后的吞吐量响应性和计算能力带来的补偿. 一个没能经过良好并发设计的应用程序,甚至比相同功能的顺序的程序性能更差. 性能"遭遇"可伸缩性 可伸缩性指的是:当增加计算资源的时候(比如增加额外CPU数量、内存、存储器、I/O带宽),吞吐量和生产量能够相应地得以改进. 对性能的权衡进行评估 避免不成熟的优化,首先使程序正确,然后再加快----如果它运行得还不够快. 很多性能的优化会损害可读性或可维护性--代码越"聪明",越"晦涩",就越难理解和维护. 在多个方案之间进行选择的时候,先问自己一些问题: 你所谓的更"快"指的是什么 在什么样的条件下你的方案能够真正运行得更快?在轻负载还是重负载下?大数据集还是小数据集?是否支持你的测量标准答案? 这些条件在你的环境中发生的频率?是否支持你的测量标准的答案? 这些代码在其他环境的不同条件下被用到的可能性? 你用什么样隐含的代价,比如增加的开发风险或维护性,换取了性能的提高?这个权衡的决定是否正确? 做出任何与性能相关的工程决定时,都应该考虑这些问题. 最好选择保守的优化方案,因为对性能的追求很可能是并发bug唯一最大的来源.通过减少同步来提高响应性,成了不遵守同步规定的常用的借口,但是因为并发bug是最难追踪和消除的,所以任何引入这类bug的行动风险都需要慎重进行. 优化改进后的代码,一定要进行压力测试.主观认为会提高性能的代码,在实际生产环境可能会出现问题. 测评,不要臆测 Amdahl 定律 Amdahl定律描述了在一个系统中,基于可并行化和串行化的组件各自所占的比重,程序通过获得额外的计算资源,理论上能够加速多少. 如果F是必须串行化执行的比重,那么Amdahl定律告诉我们,在一个N处理器的机器中,我们最多可以加速: image 串行执行的比率越大,处理器越多,处理器的利用率越低: image 线程引入的开销 调度和线程内部的协调都要付出性能的开销: 对于改进性能的线程来说,并行带来的性能优势必须超过并发所引入的开销. 切换上下文 如果可运行的线程大于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而不被阻塞. image 随着线程数的增加,并发的map吞吐量得到增长.看ConcurrentHashMap在线程数到达16的时候,它的吞吐量不在提高,因为它的内部使用的是16个分离锁的数组,可以支持16个线程同时写,当线程多余这个数量的时候,就得不到提升了(可以增加锁的数量,提高并行性) 再看同步容器,线程数越多,反而吞吐量降低. 在对锁的竞争小的境况下,每个操作花费的时间取决于真正工作的时间,吞吐量会因为线程数的增加而增加. 一旦竞争变得激烈,每个操作花费的时间就由上下文切换和调度延迟决定了,并且加入更多的线程不会对吞吐量有什么帮助. 总结 Amdahl定律告诉我们,程序的可伸缩性是由必须连续执行的代码比例决定的. Java程序中串行化首要的来源是独占的资源锁,所以可伸缩性通常可以通过以下这些方式提升: 减少获取锁的时间 减少锁的粒度 减少锁的占用时间 用非独占或非阻塞锁来取代独占锁 如果您体验到了获得新知识的快感,那就点下右下角的【推荐】按钮,让博主也高兴高兴. 如果,您希望更容易地发现我的新博客,不妨点击一下绿色通道的【关注我】。 如果,想给予我更多的鼓励,求打 我的写作热情离不开您的肯定支持,感谢您的阅读,我是【西索】!https://www.cnblogs.com/xisuo/p/9869188.html
50000+
5万行代码练就真实本领
17年
创办于2008年老牌培训机构
1000+
合作企业
98%
就业率

联系我们

电话咨询

0532-85025005

扫码添加微信