ConcurrentHashMap 底层是基于 数组 + 链表 组成的,不过在 jdk1.7 和 1.8 中具体实现稍有不同。
1.7中的数据结构
是由 Segment 数组、HashEntry 组成,和 HashMap 一样,仍然是数组加链表。HashEntry跟HashMap差不多的,但是不同点是,他使用volatile去修饰了他的数据Value还有下一个节点next。
1. ConcurrentHashMap并发度高的原因么?
(1)原理上来说,ConcurrentHashMap 采用了分段锁技术,其中 Segment 继承于ReentrantLock。不会像 HashTable 那样不管是 put 还是 get 操作都需要做同步处理,理论上 ConcurrentHashMap 支持 CurrencyLevel (Segment 数组数量)的线程并发。(2)每当一个线程占用锁访问一个 Segment 时,不会影响到其他的 Segment。(3)就是说如果容量大小是16他的并发度就是16,可以同时允许16个线程操作16个Segment而且还是线程安全的。
2. ConcurrentHashMap如何做到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 则改为阻塞锁获取,保证能获取成功。
2. 那他get的逻辑呢?
(1)get 逻辑比较简单,只需要将 Key 通过 Hash 之后定位到具体的 Segment ,再通过一次 Hash 定位到具体的元素上。
(2)由于 HashEntry 中的 value 属性是用 volatile 关键词修饰的,保证了内存可见性,所以每次获取时都是最新值。
(3)ConcurrentHashMap 的 get 方法是非常高效的,因为整个过程都不需要加锁。
3.你有没有发现1.7虽然可以支持每个Segment并发访问,但是还是存在一些问题?
因为基本上还是数组加链表的方式,我们去查询的时候,还得遍历链表,会导致效率很低,这个跟jdk1.7的HashMap是存在的一样问题,所以他在jdk1.8完全优化了。
4. jdk1.8他的数据结构
(1)其中抛弃了原有的 Segment 分段锁,而采用了 CAS + synchronized 来保证并发安全性。
(2)跟HashMap很像,也把之前的HashEntry改成了Node,但是作用不变,把值和next采用了volatile去修饰,保证了可见性,并且也引入了红黑树,在链表大于一定值的时候会转换(默认是8)。
5. 他值的存取操作么?以及是怎么保证线程安全的?
ConcurrentHashMap在进行put操作的还是比较复杂的,大致可以分为以下步骤:
(1)根据 key 计算出 hashcode 。
(2)判断是否需要进行初始化。
(3)即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。
(4)如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。
(5)如果都不满足,则利用 synchronized 锁写入数据。
(6)如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树。
6.你在上面提到CAS是什么?自旋又是什么?
(1)CAS 是乐观锁的一种实现方式,是一种轻量级锁,JUC 中很多工具类的实现就是基于 CAS 的。
(2)CAS 操作的流程如下图所示,线程在读取数据时不进行加锁,在准备写回数据时,比较原值是否修改,若未被其他线程修改则写回,若已被修改,则重新执行读取流程。
这是一种乐观策略,认为并发操作并不总会发生。正在上传…重新上传取消
7.CAS就一定能保证数据没被别的线程修改过么?
并不是的,比如很经典的ABA问题,CAS就无法判断了。
8. 什么是ABA?
就是说来了一个线程把值改回了B,又来了一个线程把值又改回了A,对于这个时候判断的线程,就发现他的值还是A,所以他就不知道这个值到底有没有被人改过,其实很多场景如果只追求最后结果正确,这是没关系的。
- 那怎么解决ABA问题?
用版本号去保证就好了,就比如说,我在修改前去查询他原来的值的时候再带一个版本号,每次判断就连值和版本号一起判断,判断成功就给版本号加1。时间戳
10. CAS性能很高,但是我知道synchronized性能可不咋地,为啥jdk1.8升级之后反而多了synchronized?
(1)synchronized之前一直都是重量级的锁,但是后来java官方是对他进行过升级的,他现在采用的是锁升级的方式去做的。
(2)针对 synchronized 获取锁的方式,JVM 使用了锁升级的优化方式,就是先使用偏向锁优先同一线程然后再次获取锁,如果失败,就升级为 CAS 轻量级锁,如果失败就会短暂自旋,防止线程被系统挂起。最后如果以上都失败就升级为重量级锁。
所以是一步步升级上去的,最初也是通过很多轻量级的方式锁定的。
11. 那我们回归正题,ConcurrentHashMap的get操作又是怎么样子的呢?
- 根据计算出来的 hashcode 寻址,如果就在桶上那么直接返回值。
- 如果是红黑树那就按照树的方式获取值。
- 就不满足那就按照链表的方式遍历获取值
TODO:文中的快速失败(fail—fast)问到,那对应的安全失败(fail—safe)也是有可能知道的
- 谈谈你理解的 Hashtable,讲讲其中的 get put 过程。ConcurrentHashMap同问。
- 1.8 做了什么优化?
- 线程安全怎么做的?
- 不安全会导致哪些问题?
- 如何解决?有没有线程安全的并发容器?
- ConcurrentHashMap 是如何实现的?
- ConcurrentHashMap并发度为啥好这么多?
- 1.7、1.8 实现有何不同?为什么这么做?
- CAS是啥?
- ABA是啥?场景有哪些,怎么解决?
- synchronized底层原理是啥?
- synchronized锁升级策略
- 快速失败(fail—fast)是啥,应用场景有哪些?安全失败(fail—safe)同问。