【392期】ConcurrentHashMap 面试十连问,看完秒懂!

>>强大,10k+点赞的 SpringBoot 后台管理系统竟然出了详细教程!

【392期】ConcurrentHashMap 面试十连问,看完秒懂!

点击加入:
后端技术内卷群,一起学习!
【392期】ConcurrentHashMap 面试十连问,看完秒懂!

看你简历里写了 HashMap,那你说说它存在什么缺点?

  • 线程不安全
  • 迭代时无法修改值

那你有用过线程安全的 Map 吗?

有,回答在哪用过。

没有,不过我了解过。

那你说说它们的实现。

Hashtable

Hashtable 本身比较低效,因为它的实现基本就是将 put、get、size 等各种方法加上 synchronized 锁。这就导致了所有并发操作都要竞争同一把锁,一个线程在进行同步操作时,其他线程只能等待,大大降低了并发操作的效率。

Collections#SynchronizedMap

同步包装器 SynchronizedMap 虽然没使用方法级别的 synchronized 锁,但是使用了同步代码块的形式,本质上还是没有改进。

ConcurrentHashMap

首先 ConcurrentHashMap 在 JDK1.7 和 JDK1.8 的实现方式是不同的。

在 JDK1.7 中,ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成。Segment 继承了 ReentrantLock,是一种可重入锁。HashEntry 则用于存储键值对数据。

一个 ConcurrentHashMap 里包含一个 Segment 数组,一个 Segment 里包含一个 HashEntry 数组 ,每个 HashEntry 是一个链表结构的元素,因此 JDK1.7 的 ConcurrentHashMap 是一种数组+链表结构。当对 HashEntry 数组的数据进行修改时,必须首先获得与它对应的 Segment 锁,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全(分段锁)。

【392期】ConcurrentHashMap 面试十连问,看完秒懂!

在 JDK1.8 中,ConcurrentHashMap 选择了与 HashMap 相同的数组+链表+红黑树结构,在锁的实现上,采用 CAS 操作和 synchronized 锁实现更加低粒度的锁,将锁的级别控制在了更细粒度的 table 元素级别,也就是说只需要锁住这个链表的首节点,并不会影响其他的 table 元素的读写,大大提高了并发度。

【392期】ConcurrentHashMap 面试十连问,看完秒懂!

那为什么 JDK1.8 要使用 synchronized 锁而不是其他锁呢?

我认为有以下两个方面:

Java 开发人员从未放弃过 synchronized 关键字,而且一直在优化,在 JDK1.8 中,synchronized 锁的性能得到了很大的提高,并且 synchronized 有多种锁状态,会从无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁一步步转换,因此并非是我们认为的重量级锁

在粗粒度加锁中像 ReentrantLock 这种锁可以通过 Condition 来控制各个低粒度的边界,更加的灵活。而在低粒度中,Condition 的优势就没有了,此时 synchronized 不失为一种更好的选择。

你提到了 synchronized 的锁状态升级,能具体说下每种锁状态在什么情况下升级吗?

无锁

无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

偏向锁

当一段同步代码一直被同一个线程所访问,无锁就会升级为偏向锁,以后该线程访问该同步代码时会自动获取锁,降低了获取锁的代价。

轻量级锁

当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。

重量级锁

若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。

为什么 ConcurrentHashMap 的 key 和 value 不能为 null?

这是因为当通过 get(k) 获取对应的 value 时,如果获取到的是 null 时,无法判断,它是 put(k,v) 的时候 value 为 null,还是这个 key 从来没有添加。

假如线程 1 调用 map.contains(key) 返回 true,当再次调用 map.get(key) 时,map 可能已经不同了。因为可能线程 2 在线程 1 调用 map.contains(key) 时,删除了 key,这样就会导致线程 1 得到的结果不明确,产生多线程安全问题,因此,ConcurrentHashMap 的 key 和 value 不能为 null。

其实这是一种安全失败机制(fail-safe),这种机制会使你此次读到的数据不一定是最新的数据。

那你谈谈快速失败(fail-fast)和安全失败(fail-safe)的区别。

快速失败和安全失败都是 Java 集合中的一种机制。

如果采用快速失败机制,那么在使用迭代器对集合对象进行遍历的时候,如果 A 线程正在对集合进行遍历,此时 B 线程对集合进行增加、删除、修改,或者 A 线程在遍历过程中对集合进行增加、删除、修改,都会导致 A 线程抛出 ConcurrentModificationException 异常。

为什么在用迭代器遍历时,修改集合就会抛异常时?

原因是迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变 modCount 的值。

每当迭代器使用 hashNext()/next() 遍历下一个元素之前,都会检测 modCount 变量是否为 expectedModCount 值,是的话就返回遍历;否则抛出异常,终止遍历。

java.util 包下的集合类都是快速失败的。

如果采用安全失败机制,那么在遍历时不是直接在集合内容上访问,而是先复制原有集合内容,在拷贝的集合上进行遍历。

由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,故不会抛 ConcurrentModificationException 异常。

java.util.concurrent 包下的并发容器都是安全失败的。

ConcurrentHashMap 的 get 方法为什么不用加锁,会不会出现数据读写不一致情况呢?

不会出现读写不一致的情况。

get 方法逻辑比较简单,只需要将 key 通过 hash 之后定位到具体的 Segment ,再通过一次 hash 定位到具体的元素上。

由于变量 value 是由 volatile 修饰的,根据 JMM 中的 happen before 规则保证了对于 volatile 修饰的变量始终是写操作先于读操作的,并且 volatile 的内存可见性保证修改完的数据可以马上更新到主存中,所以能保证在并发情况下,读出来的数据是最新的数据。

说下 ConcurrentHashMapput 方法执行逻辑。

JDK1.7:

先尝试自旋获取锁,如果自旋重试的次数超过 64 次,则改为阻塞获取锁。获取到锁后:

  • 将当前 Segment 中的 table 通过 keyhashcode 定位到 HashEntry
  • 遍历该 HashEntry,如果不为空则判断传入的 key 和当前遍历的 key 是否相等,相等则覆盖旧的 value
  • 不为空则需要新建一个 HashEntry 并加入到 Segment 中,同时会先判断是否需要扩容。
  • 释放 Segment 的锁
JDK1.8:

先定位到 Node,拿到首节点 first,判断是否为:

  • 如果为 null ,通过 CAS 的方式把数据 put 进去。
  • 如果不为 null ,并且 first.hash = MOVED = -1 ,说明其他线程在扩容,参与一起扩容。
  • 如果不为 null ,并且 first.hash != -1synchronized 锁住 first 节点,判断是链表还是红黑树,遍历插入。

说下 ConcurrentHashMapsize 方法如何计算最终大小。

JDK1.7:

虽然 count 变量是被 volatile 修饰的,但是并不是简单的把所有 Segmentcount 值相加。因为有可能在累加过程中 count 值发生了改变,那么此时结果就不正确了。但是也不能直接锁住,这样效率太低。因此在 JDK1.7 中的做法是先尝试 2 次通过不锁住 Segment 的方式来统计各个 Segment 大小,如果统计的过程中,容器的 count 发生了变化,则再采用加锁的方式来统计所有Segment 的大小。

那么 ConcurrentHashMap 是如何判断在统计的时候容器是否发生了变化呢?使用modCount变量,在put、 remove 方法里操作元素前都会将变量 modCount 进行加 1,那么在统计大小前后比较 modCount 是否发生变化,从而得知容器的大小是否发生变化。

JDK1.8:

由于没有 segment 的概念,所以只需要用一个 baseCount 变量来记录 ConcurrentHashMap 当前节点的个数。

  • 先尝试通过CAS 更新 baseCount 计数。
  • 如果多线程竞争激烈,某些线程 CAS 失败,那就 CAS 尝试将 cellsBusy 置 1,成功则可以把 baseCount 变化的次数暂存到一个数组 counterCells 里,后续数组 counterCells 的值会加到 baseCount 中。
  • 如果 cellsBusy 置 1 失败又会反复进行 CAS baseCountCAS counterCells 数组。

如何提高 ConcurrentHashMap 的插入效率?

主要从以下两个方面入手:

  • 扩容操作。主要还是要通过配置合理的容量大小和负载因子,尽可能减少扩容事件的发生。
  • 锁资源的争夺,在 put 方法中会使用 synchonized 对首节点进行加锁,而锁本身也是分等级的,因此我们的主要思路就是尽可能的避免锁升级。我们可以将数据通过 ConcurrentHashMapspread 方法进行预处理,这样我们可以将存在哈希冲突的数据放在一个桶里面,每个桶都使用单线程进行 put 操作,这样的话可以保证锁仅停留在偏向锁这个级别,不会升级,从而提升效率。

ConcurrentHashMap 是否存在线程不安全的情况?如果存在的话,在什么情况下会出现?如何解决?

【392期】ConcurrentHashMap 面试十连问,看完秒懂!

我想起来了,存在这种情况,请看如下代码

public class ConcurrentHashMapNotSafeDemo implements Runnable {

    private static ConcurrentHashMap<String, Integer> scores = new ConcurrentHashMap<>();

    public static void main(String[] args) throws InterruptedException {
        scores.put("John"0);
        Thread t1 = new Thread(new ConcurrentHashMapNotSafeDemo());
        Thread t2 = new Thread(new ConcurrentHashMapNotSafeDemo());

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println(scores);

    }

    @Override
    public void run() {
        for (int i = 0; i < 1000; i++) {
            Integer score = scores.get("John");
            Integer newScore = score + 1;
            scores.put("John", newScore);
        }
    }
}

结果输出 {John=1323},很明显发生了错误。

【392期】ConcurrentHashMap 面试十连问,看完秒懂!

原因就在于这三行在一起不是线程安全的,虽然 get 方法和 put 方法是线程安全的,但是中间的又对获取的值修改了,因此导致线程不安全。

解决方法是使用 replace 方法

【392期】ConcurrentHashMap 面试十连问,看完秒懂!

可以看到,replace 方法传入三个值,分别是当前 key、旧值、新值

最终修改如下:

【392期】ConcurrentHashMap 面试十连问,看完秒懂!
【392期】ConcurrentHashMap 面试十连问,看完秒懂!

来源:blog.csdn.net/a979331856/article/details/105922069

精彩推荐
最全的java面试题库
开源版的高仿 “ 微信 ”,吊炸天!

与其在网上拼命找题? 不如马上关注我们~

【392期】ConcurrentHashMap 面试十连问,看完秒懂!

PS:因为公众号平台更改了推送规则,如果不想错过内容,记得读完点一下“在看”,加个“星标”,这样每次新文章推送才会第一时间出现在你的订阅列表里。“在看”支持我们吧!

原文始发于微信公众号(Java面试题精选):【392期】ConcurrentHashMap 面试十连问,看完秒懂!