ConcurrentHashMap是HashMap的线程安全版本,内部也是使用(数组 + 链表 + 红黑树)的结构来存储元素。相比于同样线程安全的HashTable来说,效率等各方面都有极大地提高。
在阅读这篇文章之前,如果对HashMap不是很了解,可以阅读这篇文章:深入理解HashMap源码
先简单介绍一下各种锁,以便下文讲到相关概念时能有个印象。
java中的关键字,内部实现为监视器锁,主要是通过对象监视器在对象头中的字段来表明的。
synchronized从旧版本到现在已经做了很多优化了,在运行时会有三种存在方式:偏向锁,轻量级锁,重量级锁。
偏向锁:是指一段同步代码一直被一个线程访问,那么这个线程会自动获取锁,降低获取锁的代价。
轻量级锁:是指当锁是偏向锁时,被另一个线程所访问,偏向锁会升级为轻量级锁,这个线程会通过自旋的方式尝试获取锁,不会阻塞,提高性能。
重量级锁:是指当锁是轻量级锁时,当自旋的线程自旋了一定的次数后,还没有获取到锁,就会进入阻塞状态,该锁升级为重量级锁,重量级锁会使其他线程阻塞,性能降低。
CAS,Compare And Swap
,它是一种乐观锁,认为对于同一个数据的并发操作不一定会发生修改,在更新数据的时候,尝试去更新数据,如果失败就不断尝试。
java中的关键字,当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
自旋锁,是指尝试获取锁的线程不会阻塞,而是循环的方式不断尝试,这样的好处是减少线程的上下文切换带来的开锁,提高性能,缺点是循环会消耗CPU。
分段锁,是一种锁的设计思路,它细化了锁的粒度,主要运用在ConcurrentHashMap中,实现高效的并发操作,当操作不需要更新整个数组时,就只锁数组中的一项就可以了。
可重入锁,是指一个线程获取锁之后再尝试获取锁时会自动获取锁,可重入锁的优点是避免死锁。其实,synchronized 也是可重入锁。
在 Java 中,HashMap 是非线程安全的,如果想在多线程下安全的操作 map,有哪些解决方法呢?
第一种方法,使用Hashtable
线程安全类;
第二种方法,使用Collections.synchronizedMap
方法,对方法进行加同步锁;
第三种方法,使用并发包中的ConcurrentHashMap
类;
Hashtable 是一个线程安全的类,Hashtable 几乎所有的添加、删除、查询方法都加了synchronized
同步锁!
相当于给整个哈希表加了一把大锁,多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞等待需要的锁被释放,在竞争激烈的多线程场景中性能就会非常差,所以 Hashtable 不推荐使用!
Collections.synchronizedMap
里面使用对象锁来保证多线程场景下,操作安全,本质也是对 HashMap 进行全表锁!
使用Collections.synchronizedMap
方法,在竞争激烈的多线程环境下性能依然也非常差,所以不推荐使用!
ConcurrentHashMap 类所采用的是分段锁的思想,将 HashMap 进行切割,把 HashMap 中的哈希数组切分成小数组,每个小数组有 n 个 HashEntry 组成,其中小数组继承自ReentrantLock(可重入锁)
,这个小数组名叫Segment
, 如下图:
当然,JDK1.7 和 JDK1.8 对 ConcurrentHashMap 的实现有很大的不同!
JDK1.8 对 HashMap 做了改造,当冲突链表长度大于8时,会将链表转变成红黑树结构,上图是 ConcurrentHashMap 的整体结构,参考 JDK1.7!
我们再来看看 JDK1.8 中 ConcurrentHashMap 的整体结构,内容如下:
JDK1.8 中 ConcurrentHashMap 类取消了 Segment 分段锁,采用 CAS
+ synchronized
来保证并发安全,数据结构跟 jdk1.8 中 HashMap 结构类似,都是数组 + 链表(当链表长度大于8时,链表结构转为红黑二叉树)结构。
ConcurrentHashMap 中 synchronized 只锁定当前链表或红黑二叉树的首节点,只要节点 hash 不冲突,就不会产生并发,相比 JDK1.7 的 ConcurrentHashMap 效率又提升了 N 倍!
说了这么多,我们再一起来看看 ConcurrentHashMap 的源码实现。
Segment是什么呢?
Segment本身就相当于一个HashMap对象。
同HashMap一样,Segment包含一个 HashEntry 数组,数组中的每一个 HashEntry 既是一个键值对,也是一个链表的头节点。
单一的Segment结构如下:
像这样的Segment对象,在ConcurrentHashMap集合中有多少个呢?有2的N次方个,共同保存在一个名为segments的数组当中。 因此整个ConcurrentHashMap的结构如下:
可以说,ConcurrentHashMap 是一个二级哈希表。在一个总的哈希表下面,有若干个子哈希表。
这样的二级结构,和数据库的水平拆分有些相似。
下面我们看看ConcurrentHashMap并发读写的几种情况?
Case1:不同Segment的并发写入(可以并发执行)
Case2:同一Segment的一写一读(可以并发执行)
Case3:同一Segment的并发写入
Segment的写入是需要上锁的,因此对同一Segment的并发写入会被阻塞。
由此可见,ConcurrentHashMap 中每个Segment各自持有一把锁。在保证线程安全的同时降低了锁的粒度,让并发操作效率更高。
接下来,我们就来看下ConcurrentHashMap读写的过程。
为输入的Key做Hash运算,得到hash值。
通过hash值,定位到对应的Segment对象
再次通过hash值,定位到Segment当中数组的具体位置。
为输入的Key做Hash运算,得到hash值。
通过hash值,定位到对应的Segment对象
获取可重入锁
再次通过hash值,定位到Segment当中数组的具体位置。
插入或覆盖HashEntry对象。
释放锁。
虽然 JDK1.7 中的 ConcurrentHashMap 解决了 HashMap 并发的安全性,但是当冲突的链表过长时,在查询遍历的时候依然很慢!
在 JDK1.8 中,HashMap 引入了红黑二叉树设计,当冲突的链表长度大于8时,会将链表转化成红黑二叉树结构,红黑二叉树又被称为平衡二叉树,在查询效率方面,又大大的提高了不少。
JDK1.8 中的ConcurrentHashMap 相比 JDK1.7 中的 ConcurrentHashMap, 它抛弃了原有的 Segment 分段锁实现,采用了 CAS + synchronized
来保证并发的安全性。
JDK1.8 中的 ConcurrentHashMap 对节点Node
类中的共享变量,和 JDK1.7 一样,使用volatile
关键字,保证多线程操作时,变量的可见性!
static class Node implements Map.Entry {
final int hash;
final K key;
volatile V val;
volatile Node next;
Node(int hash, K key, V val, Node next) {
this.hash = hash;
this.key = key;
this.val = val;
this.next = next;
}
...... }
下面来具体分析一下JDK1.8 中的 ConcurrentHashMap源码。
final V putVal(K key, V value, boolean onlyIfAbsent) {
// key和value都不能为null
if (key == null || value == null) throw new NullPointerException();
// 计算hash值
int hash = spread(key.hashCode());
int binCount = 0;
for (ConcurrentHashMap.Node[] tab = table; ; ) {
ConcurrentHashMap.Node f;
int n, i, fh;
if (tab == null || (n = tab.length) == 0)
// 如果tab未初始化或者个数为0,则初始化node数组
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new ConcurrentHashMap.Node(hash, key, value, null)))
// 如果使用CAS插入元素时,发现已经有元素了,则进入下一次循环,重新操作
// 如果使用CAS插入元素成功,则break跳出循环,流程结束
break; // no lock when adding to empty bin
} else if ((fh = f.hash) == MOVED)
// 如果要插入的元素所在的tab的第一个元素的hash是MOVED,则当前线程帮忙一起迁移元素
tab = helpTransfer(tab, f);
else {
// 如果这个tab不为空且不在迁移元素,则锁住这个tab(分段锁)
// 并查找要插入的元素是否在这个tab中
// 存在,则替换值(onlyIfAbsent=false)
// 不存在,则插入到链表结尾或插入树中
V oldVal = null;
synchronized (f) {
// 再次检测第一个元素是否有变化,如果有变化则进入下一次循环,从头来过
if (tabAt(tab, i) == f) {
// 如果第一个元素的hash值大于等于0(说明不是在迁移,也不是树)
// 那就是tab中的元素使用的是链表方式存储
if (fh >= 0) {
// tab中元素个数赋值为1
binCount = 1;
// 遍历整个tab,每次结束binCount加1
for (ConcurrentHashMap.Node e = f; ; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
// 如果找到了这个元素,则赋值了新值(onlyIfAbsent=false)
// 并退出循环
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
ConcurrentHashMap.Node pred = e;
if ((e = e.next) == null) {
// 如果到链表尾部还没有找到元素
// 就把它插入到链表结尾并退出循环
pred.next = new ConcurrentHashMap.Node(hash, key,
value, null);
break;
}
}
} else if (f instanceof ConcurrentHashMap.TreeBin) {
// 如果第一个元素是树节点
ConcurrentHashMap.Node p;
// tab中元素个数赋值为2
binCount = 2;
// 调用红黑树的插入方法插入元素
// 如果成功插入则返回null
// 否则返回寻找到的节点
if ((p = ((ConcurrentHashMap.TreeBin) f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
// 如果binCount不为0,说明成功插入了元素或者寻找到了元素
if (binCount != 0) {
// 如果链表元素个数达到了8,则尝试树化
// 因为上面把元素插入到树中时,binCount只赋值了2,并没有计算整个树中元素的个数
// 所以不会重复树化
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
// 如果要插入的元素已经存在,则返回旧值
if (oldVal != null)
return oldVal;
// 退出外层大循环,流程结束
break;
}
}
}
// 成功插入元素,元素个数加1(是否要扩容在这个里面)
addCount(1L, binCount);
// 成功插入元素返回null
return null;
}
当进行 put 操作时,流程大概可以分如下几个步骤:
首先会判断 key、value是否为空,如果为空就抛异常;
接着会判断容器数组是否为空,如果为空就初始化数组;
进一步判断,要插入的元素f
,在当前数组下标是否第一次插入,如果是就通过 CAS 方式插入;
在接着判断f.hash == -1
是否成立,如果成立,说明当前f
是ForwardingNode
节点,表示有其它线程正在扩容,则一起进行扩容操作;
其他的情况,就是把新的Node
节点按链表或红黑树的方式插入到合适的位置;
节点插入完成之后,接着判断链表长度是否超过8
,如果超过8
个,就将链表转化为红黑树结构;
最后,插入完成之后,进行扩容判断。
private final ConcurrentHashMap.Node[] initTable() {
ConcurrentHashMap.Node[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
// 如果sizeCtl<0说明正在初始化或者扩容,让出CPU
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
// 如果把sizeCtl原子更新为-1成功,则当前线程进入初始化
// 如果原子更新失败则说明有其它线程先一步进入初始化了,则进入下一次循环
// 如果下一次循环时还没初始化完毕,则sizeCtl<0进入上面if的逻辑让出CPU
// 如果下一次循环更新完毕了,则table.length!=0,退出循环
try {
// 再次检查table是否为空,防止ABA问题
if ((tab = table) == null || tab.length == 0) {
// 如果sc为0则使用默认值16
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
// 新建数组
@SuppressWarnings("unchecked")
ConcurrentHashMap.Node[] nt = (ConcurrentHashMap.Node[])new ConcurrentHashMap.Node,?>[n];
// 把tab数组赋值给table
table = tab = nt;
// 设置sc为数组长度的0.75倍
// n - (n >>> 2) = n - n/4 = 0.75n
// 可见这里装载因子和扩容门槛都是写死了的
// 这也正是没有threshold和loadFactor属性的原因
sc = n - (n >>> 2);
}
} finally {
// 把sc赋值给sizeCtl,这时存储的是扩容门槛
sizeCtl = sc;
}
break;
}
}
return tab;
}
使用CAS锁控制只有一个线程初始化tab数组;
sizeCtl在初始化后存储的是扩容门槛;
扩容门槛写死的是tab数组大小的0.75倍,tab数组大小即map的容量,也就是最多存储多少个元素。
final ConcurrentHashMap.Node[] helpTransfer(ConcurrentHashMap.Node[] tab, ConcurrentHashMap.Node f) {
ConcurrentHashMap.Node[] nextTab; int sc;
// 如果tab数组不为空,并且当前tab第一个元素为ForwardingNode类型,并且nextTab不为空
// 说明当前tab已经迁移完毕了,才去帮忙迁移其它tab的元素
// 扩容时会把旧tab的第一个元素置为ForwardingNode,并让其nextTab指向新tab数组
if (tab != null && (f instanceof ConcurrentHashMap.ForwardingNode) &&
(nextTab = ((ConcurrentHashMap.ForwardingNode)f).nextTable) != null) {
int rs = resizeStamp(tab.length);
// sizeCtl<0,说明正在扩容
while (nextTab == nextTable && table == tab &&
(sc = sizeCtl) < 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || transferIndex <= 0)
break;
// 扩容线程数加1
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
// 当前线程帮忙迁移元素
transfer(tab, nextTab);
break;
}
}
return nextTab;
}
return table;
}
操作步骤如下:
第1步,对 table、node 节点、node 节点的 nextTable,进行数据校验;
第2步,根据数组的length得到一个标识符号;
第3步,进一步校验 nextTab、tab、sizeCtl 值,如果 nextTab 没有被并发修改并且 tab 也没有被并发修改,同时 sizeCtl < 0
,说明还在扩容;
第4步,对 sizeCtl 参数值进行分析判断,如果不满足任何一个判断,将sizeCtl + 1
, 增加了一个线程帮助其扩容。
private final void addCount(long x, int check) {
ConcurrentHashMap.CounterCell[] as; long b, s;
// 先尝试把数量加到baseCount上,如果失败再加到分段的CounterCell上
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
ConcurrentHashMap.CounterCell a; long v; int m;
boolean uncontended = true;
if (as == null || (m = as.length - 1) < 0 ||
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended =
U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
fullAddCount(x, uncontended);
return;
}
if (check <= 1)
return;
// 计算元素个数
s = sumCount();
}
//检查是否需要扩容,默认check=1,需要检查
if (check >= 0) {
ConcurrentHashMap.Node[] tab, nt; int n, sc;
// 如果元素个数达到了扩容门槛,则进行扩容
// 注意,正常情况下sizeCtl存储的是扩容门槛,即容量的0.75倍
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
// rs是扩容时的一个标识
int rs = resizeStamp(n);
if (sc < 0) {
// sc<0说明正在扩容中
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
// 扩容已经完成了,退出循环
break;
// 扩容未完成,则当前线程加入迁移元素中
// 并把扩容线程数加1
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
// 进入迁移元素
transfer(tab, null);
// 重新计算元素个数
s = sumCount();
}
}
}
操作步骤如下:
第1步,利用CAS将方法更新baseCount的值
第2步,检查是否需要扩容,默认check = 1,需要检查;
第3步,如果满足扩容条件,判断当前是否正在扩容,如果是正在扩容就一起扩容;
第4步,如果不在扩容,将sizeCtl更新为负数,并进行扩容处理。
以上就是整个put方法的流程,可以从中发现,里面大量的使用了CAS
方法,CAS 表示比较与替换,里面有3个参数,分别是目标内存地址、旧值、新值,每次判断的时候,会将旧值与目标内存地址中的值进行比较,如果相等,就将新值更新到内存地址里,如果不相等,就继续循环,直到操作成功为止!
public V get(Object key) {
ConcurrentHashMap.Node[] tab; ConcurrentHashMap.Node e, p; int n, eh; K ek;
// 计算hash
int h = spread(key.hashCode());
// 判断数组是否为空,通过key定位到数组下标是否为空;
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)
// hash小于0,说明是树或者正在扩容
// 使用find寻找元素,find的寻找方式依据Node的不同子类有不同的实现方式
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;
}
步骤如下:
第1步,判断数组是否为空,通过key定位到数组下标是否为空;
第2步,判断node节点第一个元素是不是要找到,如果是直接返回;
第3步,如果是红黑树结构,就从红黑树里面查询;
第4步,如果是链表结构,循环遍历判断。
public V remove(Object key) {
// 调用替换节点方法
return replaceNode(key, null, null);
}
final V replaceNode(Object key, V value, Object cv) {
// 计算hash
int hash = spread(key.hashCode());
// 循环遍历数组
for (ConcurrentHashMap.Node[] tab = table;;) {
ConcurrentHashMap.Node f; int n, i, fh;
//校验参数
if (tab == null || (n = tab.length) == 0 ||
(f = tabAt(tab, i = (n - 1) & hash)) == null)
break;
else if ((fh = f.hash) == MOVED)
// 如果正在扩容中,协助扩容
tab = helpTransfer(tab, f);
else {
V oldVal = null;
// 标记是否处理过
boolean validated = false;
//用 synchronized 同步锁,保证并发时元素移除安全
synchronized (f) {
// 再次验证当前tab元素是否被修改过
if (tabAt(tab, i) == f) {
// fh>=0表示是链表节点
if (fh >= 0) {
validated = true;
// 遍历链表寻找目标节点
for (ConcurrentHashMap.Node e = f, pred = null;;) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
V ev = e.val;
if (cv == null || cv == ev ||
(ev != null && cv.equals(ev))) {
oldVal = ev;
if (value != null)
e.val = value;
else if (pred != null)
pred.next = e.next;
else
setTabAt(tab, i, e.next);
}
break;
}
pred = e;
if ((e = e.next) == null)
break;
}
// 遍历到链表尾部还没找到元素,跳出循环
}
else if (f instanceof ConcurrentHashMap.TreeBin) {
// 如果是树节点
validated = true;
ConcurrentHashMap.TreeBin t = (ConcurrentHashMap.TreeBin)f;
ConcurrentHashMap.TreeNode r, p;
// 遍历树找到了目标节点
if ((r = t.root) != null &&
(p = r.findTreeNode(hash, key, null)) != null) {
V pv = p.val;
if (cv == null || cv == pv ||
(pv != null && cv.equals(pv))) {
oldVal = pv;
if (value != null)
p.val = value;
else if (t.removeTreeNode(p))
setTabAt(tab, i, untreeify(t.first));
}
}
}
}
}
// 如果处理过,不管有没有找到元素都返回
if (validated) {
// 如果找到了元素,返回其旧值
if (oldVal != null) {
// 如果要替换的值为空,元素个数减1
if (value == null)
addCount(-1L, -1);
return oldVal;
}
break;
}
}
}
//没找到元素返回空
return null;
}
步骤如下:
第1步,循环遍历数组,接着校验参数;
第2步,判断是否有别的线程正在扩容,如果是一起扩容;
第3步,用 synchronized 同步锁,保证并发时元素移除安全;
第4步,因为 check= -1
,所以不会进行扩容操作,利用CAS操作修改baseCount值。
ConcurrentHashMap不能解决什么问题呢?
请看下面的例子:
private static final Map map = new ConcurrentHashMap<>();
public void unsafeUpdate(Integer key, Integer value) {
Integer oldValue = map.get(key);
if (oldValue == null) {
map.put(key, value);
}
}
这里如果有多个线程同时调用unsafeUpdate()这个方法,ConcurrentHashMap还能保证线程安全吗?
答案是不能。因为get()之后if之前可能有其它线程已经put()了这个元素,这时候再put()就把那个线程put()的元素覆盖了。
那怎么修改呢?
答案也很简单,使用putIfAbsent()
方法,它会保证元素不存在时才插入元素,如下:
public void safeUpdate(Integer key, Integer value) {
map.putIfAbsent(key, value);
}
那么,如果上面oldValue
不是跟 null 比较,而是跟一个特定的值比如1进行比较怎么办?
也就是下面这样:
public void unsafeUpdate(Integer key, Integer value) {
Integer oldValue = map.get(key);
if (oldValue == 1) { map.put(key, value); }
}
这样的话就没办法使用putIfAbsent()
方法了。
其实,ConcurrentHashMap还提供了另一个方法叫replace(K key, V oldValue, V newValue)
可以解决这个问题。
replace(K key, V oldValue, V newValue)
这个方法可不能乱用,如果传入的newValue是null,则会删除元素。
public void safeUpdate(Integer key, Integer value) { map.replace(key, 1, value); }
那么,如果if之后不是简单的put()操作,而是还有其它业务操作,之后才是put(),比如下面这样,这该怎么办呢?
public void unsafeUpdate(Integer key, Integer value) {
Integer oldValue = map.get(key);
if (oldValue == 1) {
System.out.println(System.currentTimeMillis());
/**
* 其它业务操作
*/
System.out.println(System.currentTimeMillis());
map.put(key, value);
}
}
这时候就没办法使用ConcurrentHashMap提供的方法了,只能业务自己来保证线程安全了,比如下面这样:
public void safeUpdate(Integer key, Integer value) {
synchronized (map) {
Integer oldValue = map.get(key);
if (oldValue == null) {
System.out.println(System.currentTimeMillis());
/**
* 其它业务操作
*/
System.out.println(System.currentTimeMillis());
map.put(key, value);
}
}
}
这样虽然不太友好,但是最起码能保证业务逻辑是正确的。
当然,这里使用ConcurrentHashMap的意义也就不大了,可以换成普通的HashMap了。
我们不能听说ConcurrentHashMap是线程安全的,就认为它无论什么情况下都是线程安全的。
虽然 HashMap 在多线程环境下操作不安全,但是在 java.util.concurrent
包下,java 为我们提供了 ConcurrentHashMap 类,保证在多线程下 HashMap 操作安全!
在 JDK1.7 中,ConcurrentHashMap 采用了分段锁策略,将一个 HashMap 切割成 Segment 数组,其中 Segment 可以看成一个 HashMap, 不同点是 Segment 继承自 ReentrantLock,在操作的时候给 Segment 赋予了一个对象锁,从而保证多线程环境下并发操作安全。
但是 JDK1.7 中,HashMap 容易因为冲突链表过长,造成查询效率低,所以在 JDK1.8 中,HashMap 引入了红黑树特性,当冲突链表长度大于8时,会将链表转化成红黑二叉树结构。
在 JDK1.8 中,与此对应的 ConcurrentHashMap 也是采用了与 HashMap 类似的存储结构,但是 JDK1.8 中 ConcurrentHashMap 并没有采用分段锁的策略,而是在元素的节点上采用 CAS + synchronized
操作来保证并发的安全性,源码的实现比 JDK1.7 要复杂的多。