ConcurrentHashMap
HashMap不是线程安全的,而当使用同步集合Collections.synchronizedXXX方法来实现HashMap同步的话,因为它同步的粒度比较大。也就是每次加锁的时候,都会对整个对象加锁。从而导致在并发的场景下,效率比较低。
而ConcurrentHashMap的好处就在于,每次插入元素的时候都只对整个集合的一部分进行加锁。这样在并发的情景之下,性能就比HashMap的同步集合效率高。
那么,它是如何实现的呢?
在JDK1.8之前使用的是锁分段的技术,但是在1.8之后锁分段实现方式有了变化,不再是之前使用Segment来加锁了,而是对数组的某个元素进行加锁,而且里面使用了CAS的理念。
比如,所有的put方法,实际上是调用了下面的方法的实现:
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
// binCount是该桶中的元素的数量,默认是0
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
// 当table的为null,或者列表长度为空,就对table进行初始化
if (tab == null || (n = tab.length) == 0)
tab = initTable();
// 然后根据key的哈希来获取指定的table数组的元素(即一个链表或红黑树)
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 这里就是基于CAS的理念,向table中插入结点,casTabAt方法参考下面
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;
// 这里实际就是对f进行加锁了,也就是说,加锁的时候是对数组的元素,也就是一条链表,或者红黑树进行加锁
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
// 这里设置binCount为1,然后每次插入元素的时候binCount+1
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;
}
}
}
}
// 这里是当指定的桶的长度大于8的时候,调整桶的数据结构
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
//
addCount(1L, binCount);
return null;
}
这里就是上面的插入操作,这里的U就是sun.misc.Unsafe的实例。这里调用了sun.misc.Unsafe的compareAndSwapObject方法,它是一个native方法。可以这样理解它:它判断参数tab在内存中偏移((long)i << ASHIFT) + ABASE处的元素是否为null,如果为c,就将v插入到指定的位置c中。
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i, Node<K,V> c, Node<K,V> v) {
return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
好了,上面大概就是ConcurrentHashMap的简单原理分析。
size方法
Size方法的目的是统计ConcurrentHashMap的总元素数量, 自然需要把各个Segment内部的元素数量汇总起来。
但是,如果在统计Segment元素数量的过程中,已统计过的Segment瞬间插入新的元素,这时候该怎么办呢?
ConcurrentHashMap的Size方法是一个嵌套循环,大体逻辑如下:
- 遍历所有的Segment。
- 把Segment的元素数量累加起来。
- 把Segment的修改次数累加起来。
- 判断所有Segment的总修改次数是否大于上一次的总修改次数。如果大于,说明统计过程中有修改,重新统计,尝试次数+1;如果不是。说明没有修改,统计结束。
- 如果尝试次数超过阈值,则对每一个Segment加锁,再重新统计。
- 再次判断所有Segment的总修改次数是否大于上一次的总修改次数。由于已经加锁,次数一定和上次相等。
- 释放锁,统计结束。
为什么这样设计呢?这种思想和乐观锁悲观锁的思想如出一辙。
为了尽量不锁住所有Segment,首先乐观地假设Size过程中不会有修改。当尝试一定次数,才无奈转为悲观锁,锁住所有Segment保证强一致性。
public int size() {
final Segment<K,V>[] segments = this.segments;
int size;
boolean overflow; // size超过32 bits则为true
long sum; // 修改的总次数modCounts
long last = 0L; // 之前的sum
int retries = -1; // 第一次遍历时不进行重试
try {
for (;;) {
if (retries++ == RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
ensureSegment(j).lock(); // 加锁
}
sum = 0L;
size = 0;
overflow = false;
for (int j = 0; j < segments.length; ++j) {
Segment<K,V> seg = segmentAt(segments, j);
if (seg != null) {
sum += seg.modCount; // 修改的总次数之和
int c = seg.count;
if (c < 0 || (size += c) < 0)
overflow = true;
}
}
if (sum == last) // 修改的次数和上次相同,说明在这个过程中没有插入数据
break;
last = sum; // 在上述过程中有插入数据,记录上次修改次数总和
}
} finally {
if (retries > RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
segmentAt(segments, j).unlock();
}
}
return overflow ? Integer.MAX_VALUE : size;
}