独占锁, 避免热点域,分离锁

来源:互联网 发布:php pdo 教程 编辑:程序博客网 时间:2024/04/28 00:30
 缩小锁的范围(“快进快出”)

减小竞争发生可能性的有效方式是尽可能缩短把持锁的时间。这可以通过把与锁无关的代码移出synchronized块来实现,尤其是那些花费“昂贵”的操作,以及那些潜在的阻塞操作,比如I/O操作。

我们很容易观察到长时间持有“热门”锁究竟是如何限制可伸缩性的;我们在第2章看到了SynchronizedFactorizer的例子。如果一个操作持有锁超过2毫秒并且每一个操作都需要那个锁,吞吐量不会超过每秒500个操作,无论你拥有多少个空闲处理器。减少持有这个锁的时间到1毫秒,能够把这个与锁相关的吞吐量提高到每秒1 000个操作8。

清单11.4展示了一个例子,持有锁的时间超过了必要的时间。userLocationMatches方法在Map中查找用户的位置,使用正则表达式进行匹配,看得到的结果值是否符合提供的模式。整个userLocationMatches方法都是synchronized的,但是只有Map.get这一部分是真正需要调用锁的。

清单11.4  持有锁超过必要的时间

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

     }

}

清单11.5 中的BetterAttributeStore 重写了AttributeStore,从而大大减少了锁的持续时间。第一个步骤是构建Map的key与用户位置相关联,形式为:字符串users.name.location。这需要实例化一个StringBuilder对象,并向其添加几个字符串,并实例化String类型的返回值。在获得了位置之后,就用正则表达式与位置的结果字符串进行匹配。因为构建key字符串和处理正则表达事都不需要访问共享状态,因而不需要将它们置于锁守护的范围之内。BetterAttributeStore把这些步骤分解到synchronized块之外了,因此减少了占有锁的时间。

清单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,我们可以使用代理线程安全(delegating thread safety)的技术(第4.3节)。通过使用线程安全的Map(Hashtable、synchronizedMap,或者ConcurrentHashMap)来取代attributes,AttributeStore可以通过底层线程安全的容器来代理所有线程安全的职责。这样能够省去AttributeStore中显式的同步,减少Map访问中锁的范围,并减小了未来的维护者因为忘记在访问attributes前获得相应锁而造成的风险。

尽管缩小synchronized块能够提高可伸缩性,synchronized块可以变得极小——需要原子化的操作(比如在限定约束的情况下更新多个变量)必须包含在一个synchronized

块中。并且因为同步的开销非零,把一个synchronized块分拆成多个synchronized块(保证正确的情况下),在某些时刻反而会对性能产生反作用9。理想的平衡当然是与平台相关的,但是在实践中,仅当你能够“切实”地把计算和阻塞操作从synchronized块中移开,这时担心synchronized块的大小才是有意义的。

11.4.2  减小锁的粒度

减小持有锁的时间比例的另一种方式是让线程减少调用它的频率(因此减小发生竞争的可能性)。这可以通过分拆锁(lock splitting)和分离锁(lock striping)来实现,也就是采用相互独立的锁,守卫多个独立的状态变量,在改变之前,它们都是由一个锁守护的。这些技术减小了锁发生时的粒度,潜在实现了更好的可伸缩性——但是使用更多的锁同样会增加死锁的风险。

做一个思想实验,想象如果整个应用程序只有一个锁,而不是为每一个对象分配一个独立的锁。那么,执行所有的synchronized块,不考虑它们的锁的情况,就会成为串行化执行。因为很多线程都在竞争相同的全局锁,因此两个线程同时请求同一个锁的情况增加了,导致了更多的竞争。所以如果对于锁的请求替代为请求锁的大集合,就会减少很多竞争。更少的线程会因为等待锁而发生阻塞,因此增加了可伸缩性。

如果一个锁守卫数量大于一、且相互独立的状态变量,你可能能够通过分拆锁,使每一个锁守护不同的变量,从而改进可伸缩性。结果是每个锁被请求的频率都减小了。

清单11.6中的ServerStatus展现了一个数据库服务器监视器接口的一部分,它维护了当前已登录的用户和当前正在执行的请求。当一个用户登录、注销、执行请求开始或结束的时候,ServerStatus对象通过调用适当的add和remove方法进行更新。两种类型的信息完全是独立的,ServerStatus甚至能够被分拆成两个类,不会影响到功能。

不使用ServerStatus的锁守护users和queries,我们能够通过两个分离的锁来进行守卫的工作,正如清单11.7中所示。在分拆了锁之后,每一个新的更精巧的锁,相比于那些原始的粗糙锁,将会看到更少的通信量。(对于users和queries,使用线程安全的Set对其进行代理,而不是使用显式的同步,这样做隐式地分拆了锁,因为每一个Set会使用不同的锁来守护其状态。)

当我们希望对竞争不是很激烈的锁进行改进时,把一个锁分拆为两个,针对这种改进提供了最大的可能性。分拆锁对于竞争并不激烈的锁,能够在性能和吞吐量方面产生一些纯粹的改进,尽管这可能会在性能开始因为竞争而退化时增加负载的极限。分拆锁对于

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

     }

}

清单11.7  使用分拆的锁重构ServerStatus

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

         }

     }

     // 使用分拆时对remove进行类似的重构

}

中等竞争强度的锁,能够切实地把它们大部分转化成非竞争的锁,这个结果是性能和可伸缩性都期望得到的。

11.4.3  分离锁

把一个竞争激烈的锁分拆成两个,很可能形成两个竞争激烈的锁。尽管这可以通过两个线程并发执行,取代一个线程,从而对可伸缩性有一些小的改进,但这仍然不能大幅地提高多个处理器在同一系统中并发性的前景。作为分拆锁的例子,ServerStatus类并没有提供明显的机会来进行进一步分拆。

分拆锁有时候可以被扩展,分成可大可小加锁块的集合,并且它们归属于相互独立的对象,这样的情况就是分离锁。例如,ConcurrentHashMap的实现使用了一个包含16个锁的Array,每一个锁都守护Hash Bucket的1/16;Bucket N 由第N mod 16 个锁来守护。假设哈希提供合理的拓展特性,并且关键字能够以统一的方式访问,这将会把对于锁的请求减少到约为原来的1/16。这项技术使得ConcurrentHashMap能够支持16个并发的Writer。(为了对多处理器系统的大负荷访问提供更好的并发性,这里锁的数量还可以增加,但是只有当你有足够的证据证明并发Writer的竞争强度足够大时,你可以增大锁的数量超过默认的16,合理突破这个限制。)

分离锁的一个负面作用是:对容器加锁,进行独占访问更加困难,并且更加昂贵了。通常,一个操作可以通过获取最多不超过一个锁来进行,但是有个别的情况需要对整个容器加锁,比如当ConcurrentHashMap的值需要被扩展、重排,放入一个更大的Bucket时。这是获取所有分离的锁最典型的例子10。

清单11.8中,StripedMap阐释了基于哈希的Map实现,其中用到了锁分离。它拥有N_LOCKS个锁,每一个守护Bucket的一个子集,大部分方法,比如get,只需要得到一个Bucket锁。有些方法需要获得所有的锁,但是如clear方法实现的那样,并不需要同时获得所有这些锁11。

11.4.4  避免热点域

分拆锁和分离锁能够改进可伸缩性,因为它们能够使不同的线程操作不同的数据(或者相同数据结构的不同部分),而不会发生相互干扰。能够从分拆锁受益的程序,通常是

清单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 clear() {

         for (int i = 0; i < buckets.length; i++) {

             synchronized (locks[i % N_LOCKS]) {

                 buckets[i] = null;

             }

         }

     }

     ...

}

那些对锁的竞争普遍大于对锁守护数据竞争的程序。如果一个锁守护两个独立变量XY,线程A想要访问X,而线程B想要访问Y(这就好像ServerStatus中一个线程调用了addUser,而另一个调用了addQuery),这两个线程没有竞争任何数据,然而它们竞争相同的锁。

当每一个操作都请求变量的时候,锁的粒度很难被降低。这是性能和可伸缩性相互牵制的另一个方面;通常使用的优化方法,比如缓存常用的计算结果,会引入“热点域(hot fields)”,从而限制可伸缩性。

如果由你来实现HashMap,你会遇到一个选择:size方法如何计算Map条目的大小?最简单的方法是每次调用的时候数一遍。通常使用的优化方法是在插入和移除的时候更新一个单独的计数器;这会给put和remove方法造成很小的开销,以保证计数器的更新,但是,这会减少size方法的开销,从O(n)减至O(1)。

在单线程或完全同步的实现中,保存一个独立的计数能够很好地提高类似size和isEmpty这样的方法的速度,但是却使改进可伸缩性变得更难了,因为每一个修改map的操作都要更新这个共享的计数器。即使你对每一个哈希链(hash chain)都使用了锁的分离,对计数器独占锁的同步访问还是重新引入了可伸缩性问题。这看起来像是一个性能的优化——缓存size操作的结果——却已经转化为一个可伸缩性问题。这种情况下,计数器被称为热点域(hot field),因为每个变化操作都要访问它。

为避免这个问题,ConcurrentHashMap通过枚举每个条目获得size,并把这个值加入到每个条目,而不是维护一个全局计数。为了避免列举所有元素,ConcurrentHashMap为每一个条目维护一个独立的计数域。同样由分离的锁守护12。

11.4.5  独占锁的替代方法

用于减轻竞争锁带来的影响的第三种技术是提前使用独占锁,这有助于使用更友好的并发方式进行共享状态的管理。这包括使用并发容器、读-写锁、不可变对象,以及原子变量。

ReadWriteLock(参见第13章)实行了一个多读者-单写者(multiple-reader, single-write)加锁规则:只要没有更改,那么多个读者可以并发访问共享资源,但是写者必须独占获得锁。对于多数操作都为读操作的数据结构,ReadWriteLock与独占的锁相比,可以提供更好的并发性;对于只读的数据结构,不变性可以完全消除加锁的必要。

原子变量(参见第15章)提供了能够减少更新“热点域”的方式,如静态计数器、

序列发生器、或者对链表数据结构头节点的引用。(第2章中的例子中,我们使用AtomicLong来维护Servlet的计数器。)原子变量类提供了针对整数或对象引用的非常精妙的原子操作(因此更具可伸缩性),并且使用现代处理器提供的低层并发原语,比如比较并交换(compare-and-swap)实现。如果你的类只有少量热点域,并且该类不参与其他变量的不变约束,那么使用原子变量替代它可能会提高可伸缩性。(改变你的代码,减少它的热点域,这样会提高可伸缩性,甚至——原子变量减少更新热点域的开销,但是它们完全消除这种开销。)

11.4.6  监测CPU利用率

当我们测试可伸缩性的时候,我们的目标通常是保持处理器的充分利用。Unix系统的vmstat和mpstat,或者Windows系统的perfmon都能够告诉你处理器有多“忙碌”。

如果所有的CPU都没有被均匀地利用(有时CPU很忙碌地运行,有时候很“清闲”),那么你的首要目标应该是增强你程序的并行性。不均匀的利用率表明,大多数计算都由很小的线程集完成,你的应用程序将不能够利用额外的处理器资源。

如果你的CPU没有完全利用,你需要找出原因。有下面几种原因:

不充足的负载。可能被测试的程序还没有被加入足够多的负载。你可以增加负载,并检查利用率、响应时间和服务运行时间的变化。产生足够多的负载,使应用程序饱和需要计算机强大的能力;问题可能在于客户端系统是否具有足够的能力,而不是被测试系统。

I/O限制。你可以通过iostat或者perfmon判定一个应用程序是否是受限于磁盘的,或者通过监测它的网络通信量级判断它是否有带宽限制

外部限制。如果你的应用程序取决于外部服务,比如数据库,或者Web Service,那么瓶颈可能不在于你自己的代码。你可以通过使用Profiler工具,或者数据库管理工具来判断等待外部服务结果的用时。

锁竞争。使用Profiling工具能够告诉你,程序中存在多少个锁的竞争,哪些锁很“抢手”。 如果不使用剖析器(profiler),你也可以通过随机取样来获得相同的信息,触发一些线程转储,并寻找其中线程竞争锁的信息。 如果线程因等待锁被阻塞,与线程转储相应的栈框架会声明“waiting to lock monitor . . .”。非竞争的锁几乎不会出现在线程转储中;竞争激烈的锁几乎总会至少有一个线程在等待获得它,所以会频繁出现在线程转储中。

如果你的应用程序能够保持CPU处于忙碌状态,你可以使用监视工具来判断是否能通过增加额外的CPU受益。一个程序如果只有4个线程,那么可能能够保持充分利用一个4路系统,但是移植到8路未必能看到性能的提升,因为很有可能需要等待可运行的线程来利用剩余的处理器资源。(你可能能够通过重新配置程序,把工作量分配给更多的线程,比如调整线程池大小。)vmstat报告的一项是处于可运行态的线程数,但是这并不是当前运行中的线程数,因为有CPU不可用。如果CPU的利用率很高,并且没有并发的可运行线程等待CPU,你的程序也许能够很好地利用更多的处理器资源。

11.4.7  向“对象池”说“不”

在早期的JVM版本中,对象的分配(allocation)和垃圾回收是非常慢的13,但是它们的性能在那之后有了本质的提高。事实上,Java中的分配现在已经比C语言中的malloc更快了:在HotSpot 1.4.x 和 5.0中,new Object的代码路径几乎只有十个机器指令。

针对对象的“慢”生命周期,很多程序员都会选择使用对象池化技术(object pooling),这项技术中,对象会被循环使用,而不是由垃圾收集器回收并在需要时重新分配。在单线程化的程序中,即使考虑到减少的垃圾收集开销,对象池化技术对于所有不那么“昂贵”的对象(最严重的损失是轻量和中量级对象)仍然存在性能缺失14(Click, 2005)。

在并发的应用程序中,池化表现得更糟糕。当线程分配新的对象时,需要线程内部非常细微的协调,因为分配运算通常使用线程本地的分配块来消除对象堆中的大部分同步。但是,如果这些线程从池中请求对象,那么协调访问池的数据结构的同步就成为必然了,这便产生了线程阻塞的可能性。又因为由锁的竞争产生的阻塞,其代价比直接分配的代价多几百倍,即使是一个很小的池竞争都会造成可伸缩性的瓶颈。(甚至是非竞争的同步,其代价也会比分配一个对象大很多。)这虽然被认为是性能优化的另一技术,但是会产生可伸缩性危险。池化有其用途15,但是对于性能优化来说,使用是有局限性的。

原创粉丝点击