HashMap虽然经常被我们使用,但是也存在两个比较明显的缺点: 线程不安全、并发编程时容易死循环; 而使用HashTable虽然能解决并发问题,但是由于使用了synchronized来实现锁机制,也使得在并发时读写效率较低。
基于以上几点的话,我们会考虑使用ConcurrentHashMap来替代。不得不说的是concurrentHashMap的设计是比较出色的,它将数据分成一段一段的存储,然后给每一段数据加一把锁,每段之间的锁互不影响,也就是说每个Segment间是不需要考虑并发问题的,对于同一个Segment才需要锁机制。看源码会发现初始化的ConcurrentLevel为16,默认支持的并发数就是16,优雅的实现并发操作。
ConcurrentHashMap结构简介
实际来看,ConcurrentHashMap就是使用Segment数组把HashMap安全的重新"包装"了一下,其主要由Segment数组构成,Segment继承了ReentrantLock(可重入锁)
static final class Segment<K, V> extends ReentrantLock { …… transient volatile HashEntry<K, V>[] table; …… }
而Segment里面的结构又是HashEntry数组,HashEntry的结构为链表,HashEntry以键值对的形式存储数据,所以我们说ConcurrentHashMap的结构就是数组+链表
transient volatile HashEntry<K, V>[] table;
ConcurrentHashMap源码解析
ConcurrentHashMap的构造器
public ConcurrentHashMap(int initialCapacity,float loadFactor, int concurrencyLevel) { if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0) throw new IllegalArgumentException(); /*Max_SEGMENTS最大为 1 << 16,即最大并发数65536*/ if (concurrencyLevel > MAX_SEGMENTS) concurrencyLevel = MAX_SEGMENTS; // Find power-of-two sizes best matching arguments /*2的sshift次方=ssize*/ /*往下可以看到ssize为segment数组的大小,和concurrentLevel直接相关*/ int sshift = 0; int ssize = 1; while (ssize < concurrencyLevel) { ++sshift; ssize <<= 1; } /*segmentShift(段偏移量)和segmentMask(段掩码)用于定位Segment*/ this.segmentShift = 32 - sshift; this.segmentMask = ssize - 1; if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; int c = initialCapacity / ssize; if (c * ssize < initialCapacity) ++c; /*往下看可以发现,cap代表HashEntry数组的大小*/ int cap = MIN_SEGMENT_TABLE_CAPACITY; while (cap < c) cap <<= 1; /*创建并初始化第一个segment数组*/ 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; }
initialCapacity、loadFactor、concurrencyLevel若开发者未指定的话则取默认值
static final int DEFAULT_INITIAL_CAPACITY = 16; static final float DEFAULT_LOAD_FACTOR = 0.75f; static final int DEFAULT_CONCURRENCY_LEVEL = 16;
put操作
public V put(K key, V value) { Segment<K,V> s; /*ConcurrentHashMap的key和value均不能为空*/ if (value == null) throw new NullPointerException(); /*散列算法*/ int hash = hash(key); 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); }
put方法主要包含两步:定位Segment、调用segment的put方法插入元素
定位Segment,可以看到ConcurrentHashMap的put方法首先调用了Wang/Jenkins hash的变种算法对元素的hashCode进行了一次再散列
private int hash(Object k) { int h = hashSeed; if ((0 != h) && (k instanceof String)) { return sun.misc.Hashing.stringHash32((String) k); } h ^= k.hashCode(); // Spread bits to regularize both segment and index locations, // using variant of single-word Wang/Jenkins hash. h += (h << 15) ^ 0xffffcd7d; h ^= (h >>> 10); h += (h << 3); h ^= (h >>> 6); h += (h << 2) + (h << 14); return h ^ (h >>> 16); }
再散列的目的是为了尽可能的减少散列冲突(因为只要低位一样,无论高位是什么数,散列值都一致),使元素能够比较均匀的分布在不同的Segment上,也就加快了ConcurrentHashMap容器的读、取效率。倘若这个散列函数太差劲,就会导致元素过于集中式的分布在了一个Segment上,那么ConcurrentHashMap的读取效率就没法保证了,分段锁也将失去意义。如上的散列算法定位到Segment。
接着就是调用Segment的put方法插入元素,由于Segment的put方法需要对共享变量进行写操作,所以put方法是需要加锁操作的,put插入元素又分可以细分为两个步骤,是否扩容以及定位HashEntry放置元素
final V put(K key, int hash, V value, boolean onlyIfAbsent) { /*put方法要加锁*/ HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value); V oldValue; try { HashEntry<K,V>[] tab = table; int index = (tab.length - 1) & hash; /*定位HashEntry,可以看到之前在ConcurrentHashMap的put方法中产生的hash值在定位HashEntry时也用到了*/ 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; }
插入元素前会判断Segment里的HashEntry数组长度是否超过容量(threshold),超过就对HashEntry数组进行扩容(rehash),这点和HashMap的扩容判断更为"先进"一些,HashMap是插入元素后才判断数组是否达到阈值的,达到了进行扩容,这样的结果是有可能扩容后没有新的元素继续插入(ps:扩容是比较耗资源的,无效扩容就比较尴尬了…)。
顺带再看下扩容方法
private void rehash(HashEntry<K,V> node) { HashEntry<K,V>[] oldTable = table; int oldCapacity = oldTable.length; /*扩容为原来HashEntry数组的两倍*/ int newCapacity = oldCapacity << 1; threshold = (int)(newCapacity * loadFactor); HashEntry<K,V>[] newTable = (HashEntry<K,V>[]) new HashEntry[newCapacity]; int sizeMask = newCapacity - 1; for (int i = 0; i < oldCapacity ; i++) { HashEntry<K,V> e = oldTable[i]; if (e != null) { HashEntry<K,V> next = e.next; int idx = e.hash & sizeMask; if (next == null) // Single node on list newTable[idx] = e; else { // Reuse consecutive sequence at same slot HashEntry<K,V> lastRun = e; int lastIdx = idx; for (HashEntry<K,V> last = next; last != null; last = last.next) { int k = last.hash & sizeMask; if (k != lastIdx) { lastIdx = k; lastRun = last; } } newTable[lastIdx] = lastRun; // Clone remaining nodes for (HashEntry<K,V> p = e; p != lastRun; p = p.next) { V v = p.value; int h = p.hash; int k = h & sizeMask; HashEntry<K,V> n = newTable[k]; newTable[k] = new HashEntry<K,V>(h, p.key, v, n); } } } } int nodeIndex = node.hash & sizeMask; // add the new node node.setNext(newTable[nodeIndex]); newTable[nodeIndex] = node; table = newTable; }
可以看到,HashEntry扩容为原来的两倍,扩容时不时针对整个ConcurrentHashMap,而是只对需要扩容的那个Segment进行扩容(所以高效)
get操作
public V get(Object key) { Segment<K,V> s; HashEntry<K,V>[] tab; int h = hash(key); long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE; /*先定位到Segment*/ if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null && (tab = s.table) != null) { /*再便遍历segment中的HashEntry数组*/ for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile (tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE); e != null; e = e.next) { K k; if ((k = e.key) == key || (e.hash == h && key.equals(k))) return e.value; } } return null; }
ConcurrentHashMap的get操作是非常高效的,原因在于get操作不需要加锁。get方法里面使用到的共享变量都是volatile类型的,这些个变量能够在线程间保持可见性,能被多个线程同时读但是又能保证只被单线程写,并且不会读取到过期值。这样的变量就是HashEntry
transient volatile HashEntry<K,V>[] table; //Segment里的~ volatile HashEntry<K,V> next; //HashEntry链表中的~
使用volatile变量修饰后不会读取到过期值,是由于在java内存模型中的happen-before原则决定的,volatile修饰字段的写入操作总是优先于读操作,即使多个线程同时修改volatile变量字段,get字段也总能保证获取到最新的值(volatile变量相对于锁的优势体现出来了)。
引申文章:浅谈HashMap实现原理