面试——ConcurrentHashMap
ConcurrentHashMap是J.U.C(java.util.concurrent包)的重要成员,它是HashMap的一个线程安全的、支持高效并发的版本
1. 数据结构
JDK1.7版本
-
如图所示,ConcurrentHashMap本质上是一个Segment数组,而一个Segment实例则是一个小的哈希表,一个Segment实例又包含若干个桶,每个桶中都包含一条由若干个HashEntry 对象链接起来的链表。
-
一个ConcurrentHashMap中只有一个Segment<K,V>类型的segments数组,每个segment中只有一个HashEntry<K,V>类型的table数组,table数组中存放一个HashEntry节点
2. JDK1.8版本
- 1.8版本放弃了Segment,跟HashMap一样,用Node描述插入集合中的元素。但是Node中的val和next使用了volatile来修饰,保存了内存可见性。与HashMap相同的是,ConcurrentHashMap1.8版本使用了数组+链表+红黑树的结构。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
Node(int hash, K key, V val, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.val = val;
this.next = next;
}
- 同时,ConcurrentHashMap使用了CAS+Synchronized保证了并发的安全性
/** 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());//更为分散的hash值
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) {//该位置没有元素,则用cas自旋获锁,存入节点
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);//如果ConcurrentHashMap正在扩容,则协助其转移
else {
V oldVal = null;
synchronized (f) {//对根节点上锁
if (tabAt(tab, i) == f) {
if (fh >= 0) {//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) {//判断链表的值是否大于等于8,如果大于等于8就升级为红黑树。
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
2. Segment
- Segment 类继承于 ReentrantLock 类,从而使得 Segment 对象能充当锁的角色。
- 当对HashEntry数组的数据进行修改时,必须首先获得与它对应的Segment锁。
3. HashEntry
- 内部结构:
static final class HashEntry<K,V> {
final int hash;
final K key;
volatile V value; //加了volatile修饰,保存内存可见性及防止指令重排
volatile HashEntry<K,V> next;
HashEntry(int hash, K key, V value, HashEntry<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
- 在插入ConcurrentHashMap元素时,先尝试获得Segment锁,先是自旋获锁,如果自旋次数超过阈值,则转为ReentrantLock上锁
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
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<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;
}
4. 如何实现扩容?
ConcurrentHashMap 的扩容是仅仅和每个Segment元素中HashEntry数组的长度有关,但需要扩容时,只扩容当前Segment中HashEntry数组即可。也就是说ConcurrentHashMap中Segment[]数组的长度是在初始化的时候就确定了,后面扩容不会改变这个长度。
5. CAS是什么
1. 概念
- Compare and Swap===》比较并替换
- CAS属于乐观锁——没有上任何锁,所以线程不会阻塞,但依然会有上锁的效果。
- CAS机制中使用了3个基本操作数:内存地址V,旧的预期值A,要修改的新值B,更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B。
2. 举例:
1. 在内存地址V当中,存储着值为10的变量
2. 此时线程1想把变量的值增加1.对线程1来说,旧的预期值A=10,要修改的新值B=11。
3. 在线程1要提交更新之前,另一个线程2抢先一步,把内存地址V中的变量值率先更新成了11。
4. 线程1开始提交更新,首先进行A和地址V的实际值比较,发现A不等于V的实际值,提交失败。
5. 线程1 重新获取内存地址V的当前值,并重新计算想要修改的值。此时对线程1来说,A=11,B=12。这个重新尝试的过程被称为**自旋**。
6. 这一次比较幸运,没有其他线程改变地址V的值。线程1进行比较,发现A和地址V的实际值是相等的。
3. 缺点
- CPU开销过大:在高并发的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很到的压力。
- 不能保证代码块的原子性:CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用synchronized了。
- ABA问题:
-
假设内存中有一个值为A的变量,存储在地址V中
-
此时有三个线程想使用CAS的方式更新这个变量的值,每个线程的执行时间有略微偏差。线程1和线程2已经获取当前值,线程3还未获取当前值
-
接下来,线程1先一步执行成功,把当前值成功从A更新为B;同时线程2因为某种原因被阻塞住,没有做更新操作;线程3在线程1更新之后,获取了当前值B。
-
在之后,线程2仍然处于阻塞状态,线程3继续执行,成功把当前值从B更新成了A。
-
最后,线程2终于恢复了运行状态,由于阻塞之前已经获得了“当前值A”,并且经过compare检测,内存地址V中的实际值也是A,所以成功把变量值A更新成了B。
-
4. 解决ABA问题
怎么解决呢?加个版本号就可以了
-
真正要做到严谨的CAS机制,在compare阶段不仅要比较期望值A和地址V中的实际值,还要比较变量的版本号是否一致。假设地址V中存储着变量值A,当前版本号是01。线程1获取了当前值A和版本号01,想要更新为B,但是被阻塞了。
-
这时候,内存地址V中变量发生了多次改变,版本号提升为03,但是变量值仍然是A。
-
随后线程1恢复运行,进行compare操作。经过比较,线程1所获得的值和地址的实际值都是A,但是版本号不相等,所以这一次更新失败。
-
在数据库层面操作版本号:判断原来的值和版本号是否匹配,中间有别的线程修改,值可能相等,但是版本号100%不⼀样
update a set value = newValue, vision = vision + 1 where value = #{oldValue} and vision = #{vision}
6. ConcurrentHashMap效率为什么高?
- 因为ConcurrentHashMap的get方法并没有上锁(由于HashEntry的value属性使用了volatile修饰,保证了内存可见性,每次获取都是最新值。因此整个过程不需要加锁。)。get时通过hash(key)定位到Segment上,再通过一次Hash定位到具体的HashEntry上。
- HashEntry的get方法如下:
public V get(Object key) {// key由equals()确定唯一性
Segment<K, V> s; //
HashEntry<K, V>[] tab;
int h = hash(key);//h是key的hashcode二次散列值。 根据key的hashcode再做散列函数运算
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;//散列算法定位segement,u就是Segement数组的索引,Segment的散列运算,为了将不同key分散在不同segement.根据h获取segement的index
if ((s = (Segment<K, V>) UNSAFE.getObjectVolatile(segments, u)) != null && (tab = s.table) != null) {// 如果u对应的segement存在,且segement中的table也存在,则获取table中的value
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)))// 查询到对象相同或者equals相等的key则返回对应的value
return e.value;
}
}
return null;
}
7. 什么是fail-safe和fail-fast
- fail-safe:安全失败。java.util.concurrent并发包下的容器都是遵循安全失败机制。即可以在多线程下并发修改。不会抛出并发修改的异常Concurrent Modification Exception
- Fail-fast: 快速失败。Java集合在使用迭代器遍历时,如果遍历过程中对集合中的内容进行了增删改的操作时,则会抛出并发修改的异常Concurrent Modification Exception。即使不存在并发,也会抛出该异常,所以称之为快速失败。
@Override
public void forEach(BiConsumer<? super K, ? super V> action) {
Node<K,V>[] tab;
if (action == null)
throw new NullPointerException();
if (size > 0 && (tab = table) != null) {
int mc = modCount;
for (int i = 0; i < tab.length; ++i) {
for (Node<K,V> e = tab[i]; e != null; e = e.next)
action.accept(e.key, e.value);
}
if (modCount != mc)
throw new ConcurrentModificationException();
}
}