性能和可伸缩性
使用线程最主要的原因是提高性能。
9.1性能的思考
改进性能:就是用更少的资源做更多的事情。
资源:资源的概念很广泛,有 CPU周期、内存、网络、I/O、数据库、磁盘、以及其他资源。
尽管目标是希望全面提升性能,但是与单线程相比,使用多线程总会引入一些性能的开销。协调线程的开销、增加的上下文切换、线程的创建和消亡、调度的开销。所以,一个没能经过良好并发设计的应用程序,甚至比相同功能的顺序的程序性能更差。
为了实现更好的性能,需要做好两件事情:
- 更有效地利用我们现有的处理资源
- 让程序尽可能的开拓更多可用的处理资源
从性能监视器的角度来看,就是让CPU尽可能的处于忙碌状态(当然是在做有用的事情)。
如果程序是受限于计算能力,那么通过增加更多的处理器就能提高生产力,如果程序都不能保持现有的处理器处于忙碌工作的状态,添加更多的处理器也是无济于事。
线程就是通过分解应用程序,总是让空闲的处理器进行未完成的工作。
9.1.1性能“遭遇”可伸缩性
可伸缩性:当增加计算资源的时候(比如增加额外的CPU数量、内存、存储器、I/O带宽),吞吐量和生产量能够相应地得以改进。
为并发而进行的调试,其目的通常是用最小的代价完成相同的工作。比如通过缓存或是用更快更好的算法去替代原有的算法。
但是为可伸缩性进行调试的时候,目的是如何并行化你的问题,使你能够利用额外的计算资源,用更多的资源做更多的事情。
性能的两个方面——“有多快“和“有多少”是完全分离的,有时候甚至是相悖的。
为了更好的可伸缩性,我们通常会停止增加每个独立任务所需要完成的工作量,把任务分解到多个管道线的子任务中。
大多数在单线程化的程序中提高性能的窍门,都会损害可伸缩性。
9.1.2对性能的权衡进行评估
例子:
“快速排序”算法对于大的数据集有非常好的效率,“冒泡算法”对于小数据集非常有效,如果你想要为实现排序选择一个算法,你需要知道数据集的大小,还有你试图进行优化的目标:是平均计算时间,允许的最差时间,还是可预知性。
但是我们往往没有得到这些信息,我们通常在获得清晰的需求之前进行了假设。
避免不成熟的优化。首先使程序正确,然后再加快——如果它运行得还不够快。
当我们面临工程上的决定时,有时候会用某种形式的成本换取其他东西。(比如:内存换服务时间,开销换安全性,可读性和可维护性换性能)所以,如果不能识别代价或风险,那就请仔细的思考。
大多数性能的决定需要多个变量,并且高度依赖于发生的环境。
在决定某个方案比其他方案“更快”之前,先问自己几个问题:
- 所谓的更“快”指的是什么?
- 在什么样的条件下你的方案能够真正运行得更快?在轻负载还是重负载下?大数据集还是小数据集?是否支持你的测量标准的答案?
- 这些条件在你的环境中发生的频率?是否支持你的测量标准的答案?
- 这些代码在其他环境的不同条件下被用到的可能性?
- 你用什么样隐含的代价,比如增加的开发风险或维护性,换取了性能的提高?这个权衡的决定是否正确?
为什么要这么保守? 因为,对性能的追求很可能是并发bug唯一最大的来源。
评测,不要臆测。
市场上有一些成熟的剖析工具,用来评估性能,追踪性能瓶颈。例如,免费的perfbar。
9.2Amdahl定律
大多数并发程序都是由一系列并行和串行化的片断组成。
Amdahl定律描述了在一个系统中,基于可并行化和串行化的组件各自所占的比重,程序通过获得额外的计算资源,理论上能够加速多少。
如果F是必须串行化执行的比重,机器的处理器有N个,那么根据Amdahl定律:
%2FN)%0A#card=math&code=Speedup%20%3C%3D%201%2F%28F%20%2B%20%281-F%29%2FN%29%0A)
当N无限增大趋近无穷时,speedup的最大值无限趋近1/F。
这意味着,如果一个程序中50%的处理都需要串行化进行的化,speedup只能提升2倍;
如果程序10%需要串行进行的化,speedup最多能提高近10倍。
示例:
//在这里,单个任务的处理时间不仅包括执行任务RUnnable的时间,也包括从共享队列中取出任务的时间,还包括了之后要处理结果的时间(存入数据结构或者写入日志等等)
public class WorkerThread extends Thread{
private final BlockingQueue<Runnable> queue;
public WorkerThread(BlockingQueue<Runnable> queue){
this.queue = queue;
}
public void run(){
while(true){
try{
//取出任务时,是串行化的
Runnable task = queue.take();
task.run();
}catch(InterruptedException e){
break;
}
}
//如果不是每个线程各自维护自己的结果的数据结构,而是在所有任务都执行完成之后合并,那么最终的合并也是串行化的
}
}
所有的并发程序都有一些串行源。
9.2.1定性地应用Amdahl定律
直接衡量串行化非常困难,Amdahl定律在没有这样衡量的情况下仍然有用。
当我们评估一个算法的时候,考虑其在成百甚至上千个处理器的情况下受到的限制,能够帮助我们洞察伸缩性的极限的出现。
例如,减小锁的粒度:分拆锁(把一个锁分拆成两个),分离锁(把一个锁分拆成多个锁)。透过Amdahl定律来审视它们,可以发现,把一个锁分拆成两个,看上去没能在利用多处理器上帮助我们很多,但是分离锁的效果却很好,因为分离出的数量可随着处理器数量的增加而增长。
9.3线程引入的开销
调度和线程内部的协调都要付出性能的开销,对于性能改进的线程来说,并行带来的性能优势必须超过并发所引入的开销。
9.3.1切换上下文
如果可运行的线程数大于CPU的数量,那么OS最终会强行换出正在执行的线程,从而使其他线程能够使用CPU。这会引起上下文切换,它会保存当前运行线程的执行上下文,并重建新调入线程的执行上下文。
切换上下文是要付出代价的:线程的调度需要操控OS和JVM中共享的数据结构。
程序与OS、JVM使用相同的CPU,CPU在JVM和OS的代码花费越多的时间,意味着用于你的程序的时间就越少。但是JVM和OS活动的花费并不是切换上下文开销的唯一来源。
当一个**新的线程被换入后**,它**所需要的数据可能不再当前处理器本地的缓存中**,因此线程在**第一次调度的时候会运行得稍微慢一些**。
即使有很多其他正在等待的线程,调度程序也会为**每一个可运行的线程分配一个最小执行时间的定额**,就是因为这个原因:**它可以分期偿付切换上下文的开销,获得更多不中断的执行时间**。
当线程因为**竞争一个锁而阻塞时,JVM通常会将这个线程挂起**,允许它被换出。如果线程频繁发生阻塞,与受限CPU的程序相比,就会造成越多的上下文切换。
切换上下文真正的开销根据不能的平台而变化,但是在大多数通用的处理器中,上下文切换的开销相当于5000到10000个时钟周期,或者几微秒。
Unix系统的**vmstat命令**和Windows系统的**perfmon工具**都能报告上下文切换的次数和内核占用的时间等信息。**高内核占用率(超过10%)**通常象征繁重的调度活动。
9.3.2内存同步
性能的开销有几个来源。
synchronized和volatile提供的可见性保证要求使用一个特殊的、名为存储关卡的指令,来刷新缓存,使缓存无效,刷新硬件的写缓冲,并延迟执行的传递。存储关卡可能同样会对性能产生影响,因为它们抑制了其他编译器的优化:在存储关卡中,大多数操作是不能被重排序的。
评估同步给性能带来影响的同时,区分竞争同步和无竞争同步也是非常重要的。synchronized机制对无竞争同步进行了优化(volatile总是非竞争的)。
现在的JVM能够通过优化,解除经确证不存在竞争的锁,还可以使用逸出分析,来识别本地对象的引用并没有在堆中被暴露,并且因此成为线程本地的。
示例:
//本来这个示例至少需要获取/释放Vector的锁4次,每个add一次,每个toString一次。
//然而JVM发现stooges和它的内部状态一直没有逸出,因此这4次对锁的请求就可以被消除了
public String getStoogeNames(){
List<String> stooges = new Vector<String>;
stooges.add("Moe");
stooges.add("Larry");
stooges.add("Curly");
return stooges.toString();
}
即使没有逸出分析,编译器同样可以进行锁的粗化,把邻近的synchronized块用相同的锁合并起来。
不用过分担心非竞争的同步带来的开销。基础的机制已经足够块了,在这个基础上,JVM能够进行额外的优化,大大减少或消除了开销。
关注那些真正发生了锁竞争的区域中性能的优化。
一个线程中的同步也可能影响影响到其他线程的性能。同步造成了共享内存总线上的通信量,这个总线的带宽是有限的,所有的进程共享这条总线。
9.3.3阻塞
非竞争的同步可以由JVM完全掌握,而竞争的同步可能需要OS的活动,这会增大开销。
当锁为竞争性的时候,失败的线程必然发生阻塞。这时JVM既能自旋等待(不断尝试获取锁),也能在操作系统中挂起这个被阻塞的线程。
自旋等待更适合短期的等待,挂起适合长时间等待。
有一些JVM基于过去等待时间的数据来进行选择,但是大多数等待锁的线程都是被挂起的。
挂起需要两次额外的上下文切换,以及OS和缓存的相关活动:阻塞的线程在它的时间限额还没有到期前就被换出,如果能获得锁或者其等待的资源,就会被换入。(当线程释放锁时,它必须通知OS,重新开始因该锁而阻塞的线程。)
9.4减少锁的竞争
串行化会损害可伸缩性,上下文切换会损害性能,竞争性的锁会通知导致这两种损害。
所以,减少锁的竞争能够改进性能和可伸缩性。
并发程序中,对可伸缩性首要的威胁就是独占的资源锁。
有两个原因影响着锁的竞争性:
- 锁被请求的频率
- 每次持有该锁的时间。
如果这两者的乘积足够小,那么大多数请求锁的尝试都是非竞争的。
如果这个锁的请求量很大,线程将会阻塞等待锁,极端情况下,处理器将会闲置。
减少锁的竞争的方式:
- 减少持有锁的时间
- 减少请求锁的频率
- 用协调机制取代独占锁,从而允许更强的并发性
9.4.1缩小锁的范围(“快进快出”)
减少竞争发生的可能性的有效方式是尽可能缩短把持锁的时间。这可以通过把与锁无关的代码移出synchronized块来实现,尤其是那些花费“昂贵”的操作,以及那些潜在的阻塞操作,比如I/O操作。
示例:
//过度使用synchronized,
//只有Map.get这一部分是需要真正调用锁的,但是却把真个方法都变成synchronized的。
public class AttributeStore{
@GuardedBy("this")
private final Map<String,String> attributes = new HashMap<String,String>();
//而且次方发中只有一个状态变量,attributes,可以使用代理线程安全的技术,使用线程安全的Map(synchronizedMap、ConcurrentHashMap、Hashtable)来取代attributes,这样就可以省去显式的同步,减少Map访问中锁的范围。
public synchronized boolean userLocationMatches(String name,String regexp){
String key = "users." + name + ".location";
//只有这里是需要锁的
String location = attributes.get(key);
if(location == null){
return false;
}else{
return Pattern.matches(regexp, location);
}
}
}
如果我们将锁范围缩小,由Amdahl定律得知,可伸缩性增强了,因为串行化的代码少了。
尽管缩小synchronized块能够提高可伸缩性,synchronized块可以变得极小——需要原子化的操作必须包含在一个synchronized块中。
但是因为同步的开销非零,把一个synchronized块拆分成多个synchronized块,在某些时刻反而会对性能产生反作用。(如果JVM进行了锁的粗化)
理想的平衡是与平台相关的,当你能够“切实”地把计算和阻塞操作从synchronized块中一开,再担心synchronized块的大小才是有意义的。
9.4.2减小锁的粒度
减小持有锁的时间比例的另一种方式是让线程减少调用它的频率。
这可以通过分拆锁和分离锁实现,潜在也实现了更好的可伸缩性,但是使用更多的锁同样增加了死锁的风险。
如果一个锁守卫数量大于1、且相互独立的状态变量,你可能通过分拆锁,使每一个锁守护不同的变量,从而改进可伸缩性,结果是每个锁请求的频率都减小了。
示例:
//待拆分的程序
public class ServerStatus{
@GuardedBy("this")
public final Set<String> users;
@GuardedBy("this")
public final Set<String> queries;
......
public synchronized void addUser(String u){users.add(u);}
public synchronized void addQuery(String q){queries.add(q);}
public synchronized void removeUser(String u){users.remove(u);}
public synchronized void removeQuery(String q){queries.remove(q);}
}
//拆分后的程序
//使用了两个锁来拆分
public class ServerStatus{
@GuardedBy("users")
public final Set<String> users;
@GuardedBy("queries")
public final Set<String> queries;
......
public void addUser(String u){
synchronized(users){
users.add(u);
}
}
public void addQuery(String q){
synchronized(queries){
queries.add(q);
}
}
}
9.4.3分离锁
把一个竞争激烈的锁分拆成两个,很可能形成两个竞争激烈的锁,尽管这可以通过两个线程并发执行,但仍不能大幅地提高多个处理器在同一系统中并发性的前景。
分拆锁有时候可以被扩展,分成可大可小加锁块的集合,并且它们归属于相互独立的对象,这样的情况就是分离锁。
比如:
**ConcurrentHashMap的实现使用了一个包含16个锁的Array**,**每一个锁守护HashBucket的1/16**,Bucket N由第N mod 16 个锁来守护。所以ConcurrentHashMap能够支持16个并发的Writer。
分离锁的一个缺点是:
对容器加锁,进行独占访问更加困难,也更加昂贵了。(有些操作是要对整个容器加锁的)
比如:
当对ConcurrentHashMap进行扩展、重排序、放入一个更大的Bucket时。
9.4.4避免热点域
分拆锁和分离锁能够改进可伸缩性,是因为它们能够使不同的线程操作不同的数据(或者是相同数据结构的不同部分),而不会发生相互干扰。
能够从分拆锁受益的程序,通常是那些对锁的竞争普遍大于对锁守护数据竞争的程序。
在单线程或完全同步的实现中,保存一个独立的计数能够很好的提高类似size和isEmpty这样的方法的速度,但是却使改进可伸缩性变得更难了,因为每一个修改map的操作都要更新这个共享的计数器。这种情况下,计数器被称为“热点域”,因为每个变化操作都要访问它。
为了避免这个问题,ConcurrentHashMap通过枚举每个条目获得size,并把这个值加入到每个条目,而不是维护一个全局计数。ConcurrentHashMap为每一个条目维护一个独立的计数域,同样由分离的锁守护。
9.4.5独占锁的替代方法
用于**减轻竞争锁**带来的影响的第三种技术是**提前使用独占锁**。
包括使用**并发容器**、**读-写锁**、**不可变对象**、**原子变量**。
读写锁:相对于独占锁,提供了更好的并发性,对于只读的数据结构,不变性完全可以消除加锁的必要
原子变量:提供了能够减少更新“热点域”的方式,如果你的类只有少量热点域,并且该类不参与其他变量的不变约束,那么使用原子变量替代他可能会提高可伸缩性。
9.4.6监测CPU利用率
测试可伸缩性的时候,目标通常是保持处理器的充分利用。Unix系统的vmstat和mpatat,或者Windows系统的perfmon都能够告诉你处理器的工作状态。
如果你的CPU都没有被均匀地利用,那么你的首要目标应该是增强你程序的并行性。
CPU没有被完全利用的原因:
不充足的负载
- 可能被测试的程序还没有被加入足够多的负载。增加负载,并检查利用率、响应时间、服务运行时间的变化。
I/O限制
- 可以通过iostat或者perfmon判定一个应用程序是否受限于磁盘、或者监测它的网络通信量判断它是否有带宽限制
外部限制
- 如果你的应用程序取决于外部服务,比如数据库、Web Service。可以使用Profiler工具,或者数据库管理工具来判断等待外部服务结果的用时。
锁竞争
- 使用Profiling工具能够告诉你,程序中存在多少个锁竞争,哪些锁很“抢手”。也可以选择出发线程转储。
9.5比较Map的性能
单线程化的ConcurrentHashMap的性能要比同步的HashMap的性能稍微好一点,而且在并发应用中,这种作用就十分明显了。
ConcurrentHashMap的实现,假定了大多数常用的操作都是获取已存在的某个值,因此它的优化是针对get操作,提供最好的性能和并发性。
同步的Map实现中,可伸缩性最主要的障碍在于整个Map存在一个锁,所以一次只有一个线程能够访问map。
ConcurrentHashMap并没有对成功的读操作加锁,对写操作和真正需要锁的读操作使用了分离锁的方法。
ConcurrentHashMap、ConcurrentSkipListMap:是通过设计达到的线程安全。
HashMap、TreeMap:通过同步的包装器实现了线程安全。
在单线程的情况下,同步容器与ConcurrentHashMap的吞吐量一致,但是随着线程的增多,ConcurrentHashMap和ConcurrentSkipListMap的吞吐量随着线程数量的增加而增长,HashMap和TreeMap的吞吐量反而会有所下降。
9.6减少上下文切换的开销
把一些可以分离的需要锁或者是可能会产生阻塞的任务单独移到另一个线程。
比如:
日志,把I/O操作移到了另一个线程,使用户看不到这个开销,而且通过把所有的日志I/O移入一个线程,同样消除了输出流的竞争。这改进了整体的吞吐量,因为需要调度的资源更少了,上下文切换更少了,锁的管理更简单了。
把I/O操作从请求处理线程移到一个Logger线程,就好像从一群跑来跑去想要救火的人变成了排成长队以传水救火的队列。
总结
使用线程的最主要的目的:利用多处理器资源。
在并发程序性能的讨论中,通常更多地关注吞吐量和可伸缩性,而没有强调自然服务时间。
Amdahl定律告诉我们,程序的可伸缩性是由必须连续执行的代码比例决定的。
因为Java程序中串行化的首要来源是独占的资源锁,所以可伸缩性通常可以通过下列方式提升:
- 减少用于获取锁的时间(避免阻塞)
- 减小锁的粒度(调用的频率)
- 减少锁的占用时间(快进快出)
- 用非独占或非阻塞锁来取代独占锁。
