减少锁的竞争
在对某个独占锁保护的资源进行访问时,将采用串行方式,每次只有一个线程能访问它。
如果在锁上持续发生竞争,那么将限制代码的可伸缩性。
在并发程序中,对可伸缩性的最主要威胁就是独占方式的资源锁。
影响锁上发生竞争的可能性:
- 锁的请求频率
- 每次持有该锁的时间
如果二者的乘积很小,那么大多数获取锁的操作都不会发生竞争,因此在该锁上的竞争不会对可伸缩性造成严重影响。
如果在锁上的请求量很高,那么需要获取该所的线程将被阻塞并等待。
在极端情况下,即使有大量工作等待完成,处理器也会被闲置。
有三种方式可以降低锁的竞争程度:
- 减少锁的持有时间->缩小锁的范围,可将同步方法或同步代码块中与状态无关的操作分离出去,减小串行代码量;减小锁的粒度。
- 降低锁的请求频率->减小锁的粒度,将大锁分解或分段,减小并发时命中锁的概率。
- 使用带有协调机制的独占锁,这些机制将允许更高的并发性(分段锁)
缩小锁的范围(“快进快出”)
降低发生竞争可能性的一种有效方式就是尽可能缩短锁的持有时间。
例如:可以将一些与锁无关的代码移出同步代码块,尤其是那些开销比较大的操作, 以及可能被阻塞的操作,比如I/O操作。
@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);
}
}
上面的整个userLocationMatches
方法都使用了synchronized
来修饰,但只有Map.get
这个方法才真正需要锁。
@ThreadSafe
public class BatterAttributeStore {
@GuardedBy("this")
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);
}
}
通过缩小userLocationMatches
方法中锁的作用范围,能极大的减少在持有锁时需要执行的指令数量。
根据Amdahl定律,这样消除了限制可伸缩性的一个因素,因为穿行代码的总量减少了。
注:Amdahl定律 假定F是必须被串行执行的部分,那么根据Amdahl定律,在包含N个处理器的机器中,最高的加速比为: Speedup <= 1 / (F + (1-F) / N) 串行执行的代码越多,可伸缩性就越小,串行代码即需要同步的代码。
由于在AttributeStore
中只有一个状态变量 attributes
,因此可以通过将线程安全性委托给其他的类来进一步提升它的性能。
通过线程安全的Map
(HashTable
,synchronizedMap
,ConcurrentHashMap
)来代替attributes
,AttributeStore
可以将确保线程安全性的任务委托给顶层的线程安全容器来实现。
这样就无须在AttributeStore
中采用显式的同步,缩小在访问Map
期间锁的范围,并降低了将来代码维护者无意破坏线程安全性的风险。
(例如在访问attributes
之前忘记获得相应的锁)。
尽管缩小同步代码块能提高可伸缩性,但是同步代码块也不能过小,一些需要采用原子方式执行的操作
(例如对某个不变性条件中的多个变量进行更新)必须包含在一个同步代码块中。
此外,同步需要一定的开销,当把一个同步代码块分解为多个同步代码块时(在确保正确性的情况下),反而会对性能提升产生负面影响。
在分解同步代码块时,理想的平衡点将与平台相关, 但在实际情况中,仅当可以将一些“大量”的计算或阻塞操作从同步代码块中移出时,
才应该考虑同步代码块的大小。
注:如果JVM执行锁粒度粗化操作,那么可能将分解的同步块又重新合并起来。JVM锁粒度粗化将多个相邻的同步代码块合并起来执行,用来减少加锁成本。
虽然 Hotspot 不会对整个循环进行锁粗化,但是使用循环展开优化为锁粗化做了准备,因为循环展开后的代码就是 N 个连续的加锁代码块。这样既获得了性能收益,又限制了粗化的粒度,避免了对循环的过度粗化。
减小锁的粒度
另一种减小锁的持有时间的方式就是降低线程请求锁的频率(从而减小发生竞争的可能性)。
这可以通过锁分解和锁分段等技术来实现,在这些技术中将采用多个独立的锁来保护独立的状态变量,从而改变这些变量在之前
由单个锁来保护的情况。这些技术能减小锁操作的粒度,并能实现更高的可伸缩性,然而,使用的锁越多,那么发生死锁的风险
也就越高。
如果整个应用程序中只有一个锁,而不是为每个对象分配一个独立的锁,那么,所有同步代码块的执行将变成串行化执行,而不考虑各个同步
代码块中的锁。由于很多线程将竞争同一个全局锁,因此两个线程同时请求这个锁的概率将剧增,从而导致更严重的竞争,所以如果将这些锁请求
分布到更多的锁上,那么能有效的降低竞争程度。由于等待锁而被阻塞的线程将更少,因此可伸缩性将提高。
如果一个锁需要保护多个独立的状态变量,那么可以将这个锁分解为多个锁,并且每个锁只保护一个变量,从而提高可伸缩性,并最终降低每个锁被请求的频率。
@ThreadSafe
public class ServerStatus {
@GuardedBy("this")
public final Set<String> users;
@GuardedBy("this")
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);
}
}
如果上述代码不是用ServerStatus
来保护用户状态和查询状态,而是每个状态都通过一个锁来保护,对锁进行分解后,
每个新的细粒度锁上的访问量将比最初的访问量减少。
(通过将用户状态和查询状态委托给一个线程安全的Set
,而不是使用显式的同步,能隐含的对锁进行分解,因为每个Set
都会使用一个不同的锁来保护其状态)
@ThreadSafe
public class ServerStatusNew {
@GuardedBy("this")
public final Set<String> users;
@GuardedBy("this")
public final Set<String> queries;
public ServerStatusNew(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);
}
}
}
如果锁上存在适中而不是激烈的竞争时,通过将一个锁分解为两个锁,能最大限度的提升性能。
如果对竞争并不激烈的锁进行分解,那么在性能和吞吐量等方面的提升将非常有限,但是也会提高性能随着竞争提高而下降的拐点。
对竞争适中的锁进行分解时,实际上是把这些锁转变为非竞争的锁,从而有效的提高性能和可伸缩性。
锁分段
把一个竞争激烈的锁分解为两个锁时,这两个锁可能都存在激烈的竞争。
虽然采用两个线程并发执行能提高一部分的可伸缩性,但在一个拥有多核处理器的系统中,仍然无法给可伸缩性带来极大的提高。
在某些情况下,可以将锁分解技术进一步扩展为对一组独立对象上的锁进行分解,这种情况被称为锁分段。
-
优点:将一个大锁分解成多个小锁,能够提高伸缩性,降低单个锁的并发度。
-
缺点:与采用单个锁实现独占访问相比,要获取多个锁来实现独占访问将更加困难并且开销更高。
例如:在执行一个操作时最多只需要获取一个锁,但在某些情况下需要加锁整个容器,比如当ConcurrentHashMap
需要扩展映射范围,以及重新计算键值的散列值要分布到更大的容器中时,
就需要获取分段锁集合中所有的锁。
要获取内置锁的一个集合,能采用的唯一方式就是递归。
@ThreadSafe
public class StripeMap {
//同步策略:buckets[n]由locks[n%N_LOCKS]来保护
private static final int N_LOCKS = 16;
private final Node[] buckets;
private final Object[] locks;
public StripeMap(Node[] buckets, Object[] locks) {
this.buckets = buckets;
this.locks = locks;
}
private static class Node{
Node next;
Object key;
Object value;
}
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;
}
}
}
}
注:这种清除Map
的方式并不是原子操作,因此可能当StripedMap
为空时其他的线程正在并发地向其中添加元素。
如果要使该操作成为一个原子操作,那么需要同时获得所有的锁。然而,如果客户代码不加锁并发容器来实现独占访问,
那么像size
或isEmpty
这样的方法的计算结果在返回时可能会变得无效,因此,尽管这些行为有些奇怪,但通常是可以接受的。
避免热点域
锁分解或者锁分段技术都能提高可伸缩性,因为它们都能使不同的线程在不同的数据(或者同一个数据的不同部分)上操作,而不会互相干扰。
如果程序采用锁分段技术,那么一定要表现出在锁上的竞争频率高于在锁保护上发生竞争的频率。比如:如果一个锁保护两个独立变量X
和Y
,并且线程A
要访问X
,
线程B
要访问Y
(这类似于ServerStatus
中,一个线程调用addUser
,而另一个线程调用addQuery
),那么这两个线程不会在任何数据上发生竞争,即使它们
会在同一个锁上发生竞争。
当每个操作都请求多个变量时,锁的粒度将很难降低。这是在性能与可伸缩性之间相互制衡的一个方面,一些常见的优化措施,例如将一些反复计算的结果缓存起来,
都会引入一些“热点域(Hot Field
)”,而这些热点域往往会限制可伸缩性。
当实现HashMap
时,需要考虑如何在size
方法中计算Map
中的元素数量。最简单的方法就是,在每次调用时都统计一次元素的数量。一种常见的优化措施是,
在插入和移除元素时更新一个计数器,虽然在put
和remove
等方法中略微增加了一些开销,以确保计数器时最新的值,但这将size
方法的开销从O(n)
降到了
O(1)
在单线程或者采用完全同步的实现中,使用一个独立的计数能很好地提高类似size
和isEmpty
这些方法的执行速度,但却导致更难以提升实现的可伸缩性,因为每个修改
map
的操作都需要更新这个共享的计数器。即使使用锁分段技术来实现散列链,那么在计数器的访问进行同步时,也会重新导致在使用独占锁时存在的可伸缩性问题。
一个看似性能优化的措施——缓存size
操作的结果,已经变成了一个可伸缩性问题。在这种情况下,计数器也被称为热点域,因为每个导致元素数量发生变化的操作都
需要访问它。
为了避免这个问题,ConcurrentHashMap
中的size
将对每个分段进行枚举并将每个分段中的元素数量相加,而不是维护一个全局计数。为了避免枚举每个元素,
ConcurrentHashMap
为每个分段都维护了一个独立的计数,并通过每个分段的锁来维护这个值。
一些替代独占锁的方法
第三种降低竞争锁的影响的技术就是放弃使用独占锁,从而有助于使用一种友好并发的方式来管理共享状态。例如,使用并发容器,读-写锁,不可变对象以及原子变量。
ReadWriteLock
实现了一种在多个读取操作以及单个写入操作情况下的加锁规则:如果多个读取操作都不会修改共享资源,那么这些读取操作可以同时访问该共享资源,但在执行写入操作时必须以独占方式来获取锁。对于读取操作占大多数的数据结构,ReadWriteLock
能提供比独占锁更高的并发性。而对于只读的数据结构,其中包含的不变性可以完全不需要加锁操作。
原子变量提供了一种方式来降低更新“热点域”时的开销,例如静态计数器,序列发生器,或者对链表数据结构中头节点的引用。(例如使用AtomicLong
来维护Servlet
的计数器。)原子变量类提供了在整数或者对象引用上的细粒度原子操作(因此可伸缩性更高),并使用了现代处理器中提供的底层并发原语(例如比较并交换compare-and-swap CAS
)。如果在类中值包含少量的热点域,并且这些域不会与其他变量参与到不变性条件中,那么用原子变量来替代它们能提高可伸缩性。(通过减少算法中的热点域,可以提高可伸缩性–虽然原子变量能降低热点域的更新开销,但并不能完全消除。)
检测CPU的利用率
当测试可伸缩性时,通常要确保处理器得到充分利用。一些工具,例如UNIX
系统上的vmstat
和mpstat
,或者Windows
系统的perfmon
,都能给出处理器的“忙碌”状态。
如果所有CPU
的利用率并不均匀(有些CPU
忙碌地运行,而其它CPU
却并非如此),那么你的首要目标就是进一步找出程序中的并行性。不均匀的利用率表明大多数计算都是由一小组线程完成的,并且应用程序没有利用其它的处理器。
如果CPU
没有得到充分利用,那么需要找出其中的原因。通常由以下几种原因:
- 负载不充足。
测试的程序中可能没有足够多的负载,因而可以在测试时增加负载,并检查利用率,响应时间和服务时间等指标的变化。如果产生足够多的负载使应用程序达到饱和,那么可能需要大量的计算机能耗,并且问题可能在于客户端系统是否具有足够的能力,而不是被测试系统。
- I/O密集。
可以通过iostat或perfmon来判断某个应用程序是否是磁盘I/O密集型的,或者通过检测网络的通信流量级别来判断它是否需要高带宽。
- 外部限制。
如果应用程序以来于外部服务,例如数据库或Web服务,那么性能瓶颈可能不在自己的代码中。可以使用某个分析工具或数据库管理工具来判断在等待外部的结果时需要多少时间。
- 锁竞争。
使用分析工具可以知道在程序中存在何种程度的锁竞争,以及在那些锁上存在“激烈的竞争”。然而,也可以通过其他一些方式来获得相同的信息,例如随机抽样, 触发一些线程转储并在其中查找在锁上发生竞争的线程。如果线程由于等待某个锁而被阻塞,那么在线程转储信息中将存在相应的栈帧,其中包含的信息形如“waiting to lock monitor...”。非竞争的锁很少会出现在线程转储中,而对于竞争激烈的锁,通常至少会有一个线程在等待获取它,因此将在线程转储中频繁出现。