思维导图:
引言:
使用多线程最主要的目的就是提升程序的运行性能,本章的主要内容就是介绍如何分析多线程程序的性能以及如何提高性能。全文大体分为以下两个部分:
- 理论部分:性能分析,包括如何分析多线程程序的执行性和可伸缩性
- 使用部分:性能提升,包括使用锁分段,减小锁粒度,减小锁范围等一系列手段以提升程序的性能。
一.性能分析
这个小节会对两部分性能进行分析。
- 执行性:指在当前已有资源下程序的处理能力
- 可伸缩性:指在可获得资源增加后,程序的处理能力是否能够增加的能力
1.1 执行性
我们将从外部环境条件和线程引入开销来分析执行性。
1.1.1 执行性的外部条件
在我们分析并发程序之前,首先,应该确保其外部环境是优良的。
- 我们得首先保证程序是可正常运行的,然后才能去分析其性能如何
- 分析并发程序的性能如何不能只靠理论分析,分析并发程序性能的基准是测试
- 应对不同的环境,性能差别可能很大,比如快速排序和冒泡排序的性能会因为待排序元素个数的多少而变化,元素少时冒泡排序会高一些,元素多时,快速排序则高一些。总之,我们需要分析外部环境的各种因素,才能编写最合适的并发程序。
1.1.2 线程引入的开销
使用多个线程是比只是用单个线程会多出一些线程开销的。一般来说,可以分析三个部分
- 上下文切换
如果可运行的线程数量大于CPU的数量,那么,操作系统就会在某个时刻将某个正在运行的线程调度出去,然后将某个即将执行的线程调度进来。这将导致一次上下文切换,在保存当前线程的上下文后,将把待执行线程的上下文设置为当前上下文。
切换上下文时,操作系统和JVM会访问他们共享的数据结构,这将会消耗一段时间。如果当前线程如果从没有加载过,或者它所需要的某些数据不再缓存中时,会导致缓存缺失,其结果就是会在消耗一段时间。这也是为什么线程都有一个最小执行时间的原因,以此将上下文切换的开销分摊到不会中断的执行时间上。
上下文切换的实际开销会随着平台的不同而变化,在大多数处理器中,上下文切换将花费5000-10000个时钟周期,即几微秒的时间。
- 内存同步
使用volatile和synchronized关键字会对内存进行同步以保证可见性。其实质操作是使用“内存栅栏”指令去刷新缓存,刷新硬件的写缓冲,以及停止执行管道。所以,这就可能会导致一些编译器的类似指令重排的优化手段无效化,从而导致性能降低。
现代的JVM能通过优化来去掉一些不会发生竞争的锁,从而减少不必要的同步开销。
- 阻塞
多个线程竞争某个锁时,竞争失败的线程会陷入阻塞。JVM实现阻塞行为时,可以采用自旋等待(通过循环不断尝试获取锁),或者通过操作系统将此线程挂起。
如果等待时间段,使用自旋等待,如果时间长,则直接挂起。一般来说,JVM大多数会选择直接挂起。
1.2 可伸缩性
是不是随着资源增加,并发程序的处理能力就能无限的增加呢?
1.2.1 Amdahl定律
我们用加速比Speedup来衡量处理能力随着资源增加而增加的能力。F表示必须并发程序中被串行执行的部分,N表示CPU的数量。
Amdahl定律的以上公式告诉我们,只有并发程序还有需要串行执行的部分(基本上都有),那么性能就不会随着CPU的增加而无限制的增加。看下图:
我们在看看同步List和并发List的性能区别,这就是因为他们锁的粒度不同和锁分段而导致的。
二.性能提升
那么,我们应该如何提升并发程序的性能呢?主要手段就是减少锁之间的竞争,并使用合适的CPU数量。
2.1 缩小锁的范围
我们可以尽可能的缩小锁的持有时间。将不需要加锁的代码移除同步代码块是个不存的选择。
在下列代码中,其实只有get(key)时需要进行同步。
@ThreadSafe
public class AttributeStore {
@GuardedBy("this") private final Map<String, String> attributes = new HashMap<String, String>();
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);
}
}
}
范围缩小以后代码如下:
@ThreadSafe
public class BetterAttributeStore {
@GuardedBy("this") private final Map<String, String> attributes = new HashMap<String, String>();
public boolean userLocationMatches(String name, String regexp) {
String key = "users." + name + ".location";
String location;
synchronized (this) {
location = attributes.get(key);
}
if (location == null) {
return false;
} else {
return Pattern.matches(regexp, location);
}
}
}
2.2 减小锁的粒度
另一种减小锁的持有时间的方式是减低线程请求锁的频率。我们可以通过锁分解和锁分段来实现。
2.2.1 锁分解
如果一个锁需要保护多个独立的状态变量,那么可以将这个锁分解为多个锁,并且每个锁只保护一个变量。
在下列代码中,我们直接对整个对象加锁。
@ThreadSafe
public class ServerStatusBeforeSplit {
@GuardedBy("this") public final Set<String> users;
@GuardedBy("this") public final Set<String> queries;
public ServerStatusBeforeSplit() {
users = new HashSet<String>();
queries = new HashSet<String>();
}
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);
}
}
但是我们可以将锁分解为两个锁,以减小出现竞争的可能性,代码如下所示:
@ThreadSafe
public class ServerStatusAfterSplit {
@GuardedBy("users") public final Set<String> users;
@GuardedBy("queries") public final Set<String> queries;
public ServerStatusAfterSplit() {
users = new HashSet<String>();
queries = new HashSet<String>();
}
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 (users) {
queries.remove(q);
}
}
}
2.2.2 锁分段
我们将锁分解进一步扩展为对一组独立对象上锁进行分解,这种情况被称为锁分段。例如ConcurrentHashMap的实现中,使用了一个包含16个锁的数据,每个所锁保护所有散列桶的1/16。
2.2.3 避免热点域
在一个普通的或者加锁了的List中,只要修改了List的大小,那么,就必须修改List的size值,这个size就被称为热点域。这将导致在并发量过大时缺乏伸缩性的问题。
解决手段就是尽量避免使用热点域。比如ConcurrentHashMap会对每个分段的大小进行维护,然后统计ConcurrentHashMap的大小时返回所有分段的size之和,这是一种避免热点域的手段。
2.2.4 替代独占锁
可以使用独占锁的地方则不用,比如某个数据的读操作,在保证了可见性的前提下,就完全不需要加锁。相反,例如某个数据的写操作,就必须获得独占锁。
为了保证可见性,我们可以恰当的使用原子变量。
2.2.5 不使用对象池
由于早期Java的垃圾回收的执行速度不是特别快,所以会使用一些对象池,循环使用对象以提升效率。但是现在的JVM垃圾回收的性能已经相当快了。事实上Java分配操作已经比C语言的malloc调用更快了。
在并发对象中,如果使用对象池就必然意味着使用某种同步机制以在多个线程之间保持数据的线程安全性,这种操作的开销将是分配操作的数百倍。所以,不要使用对象池。