在并发程序中,对可伸缩性的最主要威胁就是独占方式的资源锁,有3中方式可以降低锁的竞争程度:
- 减少锁的持有时间
- 降低锁的请求频率
- 使用带有协调机制的独占锁
减少锁的范围
降低发生竞争可能性的一种有效方式就是缩短锁的持有时间。例如,将一些与锁无关的代码移除同步代码块,尤其是那些开销较大的操作,以及可能被阻塞的操作,例如I/O操作。
比如在以下程序清单中,其中的锁被持有过长的时间。userLocationMatches方法在一个Map对象中查找用户的位置,并使用正则表达式进行匹配以判断结果值是否匹配所提供的模式。整个userLocationMatches方法都使用了synchronized来修饰,但只有Map.get这个方法才真正需要锁。
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Pattern;
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);
}
}
重写以上方法。
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Pattern;
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);
}
}
第一个步骤是构建Map中与用户位置相关的键值,这是一个字符串,形式为users,name.location。这个步骤包括实例化一个StringBuilder对象,向其中添加几个字符串,并将结果实例化为一个String类型对象。在获得了位置后,就可以将正则表达式与位置字符串进行匹配,由于在进行构建键值字符串以及处理正则表达式等过程中都不需要访问共享状态, 因此在执行时不需要持有锁。通过以上步骤从而减少了锁被持有的时间。
通过缩小userLocationMatches方法中锁的作用范围,能最大地减少在持有锁时需要的指令数量。根据Amdahl定律,这样消除了限制可伸缩性的一个因素,因为串行代码的总量减少了。
由于在AttributeStore 中只有一个状态变量attributes,因此可以通过将线程安全性委托给其他的类来进一步提升它的性能。通过使用线程安全的Map(Hashtable、synchronizedMap或ConcurrentHashMap)来代替HashMap,这样就可以将AttributeStore的线程安全性委托给顶层的线程安全容器来实现。这样就无需采用显式的同步,缩小在访问Map期间锁的范围,并降低了将来的代码维护者无意破坏线程安全性的风险。
尽管缩小同步代码块能提高可伸缩性,但同步代码块也不能过小——一些需要采用原子方式执行的操作(例如对某个不变性条件总的多个变量进行更新)必须包含在一个同步块中。此外,同步需要一定的开销,当把一个同步代码块分解为多个同步代码块时,理想的平衡点将与平台相关,但是实际情况中,仅当可以将一些"大量"的计算或者阻塞操作从同步代码块中移出时,才应该考虑同步代码块的大小。
减小锁的粒度
另一种减少锁的持有时间的方式是降低线程请求锁的频率(从而减少发生竞争的可能性)。这可以通过锁分解和锁分段等技术来实现。
锁分解
如果一个锁需要保护多个相互独立的状态变量,那么可以将这个锁分解为多个锁,并且每个锁只保护一个变量,从而提高可伸缩性,并最终降低每个锁被请求的频率。
在下面的程序清单中,在ServerStatus类中维护了登录用户和正在执行的查询信息,当一个用户登录、注销、开始查询或者结束查询时,都会调用对应的add和remove等方法来更新对象。
import java.util.Set;
@ThreadSafe
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);
}
}
但这两种类型的信息是完全独立的,ServerStaus甚至可以被分解为两个类,同时确保不会丢失功能。在以下代码中,使用锁分解技术重新改谢ServerStatus类,不再使用ServerStatus锁来保护用户状态和查询状态,而是每个状态都同一个锁来保护。在对锁进行了分解之后,每个新的细粒度锁上的访问量将比最初的访问量少。
import java.util.Set;
@ThreadSafe
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);
}
}
}
如果在锁上存在适中而不是激烈的竞争时,通过将一个锁分解为两个锁,能最大限度地提升性能。如果对竞争不激烈的锁进行分解,那么在性能和吞吐量等方面带来的提升将非常有限,但是也会提高性能随着竞争提高而下降的拐点值。对竞争适度的锁进行分解时,实际上是把这些锁转变为非竞争的锁,从而有效地提升性能和可伸缩性。
锁分段
把一个竞争激烈的锁分解为两个锁时,这两个锁都可能存在激烈的竞争。在某些情况下,可以将锁分解技术进一步扩展为对一组独立对象上的锁进行分解,这种情况被称为锁分段。
在ConcurrentHashMap的实现中使用了一个包含16个锁的数组,每个锁保护所有散列桶的1/16,其中第N个散列桶由第(N mod 16)个锁来保护。假设散列函数具有合理的分布性,并且关键字能够实现均匀分布,那么这大约能把对于锁的请求减少到原来的1/16。正是这项技术使得ConcurrentHashMap能够支持多达16个并发的写入器。
在以下程序清单中给出了基于散列的Map实现,其中使用了锁分段技术。它拥有N_LOCKS个锁,并且每个锁保护散列桶的一个子集,大多数方法,例如get,都只需要获得一个锁,而有些方法则需要获得所有的锁,但并不要求同时获得,例如clear方法的实现。
@ThreadSafe
public class StripedMap {
private static final int N_LOCKS = 16;
private final Node[] buckets;
private final Object[] locks;
private static class Node {
...
}
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) {
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;
}
}
}
...
}
锁分段的劣势在于:与采用单个锁来实现独占访问相比,要获得多个锁来实现独占访问将更加困难并且开销更高。通常,在执行一个操作时最多只需要一个锁,但是在某些情况下,将加锁整个容器,例如当ConcurrentHashMap需要扩散映射范围,以及重新计算键值的散列值要分布到更大的桶集合中时,就需要获取分段锁集合中所有的锁。
避免热点域
锁分解和锁分段技术都能提升可伸缩性,因为它们都能使不同的线程在不同的数据(或者同一个数据的不同部分)上操作,而不会相互干扰。如果程序采用锁分段技术,那么一定要表现出在锁上的竞争频率高于在锁保护的数据上发生竞争的频率。如果一个锁保护的两个独立变量X和Y,并且线程A想要访问X,而线程B想要访问Y,即使两个线程会在同一个锁上发生竞争,但是它们不会在任何数据上发生竞争(前面锁分解的案例)。
当每个操作都请求多个变量时,锁的粒度将很难降低。这是在性能与可伸缩性之间相互制衡的另一个方面,一些常见的优化措施,例如将一些反复计算的结果缓存起来,都会引入一些”热点域(Hot Field)”,而这些热点域往往会限制可伸缩性。
当实现HashMap时,你需要考虑如何在size方法中计算Map中的元素数量,最简单的一个方法是,在每次调用时都统计一次元素的数量。一种常见的优化措施是,在插入和移除元素时更新一个计数器,虽然这在put和romove等方法中略微增加了一些开销,以确保计数器是最新的值,但这将把size方法的开销从O(n)降低为O(1)。
在单线程或者采用完全同步的实现中,使用一个独立的计数能够很好地提高类似size和isEmpty这些方法的执行速度,但却导致更难以提升实现的可伸缩性,因为每个修改map的操作都需要更新这个共享的计数器。即使使用锁分段计数来实现散列链,那么在对计数器的访问进行同步时,也会重新导致在使用独占锁时存在的可伸缩性问题。一个看似性能优化的措施——缓存size操作的结果,已经变成了一个可伸缩性问题。在这种情况下,计数器也被称为热点域,因为每个导致元素数量发生变化的操作都需要访问它。
为了避免这个问题,ConcurrentHashMap中的size将对每个分段进行枚举并将每个分段中的元素数量相加,而不是维护一个全局计数。为了避免枚举每个元素,ConcurrentHashMap为每个分段都维护了一个独立的计数,并通过每个分段的锁来维护这个值。
一些替代独占锁的方法
第三种降低竞争锁的影响的技术是放弃使用独占锁,从而有助于使用一种友好并发的方式来管理共享状态,例如,使用并发容器、读-写锁、不可变对象以及原子变量。
ReadWriteLock实现了一种在多个读取操作以及单个写入操作情况下的加锁规则:如果多个读取操作都不会修改共享资源,那么这些读取操作可以同时访问该共享资源,但是执行写入操作必须以独占方式来获取锁。对于读取操作占多数的数据结构,ReadWriteLock能提供比独占锁更高的并发性,而对于只读的数据结构,其中包含的不变性可以完全不需要加锁操作。
原子变量提供了一种方式来降低更新热点域时的开销,例如静态计数器、序列发生器、或者对链表数据结构中头节点的引用。原子变量类提供了在整数或者对象易用上的细粒度原子操作(因此可伸缩性更高),并使用了现代处理器中提供的底层并发原语(compare-and-swap).如果在类中只包含少量的热点域,并且这些域不会与其他变量参与到不变性条件中,那么用原子变量来替代它们提高可伸缩性。
|