文章目录
本章介绍各种分析、监测以及提升并发程序性能的技术
1.对性能的思考
当操作性能由于某种特定的资源收到限制时,称该操作位资源密集型操作,例如,CPU密集型、数据库密集型等。
多线程会造成额外的开销:线程之间的协调(如加触发信号以及内存同步等),增加上下文切换,线程的创建和销毁,以及线程的调度等。
想要通过并发获得更好的性能:
- 更有效地利用现有处理资源
- 出现新地处理资源时使程序尽可能地利用这些新地资源
1.1 性能与可伸缩性
可伸缩性指的是,当增加计算资源时(如CPU,内存,存储容量或I/O宽带),程序地吞吐量或者处理能力相应地增加。
1.2 评估各种性能权衡因素
避免不成熟的优化。首先使程序正确,然后再提高运行速度—如果它还运行的不够快。
以测试为基准,不要猜测
2.Amdahl定律
描述:在增加计算资源的情况下,程序在理论上能够实现最高加速比,这个值取决于程序中可并行组件与串行组件所占的比重。
假定F是必须被串行执行的部分,那么在包含N个处理器的机器中,最高的加速比为:
当N趋近于无穷大时,最大的加速比趋近于1/F。因此程序有50%的计算需要串行执行,那么最高的加速比只能是2,如果有10%的计算需要串行执行,那么最高的加速比将接近10.
在拥有10个处理器的系统,如果程序中有10%的部分需要串行执行,那么最高的加速比为5.3,拥有100个处理器的系统中,加速比可以达到9.2,即使拥有无限的CPU,加速比也不可能为10。
在预测应用程序在某个多处理器系统中将实现多大的加速比,还需要找出任务中的串行部分。
public class WorkerThread extends Thread {
//并发时,只能有一个线程从队列中取任务,因此这是串行的部分
private final BlockingQueue<Runnable> queue;
public WorkerThread(BlockingQueue<Runnable> queue) {
this.queue = queue;
}
@Override
public void run() {
while (true) {
try {
Runnable take = queue.take();
take.run();
} catch (InterruptedException e) {
break;
}
}
}
}
上面代码看似可以通过并发提高性能,但并不是处理器越多越快,因为其中包含了串行部分。
还隐藏了一个串行部分,对结果的处理–例如将结果存如数据库
在所有并发程序中都包含一些串行部分。如果你认为在你的程序中不存在串行部分,那么可以再仔细检查一遍。
2.1 示例:在各种框架中隐藏的串行部分
要想知道串行部分是如何隐藏在应用程序中的,可以比较当增加线程时的吞吐量变化,并根据观察到的可伸缩性变化来推断串行部分中的差异。
当使用同步保证线程安全时,线程数量过高可能导致性能下降,因为锁竞争更激烈。
2.2 Amdahl定律的应用
评估一个算法时,要考虑算法在数百个或数千个处理器的情况下的性能表现
3.线程引入开销
对于为了提升性能而引入的线程来说,并行带来的性能提升必须超过并发导致的性能开销。
3.1 上下文切换
如果可运行的线程数量大于CPU的数量,那么操作系统最终会将某个正在执行的线程调度出来,从而使其它线程能使用CPU。这将导致一次上下文切换,在这个过程中将保存当前运行线程的上下文,并将新调度进来的线程的执行上下文设置为当前上下文。
调度器会为每个可运行的线程分配一个最小执行时间,即使有许多其它的线程正在等待执行:它将上下文切换的开销分摊到更多不会中断的执行时间上,从而提高整体的吞吐量(以损失响应性为代价)。
如果线程频繁地发生阻塞,那么它们将无法使用完整的调度时间片。越多的阻塞,与CPU密集型的从程序就会发生越多的上下文切换,从而增加调度开销,并因此降低吞吐量。
如果内核占用率较高(超过10%),那么通常表示调度活动发生得很频繁,这很可能是由I/O或竞争锁导致得阻塞引起的。
3.2 内存同步
synchronized和 volatile 提供的可见性保证中肯能会使用一些特殊指令,即 内存栅栏。内存栅栏可以刷新缓存,使缓存无效,刷新硬件的写缓冲,以及停止执行管道。内存栅栏可能同样会对性能带来间接影响,因为它们将抑制一些编译器的优化操作(栅栏中大多操作是不能重排序的)
如果一个锁对象只能由当前线程访问,那么JVM就可以通过优化来去掉这个锁获取操作,因为另一个线程无法与当前线程在这个锁上发生同步。
//每次add和toString都会获取/释放锁,至少4次
public String getStoogeNames() {
Vector<String> stooges = new Vector<>();
stooges.add("Moe");
stooges.add("Larry");
stooges.add("Curly");
return stooges.toString();
}
JVM通过逸出分析,会去掉这4次锁获取/释放操作,因为该方法的内部状态不会逸出
编译器也可以执行锁粒度粗化操作,该方法中,可能会把3个add和1个toString调用合并为单个锁获取/释放操作,并采用启发式方法来评估同步代码块中采用同步操作以及指令之间的相对开销。这不仅减少了同步开销,同时还能使优化器处理更大的代码块,从而可能实现进一步优化。
不要过度担心非竞争同步带来的开销。这个基本的机制已经非常快了,并且JVM还能进行额外的优化以进一步降低或消除开销。因此我们应该将优化重点放在那些发生锁竞争的地方。
某个线程的同步可能会影响其他线程的性能。同步会增加内存总线上的通信量,总线的带宽是有限的,并且所有处理器都共享这条总线。
3.3 阻塞
发生锁竞争时,竞争失败的线程会阻塞。JVM会采用自旋等待(即循环不断尝试获取锁,知道成功)或者通过操作系统挂起被阻塞的线程。
阻塞的线程将包含两次额外的上下文切换,以及所有必要的操作系统操作和缓存操作。
4.减少锁的竞争
减少锁竞争能提高性能和可伸缩性
在并发程序中,对可伸缩性的最主要威胁就是独占方式的资源锁。
两个影响锁竞争的可能性
- 锁的请求频率
- 每次持有该锁的时间
如果二者乘积很小,那么大多数获取锁操作都不会发生竞争。
三种方法降低锁的竞争程度:
- 减少锁的持有时间
- 降低锁的请求频率
- 使用带有协调机制的独占锁,这些机制允许更高的并发性。
4.1 缩小锁的范围(“快进快出)
尽可能降低锁的持有时间。
可以将一些与锁无关的代码移出同步代码块,尤其那些开销较大的操作,以及可能被阻塞的操作。
public class AttributeStore {
private final Map<String,String> attribute = new HashMap<>();
//synchronized 锁住整个方法,导致锁被持有过长的时间
public synchronized boolean userLocationMatches(String name,String regexp) {
String key = "users." + name + ".location";
String location = attribute.get(key);
if (location == null) {
return false;
} else {
return Pattern.matches(regexp,location);
}
}
}
public class AttributeStore {
private final Map<String,String> attribute = new HashMap<>();
public boolean userLocationMatches(String name,String regexp) {
String key = "users." + name + ".location";
String location;
//实际只有Map.get的方法需要同步操作,只需要锁该方法
synchronized (this) {
location = attribute.get(key);
}
if (location == null) {
return false;
} else {
return Pattern.matches(regexp,location);
}
}
}
同步代码块不能过小–一些需要采用原子方式执行的操作(对某个不变性条件中的多个变量进行更新)必须包含在一个同步块中。
4.2 减小锁的粒度
降低线程请求锁的频率,可以通过锁分解和锁分段等技术实现,在这个技术中将采用多个相互独立的锁来保护独立的状态变量,从而改变这些变量在之前由单个锁来保护的情况。
使用的锁越多,发生死锁的概率就越大
public class ServerStatus {
public final Set<String> users;
public final Set<String> queries;
public ServerStatus(Set<String> users, Set<String> queries) {
this.users = users;
this.queries = 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);}
}
上面例子中,每次add和remove操作都获取的同一把锁,导致锁竞争激烈
public class ServerStatus {
public final Set<String> users;
public final Set<String> queries;
public ServerStatus(Set<String> users, Set<String> queries) {
this.users = users;
this.queries = queries;
}
public void addUser(String u) {
synchronized (users) {
users.add(u);
}
}
public void addQuery(String q) {
synchronized (queries) {
queries.add(q);
}
}
public void removeUser(String u) {
synchronized (users) {
users.remove(u);
}
}
public void removeQuery(String q) {
synchronized (queries){
queries.remove(q);
}
}
}
将锁分解后,锁竞争减少
如果锁竞争不激烈时,通过将一个锁分解为两个锁,能最大限度提升性能。
4.3 锁分段
锁竞争激烈时,通过将锁分解为两个锁,并不能有效提高可伸缩性。
某些情况下,可以将锁分解技术进一步拓展为对一组独立对象上的锁进行分解,这种情况被称为锁分段
ConcuurentHashMap
使用了一个包含16个锁的数组,每个锁保护所有散列桶的1/16
劣势:获取多个锁来实现独占访问将更加困难并且开销更高。
锁分段可以参考ConcuurentHashMap
的源码实现。
4.4 避免热点域
如果使用锁分段技术,那么一定要表现出在锁上的竞争频率高于在锁保护的数据上发生竞争的频率。
常见的优化措施,会把一些需要重复计算的结果缓存起来,都会引入一些“热点域”,而热点域会限制可伸缩性。
HashMap中的size方法,会计算Map中的元素数量,它引入了一个全局的计数器,每次put和remove都会修改这个计数器,虽然增加了put和remove的开销,但是使size方法的时间复杂度由O(n)降到了O(1)
但是每个修改元素数量的地方都需要修改这个计数器,限制了可伸缩性。
为了避免,ConcurrentHashMap的size将每个分段进行枚举并将每个分段中的元素数量相加,而不是维护一个全局计数器。为了避免枚举每个元素,ConcurrentHashMap为每个分段都维护了一个独立的计数,并通过每个分段锁来维护这个值
ConcurrentHashMap对于大多数读操作并不会加锁,并且在写入操作以及其它一些需要锁的读操作中使用了锁分段技术。
4.5 一些替代独占锁的方法
例如,使用并发容器、读-写锁、不可变对象以及原子变量。
ReadWriteLock实现了多个读取操作单个写操作的加锁规则,读操作如果不修改共享资源,那么多个读操作可以同时访问,单写入操作必须使用独占方式获取锁。
原子变量提供了一种方式来降低更新“热点域”时的开销,例如静态计数器、序列发生器、或者对链表数据结构中头节点的引用。
4.6 检测CPU利用率
如果CPU没有全部忙碌地运行,就表示 大多数计算是由一小组线程完成地,并且应用程序没有利用其它地处理器。
通常有以下几个原因:
- 负载不充足:测试时增加负载,并检查利用率、响应时间和服务时间等指标地变化。
- I/O密集:可以通过iostat或perfomn来判断某个应用程序是否是磁盘I/O密集型的,或者通过监测网络的通信流量级别来判断它是否需要高带宽
- 外部限制:如果程序依赖外部服务,那么性能瓶颈可能不在代码中。判断在等待外部服务结果时需要多长时间。
- 锁竞争:使用工具可以知道在程序中存在何种程度的锁竞争,以及在那些锁上存在“激烈的竞争”。也可以通过其它方式来获取相同的信息,如随机取样,触发一些线程转储并在其中查找锁上发生竞争的线程。如果线程由于某个锁被阻塞,那么在线程转储信息中会存在响应的栈帧。竞争激烈的锁,会在线程转储中频繁出现。
4.7 向对象池说不
通常,对象分配操作的开销比同步的开销更低。
5.减少上下文切换的开销
将I/O操作转移到一个专门的线程