HashTable、ConcurrentHashMap
上次我们最后聊到HashMap在多线程环境下存在线程安全问题,那你⼀般都是怎么处理这种情况的?
貌美如花的面试官您好,一般在多线程的场景,我都会使用好几种不同的方式去代替:
使用Collections.synchronizedMap(Map)创建线程安全的map集合
HashTable
ConcurrentHashMap
不过出于线程并发度的原因,我都会舍弃前两者使用最后的ConcurrentHashMap,他的性能和效率明显高于前两者。
哦,Collections.synchronizedMap是怎么实现线程安全的你有了解过吗?
在SynchronizedMap内部中维护了一个普通的对象Map,还有排斥锁mutex,如下图所示:


我们在调用这个方法的时候需要传入一个map对象,可以看到它有两个构造器,如果传入了mutex参数,那就回把这个传入的对象赋值给mutex对象排斥锁
如果没有传mutex的参数的话,那么就会将对象排斥锁赋值为this,即调用synchronizedMap的对象,就是上面的map。
创建出synchornizedMap之后,再操作map的时候,就会对方法进行上锁,如图全是


能继续聊一下HashTable吗?
其实跟HashMap相比,HashTable是线程安全的,适合在多线程情况下使用,但是效率可能不太乐观,会比较低。
为什么HashTable的效率比较低呢?
我看过他的源码,在源码中,它对数据进行操作的时候都会进行上锁,所以效率会比较低。

除了这个你还能说出⼀些Hashtable 跟HashMap不⼀样点么?
Hashtable 是不允许键或值为 null 的,HashMap 的键值则都可以为 null
呃我能打断你⼀下么?为什么 Hashtable 是不允许 KEY 和 VALUE 为 null, ⽽ HashMap 则可以呢?
因为Hashtable在我们put 空值的时候会直接抛空指针异常,但是HashMap却做了特殊处理。

但是你还是没说为什么Hashtable 是不允许键或值为 null 的,HashMap 的键值则都可以为null?
这是因为Hashtable使⽤的是安全失败机制(fail-safe),这种机制会使你此次读到的数据不⼀定是最新的数据。
如果你使⽤null值,就会使得其⽆法判断对应的key是不存在还是为空,因为你⽆法再调⽤⼀次contain(key)来对key是否存在进⾏判断,ConcurrentHashMap同理。
好的你继续说不同点吧。
实现⽅式不同:Hashtable 继承了 Dictionary类,⽽ HashMap 继承的是AbstractMap 类。Dictionary 是 JDK 1.0 添加的,貌似没⼈⽤过这个,我也没⽤过。
初始化容量不同:HashMap 的初始容量为:16,Hashtable 初始容量为:11,两者的负载因⼦默认都是:0.75。
扩容机制不同:当现有容量⼤于总容量 * 负载因⼦时,HashMap 扩容规则为当前容量翻倍,Hashtable 扩容规则为当前容量翻倍 + 1。
迭代器不同:HashMap 中的 Iterator 迭代器是 fail-fast 的,⽽ Hashtable 的 Enumerator 不是 fail-fast 的。
所以,当其他线程改变了HashMap 的结构,如:增加、删除元素,将会抛出ConcurrentModificationException 异常,⽽ Hashtable 则不会。
fail-fast是什么呢?
快速失败(fail—fast)**是java集合中的⼀种机制, 在⽤迭代器遍历⼀个集合对象时,如果遍历过程中对集合对象的内容进⾏了修改(增加、删除、修改),则会抛出Concurrent Modification Exception。
那它的原理是什么呢?
迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使⽤⼀个 modCount 变量。
集合在被遍历期间如果内容发⽣变化,就会改变modCount的值。 每当迭代器使⽤hashNext()/next()遍历下⼀个元素之前,都会检测modCount变量是否为expectedmodCount值,是的话就返回遍历;否则抛出异常,终⽌遍历。
Tip:这⾥异常的抛出条件是检测到 modCount!=expectedmodCount 这个条件。如果集合发⽣变化时修改modCount值刚好⼜设置为了expectedmodCount值,则异常不会抛出。
因此,不能依赖于这个异常是否抛出⽽进⾏并发操作的编程,这个异常只建议⽤于检测并发修改的bug。
说说他的场景?
java.util包下的集合类都是快速失败的,不能在多线程下发⽣并发修改(迭代过程中被修改)算是⼀种安全机制吧。
Tip:安全失败(fail—safe)⼤家也可以了解下,java.util.concurrent包下的容器都是安全失败,可以在多线程下并发使⽤,并发修改。
都说了他的并发度不够,性能很低,这个时候你都怎么处理的?
这样的场景,我们在开发过程中都是使⽤ConcurrentHashMap,他的并发的相⽐前两者好很多。
哦?那你跟我说说它的数据结构吧,以及为什么他并发度这么⾼?
ConcurrentHashMap 底层是基于 数组 + 链表 组成的,不过在 jdk1.7 和 1.8 中具体实现稍有不同。
我先说⼀下他在1.7中的数据结构吧:

如图所示,是由 Segment 数组、HashEntry 组成,和 HashMap ⼀样,仍然是数组加链表。
Segment 是 ConcurrentHashMap 的⼀个内部类,主要的组成如下:
static final class Segment<K,V> extends ReentrantLock implements Serializable {
private static final long serialVersionUID = 2249069246763182397L;
// 和 HashMap 中的 HashEntry 作⽤⼀样,真正存放数据的桶
transient volatile HashEntry<K,V>[] table;
transient int count;
// 记得快速失败(fail—fast)么?
transient int modCount;
// ⼤⼩
transient int threshold;
// 负载因⼦
final float loadFactor;
}
HashEntry跟HashMap差不多的,但是不同点是,他使⽤volatile去修饰了他的数据Value还有下⼀个节点next。
volatile的特性是啥?
保证了不同线程对这个变量进⾏操作时的可⻅性,即⼀个线程修改了某个变量的值,这新值对其他线程来说是⽴即可⻅的。(实现可⻅性)
禁⽌进⾏指令重排序。(实现有序性)
volatile 只能保证对单次读/写的原⼦性。i++ 这种操作不能保证原⼦性。
之后多线程会详细介绍的!!!
那你能说说他并发度⾼的原因么?
原理上来说,ConcurrentHashMap 采⽤了分段锁技术,其中 Segment 继承于 ReentrantLock。
不会像 HashTable 那样不管是 put 还是 get 操作都需要做同步处理,理论上 ConcurrentHashMap ⽀持CurrencyLevel (Segment 数组数量)的线程并发。
每当⼀个线程占⽤锁访问⼀个 Segment 时,不会影响到其他的 Segment。
就是说如果容量⼤⼩是16他的并发度就是16,可以同时允许16个线程操作16个Segment⽽且还是线程安全的。
他先定位到Segment,然后再进⾏put操作。
public V put(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();//这就是为啥他不可以put null值的原因
int hash = hash(key);
int j = (hash >>> segmentShift) & segmentMask;
if ((s = (Segment<K,V>)UNSAFE.getObject
(segments, (j << SSHIFT) + SBASE)) == null)
s = ensureSegment(j);
return s.put(key, hash, value, false);
}
我们看看他的put源代码,你就知道他是怎么做到线程安全的了,关键句⼦我注释了。
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
// 将当前 Segment 中的 table 通过 key 的 hashcode 定位到 HashEntry
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;
// 遍历该 HashEntry,如果不为空则判断传⼊的 key 和当前遍历的 key 是否相等,相等则覆盖旧的 value。
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 {
// 不为空则需要新建⼀个 HashEntry 并加⼊到 Segment 中,同时会先判断是否需要扩容。
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;
}
⾸先第⼀步的时候会尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利⽤scanAndLockForPut() ⾃旋获取锁。
尝试⾃旋获取锁。
如果重试的次数达到了 MAX_SCAN_RETRIES 则改为阻塞锁获取,保证能获取成功。
那他get的逻辑呢?
get 逻辑⽐较简单,只需要将 Key 通过 Hash 之后定位到具体的 Segment ,再通过⼀次 Hash 定位到具体的元素上。
由于 HashEntry 中的 value 属性是⽤ volatile 关键词修饰的,保证了内存可⻅性,所以每次获取时都是最新值。
ConcurrentHashMap 的 get ⽅法是⾮常⾼效的,因为整个过程都不需要加锁。
你有没有发现1.7虽然可以⽀持每个Segment并发访问,但是还是存在⼀些问题?
是的,因为基本上还是数组加链表的⽅式,我们去查询的时候,还得遍历链表,会导致效率很低,这个跟jdk1.7的HashMap是存在的⼀样问题,所以他在jdk1.8完全优化了。
>• 那你再跟我聊聊jdk1.8他的数据结构是怎么样⼦的呢?
其中抛弃了原有的 Segment 分段锁,⽽采⽤了 CAS + synchronized 来保证并发安全性。
跟HashMap很像,也把之前的HashEntry改成了Node,但是作⽤不变,把值和next采⽤了volatile去修饰,保证了可⻅性,并且也引⼊了红⿊树,在链表⼤于⼀定值的时候会转换(默认是8)。
同样的,你能跟我聊⼀下他值的存取操作么?以及是怎么保证线程安全的?
ConcurrentHashMap在进⾏put操作的还是⽐较复杂的,⼤致可以分为以下步骤:
根据 key 计算出 hashcode 。
判断是否需要进⾏初始化
即为当前 key 定位出的 Node,如果为空表示当前位置可以写⼊数据,利⽤ CAS 尝试写⼊,失败则⾃旋保证成功。
如果当前位置的 hashcode == MOVED == -1 ,则需要进⾏扩容。
如果都不满⾜,则利⽤ synchronized 锁写⼊数据。
如果数量⼤于 TREEIFY_THRESHOLD 则要转换为红⿊树。

你在上⾯提到CAS是什么?⾃旋⼜是什么?
CAS 是乐观锁的⼀种实现⽅式,是⼀种轻量级锁,JUC 中很多⼯具类的实现就是基于CAS 的。
CAS 操作的流程如下图所示,线程在读取数据时不进⾏加锁,在准备写回数据时,⽐较原值是否修改,若未被其他线程修改则写回,若已被修改,则重新执⾏读取流程。
这是⼀种乐观策略,认为并发操作并不总会发⽣。

还是不明⽩?那我再说明下,乐观锁在实际开发场景中⾮常常⻅,⼤家还是要去理解。
就⽐如我现在要修改数据库的⼀条数据,修改之前我先拿到他原来的值,然后在SQL⾥⾯还会加个判断,原来的值和我⼿上拿到的他的原来的值是否⼀样,⼀样我们就可以去修改了,不⼀样就证明被别的线程修改了你就return错误就好了。
SQL伪代码⼤概如下:
update a set value = newValue where value = #{oldValue} #oldValue就是我们执⾏前查询出来的值
CAS就⼀定能保证数据没被别的线程修改过么?
并不是的,⽐如很经典的ABA问题,CAS就⽆法判断了。
什么是ABA呢?
就是说来了⼀个线程把值改回了B,⼜来了⼀个线程把值⼜改回了A,对于这个时候判断的线程,就发现他的值还是A,所以他就不知道这个值到底有没有被⼈改过,其实很多场景如果只追求最后结果正确,这是没关系的。
但是实际过程中还是需要记录修改过程的,⽐如资⾦修改什么的,你每次修改的都应该有记录,⽅便回溯。
那怎么解决ABA问题?
⽤版本号去保证就好了,就⽐如说,我在修改前去查询他原来的值的时候再带⼀个版本号,每次判断就连值和版本号⼀起判断,判断成功就给版本号加1。
update a set value = newValue ,vision = vision + 1where value = #
{oldValue} and vision = #{vision} #判断原来的值和版本号是否匹配,中间有别的线程
修改,值可能相等,但是版本号100%不⼀样
有点东⻄,除了版本号还有别的⽅法保证么?
其实有很多⽅式,⽐如时间戳也可以,查询的时候把时间戳⼀起查出来,对的上才修改并且更新值的时候⼀起修改更新时间,这样也能保证,⽅法很多但是跟版本号都是异曲同⼯之妙,看场景⼤家想怎么设计吧。
CAS性能很⾼,但是我知道synchronized性能可不咋地,为啥jdk1.8升级之后反⽽多了synchronized?
synchronized之前⼀直都是重量级的锁,但是后来java官⽅是对他进⾏过升级的,他现在采⽤的是锁升级的⽅式去做的。
针对 synchronized 获取锁的⽅式,JVM 使⽤了锁升级的优化⽅式,就是先使⽤偏向锁优先同⼀线程然后再次获取锁,如果失败,就升级为 CAS 轻量级锁,如果失败就会短暂⾃旋,防⽌线程被系统挂起。最后如果以上都失败就升级为重量级锁。
所以是⼀步步升级上去的,最初也是通过很多轻量级的⽅式锁定的。
那我们回归正题,ConcurrentHashMap的get操作⼜是怎么样⼦的呢?
根据计算出来的 hashcode 寻址,如果就在桶上那么直接返回值。
如果是红⿊树那就按照树的⽅式获取值。
就不满⾜那就按照链表的⽅式遍历获取值。

⼩结:1.8 在 1.7 的数据结构上做了⼤的改动,采⽤红⿊树之后可以保证查询效率( O(logn) ),甚⾄取消了 ReentrantLock 改为了 synchronized,这样可以看出在新版的 JDK 中对 synchronized 优化是很到位的。
常见问题
谈谈你理解的 Hashtable,讲讲其中的 get put 过程。ConcurrentHashMap同问。
1.8 做了什么优化?
线程安全怎么做的?
不安全会导致哪些问题?
如何解决?有没有线程安全的并发容器?
ConcurrentHashMap 是如何实现的?
ConcurrentHashMap并发度为啥好这么多?
1.7、1.8 实现有何不同?为什么这么做?
CAS是啥?
ABA是啥?场景有哪些,怎么解决?
synchronized底层原理是啥?
synchronized锁升级策略
快速失败(fail—fast)是啥,应⽤场景有哪些?安全失败(fail—safe)同问。