第十一章——性能与可伸缩性
11.3 线程引入的开销
11.3.1 上下文切换
切换上下文需要一定的开销,而在线程调度过程中需要访问由操作系统和 JVM 共享的数据结构。应用程序、操作系统以及 JVM 都是用一组相同的 CPU。在 JVM 和操作系统的代码中消耗越多的 CPU 时钟周期,应用程序的可用 CPU 时钟周期就越少。但上下文切换的开销并不只是包含 JVM 和操作系统的开销。当一个新的线程被切换进来时,它所需的数据可能不在当前处理器的本地缓存中,因此上下文切换将导致一些缓存缺失,因而线程在首次调度运行时会更加缓慢。这就是为什么调度器会为每个可运行的线程分配一个最小执行时间,即使有许多其他的线程正在等待执行:它将上下文切换的开销分摊到更多不会中断的执行时间上,从而提高整体的吞吐量(以损失响应性为代价)。
11.3.2 内存同步
同步操作的性能开销包括多个方面。在 synchronized
和 volatile
提供的可见性保证中可能会使用一些特殊指令,即内存栅栏(Memory Barrier)。内存栅栏可以刷新缓存,是缓存无效,刷新硬件的写缓冲,以及停止执行管道。内存栅栏可能同样会对性能带来间接的影响,因为它们将抑制一些编译器优化操作。在内存栅栏中大多数操作都是不能被重排序的。
在评估同步操作带来的性能影响时,区分有竞争的同步和无竞争的同步非常重要。synchronized
机制针对无竞争的同步进行了优化(volatile
通常是无竞争的)。哪些是无竞争的同步呢?例如,如下两段代码:
// 程序清单 11-2
synchronized (new Object()) {
// 执行一些操作
}
// 程序清单 11-3
public String getStoogeNames() {
List<String> stooges = new Vector<>();
stooges.add("Moe");
stooges.add("Larry");
stooges.add("Curly");
return stooges.toString();
}
程序清单 11-2 显而易见是一个没有作用的同步。
程序清单 11-3 中,Vector
是一个同步容器,它的 add
,toString
方法隐式地会加锁。因此代码块中至少有 4 次所获取 / 释放。但是现代的编译器,会执行锁粒度粗化(Lock Coarsening)操作,即将邻近的同步代码块用同一个锁合并起来。在 getStoogesName
中,如果 JVM 进行锁粒度粗化,那么可能会把 3 个 add
与 1 个 toString
调用合并为单个锁获取 / 释放操作。
不要过度担心非竞争同步带来的开销。这个基本的机制已经非常快了,并且 JVM 还能进行额外的优化以进一步降低或消除开销。因此,我们应该将优化重点放在那些发生所竞争的地方。
11.3.3. 阻塞
非竞争的同步可以完全在 JVM 中进行处理,而竞争的同步可能需要操作系统的介入,从而增加开销。当在锁上发生竞争时,竞争失败的线程肯定会阻塞。JVM 在实现阻塞行为时,可以采用自旋等待(Spin-Waiting,指通过循环不断地尝试获取锁,直到成功)或者通过操作系统挂起被阻塞的线程。目前大多数 JVM 在等待锁时都只是将线程挂起。
11.4 减少锁的竞争
有 3 种方式可以降低锁的竞争程度:
- 减少锁的持有时间
- 降低锁的请求频率
- 使用滴啊有协调机制的独占锁,这些机制允许更高的并发性
11.4.1 缩小锁的范围(“快进快出”)
降低发生竞争可能性的一种有效方式就是尽可能缩短锁的持有时间。例如,可以将一些与锁无关的代码移除同步代码块,尤其是那些开销较大的操作,以及可能被阻塞的操作,例如 I/O 操作。
// 代码清单 11-4
public class AttributeStore {
private final Map<String, String> attributes = new HashMap<>();
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);
}
}
}
上面代码中显然,我们真正需要同步的只是 attributes
这个 Map
而已,但是却使用了 synchronized
修饰了整个 userLocationMatches
方法。
// 程序清单 11-5
public class BetterAttributeStore {
private final Map<String, String> attributes = new HashMap<>();
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);
}
}
}
代码清单 11-5 中使用了 synchronized
只对 attributes
进行了同步。通过缩小 userLocationMatches
方法中锁的作用范围,能极大地减少在持有锁时需要执行的指令数量。
由于在 AttributeStore
中只有一个状态变量 attributes
,因此可以通过将线程安全性委托给其他的类来进一步提升它的性能(参见 4.3 节)。通过用线程安全的 Map
(Hashtable
、synchronizedMap
或 ConcurrentHashMap
)来代替 attributes
,AttributeStore
可以确保线程安全性的任务委托给顶层的线程安全容器来实现。
11.4.2 减小锁的力度
另一个减小锁的持有时间的方式是降低线程请求的频率(从而减小发生竞争的可能性)。这可以通过锁分解和锁分段等技术来实现,在这些技术中将采用多个相互独立的锁来保护独立的状态变量,从而改变这些变量在之前由单个锁来保护的情况。然而,使用的锁越多,那么发生死锁的风险也就越高。
// 程序清单 11-6
public class ServerStatus {
public final Set<String> users;
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);
}
}
上面代码中,虽然 users
和 queries
之间没有任何关系,但是所有的相关方法都是用了 synchronized
关键字来同步,因此两个相互独立的状态变量却使用了同一个锁来进行同步。
相互独立的状态变量,应该每一个状态都是用一个独立的锁来保护:
// 程序清单 11-7
public class ServerStatus {
public final Set<String> users;
public final Set<String> queries;
public synchronized void addUser(String u) {
synchronized (users) {
users.add(u);
}
}
public synchronized void addQuery(String q) {
synchronized (queries) {
queries.add(q);
}
}
}
可以通过上面的方法,对 users
和 queries
分别进行加锁。同样也可以将 users
和 queries
两个状态委托给一个线程安全的 Set
,而不是使用显式的同步,能隐含地对锁进行分解,因为每个 Set
都会使用一个不同的锁来保护器状态。
11.4.3 锁分段
在某些情况下,可以将锁分解技术进一步扩展为对一组对象上的锁进行分解,这种情况被称为锁分段。例如,在 ConcurrentHashMap
的实现中使用了一个包含 16 个锁的数组,每个所保护所有散列通的 1/16,其中第 N 个散列桶由第(N mod 16)个锁来保护。假设散列函数具有合理的分布性,并且关键字能够实现均匀分布,那么这大约能把对于锁的请求减少到原来的 1/16。这是这项技术使得 ConcurrentHashMap
能够支持多达 16 个并发的写入器。
锁分段的一个劣势在于:与采用单个锁实现独占访问相比,要获取多个锁来实现独占访问将更加困难并且开销更高。通常,在执行一个操作时最多只需获取一个锁,但在某些情况下需要加锁整个容器,例如当 ConcurrentHashMap
需要扩展映射范围,以及重新计算键值得散列值要分布到更大的桶集合中时,就需要获取分段锁集合中所有的锁。
下面的 程序清单 11-8 给出了基于散列的 Map
实现,其中使用了锁分段技术。
// 程序清单 11-8
public class StripedMap {
// 同步策略:buckets[n] 由 locks[n % N_LOCKS] 来保护
private static final int N_LOCKS = 16;
private final Node[] buckets;
private final Object[] locks;
private static class Node {
Object key;
Object value;
Node next;
}
public StripedMap(int numBuckets) {
buckets = new Node[numBuckets];
locks = new Object[N_LOCKS];
for (int i = 0; i < N_LOCKS; i++) {
locks[i] = new Object();
}
}
private final int hash(Object key) {
// 将一个 key 映射到某一个桶中
return Math.abs(key.hashCode() % buckets.length);
}
public Object get(Object key) {
int hash = hash(key);
synchronized (locks[hash % N_LOCKS]) { // 不同序号的桶可能由相同的锁来保护
for (Node m = buckets[hash]; m != null; m = m.next) {
if (m.key.equals(key)) {
return m.value;
}
}
}
return null;
}
public void clear() {
for (int i = 0; i < buckets.length; i++) {
synchronized (locks[i % N_LOCKS]) {
buckets[i] = null;
}
}
}
}
上面代码中,拥有 N_LOCKS
个锁,并且每个锁保护散列桶的一个子集。大多数方法,例如 get
,都只需要获得一个锁,而有些方法则需要获得所有的锁,并且不要求同时获得,例如 clear
方法的实现。
11.4.7 向对象池说 “不”
在 JVM 的早期版本中,对象分配和垃圾回收等操作的执行速度非常缓慢,但在后续的版本中,这些操作的性能得到了极大的提高。事实上,现在 Java 的分配操作已经比 C 语言的 malloc
调用更快:在 HotSpot 1.4.x 和 5.0 中,new Object
的代码大约只包含 10 条机器指令。
为了解决 “缓慢的” 对象生命周期问题,许多开发人员都选择使用对象池技术,在对象池中,对象能被循环使用,而不是由垃圾收集器回收并在需要时重新分配。
在并发程序中,对象池的表现更加糟糕。当线程分配新对象时,基本上不需要在线程之间进行协调,因为对象分配器通常会使用线程本地的内存块,所以不需要在堆数据结构上进行同步。然而,如果这些线程从对象池中请求一个对象,那么就需要通过某种同步来协调对对象池数据结构的访问,从而可能使某个线程被阻塞。如果某个线程由于所竞争而被阻塞,那么这种阻塞的开销将是内存分配操作开销的数百倍。虽然这看似是一种性能优化技术,但实际上却会导致可伸缩性问题。
事实上,对象池有其特定的用途。在特定环境中,例如 J2ME 或 RTSJ,需要对象池技术来提高内存管理或响应性管理的效率。在 Android 中,一个应用程序在堆中分配内存的上限是有限的,根据手机的 RAM 大小不同而不同。因此,在这种内存受限的环境中,使用线程池可能又是必要的:可以避免堆内存持续上涨,从而频繁触发主动 GC。