此文被笔者收录在系列文章 架构师必备(系列) 中
设计并发程序时,性能并不是最优先的考虑,安全总是放在第一位的。首先要保证程序的正确性,而后只有当你的性能需求和评估标准需要程序运行得更快时,才去进行改进。
一、性能的思考
改进程序的目的就是为了充分利用硬件的能力,所以测试时我们要尽量分析CPU的问题还是内存的问题;1、要有效的利用我们现有的资源;2、出现新的问题时能更有效的利用现有资源。有时经过测试后,可调节线程池大小的多线程程序的池最适合大小为1,这时多线程的设计就属于多余的。
性能遭遇可伸缩性
应用程序可以从很多角度来衡量:比如服务时间、等待时间、吞吐量、效率、可伸缩性、生产量,可伸缩性指的是当增加计算资源(硬件)的时候,吞吐量和生产量能够相应地得以改进。为伸缩性进行调试的时候目的是能够利用额外的计算资源,用更多的资源做更多的事情。
在结构和性能之间需要权衡来考虑,性能有多快和有多少任务协同要分别进行调试。很多单线程调优的方式,在多线程程序中都会起到反作用。传统的三层程序,虽然结构清晰,但是存在队列、协调、数据拷贝的开销。
对性能的权衡进行评估
有时候优化会违背面向对象的设计原则。通常来讲越快的程序越复杂,也就越难以维护。在决定优化一个程序之前,可以保守地问下以下问题:
1、所谓的“快”指的是什么。
2、在什么样的条件下你的方案能够真正运行的更快?在轻负载还是重负载下?等
3、这些条件在你的环境中发生的频率?
4、这些代码在其他环境的不同条件下可被复用的可能性?
5、你用什么样隐含的代价,比如增加的开发风险或维护性,换取了性能的提高?
我们通常在优化程序时很多时候都是靠直觉。我们可以借助一些工具,比如perfbar来测试。
二、Amdahl定律
使用线程的重要原因之一是为了支配多处理器的能力,我们必须保证问题被恰当地进行了并行化的分解,并且我们的程序有效地使用了这种并行的潜能。
这个定律描述的是,程序是由一系列并行和串行化的片断组成。基于可并行化和串行化的组件各自所占的比重,程序通过获得额外的计算资源,我们最多可以加速:
Speedup<=1/(F+(1-F) / N):F指串行化程序的比重,N指处理器的个数。
当N无限增大趋近无穷时,这意味着如果50%的处理都需要串行进行的话,speedup只能提升2倍。如果程序的10%需要串行进行,speedup最多能够提高近10倍。
Amdahl同样量化了串能行化的效率开销,有有10个处理器的系统中,如果10%的程序是串行化,那么最多可以加速5.3倍,在100个处理器的系统中,可以达到9.2倍。
我们在写一些多线程的程序时,如果想提高性能,最好的办法是要充分了解各个容器类的原理,然后再加以利用。 任务执行的时间除了运算时间,还有从共享容器中取出数据的时间。充分发现串行源,可以预先估算速度。一般来说,并发数和CPU数是一对一的(并不绝对)。
三、线程引入的开销
如果可运行的线程数超过CPU的个数,就会发生上下文切换。此时即使有很多其他正在等待的线程,调度程序也会为每一个可运行的线程分配一个最小执行时间的定额,就是这个原因:它分期偿付切换上下文的开销,获得更多不中断的执行时间,从整体上提高了吞吐量。当线程因为竞争一个锁而阻塞时,JVM通常会将这个线程换出。
一般来讲处理器上下文切换的开销相当于5000-10000个时钟周期即几微秒。UNIX可以用vmstat查看切换次数。高内核占用率超过10%时就象征着频繁的调度活动,可能是I/O阻塞或是竞争锁引起的。
不要过分担心非竞争的同步带来的开销,硬件的基础机制已经足够快了,在这个基础上,JVM能够进行额外的优化,大大减少或消除了开销,我们只需要关注那些真正发生的锁竞争在区域中性能的优化。
减少上下文切换的开销
这处要根据实际情况来设计自己的程序,比如日志的例子大多数日志框架都是围绕println进行瘦包装的,另一种方案就是日志记录的工作由一个专职的后台线程完成。后一种方式是比较好的方式。构建一个logger,它会把I/O移到另一个线程,这样可以提高性能,但也会引入更多的设计复杂度,比如中断、服务担保、饥饿策略、服务的生命周期。
四、减少锁的竞争
串行化、上下文切换以及锁竞争都会影响可伸缩性,在并发程序中,可伸缩性的最大威胁是独占的资源锁。有两个原因影响着锁的竞争性,锁被请求的频率以及每次持有该锁的时间,如果这两者的×积足够小,那么大多数请求锁的尝试都是非竞争的。
有几种方式可以减少锁的竞争:1、减少持有锁的时间;2、减少请求锁的频率;3、用协调机制取代独占锁。
缩小锁的范围(快进快出)
减小竞争发生可能性的有效方式是尽可能缩短把持锁的时间,可以通过把与锁无关的代码移出synchronized块来实现,尤其是那些昂贵的操作,以及潜在的阻塞操作。比如下面一个例子,只有map一行代码是需要同步的。
理论上把一个大的synchronized拆分成多个小synchronized是能提高性能的,实际上是与平台相关的,如果JVM进行了锁的粗化,这种拆分是无效的,仅当能够“切实”地把计算和阻塞操作从synchronized块中移开时才会有意义。
减小锁的粒度(定义多个不同功能的锁)
减小持有锁的时间比例的另一种方式是让线程减小调用它的频率,可以通过分拆锁和分离锁来实现,采用相互独立的锁来守护相互独立的状态变量,但同时更多的也增加了死锁的风险。
如果一个锁守卫数量大于一、且相互独立的状态变量,你可能能够通过分拆锁,使每一个锁守护不同的变量,从而改进伸缩性,结果是每个锁被请求的频率都减小了。分拆锁例子如下:这个例子中users和queries是相互独立的,分拆锁适合原来竞争并不激烈的锁来进行优化。
分离锁(同样功能的锁拆分成多把锁)
把一个竞争锁分拆成两个,很可能形成两个竞争激烈的锁,也可以改进伸缩性,但这仍然不能大幅地提高多个处理器在同一系统中并发性的前景。分拆锁有时可以被扩展,分成可大可小加锁块的集合,并且它们归属相互独立的对象,这样的情况称为分离锁。
ConcurrentHashMap的实现使用了一个包含16个锁的Array,每一个锁都守护Hash Bucket的1/16,Bucket N由第N mod 16个锁来守护,并且这些关键字能够以统一的方式访问,这将会把对于锁的请求减少到约为原来的1/16,同时也意味着ConcurrentHashMap最多可以同时被16个线程writer。
分离锁的一个负面作用就是:对容器加锁,进行独占访问时更加困难,获得内部锁的一个任意集合的唯一方式是递归。
避免热点域
上述两种技术可以改变伸缩性,当每一个操作都请求变量的时候,锁的粒度很难被降低。这是性能和伸缩性相互牵制的另一个方面,通常使用的优化方法是引入“热点域”。比如Map实现的size方法,size()的实现就是当每改变一次Map就缓存一次size的值,虽然会使size方法的开销从O(n)减小到O(1),但这样实现后会限制伸缩性。这时这个共享的全局计数量就称为“热点域”,因为对Map的每次操作都要改变它,程序中应该避免这种情况发生,比如ConcurrentHashMap的size实现是通过枚举每个条目获得size,并把这个值加入到每个条目,而不是共享一个独立的计数器。
伸缩性指保持处理器的充分利用,并行可以运行更多的线程的能力。
监测CPU利用率
CPU不均匀的利用率说明,大多数计算都由很小的线程集完成,这时应该增加程序的并发性。可能由以下几种原因:
1、不充足和负载测试,这时要再加入足够多的负载。
2、I/O限制,是否受限于磁盘还是网络。
3、外部限制,比如数据库。
4、锁竞争,使用Profiling可以看出程序中存在多少个锁竞争。
“对象池”不适用的场景
用线程池的技术来人为减小创建对象的开锁,防止GC回收这样的性能开锁。在单线程程序中,也只有对重量级的对象有性能提升。在多线程并发的应用程序中,池化表现得很糟糕,因为池要管理很多东西,池化在多线程程序的使用中也是有局限性的。分配对象通常比同步对象要便宜。
五、比较Map的性能
单线程化的ConcurrentHashMap的性能要比同步的(synchronized)限制的HashMap的性能要好一些,尤其在并发程序中,ConcurrentHashMap并没有对成功的读操作加锁,对写操作和真正需要锁的读操作使用了分离锁的方法。因此并发的程序能够并发访问ConcurrentHashMap而不被阻塞。
同步容器的数量并不是越多越好,单线程的情况与ConcurrentHashMap一致,但是一旦负载由多数为非竞争的情况变成多数为竞争性的情况---同步的容器会表现的很糟糕。这在锁竞争的代码行为中很常见。只要竞争小,每个操作所花费的时间取决于真正工作的时间,吞吐量会因为线程数的增加而增加。一旦竞争大,每个操作的时间由上下文切换和调试时间决定了,并且加入更多的线程不会对吞吐量有什么帮助。