【面试】HashMap常见面试题

本文详细探讨了HashMap在面试中的常见问题,包括0.75负载因子的原因,链表转红黑树的优化,以及HashMap线程不安全的分析。还讨论了key需重写hashCode()和equals()的原因,以及JDK1.8的改进,如使用红黑树降低查询复杂度和改进扩容策略。

一、为什么会有HashMap?

二、HashMap是什么?

1、为什么是 0.75 这个值呢?

链表法查找时间是 O ( 1 + n ) O(1+n) O(1+n) [n - 链表长度],0.75是对时间和空间的平衡。
1)加载因子越大,空间利用就越充分,但链表长度越长,查找效率也就越低。
2)加载因子太小,数据过于稀疏,空间严重浪费,当然,时间查找会更高。

追问1、加载因子什么时候适合减少,什么时候适合增加?【优化】

查询操作频繁可适当地减少加载因子;内存利用率要求高可适当增加加载因子。

  • 补充:若能预知存储数据量,设置初始容量 = 预知数据量 / 加载因子。可减少resize()操作,提高效率。

2、什么办法来解决因链表过长导致查询时间复杂度高的问题呢?

引入红黑树数据查询效率,当链表长度超过threshold(默认8)会将链表转换为红黑树,值得注意的是新增由于存在左旋、右旋效率会降低。

追问1:为什么红黑而非平衡树?

不需要因为新增节点频繁调整二叉树。……

追问2:红黑树具体结构及实现,红黑树与查找树的区别体现

……

3、影响 HashMap 性能的因素?

  • 1)负载因子与边界值。前者涉及时间与空间的平衡,后者可能会涉及resize()影响性能。
  • 2)哈希值。理想情况是均匀散列。一般 HashMap 使用 String 类型作为 key,而 String 类重写了 hashCode 函数。

4、HashMap 的 key 需要满足什么条件?

答:必须重写 hashCode 和 equals 方法, 常用的 String 类实现了这两个方法。

追问1:HashMap 允许 key/value 为 null, 但最多只有一个,为什么?

答: 如果 key 为 null 会放在第一个桶(即下标 0)位置, 而且是在链表最前面(即第一个位置)。

追问2:如果重写了equals(),不重写hashCode()会发生什么?

默认的equals()hashCode()比较的是内存地址,不重写会认为hashCode()不一样,就认为是不同对象,不需要再进行equals()比较,效率和正确性都会有问题。

HashMap()比较顺序:
1hashCode()比较结果不相同,则说明是不同对象;
2hashCode()比较结果相同,则需要再进行equals()比较,相等则说明相同,否则不同。

三、HashMap怎么实现的?

1、HashMap的哈希函数怎么设计的?

先拿到 key 的hashCode(32位int值),然后让高16位和低16位进行异或得到Hash值。

  • 补充:get()和put()中还要再通过 (n-1) & hash找到桶位置。
// 补充:源码分析键值对添加
public V put(K key, V value) {
	return putVal(hash(key), key, value, false, true);
}


static final int hash(Object key) {
	int h;
	// 【1】return code1 = key.hashCode() 
	// 【2】hash(code1) 计算出 hash 值
	return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);  //h>>>16:无符号右移16位
}


if ((tab = table) == null || (n = tab.length) == 0)
	n = (tab = resize()).length;
	// 【3】putVal 方法中 (n - 1) & hash 决定 Node 存储位置。
	if ((p = tab[i = (n - 1) & hash]) == null)
		tab[i] = newNode(hash, key, value, null);
追问1:初始容量为什么设置为 2 的整数次幂?【(n - 1) & hash 】

为了服务hash到桶位置的算法 — (n - 1) & hash2的幂次方减1后每一位都是1,让数组每一个位置都能添加到元素。如果不是,那计算结果总有一位总0,对应下标位置总是空的

如果初始化设置不是2的幂次方,HashMap也会调整到比初始化值大 & 最近的2的幂作为capacity。

追问2:如果没使用 hash() 方法计算 hashCode,直接使用对象的 hashCode 值,会出现什么问题呢?

碰撞很严重,不是好的哈希算法。但若将 hashCode 二进制值对半切开,并且使用位异或运算,就能避免大量碰撞。简而言之,尽量打乱 hashCode 真正参与运算的低 16 位。

补充:没采用hash()的情况,直接用hashCode计算

假设添加两个对象 a 和 b,数组长度是16,公式(n - 1) & hash。
-> (16-1)&a.hashCode 和 (16-1)&b.hashCode,
   0000000000000000000000000001111 [15的二进制]
   1000010001110001000001111000000 [假设A]
   0111011100111000101000010100000 [假设B]
   = 0。
哈希结果太让人失望了。
追问3:为什么获取下标时用按位与 &,而不是取模 %?

& 效率更高。

如果 l e n g t h = 2 n length = 2^n length=2n
那么 x x x % l e n g t h = length = length= x x x & ( l e n g t h − 1 ) (length-1) (length1)
即:当长度为 2 n 2^n 2n 时,模运算% 可变换为按位与 & 运算。

2、HashMap get和put源码,

1、get过程
hash()得到key的hash值,调用getNode(),判断首节点为树节点或者链表,然后进行遍历,找到返回,否则返回null。

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

/**
 * Implements Map.get and related methods.
 *
 * @param hash hash for key
 * @param key the key
 * @return the node, or null if none
 */
final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

2、put过程
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;
    if ((tab = table) == null || (n = tab.length) == 0)
//1、判断当table为null或者tab的长度为0时,即table尚未初始化,此时通过resize()方法得到初始化的table
        n = (tab = resize()).length;
    if ((p = tab[i = (n - 1) & hash]) == null)
//1.1、此处通过(n - 1) & hash 计算出的值作为tab的下标i,并另p表示tab[i],也就是该链表第一个节点的位置。并判断p是否为null
        tab[i] = newNode(hash, key, value, null);
//1.1.1、当p为null时,表明tab[i]上没有任何元素,那么接下来就new第一个Node节点,调用newNode方法返回新节点赋值给tab[i]
    else {
//2.1下面进入p不为null的情况,有三种情况:p为链表节点;p为红黑树节点;p是链表节点但长度为临界长度TREEIFY_THRESHOLD,再插入任何元素就要变成红黑树了。
        Node<K,V> e; K k;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
//2.1.1HashMap中判断key相同的条件是key的hash相同,并且符合equals方法。这里判断了p.key是否和插入的key相等,如果相等,则将p的引用赋给e

            e = p;
        else if (p instanceof TreeNode)
//2.1.2现在开始了第一种情况,p是红黑树节点,那么肯定插入后仍然是红黑树节点,所以我们直接强制转型p后调用TreeNode.putTreeVal方法,返回的引用赋给e
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
//2.1.3接下里就是p为链表节点的情形,也就是上述说的另外两类情况:插入后还是链表/插入后转红黑树。另外,上行转型代码也说明了TreeNode是Node的一个子类
            for (int binCount = 0; ; ++binCount) {
//我们需要一个计数器来计算当前链表的元素个数,并遍历链表,binCount就是这个计数器

                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) 
// 插入成功后,要判断是否需要转换为红黑树,因为插入后链表长度加1,而binCount并不包含新节点,所以判断时要将临界阈值减1
                        treeifyBin(tab, hash);
//当新长度满足转换条件时,调用treeifyBin方法,将该链表转换为红黑树
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

3、JDK1.8后对HashMap的改进(*3)

  • 1)数据结构。从 数组+链表 改成 数组+链表或红黑树
  • 2)算法
    链表插入。从 头插法 改成 尾插法
    扩容优化
  • [2.1]-索引定位:1.7需要对原数组中的元素进行重新hash定位在新数组的位置,1.8采用更简单的判断逻辑,位置不变或索引+旧容量大小
  • [2.2]-判断顺序1.7先判断是否需要扩容,再插入;1.8先插入,再判断是否需要扩容。
追问1:为什么要做这几点优化?
  • 1)数据结构。降低链表访问时间复杂度,从 O ( n ) O(n) O(n)降到 O ( l o g n ) O(logn) O(logn)

  • 2)链表插入。头插改变原本元素顺序,并发场景会导致链表成环,而尾插不会。
    7

  • 3)扩容优化

[1]索引定位:新扩容算法效率更高。高1位为0,索引没变;是1,索引变成“原位置+旧容量”。

8
[2]判断顺序:JDK1.7 中先进行扩容后进行插入,而在 JDK1.8 中是先进行插入后进行扩容。

  • JDK1.7 中:先扩容后插入
    当发现你插入桶不为空,说明发生了hash冲突,那必须得扩容,但如果不发生Hash冲突,说明当前桶是空(后面并没有挂有链表),那就等到下一次发生Hash冲突时候再进行扩容。如果以后都没hash冲突,那就不会进行扩容了,这减少了一次无用扩容。

  • JDK1.8 中:先插入后扩容
    主要因为链表转为红黑树的优化。
    If 插入节点== 链表节点,判断是否达到链表转化为红黑树的阈值(如8),如果没,那可以继续插入。
    If 插入节点== 红黑树节点,判断插入节点是否还满足当前是红黑树的特性,如果能满足,那不会扩容。

追问2:1.8 中的 HashMap 是否线程安全?

1.8虽然解决了链表成环,但还有其他并发问题,比如:上秒 put 的值,下秒 get 的时候却不是刚 put 的值;因为操作都没有加锁,不是线程安全的。

追问3:什么时机执行 resize()?

答:

  1. 哈希table为null或长度为0;
  2. Map中存储的k-v对数量超过了阈值threshold;
  3. 链表中的长度超过了TREEIFY_THRESHOLD,但表长度却小于MIN_TREEIFY_CAPACITY。
追问4:resize() 如何实现的?

一般分2步::

  • Step1:对哈希表长度的扩展(2倍);
  • Step2:将旧table中的数据搬到新table上。
补充:扩容后判断高位是否为1,是1,则newPos=oldPos+oldCapacity;否则不变。

源码:

...
// 前面已经做了第1步的长度拓展,我们主要分析第2步的操作:如何迁移数据
table = newTab;
if (oldTab != null) {
    // 循环遍历哈希table的每个不为null的bucket
    // 注意,这里是"++j",略过了oldTab[0]的处理
    for (int j = 0; j < oldCap; ++j) {
        Node<K,V> e;
        if ((e = oldTab[j]) != null) {
            oldTab[j] = null;
            // 若只有一个结点,则原地存储
            if (e.next == null)
                newTab[e.hash & (newCap - 1)] = e;
            else if (e instanceof TreeNode)
                ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
            else { // preserve order
                // "lo"前缀的代表要在原bucket上存储,"hi"前缀的代表要在新的bucket上存储
                // loHead代表是链表的头结点,loTail代表链表的尾结点
                Node<K,V> loHead = null, loTail = null;
                Node<K,V> hiHead = null, hiTail = null;
                Node<K,V> next;
                do {
                    next = e.next;
                    // 以oldCap=8为例,
                    //   0001 1000  e.hash=24
                    // & 0000 1000  oldCap=8
                    // = 0000 1000  --> 不为0,需要迁移
                    // 这种规律可发现,[oldCap, (2*oldCap-1)]之间的数据,
                    // 以及在此基础上加n*2*oldCap的数据,都需要做迁移,剩余的则不用迁移
                    if ((e.hash & oldCap) == 0) {
                        // 这种是有序插入,即依次将原链表的结点追加到当前链表的末尾
                        if (loTail == null)
                            loHead = e;
                        else
                            loTail.next = e;
                        loTail = e;
                    }
                    else {
                        if (hiTail == null)
                            hiHead = e;
                        else
                            hiTail.next = e;
                        hiTail = e;
                    }
                } while ((e = next) != null);
                if (loTail != null) {
                    loTail.next = null;
                    newTab[j] = loHead;
                }
                if (hiTail != null) {
                    hiTail.next = null;
                    // 需要搬迁的结点,新下标为从当前下标往前挪oldCap个距离。
                    newTab[j + oldCap] = hiHead;
                }
            }
        }
    }
}

PS:可见底层数据结构用到了数组,到最后会因为容量问题都需要进行扩容操作

四、延申

1、HashMap 1.7 VS 1.8

HashMapJDK1.7JDK1.8
底层结构数组+链表数组+链表/红黑树
插扩顺序先扩容再插值先插值再扩容
插入顺序表头插入法表尾插入法
扩容后的索引计算扩容时需要重新计算哈希值和索引位置不需要重新计算
并发问题改变原有顺序,并发时引起链表闭环保持原有顺序,不会出现闭环

追问1:HashMap/HashTable/ConcurrentHashMap数据结构,底层(*4),如何保证线程安全,怎么实现(*3)
……

追问1:为什么HashMap线程不安全?

1.7中transfer()链表使用头插法,多线程情况下,会成环;
1.8中putVal()若桶为空,多线程操作,值会出现覆盖情况。

2、你平常怎么解决这个线程不安全的问题?

HashTableCollections.synchronizedMapConcurrentHashMap都是实现线程安全的Map。

  • 1)HashTable:直接在方法上加synchronized来锁住整个数组,粒度比较大。
  • 2)Collections.synchronizedMap:Collections集合工具的内部类,通过传入Map封装出一个SynchronizedMap对象,内部定义了一个对象锁,方法内通过对象锁实现。
  • 3)ConcurrentHashMap:使用分段锁,降低锁粒度,让并发度大大提高。

3、那你知道ConcurrentHashMap的分段锁的实现原理吗?

ConcurrentHashMap成员变量使用volatile修饰,免除指令重排序,同时保证内存可见性,另外使用CAS操作和synchronized结合实现赋值操作,多线程操作只会锁住当前操作索引的节点。

如下图,线程A锁住A节点所在链表,线程B锁住B节点所在链表,互不干涉。
9

五、参考

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值