HashTable、ConcurrentHashMap面试相关八股文

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() ⾃旋获取锁。

  1. 尝试⾃旋获取锁。

  1. 如果重试的次数达到了 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操作的还是⽐较复杂的,⼤致可以分为以下步骤:

  1. 根据 key 计算出 hashcode 。

  1. 判断是否需要进⾏初始化

  1. 即为当前 key 定位出的 Node,如果为空表示当前位置可以写⼊数据,利⽤ CAS 尝试写⼊,失败则⾃旋保证成功。

  1. 如果当前位置的 hashcode == MOVED == -1 ,则需要进⾏扩容。

  1. 如果都不满⾜,则利⽤ synchronized 锁写⼊数据。

  1. 如果数量⼤于 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)同问。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值