以上程序表示的是某个数据库服务器的部分监视接口,该数据库维护了当前已经登录的用户以及正在执行的请求。当一个用户登录、注销、开始查询或者结束查询时,都会调用相应的add或者remove方法来更新ServerStatus对象。这两种类型信息是完全独立的,因此,我们可以尝试用锁分解来提升该程序的性能。
@ThreadSafe public class ServerStatus{ @GuardedBy("users") public final Set<String> users; @GuardedBy("queries") public final Set<String> queries; public ServerStatusAfterSplit() { users = new HashSet<String>(); queries = new HashSet<String>(); } public void addUser(String u) { synchronized (users) { users.add(u); } } public void addQuery(String q) { synchronized (queries) { queries.add(q); } } public void removeUser(String u) { synchronized (users) { users.remove(u); } } public void removeQuery(String q) { synchronized (users) { queries.remove(q); } } }我们将原来的ServerStatus分解,使用新的细粒度锁来同步对状态变量的维护。减少了锁的竞争,提升了性能。
锁分段把一个竞争激烈的锁分解为两个锁时,这两个锁可能都存在激烈的竞争。在上面的锁分解例子中,并不能进一步对锁进行分解。
在某些情况下,可以将锁分解技术进一步扩展为对一组独立对象上的锁进行分解,这种情况被称为锁分段。
例如,ConcurrentHashMap的实现中使用了一个包含16个锁的数组,每个锁保护所有散列桶的\(\frac{1}{16}\) ,其中第N个散列桶由第(N mod 16)个锁来保护。
假设散列函数具有合理的分布性,并且关键字能够实现均匀分布,那么这大约能把对于锁的请求减少到原来的\(\frac{1}{16}\) 。正是因为这项技术,使用ConcurrentHashMap可以支持多大16个并发的写入器。
锁分段的一个劣势在于:需要获取多个锁来实现独占访问将更加困难且开销更高。例如当ConcurrentHashMap需要扩展映射范围,以及重新计算键值的散列值需要分不到更大的桶集合中时,就需要获取所有分段锁。
下面的代码展示了在基于散列的Map中使用锁分段的技术。它拥有N_LOCKS个锁,并且每个锁保护散列桶的一个子集。大多数方法都只需要获得一个锁,如get(),而有些方法则需要获取到所有的锁,但不要求同时获得,如clear()。(例子来自《Java并发编程实践》)
@ThreadSafe public class StripedMap { // Synchronization policy: buckets[n] guarded by locks[n%N_LOCKS] private static final int N_LOCKS = 16; private final Node[] buckets; private final Object[] locks; private static class Node { Node next; Object key; Object value; } 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; } } } } 一些代替独占锁的方法除了缩小锁的范围、减少请求锁的粒度,还有第三种降低锁的影响的技术就是放弃使用独占锁。
使用一些无锁的算法或者数据结构来管理共享状态。例如,使用并发容器、读-写锁、不可变对象以及原子变量。
后面也会陆续介绍这些方案。
小结结合我们前面讲的并发知识,我们现在可以从微观和宏观来理解并发编程。在微观上,设计并发程序时我们要考虑到原子性、可见性和有序性问题。跳出微观,从宏观上来看,我们设计程序,要考虑到到线程的安全性、活跃性以及性能问题。我们在做性能优化的前提是要保证线程安全性,如果会优化后出现并发问题,那么结果将会与我们的预期背道而驰。
参考:
[1]极客时间专栏王宝令《Java并发编程实战》
[2]Brian Goetz.Tim Peierls. et al.Java并发编程实战[M].北京:机械工业出版社,2016