Java集合: HashMap

1、HashMap 了解吗?平时在什么地方使用过它呢?

我们需要根据键值获取到元素值时就选用 Map 接口下的集合,需要排序时选择 TreeMap,不需要排序时就选择 HashMap,需要保证线程安全就选用 ConcurrentHashMap。 HashMap底层利用数组支持下标随机访问数据的特性,快速的对键值对进行增删改查操作。

2、HashMap 底层数据结构说一下?(直接说最新的即可,无需对比以前的版本,但要强调介绍的版本号,保证严谨)

在最新的 JDK 1.8 中,HashMap 的底层数据结构为 “哈希表 + 链表 + 红黑树”。当哈希表中出现哈希冲突时,HashMap 采用 “链地址法” 来解决,也就是哈希表中的每个槽位,都会对应一个链表,所有哈希值相同的元素都会被放到同一个槽位对应的链表中。但随着链表长度的增加,元素的读取效率会下降,直到达到某个阈值时(目前JDK是8),HashMap 会将链表转化为红黑树,进一步提升性能。

将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树。

3、为什么用红黑树呢?用平衡二叉树不可以吗?或者你讲一讲他们各自的优缺点吗?

  • 红黑树是弱平衡二叉树,整棵树可以有局部的不平衡。
  • AVL 树是强平衡二叉树,它严格要求整棵树的平衡性。

也就是说,虽然两者的插入,删除复杂度都为 O(logn),实际中 AVL 树需要执行更多的旋转操作来保证强平衡性,效率要低于红黑树。但红黑树也有缺点,它需要额外的字段来记录每个节点的颜色,因此会占用更多的存储空间。

4、为什么选择 8 之后转为红黑树呢?另外链表转为红黑树之后,还会继续转为链表吗?

这个在源码的注释中有解释,大致意思为:如果元素的哈希值足够随机,理想情况下链表的长度对应的概率符合泊松分布,达到 8 的概率小于千万分之一。也就是说,一般情况下并不会发生链表到红黑树的转化,更多是一种防止自己选取的哈希算法不好的保底策略,在极端情况下仍会有较好的效率。

但是,当红黑树的节点小于 6 时,红黑树又会转回链表,原因是数据量很小的情况下,空间和时间上链表都要比红黑树优秀。至于为什么要把这个阈值定为 6,而不同样定为 8,主要是而为了防止元素数量在 8 附近导致两种数据结构的频繁转换。

5、简单描述下 put 的流程?可以说一下JDK位了效率更快,在 put 的时候,做了哪些优化不?

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // table未初始化或者长度为0,进行扩容
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // (n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    // 桶中已经存在元素(处理hash冲突)
    else {
        Node<K,V> e; K k;
        //快速判断第一个节点table[i]的key是否与插入的key一样,若相同就直接使用插入的值p替换掉旧的值e。
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
        // 判断插入的是否是红黑树节点
        else if (p instanceof TreeNode)
            // 放入树中
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 不是红黑树节点则说明为链表结点
        else {
            // 在链表最末插入结点
            for (int binCount = 0; ; ++binCount) {
                // 到达链表的尾部
                if ((e = p.next) == null) {
                    // 在尾部插入新结点
                    p.next = newNode(hash, key, value, null);
                    // 结点数量达到阈值(默认为 8 ),执行 treeifyBin 方法
                    // 这个方法会根据 HashMap 数组来决定是否转换为红黑树。
                    // 只有当数组长度大于或者等于 64 的情况下,才会执行转换红黑树操作,以减少搜索时间。否则,就是只是对数组扩容。
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    // 跳出循环
                    break;
                }
                // 判断链表中结点的key值与插入的元素的key值是否相等
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    // 相等,跳出循环
                    break;
                // 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
                p = e;
            }
        }
        // 表示在桶中找到key值、hash值与插入元素相等的结点
        if (e != null) {
            // 记录e的value
            V oldValue = e.value;
            // onlyIfAbsent为false或者旧值为null
            if (!onlyIfAbsent || oldValue == null)
                //用新值替换旧值
                e.value = value;
            // 访问后回调
            afterNodeAccess(e);
            // 返回旧值
            return oldValue;
        }
    }
    // 结构性修改
    ++modCount;
    // 实际大小大于阈值则扩容
    if (++size > threshold)
        resize();
    // 插入后回调
    afterNodeInsertion(evict);
    return null;
}

“首先 put( ) 会计算出要插入 key 的哈希值,通过哈希值计算出其在数组中的索引位置,如果该位置上没有元素则直接插入,有元素则需要遍历这个位置上的所有元素。如果能找到与当前键相等的键值对则将其更新为当前值并返回旧值,如果找不到与当前键相等的键值对,则需要执行真正的插入操作,将其插入到链表或者红黑树中,最后判断插入后是否需要扩容。”

put( ) 的优化我印象深的是计算 key 哈希值的 hash( ),主要有两个优化的点

  • 使用位运算代替取模运算和对 hashCode 进行搅动计算。具体来说,可以用x这个公式将取模转变为位运算来提升性能,但是同时也需要底层数组的长度是 2 的倍数,这个在 HashMap 的初始化和扩容方法中做了保证。
  • 除此之外,为了进一步降低哈希冲突的概率,hash( ) 又通过多个与运算将哈希值的高位和低位进行搅动,尽可能的做到在不同 key 中哪怕有一个位的不同,都会对最终产生的哈希值造成影响。

6、多线程情况下,put 是线程安全的吗?可以简单举个例子,说一下哪里不安全吗?

不是,在 JDK 1.7 中多线程同时进行 put( ) 会出现数据覆盖问题,在需要扩容时也可能会出现链死循环问题。JDK 1.8 修复了链死循环,但数据覆盖问题依然存在。

JDK 1.7 的 HashMap 底层为数组 + 链表,扩容的 transfer( ) 会遍历原链表中的每个节点,采用头插法将其转移到新哈希表槽位的链表中,这个过程在多线程下会导致新链表中出现环路,并造成某些元素丢失。

JDK 1.8 采用的是尾插法,保证了元素在扩容前后的顺序一致,避免了死循环问题,但还会造成数据覆盖。比如两个线程同时执行 put( ),且两个线程都同时判断槽位为空,则后插入的数据会覆盖先插入的数据。

但是还是不建议在多线程下使用 HashMap,因为多线程下使用 HashMap 还是会存在数据覆盖的问题。并发环境下,推荐使用 ConcurrentHashMap 。

7、如果我想要让 hashmap 变成线程安全的,你觉得可以怎么做?(有时候会扯到 concurrentHashMap,不过咱们这里先不追击这个)

想要解决 HashMap 的线程不安全问题,首先我们不能修改源码,那就要么使用一些 “辅助” 操作,让它变得安全,要么就寻找替代品。首先说的 “辅助” 操作是指,使用 Collections 类的 synchronizedMap 方法包装一下,它返回由指定映射支持的同步映射,是线程安全的。换替代品的话,可以考虑 HashTable,HashTable 通过将整个表上锁来实现线程安全,某些情况下效率很低。还可以使用 ConcurrentHashMap,它使用分段锁或者 CAS 操作来保证线程安全。

7.5、ConcurrentHashMap和HashTable线程安全的具体实现方式

  • 底层数据结构: JDK1.7 的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑二叉树。Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;
  • 实现线程安全的方式(重要):
    • 在 JDK1.7 的时候,ConcurrentHashMap 对整个桶数组进行了分割分段(Segment,分段锁),每一把锁只锁容器其中一部分数据(下面有示意图),多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。
    • 到了 JDK1.8 的时候,ConcurrentHashMap 已经摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。

HashTable(上) 和 ConcurrentHashMap(下)

 

8、头插法会导致死循环,那你觉得在以前的版本中,为啥会使用头插法呢?

采用头插法的话,最新插入的数据就会在链表的最前边,根据程序的局部性原理,最近被访问的数据很可能不久之后会再次访问,那么此时可以在 O(1) 时间返回。

9、那我们再说一说 HashMap 的扩容吧,什么时候会扩容呢?你觉得为啥负载因子为啥选择 0.75 呢?

HashMap 需要扩容时,可以分为几种情况来考虑。

  • 首先是在无参的构造函数中。在第一次进行 put 操作之前,HashMap 内部数组为 null,第一次 put 后才会开始第一次初始化扩容,默认为 16 。
  • 其次是指定了初始容量的构造参数,也是在第一次 put 操作之后才开始初始化扩容,但此时的容量是第一个不小于指定容量的 2 的幂数,阈值为计算后容量乘负载因子。
  • 其它情况就是,非首次 put,导致容量大于阈值,需要扩容。容量和阈值都变为原来的 2 倍,负载因子不变。

负载因子为 0.75 的原因,简单来说是 “哈希冲突” 和 “空间利用率“ 矛盾的一个折中。原因是,扩容因子是用来计算阈值的,阈值为底层 table 长度乘负载因子,当 HashMap 容量大于阈值时会触发扩容。所以如果负载因子过小,table 中还没填几个元素就要扩容,虽然哈希冲突概率很小,但空间浪费太多。相反,如果负载因子过大,空间利用率是高,但哈希冲突的概率也大大增加。那就取个折中吧,为 0.75。

10、频繁扩容会导致效率比较低下,那你觉得在平时,在实际的开发场景中,可以怎么优化来避免频繁扩容呢?

容易想到的就是,提前预估业务的存储量,设置一个较大的初始容量。这时不用考虑它是否是 2 的次幂,HashMap 自己会计算出第一个大于等于给定容量的 2 次幂来作为初始容量。除此之外,可以自定义负载因子的大小,对哈希函数优化等等。

11、一个场景题:只存60个键值对,需要设置初始化容量吗?设置的话设置多少初始化容量比较好呢?

HashMap 默认的初始容量大小为 16。如果不设置初始容量的话,根据规则 size > threshold 时会触发扩容,且 threshold = loadFactor *capacitry,最终 capacity 会经历 16 – 32 – 64 – 128 三次扩容操作。考虑到HashMap 自己会计算出第一个大于等于给定容量的 2 次幂来作为初始容量,所以随机选一个 65 – 128 之间的数作为初始容量即可。

  • 13
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值