介绍
ConcurrentHashMap是在Java.util.concurrent并发包下的一个类,它相当于是一个线程安全的HashMap。这里可能还有人会提到HashTable,这也相当于是一个线程安全的HashMap,但是它因为效率太低,所以现在很少被使用了,HashTable是对整个对象上了一把锁,在synchronized被优化之前,他的效率可以说是相当的低,所以就有了ConcurrentHashMap的诞生。
Java1.8之前的ConcurrentHashMap
在JDK1.8之前ConcurrentHashMap是采用一种叫分段锁(Segment)的机制,默认情况下一般会把整个HashMap分为16段,所以在多线程下效率默认会提升16倍。使用put插入元素时并不是对这个对象上锁,而是对每一个段上锁,首先计算HashCode,确定这是属于哪一个段,然后对这个段上锁,这样就可以同时保证有多个线程在操作HashMap,效率会提高许多。
在这个版本的ConcurrentHashMap中加入了一个Segment数组,一个Segment存放一个HashEntry数组,里面储存的是一个链表的头结点,也就是相当于把原来的哈希表分段又重新那一个数组储存起来了。
Java1.8之后的ConcurrentHashMap
JDK1.8之后做出了很多大的改动,加入了许多新的机制,对ConcurrentHashMap来说,直接摒弃了上个版本的锁分段技术,通过引入大量的volatile关键字和CAS操作来解决线程安全问题,效率有了很大的提升,同时由于这个版本的synchronized经过了大更新,所以效率就又一步提升了。
读操作的改动
我们知道读操作是不涉及线程安全问题的,不管多少个线程并发读取数据都没有问题,所以这里就干脆直接不上锁了,而是在Node的val和next属性前加上了volatile字段,这虽然能保证一定能读到主内存的数据,但是却不能保证能读到最新的数据,但是这样的情况也无伤大雅。
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
//计算哈希值
int h = spread(key.hashCode());
//如果数组不为空或者长度不等于0且第一个节点不等于空时直接返回第一个节点的val
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;
}
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;
}
从上述源码中能看到,在读数据时,首先会计算 key 的 hash 值,如果 Node 数组里匹配到首结点即返回,否则如果eh小于0说明节点在红黑树上,直接寻找,否则就遍历链表,由于可能会有冲突,因此需调用 equals 方法。
写操作的改动
写操作如果不上锁,多线程情况下发生线程安全问题的概率就很大,但是如果像HashTable那样或者是前面每次调用put方法都会上锁的话,即便是synchronized经过了改动,这仍然是一个很大的开销,所以在这个版本中,在写操作中加入了CAS操作去配合synchronized一起使用,效率就高很多了。
public V put(K key, V value) {
return putVal(key, value, false);
}
/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
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) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
- 我们知道在HashMap和HashTable中key和value值都可以为null,但是ConcurrentHashMap不允许key和value为null。
- 先通过Key的值计算出他的hash值,然后进入到一个死循环中,如果初始数组长度为0,则先初始化数组,如果数组不为空,就计算出该节点在数组中的位置,如果这个位置为空的话,直接使用CAS操作插入,这里是不需要上锁的。
- 如果这个位置存在结点的话,说明发生了hash碰撞,这时会对这个结点上锁,进入锁之后首先会判断这是一棵树还是一个链表,如果是树的话就直接调用树的插入方法,如果是一个链表的话就向后遍历,直到找到hash值和key值都相同的结点,然后更新val就可以了,或者是插入到链表的最后。
- 最后还需要判断,如果加入这个结点之后,链表长度超过了8,就会把链表转成树结构。
扩容
在前面版本中,如果数组需要扩容的话,是由一个线程来完成的,扩容就会涉及元素搬移,当元素数量很大的时候,这个操作就会很慢,这时如果是前面版本的ConcurrentHashMap的话其他进程就会全部堵塞,等待扩容完成,这样效率就会很低,所以在这个版本扩容是由多个线程来完成的,每个线程只负责一部分元素的搬移,那么不同线程怎么直到什么时候要扩容了呢?在这个版本中加入了sizeCtl这个属性。可以说它是ConcurrentHashMap中出镜率很高的一个属性,因为它是一个控制标识符,在不同的地方有不同用途,而且它的取值不同,也代表不同的含义。
- 负数代表正在进行初始化或扩容操作
- -1代表正在初始化
- -N 表示有N-1个线程正在进行扩容操作
- 正数或0代表hash表还没有被初始化,这个数值表示初始化或下一次进行扩容的大小
这个版本ConcurrentHashMap的扩容大致可以分为两个部分:
- 第一个部分是单线程完成的,首先先以两倍扩容的方式创建一个newTable数组
- 第二个部分就是元素的搬移,这是由多个线程共同完成的
扩容操作是一个很复杂的过程,这里面多个线程参与过,如果一个线程扩容了那一部分之后,然会把头结点的值set为forward,另一个线程看到这个值后就会向后遍历,这样就会交替完成工作,如果一个put方法进来,他看到一个为forward的结点,那么它也会立刻参与到扩容的操作任务中。