【Java并发编程二】同步容器和并发容器
一、同步容器
在Java中,同步容器包括两个部分,一个是vector和HashTable,查看vector、HashTable的实现代码,可以看到这些容器实现线程安全的方式就是将它们的状态封装起来,并在需要同步的方法上加上关键字synchornized。
另一个是Collections类中提供的静态工厂方法创建的同步包装类。
同步容器都是线程安全的。但是对于复合操作(迭代、缺少即加入、导航:根据一定的顺序寻找下一个元素),有时可能需要使用额外的客户端加锁进行保护。在一个同步容器中,复合操作是安全的。但是当其他线程能够并发修改容器的时候,它们就可能不会按照期望工作了。
有一些原因造成我们不愿意在迭代期间对容器加锁,当其它线程需要访问容器时,必须等待,直到迭代结束,如果容器很大,或者对每一个元素执行的任务耗时比较长,它们可能需要等待很长一段时间。另外,如果对元素的操作还要持有另一个锁,这是一个产生死锁风险的因素。在迭代期间,对容器加锁的一个替代方法是复制容器,因为复制是线程限制的,没有其他的线程能够在迭代期间对其进行修改,这样就消除了ConcurrentModificationException发生的可能性。
二、并发容器
同步容器通过对容器的所有状态进行串行访问,从而实现它们的线程安全。这样做的代价是削弱了并发性,当多个线程共同竞争容器级的锁时,吞吐量就会降低。并发容器是为多线程并发访问而设计的。Java 5.0中ConcurrentHashMap,来替代同步的哈希Map实现。Queue用来临时保存正在等待被进一步处理的一系列元素,JDK提供了几种实现,包括一个传统的FIFO队列ConcurrentLinkedQueue,一个具有优先级顺序的队列PriorityQueue。Queue的操作不会阻塞,如果队列是空的,那么从队列中获取元素的操作会返回空值。BlockingQueue扩展了Queue,增加了可阻塞的插入和获取操作。如果队列是空的,一个获取操作就会一直阻塞直到队列中存在可用元素,如果队列是满的,插入操作就会一直阻塞到队列中存在可用空间。阻塞队列在生产者和消费者设计中非常有用。
2.1 ConcurrentHashMap
ConcurrentHashMap与HashMap一样是一个哈希表,但是它使用完全不同的锁策略,可以提供更好的并发性和可伸缩性。在ConcurrentHashMap以前,程序使用一个公共锁同步一个方法,并严格地控制只能在一个线程中可以同时访问容器,而ConcurrentHashMap使用一个更为细化的锁机制,名叫分离锁。这个机制允许任意数量的读线程可以并发访问Map,读者和写者也可以并发访问Map,并且有限数量的写进程还可以并发修改Map,结果是为并发访问带来更高的吞吐量,同时几乎没有损失单个线程访问的性能。
ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment是一种可重入锁ReentrantLock,在ConcurrentHshMap里扮演锁的角色,HashEntry则用于存储键值对数据。一个ConcurrentHashMap里包含一个Segment数组,Segment的结构和HashMap类似,是一种数组和链表结构,一个Segment里包含一个HashEntry数组,每一个HashEntry是一个链表结构的元素,每个Segment守护着HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得它对应的Segment的锁。
HashEntry类用来封装散列表中的键值对。类定义如下:
static final class HashEntry<K,V> { final int hash; final K key; volatile V value; volatile HashEntry<K,V> next; HashEntry(int hash, K key, V value, HashEntry<K,V> next) { this.hash = hash; this.key = key; this.value = value; this.next = next; } /** * Sets next field with volatile write semantics. (See above * about use of putOrderedObject.) */ final void setNext(HashEntry<K,V> n) { UNSAFE.putOrderedObject(this, nextOffset, n); } // Unsafe mechanics static final sun.misc.Unsafe UNSAFE; static final long nextOffset; static { try { UNSAFE = sun.misc.Unsafe.getUnsafe(); Class k = HashEntry.class; nextOffset = UNSAFE.objectFieldOffset (k.getDeclaredField("next")); } catch (Exception e) { throw new Error(e); } } }
在JDK1.6中,HashEntry中的next域定义为final类型,新节点只能在链表的表头插入。而且在每次删除节点之前的所有节点拷贝一份组成一个新的链,而将当前节点的next指向当前节点的下一个节点,从而在删除之后有两条链存在,因而可以保证即使在同一条链中,有一个线程在删除,而另一个线程在遍历,它们都能工作良好。如果遍历线程在删除线程结束后开始,则它能看到删除后的变化,如果它发生在删除线程正在执行中间,则它会使用原有的链,而不会等到删除线程结束后再执行,即看不到删除线程的影响。
HashEntry类的value域被声明为Volatile类型,Java的内存模型可以保证:某个写线程对value域的写入可以马上被后续的某个读线程“看”到。
Segment类继承于ReentrantLock,从而使得Segment对象能充当锁的角色。
static final class Segment<K,V> extends ReentrantLock implements Serializable { private static final long serialVersionUID = 2249069246763182397L; static final int MAX_SCAN_RETRIES = Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1; transient volatile HashEntry<K,V>[] table; transient int count; //在本segment范围内,包含的HashEntry的个数 transient int modCount; //table被更新的次数 transient int threshold; //当table中包含的HashEntry元素超过threshold时,触发table再散列 /* * table是由HashEntry对象组成的数组 * 如果散列时发生碰撞,碰撞的HashEntry对象就以链表的形式链接成一个链表 * table数组的数组成员代表散列映射表的一个桶 * 每一个table守护整个ConcurrentHashMap包含桶总数的一部分 * 如果并发级别为16,table则守护ConcurrentHashMap包含桶总数的1/16 */ final float loadFactor; //装填因子 Segment(float lf, int threshold, HashEntry<K,V>[] tab) { this.loadFactor = lf; this.threshold = threshold; this.table = tab; } ... }
每个Segment对象用来守护其(成员对象table中)包含的若干个桶。table是一个由HashEntry对象组成的数组,table数组的每一个数组成员就是散列表的一个桶。count变量是一个计数器,它表示每个Segment对象管理的table数组包含的HashEntry对象的个数。每一个Segment对象都有一个count对象来表示本Segment中包含的HashEntry对象的总数。在ConcurrentHashMap中,每一个Segment对象都有一个count对象来表示本Segment中包含的HashEntry对象的个数,这样当需要更新计数器时,不用锁定整个ConcurrentHashMap。
ConcurrentHashMap类在默认并发级别会创建包含16个Segment对象的数组 。每个 Segment 的成员对象 table 包含若干个散列表的桶。每个桶是由 HashEntry 链接起来的一个链表。如果键能均匀散列,每个 Segment 大约守护整个散列表中桶总数的 1/16。
类定义如下:
//创建一个带有指定初始容量、加载因子和并发级别的空映射 public ConcurrentHashMap(int initialCapacity,float loadFactor, int concurrencyLevel) { if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0) throw new IllegalArgumentException(); if (concurrencyLevel > MAX_SEGMENTS) concurrencyLevel = MAX_SEGMENTS; // Find power-of-two sizes best matching arguments int sshift = 0; int ssize = 1; while (ssize < concurrencyLevel) { ++sshift; ssize <<= 1; } this.segmentShift = 32 - sshift; //段偏移量 this.segmentMask = ssize - 1; //Segment的掩码值,key的散列码的高位来选择具体的Segment if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; int c = initialCapacity / ssize; if (c * ssize < initialCapacity) ++c; int cap = MIN_SEGMENT_TABLE_CAPACITY; while (cap < c) cap <<= 1; //创建 segments和segments[0] Segment<K,V> s0 =new Segment<K,V>(loadFactor, (int)(cap * loadFactor),(HashEntry<K,V>[])new HashEntry[cap]); Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize]; UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0] this.segments = ss; }
在ConcurrentHashMap中,线程对映射表做读操作,一般情况下不要加锁就可以完成,对容器做结构性修改的操作才需要加锁。
以put操作为例说明对ConcurrentHashMap做结构性修改的过程:
public V put(K key, V value) { Segment<K,V> s; if (value == null) //ConcurrentHashMap 中不允许用 null作为映射值,当读到null时,便知道产生了冲突-发生了重排序现象,需加锁后重新读入这个value值 throw new NullPointerException(); int hash = hash(key); //根据hash值找到相应的Segment int j = (hash >>> segmentShift) & segmentMask; if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck (segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment s = ensureSegment(j); return s.put(key, hash, value, false); }
首先,根据key计算出对应的hash值,然后根据hash值找到对应的Segment对象:
private Segment<K,V> ensureSegment(int k) { final Segment<K,V>[] ss = this.segments; long u = (k << SSHIFT) + SBASE; // raw offset Segment<K,V> seg; if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) { Segment<K,V> proto = ss[0]; // use segment 0 as prototype int cap = proto.table.length; float lf = proto.loadFactor; int threshold = (int)(cap * lf); HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap]; if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))== null) { // recheck Segment<K,V> s = new Segment<K,V>(lf, threshold, tab); while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))== null) { if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s)) break; } } } return seg; }
最后,在这个Segment中执行具体的put操作:
final V put(K key, int hash, V value, boolean onlyIfAbsent) { HashEntry<K,V> node = tryLock() ? null :scanAndLockForPut(key, hash, value);//加锁 V oldValue; try { HashEntry<K,V>[] tab = table; /* * 把hash值与table数组的长度减1的值相与 * 得到该hash值对应的table数组的下标值 */ int index = (tab.length - 1) & hash; //找到hash值对应的具体的那个桶 HashEntry<K,V> first = entryAt(tab, index); for (HashEntry<K,V> e = first;;) { if (e != null) //如果键值对已经存在 { K k; if ((k = e.key) == key ||(e.hash == hash && key.equals(k))) { oldValue = e.value; if (!onlyIfAbsent) { e.value = value; ++modCount; } break; } e = e.next; } else //键值对不存在 { if (node != null) node.setNext(first); else node = new HashEntry<K,V>(hash, key, value, first);//创建新的节点到链表中 int c = count + 1; if (c > threshold && tab.length < MAXIMUM_CAPACITY) rehash(node); else setEntryAt(tab, index, node); ++modCount; count = c; oldValue = null; break; } } } finally { unlock(); //解锁 } return oldValue; }
这里的加锁操作是针对(键的hash值对应的)某个具体的Segment,锁定的是该Segment而不是整个的ConcurrentHashMap,其他写线程对另外的15个Segment的加锁并不会因为当前线程对这个Segment的加锁而阻塞。同时,所有读线程几乎不会因本线程的加锁而阻塞(除非读线程刚好读到这个Segment中某个HashEntry的value域值为null,此时需要加锁后重新读取该值)。
相较于HashTable和由同步包装器包装的HashMap每次只能有一个线程执行读或写操作,ConcurrentHashMap在并发访问性能上有了质的提高。在理想状态下,ConcurrentHashMap可以支持16个线程执行并发写操作(如果并发级别设置为16),及任意数量的读操作。
线程写入有两种情形:对散列表做非结构性修改的操作和对散列表做结构性修改的操作。
非结构性修改操作只是更改某个HashEntry的value域的值。由于对 Volatile 变量的写入操作将与随后对这个变量的读操作进行同步。当一个写线程修改了某个 HashEntry 的 value 域后,另一个读线程读这个值域,Java 内存模型能够保证读线程读取的一定是更新后的值。所以,写线程对链表的非结构性修改能够被后续不加锁的读线程“看到”。
对 ConcurrentHashMap 做结构性修改,实质上是对某个桶指向的链表做结构性修改。如果能够确保:在读线程遍历一个链表期间,写线程对这个链表所做的结构性修改不影响读线程继续正常遍历这个链表。那么读 / 写线程之间就可以安全并发访问这个 ConcurrentHashMap。结构性修改操作包括 put,remove,clear。
下面来看一下remove的操作:
public V remove(Object key)
{ int hash = hash(key); Segment<K,V> s = segmentForHash(hash); return s == null ? null : s.remove(key, hash, null); }
首先根据key来计算对应的hash值,然后根据hash值找到对应的Segment对象,下面是真正的remove操作:
final V remove(Object key, int hash, Object value) { if (!tryLock()) //加锁 scanAndLock(key, hash); V oldValue = null; try { HashEntry<K,V>[] tab = table; //根据hash值找到table的下标值 int index = (tab.length - 1) & hash; //找到hash对应的那个桶 HashEntry<K,V> e = entryAt(tab, index); HashEntry<K,V> pred = null; while (e != null) { K k; HashEntry<K,V> next = e.next; if ((k = e.key) == key ||(e.hash == hash && key.equals(k))) { V v = e.value; if (value == null || value == v || value.equals(v)) //找到要删除的节点 { if (pred == null) setEntryAt(tab, index, next); else pred.setNext(next); ++modCount; --count; oldValue = v; } break; } pred = e; e = next; } } finally { unlock(); //解锁 } return oldValue; }
clear操作的源码如下,对每个段Segment进行遍历,然后进行clear操作
public void clear()
{ final Segment<K,V>[] segments = this.segments; for (int j = 0; j < segments.length; ++j)
{ Segment<K,V> s = segmentAt(segments, j); if (s != null) s.clear(); } }
final void clear() { lock(); try { HashEntry<K,V>[] tab = table; for (int i = 0; i < tab.length ; i++) setEntryAt(tab, i, null); ++modCount; count = 0; } finally { unlock(); } }
三、总结
ConcurrentHashMap 是一个并发散列映射表的实现,它允许完全并发的读取,并且支持给定数量的并发更新。相比于 HashTable 和
用同步包装器包装的 HashMap(Collections.synchronizedMap(new HashMap())),ConcurrentHashMap 拥有更高的并发性。在 HashTable 和由同步包装器包装的 HashMap 中,使用一个全局的锁来同步不同线程间的并发访问。同一时间点,只能有一个线程持有锁,也就是说在同一时间点,只能有一个线程能访问容器。
ConcurrentHashMap 的高并发性主要来自于三个方面:
- 用分离锁实现多个线程间的更深层次的共享访问。
- 用 HashEntery 对象的不变性来降低执行读操作的线程在遍历链表期间对加锁的需求。
- 通过对同一个 Volatile 变量的写 / 读访问,协调不同线程间读 / 写操作的内存可见性。
在实际的应用中,散列表一般的应用场景是:除了少数插入操作和删除操作外,绝大多数都是读取操作,而且读操作在大多数时候都是成功的。正是基于这个前提,ConcurrentHashMap 针对读操作做了大量的优化。通过 HashEntry 对象的不变性和用 volatile 型变量协调线程间的内存可见性,使得 大多数时候,读操作不需要加锁就可以正确获得值。
四、参考资料