说到ConcurrentHashMap,就会很自然地想到HashTable。有了HashTable为什么还需要ConcurrentHashMap呢?
原来虽然HashTable是多线程安全的,但是因为HashTable实现多线程的原理是通过给每一个put、get等函数都加上synchronized关键字。也就是说。同一个时刻,只能有一个线程去操作HashTable,另外一个想要并发操作HashTable的线程只能等待锁释放后才能操作线程,可见HashTable的效率十分低。为了解决HashTable这个低效问题所以出现了ConcurrentHashMap。
通俗地说,把HashTable理解为一个大仓库,这个大仓库里面只能有一个人(线程)进去,要等里面的人出来了才能进入下一个人(线程)。然后,ConcurrentHashMap也是一个大仓库,但是ConcurrentHashMap很智能,它给这个大仓库划分了很多小房间,每个小房间只能进入一个人(线程),要等房间里的人(线程)出来之后才能进入下一个人(线程),这样子就能多个想去不同房间的人(线程)能够同时进入大仓库了。所以ConcurrentHashMap明显比HashTable要高效得多。
源码解析
注意:此篇源码解析是基于jdk1.8
先研究一下concurrentHashMap.put()
public V put(K key, V value) {
//其实调用putVal()
return putVal(key, value, false);
}
/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
//这里可以知道concurrentHashMap不能存放null的key或者null的value
if (key == null || value == null) throw new NullPointerException();
//对object中的hashCode进行二次运算,得到了key的哈希值
int hash = spread(key.hashCode());
int binCount = 0;
//死循环
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
//如果数组为空或者数组长度为0,则初始化数组
if (tab == null || (n = tab.length) == 0)
tab = initTable();
// (n - 1) & hash算出的结果i是这个新插入的Node节点应该放在tab中的下标,如果tab中下标i上对应的元素为null,说明没有发生哈希冲突
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//用cas操作在tab的下标i处加入数据新的node,如果cas操作成功,可以直接break退出循环。
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
//执行到这里,说明发生了哈希冲突了,tab的i下标的地方已经存放了f了,fh==MOVE说明该节点有线程处理过了(这里有点疑问,先打个TODO标记)
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
//在这里上锁了,相当于给数组中的每一个下标位置上了锁(锁住的对象是这个数组上面每一个下标对应的对象)
synchronized (f) {
if (tabAt(tab, i) == f) {
//fh大于0,说明不是树节点
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
//如果发现链表中已经存放了相同的key
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
//只要改变这个key对应的value就好了
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
//如果寻找到尾部都没有找到这个key,则在链表尾部插入这个node(这里体现了尾插入法)
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
//如果是一个树节点
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
//节点个数大于等于8,则变成红黑树
if (binCount >= TREEIFY_THRESHOLD)
//tab中i下标转成红黑树
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
//不是很懂这个函数干嘛的,先打个问号(TODO)
addCount(1L, binCount);
return null;
}
看一下spread(),函数的参数传入值是key.hashCode(),所以spread函数是对这个hash值的二次运算,(h ^ (h >>> 16))的处理和jdk1.8中的hashmap处理一样,但是多了一步&HASH_BITS的运算,其中HASH_BITS = 0x7fffffff,这显然有点像子网掩码一样的作用,把高位的去掉,留下低位。spread函数其实为了减少哈希冲突的。
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}
看一下ConcurrentHashMap的get函数
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
//根据key中的hashCode再经过扰动函数,算出h
int h = spread(key.hashCode());
//该key存放在table数组中的位置是:(n - 1) & h),
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
//eh小于0,则另外处理。看到find里面是对链表的遍历。为什么下面还会有while循环对链表进行遍历的呢?这里不是很懂,打个标记TODO
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
为什么hash会有小于0这种情况的呢?
hash值大于等于0,则是链表节点 hash值为-1 MOVED,则是forwarding
nodes,存储nextTable的引用。只有table发生扩容的时候,ForwardingNode才会发挥作用,作为一个占位符放在table中表示当前节点为null或则已经被移动。
hash值为-2 TREEBIN,则是红黑树根,TreeBin类型 hash值为-3 RESERVED,则是reservation
nodes, static final int MOVED = -1; // hash for forwarding nodes
static final int TREEBIN = -2; // hash for roots of trees static
final int RESERVED = -3; // hash for transient reservations
看源码看到,对其中一段代码这里产生了一些疑问。eh小于0,则另外处理。看到find里面是对链表的遍历。为什么下面还会有while循环对链表进行遍历的呢?这里的代码不就是重复了吗?
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
//这个while不就和上面调用的find函数重复了吗?,
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
接着看一下find函数,其实就是对链表进行了遍历。这个函数的注释的意思是:对map.get()的虚拟化支持;在子类覆盖。
/**
* Virtualized support for map.get(); overridden in subclasses.
*/
Node<K,V> find(int h, Object k) {
Node<K,V> e = this;
if (k != null) {
do {
K ek;
if (e.hash == h &&
((ek = e.key) == k || (ek != null && k.equals(ek))))
return e;
} while ((e = e.next) != null);
}
return null;
}
解决问题:又回去看了几遍源码,把问题给理清楚了。为什么还需要多出一个find函数呢?正如官方所说的
对map.get()的虚拟化支持;在子类覆盖。
eh是key对应的hash值,如果eh大于等于零的话,就会执行下面的那个while循环去执行里面的链表遍历操作。如果eh小于0,就要看tabAt(tab, (n - 1) & h))
这语句代码返回来的Node对象是TreeBin还是Node了,如果是TreeBin,就会调用TreeBin中的find方法,如果还是Node的话,继续保持原来的执行操作。只能说,这么设计有点点巧妙了。
看一下TreeBin中的find函数
final Node<K,V> find(int h, Object k) {
//key不能为null
if (k != null) {
for (Node<K,V> e = first; e != null; ) {
int s; K ek;
if (((s = lockState) & (WAITER|WRITER)) != 0) {
if (e.hash == h &&
((ek = e.key) == k || (ek != null && k.equals(ek))))
return e;
e = e.next;
}
else if (U.compareAndSwapInt(this, LOCKSTATE, s,
s + READER)) {
TreeNode<K,V> r, p;
try {
p = ((r = root) == null ? null :
r.findTreeNode(h, k, null));
} finally {
Thread w;
if (U.getAndAddInt(this, LOCKSTATE, -READER) ==
(READER|WAITER) && (w = waiter) != null)
LockSupport.unpark(w);
}
return p;
}
}
}
return null;
}
小结
其实,jdk1.8的concurrentHashMap的底层原理和jdk1.8的hashmap的相似度还是挺高的。concurrentHashMap其实也是一个数组,只不过数组里面存储的一个一个的链表(链表长度大于等于8的时候会变成红黑树,以提高查询效率)。使用put函数的过程就是:调用key的hashCode函数得到哈希值,然后将这个hash经过扰动函数spread函数处理(扰动函数是为了能够减少哈希值冲突的)。在通过((n - 1) & hash)这一步运算,因为n为2的次幂,所以((n - 1) & hash)相当于hash%n,这步计算结果就是这个包含key、value的entry该存储在数组中的位置,如果,那个位置上面已经存储有其他的元素了,说明就是发生哈希冲突了,jdk1.8的concurrentHashMap是使用链表法解决了这个哈希冲突的问题,就是使用尾插入法来将要插入的entry放在原来的的entry的后面,形成一个链表。如果链表的长度超过8的时候,会变成红黑苏,以提高查询效率。