怎样减少锁的竞争

我们已经看到,串行操作会降低可伸缩性,并且上下文切换也会降低性能。在锁上发生竞争时将同时导致这两种问题,因此减少锁的竞争能够提高性能和可伸缩性。

在对由某个独占锁保护的资源进行访问时,将采用串行方式——每次只有一个线程能访问它。当然,我们有很好的理由来使用锁,例如避免数据被破坏,但获得这种安全性是需要付出代价的。如果在锁上持续发生竞争,那么将限制代码的可伸缩性。

在并发程序中,对可伸缩性的最主要威胁就是独占方式的资源锁。

有两个因素将影响在锁上发生竞争的可能性:锁的请求频率,以及每次持有该锁的时间⑤。如果二者的乘积很小,那么大多数获取锁的操作都不会发生竞争,因此在该锁上的竞争不会对可伸缩性造成严重影响。然而,如果在锁上的请求量很高,那么需要获取该锁的线程将被阻塞并等待。在极端情况下,即使仍有大量工作等待完成,处理器也会被闲置。

                

○   有时候,人们会拿这个方面与不包含“避让(Backoff)”机制的非阻塞算法相比较,因为在激烈的竞争下,非阻塞算法能比基于锁的算法产生更多的同步通信量。请参见第15章。

②   这是Little定律的必然结论,也是排队理论的一个推论,“在一个稳定的系统中,顾客的平均数量等于他们的平均到达率乘以在系统中的平均停留时间”(Little,1961)。

有3种方式可以降低锁的竞争程度:

·减少锁的持有时间。

·降低锁的请求频率。

·使用带有协调机制的独占锁,这些机制允许更高的并发性。

 缩小锁的范围(“快进快出”)

降低发生竞争可能性的一种有效方式就是尽可能缩短锁的持有时间。例如,可以将一些与锁无关的代码移出同步代码块,尤其是那些开销较大的操作,以及可能被阻塞的操作,例如I/O操作。

我们都知道,如果将一个“高度竞争”的锁持有过长的时间,那么会限制可伸缩性,例如在第2章中介绍的SynchronizedFactorizer的示例。如果某个操作持有锁的时间超过2毫秒并且所有操作都需要这个锁,那么无论拥有多少个空闲处理器,吞吐量也不会超过每秒500个操作。如果将这个锁的持有时间降为1毫秒,那么能够将这个锁对应的吞吐量提高到每秒1000个操作。⊖

程序清单11-4 给出了一个示例,其中锁被持有过长的时间。userLocationMatches方法在一个Map 对象中查找用户的位置,并使用正则表达式进行匹配以判断结果值是否匹配所提供的模式。整个userLocationMatches方法都使用了synchronized 来修饰,但只有Map. get 这个方法才真正需要锁。

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);

}

}

                                                                      

在程序清单11-5 的BetterAttributeStore中重新编写了AttributeStore,从而大大减少了锁的持有时间。第一个步骤是构建Map中与用户位置相关联的键值,这是一个字符串,形式为

                  

事实上,这里的计算仅考虑了锁的持有时间过长而导致的开销,而并没有考虑在锁的竞争中导致切换上下文而导致的开销。

users. name. location。这个步骤包括实例化一个StringBuilder对象,向其添加几个字符串,并将结果实例化为一个String 类型对象。在获得了位置后,就可以将正则表达式与位置字符串进行匹配。由于在构建键值字符串以及处理正则表达式等过程中都不需要访问共享状态,因此在执行时不需要持有锁。通过在BetterAttributeStore中将这些步骤提取出来并放到同步代码块之外,从而减少了锁被持有的时间。

                                 程序清单11-5减少锁的持有时间                                 

@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);

}

                           }                                                                  

通过缩小userLocationMatches方法中锁的作用范围,能极大地减少在持有锁时需要执行的指令数量。根据Amdahl定律,这样消除了限制可伸缩性的一个因素,因为串行代码的总量减少了。

由于在AttributeStore中只有一个状态变量attributes,因此可以通过将线程安全性委托给其他的类来进一步提升它的性能(参见4.3节)。通过用线程安全的Map (Hashtable、synchronizedMap或ConcurrentHashMap)来代替attributes,AttributeStore可以将确保线程安全性的任务委托给顶层的线程安全容器来实现。这样就无须在AttributeStore中采用显式的同步,缩小在访问Map期间锁的范围,并降低了将来的代码维护者无意破坏线程安全性的风险(例如在访问attributes 之前忘记获得相应的锁)。

尽管缩小同步代码块能提高可伸缩性,但同步代码块也不能过小——一些需要采用原子方式执行的操作(例如对某个不变性条件中的多个变量进行更新)必须包含在一个同步块中。此外,同步需要一定的开销,当把一个同步代码块分解为多个同步代码块时(在确保正确性的情况下),反而会对性能提升产生负面影响。在分解同步代码块时,理想的平衡点将与平台相关,但在实际情况中,仅当可以将一些“大量”的计算或阻塞操作从同步代码块中移出时,才应该考虑同步代码块的大小。

                 

○   如果JVM执行锁粒度粗化操作,那么可能会将分解的同步块又重新合并起来。

 减小锁的粒度

另一种减小锁的持有时间的方式是降低线程请求锁的频率(从而减小发生竞争的可能性)。这可以通过锁分解和锁分段等技术来实现,在这些技术中将采用多个相互独立的锁来保护独立的状态变量,从而改变这些变量在之前由单个锁来保护的情况。这些技术能减小锁操作的粒度,并能实现更高的可伸缩性,然而,使用的锁越多,那么发生死锁的风险也就越高。

设想一下,如果在整个应用程序中只有一个锁,而不是为每个对象分配一个独立的锁,那么,所有同步代码块的执行就会变成串行化执行,而不考虑各个同步块中的锁。由于很多线程将竞争同一个全局锁,因此两个线程同时请求这个锁的概率将剧增,从而导致更严重的竞争。所以如果将这些锁请求分布到更多的锁上,那么能有效地降低竞争程度。由于等待锁而被阻塞的线程将更少,因此可伸缩性将提高。

如果一个锁需要保护多个相互独立的状态变量,那么可以将这个锁分解为多个锁,并且每个锁只保护一个变量,从而提高可伸缩性,并最终降低每个锁被请求的频率。

在程序清单11-6的ServerStatus 中给出了某个数据库服务器的部分监视接口,该数据库维护了当前已登录的用户以及正在执行的请求。当一个用户登录、注销、开始查询或结束查询时,都会调用相应的add 和remove等方法来更新ServerStatus对象。这两种类型的信息是完全独立的,ServerStatus甚至可以被分解为两个类,同时确保不会丢失功能。

                           程序清单11-6对锁进行分解                                 

@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);

}

}

在代码中不是用ServerStatus锁来保护用户状态和查询状态,而是每个状态都通过一个锁来保护,如程序清单11-7所示。在对锁进行分解后,每个新的细粒度锁上的访问量将比最初的访问量少。(通过将用户状态和查询状态委托给一个线程安全的Set,而不是使用显式的同步,能隐含地对锁进行分解,因为每个Set都会使用一个不同的锁来保护其状态。)

                           程序清单11-7   将ServerStatus重新改写为使用锁分解技术                     

@ThreadSafe

public class ServerStatus {

@GuardedBy("users") public final Set<String>users;

@GuardedBy("queries") public final Set<String>queries;

...

public void add'ser(String u){

synchronized (users){

users. add(u);

}

}

public void addQuery(String q){

synchronized (queries){

queries. add(q);

}

}

去掉同样被改写为使用被分解锁的方法

}

                                                                    

如果在锁上存在适中而不是激烈的竞争时,通过将一个锁分解为两个锁,能最大限度地提升性能。如果对竞争并不激烈的锁进行分解,那么在性能和吞吐量等方面带来的提升将非常有限,但是也会提高性能随着竞争提高而下降的拐点值。对竞争适中的锁进行分解时,实际上是把这些锁转变为非竞争的锁,从而有效地提高性能和可伸缩性。

  锁分段

把一个竞争激烈的锁分解为两个锁时,这两个锁可能都存在激烈的竞争。虽然采用两个线程并发执行能提高一部分可伸缩性,但在一个拥有多个处理器的系统中,仍然无法给可伸缩性带来极大的提高。在ServerStatus类的锁分解示例中,并不能进一步对锁进行分解。

在某些情况下,可以将锁分解技术进一步扩展为对一组独立对象上的锁进行分解,这种情况被称为锁分段。例如,在ConcurrentHashMap的实现中使用了一个包含16个锁的数组,每个锁保护所有散列桶的1/16,其中第N个散列桶由第(Nmod 16)个锁来保护。假设散列函数具有合理的分布性,并且关键字能够实现均匀分布,那么这大约能把对于锁的请求减少到原来的1/16。正是这项技术使得ConcurrentHashMap能够支持多达16个并发的写入器。(要使得拥有大量处理器的系统在高访问量的情况下实现更高的并发性,还可以进一步增加锁的数量,但仅当你能证明并发写入线程的竞争足够激烈并需要突破这个限制时,才能将锁分段的数量超过默认的16个。)

锁分段的一个劣势在于:与采用单个锁来实现独占访问相比,要获取多个锁来实现独占访问将更加困难并且开销更高。通常,在执行一个操作时最多只需获取一个锁,但在某些情况下需要加锁整个容器,例如当ConcurrentHashMap需要扩展映射范围,以及重新计算键值的散列值要分布到更大的桶集合中时,就需要获取分段所集合中所有的锁。         θ

在程序清单11-8的StripedMap中给出了基于散列的Map实现,其中使用了锁分段技术。它拥有N   LOCKS个锁,并且每个锁保护散列桶的一个子集。大多数方法,例如 get,都只需要获得一个锁,而有些方法则需要获得所有的锁,但并不要求同时获得,例如clear

——

⊖   要获取内置锁的一个集合,能采用的唯一方式是递归。

㊀方法的实现。

   程序清单11-8在基于散列的 Map 中使用锁分段技术                                       

@ThreadSafe

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 {…}

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 -lear(){

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(请参见第13章)实现了一种在多个读取操作以及单个写入操作情况下的加锁规则:如果多个读取操作都不会修改共享资源,那么这些读取操作可以同时访问该共享资源,但在执行写入操作时必须以独占方式来获取锁。对于读取操作占多数的数据结构,ReadWriteLock能提供比独占锁更高的并发性。而对于只读的数据结构,其中包含的不变性可以完全不需要加锁操作。

原子变量(请参见第15章)提供了一种方式来降低更新“热点域”时的开销,例如静态计数器、序列发生器、或者对链表数据结构中头节点的引用。(在第2章的示例中使用了AtomicLong 来维护Servlet 的计数器。)原子变量类提供了在整数或者对象引用上的细粒度

                 

果size 法的调用频率与修改Map操作的执行频率大致相当,那么可以采用这种方式来优化所有已分段的数据结构,即每当调用size时,将返回值缓存到一个volatile 变量中,并且每当容器被修改时,使这个缓存中的值无效(将其设为-1)。如果发现缓存的值非负,那么表示这个值是正确的,可以直接返回,否则,需要重新计算这个值。

原子操作(因此可伸缩性更高),并使用了现代处理器中提供的底层并发原语(例如比较并交换[compare-and-swap])。如果在类中只包含少量的热点域,并且这些域不会与其他变量参与到不变性条件中,那么用原子变量来替代它们能提高可伸缩性。(通过减少算法中的热点域,可以提高可伸缩性——虽然原子变量能降低热点域的更新开销,但并不能完全消除。)

   监测CPU的利用率

当测试可伸缩性时,通常要确保处理器得到充分利用。一些工具,例如UNIX系统上的vmstat 和mpstat,或者Windows 系统的perfmon,都能给出处理器的“忙碌”状态。

如果所有CPU的利用率并不均匀(有些CPU在忙碌地运行,而其他CPU却并非如此),那么你的首要目标就是进一步找出程序中的并行性。不均匀的利用率表明大多数计算都是由一小组线程完成的,并且应用程序没有利用其他的处理器。

如果CPU没有得到充分利用,那么需要找出其中的原因。通常有以下几种原因:

负载不充足。测试的程序中可能没有足够多的负载,因而可以在测试时增加负载,并检查利用率、响应时间和服务时间等指标的变化。如果产生足够多的负载使应用程序达到饱和,那么可能需要大量的计算机能耗,并且问题可能在于客户端系统是否具有足够的能力,而不是被测试系统。

I/O密集。可以通过iostat 或perfmon 来判断某个应用程序是否是磁盘I/O密集型的,或者通过监测网络的通信流量级别来判断它是否需要高带宽。

外部限制。如果应用程序依赖于外部服务,例如数据库或Web服务,那么性能瓶颈可能并不在你自己的代码中。可以使用某个分析工具或数据库管理工具来判断在等待外部服务的结果时需要多少时间。

锁竞争。使用分析工具可以知道在程序中存在何种程度的锁竞争,以及在哪些锁上存在“激烈的竞争”。然而,也可以通过其他一些方式来获得相同的信息,例如随机取样,触发一些线程转储并在其中查找在锁上发生竞争的线程。如果线程由于等待某个锁而被阻塞,那么在线程转储信息中将存在相应的栈帧,其中包含的信息形如“waiting to lock monitor…”。非竞争的锁很少会出现在线程转储中,而对于竞争激烈的锁,通常至少会有一个线程在等待获取它,因此将在线程转储中频繁出现。

如果应用程序正在使CPU保持忙碌状态,那么可以使用监视工具来判断是否能通过增加额外的CPU来提升程序的性能。如果一个程序只有4个线程,那么可以充分利用一个4路系统的计算能力,但当移植到8路系统上时,却未必能获得性能提升,因为可能需要更多的线程才会有效利用剩余的处理器。(可以通过重新配置程序将工作负载分配给更多的线程,例如调整线程池的大小。)在vmstat 命令的输出中,有一栏信息是当前处于可运行状态但并没有运行(由于没有足够的CPU)的线程数量。如果CPU的利用率很高,并且总会有可运行的线程在等待CPU,那么当增加更多的处理器时,程序的性能可能会得到提升。

  向对象池说“不”

在JVM的早期版本中,对象分配和垃圾回收等操作的执行速度非常慢,但在后续的版本中,这些操作的性能得到了极大提高。事实上,现在Java 的分配操作已经比C语言的malloc 调用更快:在HotSpot1.4. x和5.0中,“new Object”的代码大约只包含10条机器指令。

为了解决“缓慢的”对象生命周期问题,许多开发人员都选择使用对象池技术,在对象池中,对象能被循环使用,而不是由垃圾收集器回收并在需要时重新分配。在单线程程序中(Click,2005),尽管对象池技术能降低垃圾收集操作的开销,但对于高开销对象以外的其他对象来说,仍然存在性能缺失⑤(对于轻量级和中量级的对象来说,这种损失将更为严重)。

在并发应用程序中,对象池的表现更加糟糕。当线程分配新的对象时,基本上不需要在线程之间进行协调,因为对象分配器通常会使用线程本地的内存块,所以不需要在堆数据结构上进行同步。然而,如果这些线程从对象池中请求一个对象,那么就需要通过某种同步来协调对对象池数据结构的访问,从而可能使某个线程被阻塞。如果某个线程由于锁竞争而被阻塞,那么这种阻塞的开销将是内存分配操作开销的数百倍,因此即使对象池带来的竞争很小,也可能形成一个可伸缩性瓶颈。(即使是一个非竞争的同步,所导致的开销也会比分配一个对象的开销大。)虽然这看似是一种性能优化技术,但实际上却会导致可伸缩性问题。对象池有其特定的用途。但对于性能优化来说,用途是有限的。

通常,对象分配操作的开销比同步的开销更低。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

伟大先锋

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值