美文网首页面试精选
HashMap面试问题整理

HashMap面试问题整理

作者: Divenier | 来源:发表于2020-12-29 09:22 被阅读0次

    1. HashMap的实现

    1.1 Hash的实现

    image.png

    1.2. 底层实现

    1.2.1. JDK1.8之前

    拉链法
    创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。

    图源见水印

    1.2.2. JDK1.8之后

    链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。

    图源:https://thinkwon.blog.csdn.net

    2. Jdk 1.7和1.8 hashMap 区别

    2.1 扩容时方式不同

    • 在移动链表时,1.7是头插法,1.8尾插法,导致1.7链表元素顺序会颠倒,1.8不会

    • 确定元素新的索引时,1.8只需要看多出来的一位index是0还是1,1.7需要重新计算h&(length - 1)

    2.1.1 头插法和尾插法

    头插

    新增在链表上元素的位置为链表头部,也就是数组桶位上的那个位置

    可能是考虑到热点数据的原因,即最近插入的元素也很可能最近会被使用到

    尾插

    为了避免出现逆序导致的成环的问题

    2.1.2 头插法导致的后果(1.7并发为何不安全)

    transfer()方法中,因为新的Table顺序和旧的不同,所以在多线程同时扩容情况下,会导致第二个扩容的线程next混乱,本来是A -> B,但t1线程已经B -> A了,所以就成环了。

    2.1.3 Java1.8如何解决成环的问题的

    1.8扔掉了transfer()方法,用resize()扩容:

    使用do while循环一次将一个链表上的所有元素加到链表上,然后再放到新的Table上对应的索引位置。

    2.2 底层结构不同

    JDK1.7的时候使用的是数组+ 单链表的数据结构。但是在JDK1.8及之后时,使用的是数组+链表+红黑树的数据结构(当链表的深度达到8的时候,也就是默认阈值,就会自动扩容把链表转成红黑树的数据结构来把时间复杂度从O(n)变成O(logN)提高了效率)

    2.3 索引计算方式不同

    1.8的索引 只用了一次移位,一次位运算就确定了索引,计算过程优化。

    二者的hash扰动函数也不同,1.7有4次移位和5次位运算,1.8只有一次移位和一次位运算

    3. HashMap参数理解

    3.1 常见参数

    初始容量和加载因子会影响HashMap的性能:

    1. 初始容量设置过小会导致大量扩容(最好预估一下)
    2. 加载因子设置不当,导致频繁扩容操作

    3.1.1 capacity

    常说的capacity指的是DEFAULT_INITIAL_CAPACITY(初始容量),值是1<<4,即16;

    capacity()是个方法,返回数组的长度。

    final int capacity() {
        return (table != null) ? table.length :
        (threshold > 0) ? threshold :
        DEFAULT_INITIAL_CAPACITY;
    }
    

    3.1.2 loadFactor(加载因子)

    final float loadFactor;
    

    在hashMap构造函数中,赋值为DEFAULT_LOAD_FACTOR(0.75f)

    加载因子可设为>1,即永不会扩容,(牺牲性能节省内存)

    3.1.3 size

    Map中现在有的键值对数量,每put一个entry,++size

    The number of key-value mappings contained in this map.

    3.1.4 threshold

    数组扩容阈值。

    即:HashMap数组总容量 * 加载因子(16 * 0.75 = 12)。当前size大于或等于该值时会执行扩容resize()。扩容的容量为当前 HashMap 总容量的两倍。比如,当前 HashMap 的总容量为 16 ,那么扩容之后为 32

    3.2 关于树

    1. 树形化阈值TREEIFY_THRESHOLD。当链表的节点个数大于等于这个值时,会将链表转化为红黑树。

    2. 解除树形化阈值UNTREEIFY_THRESHOLD 。当链表的节点个数小于等于这个值时,会将红黑树转换成普通的链表

    3. MIN_TREEIFY_CAPACITY 树形化阈值的第二条件。当数组的长度小于这个值时,就算树形化阈达标,链表也不会转化为红黑树,而是优先扩容数组resize()

    4. hashCode

    4.1 hashCode()

    获取哈希码,objecthashCode()方法是个本地方法,是由C实现的。

    理论上hashCode是一个int值,这个int值范围在-2147483648和2147483648之间,如果直接拿这个hashCode作为HashMap中数组的下标来访问的话,正常情况下是不会出现hash碰撞的。
    但是这样的话会导致这个HashMap的数组长度比较长,长度大概为40亿,内存肯定是放不下的,所以这个时候需要把这个hashCode对数组长度取余,用得到的余数来访问数组下标。

    4.2 计算索引的过程

    1. 调用hashCode()得到一串数字超长的数字h
    2. 将h右移16位,与h按位异或(^),得到hash(扰动函数)
    3. 将(n-1)与hash按位与(&)
      此处n-1指 (1111)_2(即15),因为数组默认长度16(n)
    4. 得到下标

    你知道hash的实现吗?为什么要这样实现?

    在Java 1.8的实现中,是通过把计算得到的hashCode()的右移16位,异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),这里得到的hash(n-1)按位与之后就是索引。

    主要是从速度、功效、质量来考虑的,

    1. 这么做保证高低bit都参与到hash的计算中,保留了一部分高位的信息(加大了低位随机性,减少了冲突)(如果不移位,高位信息就用不上)
    2. 同时不会有太大的开销。

    高低位异或,避免高位不同,低位相同的两个hashCode值 产生碰撞。

    Java1.7中 hash的实现

    static int hash(int h) {
        // This function ensures that hashCodes that differ only by
        // constant multiples at each bit position have a bounded
        // number of collisions (approximately 8 at default load factor).
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }
    
    /**
         * Returns index for hash code h.
         */
    static int indexFor(int h, int length) {
        return h & (length-1);
        //这里为什么用&位运算??(下文)
    }
    

    一个数对2^n取模 == 一个数和(2^n – 1)做按位与运算 。

    X % 2^n == X & (2^n – 1)
    //2^n表示2的n次方
    
    假设n为3,则2^3 = 8,表示成2进制就是1000。2^3 -1 = 7 ,即0111。
    
    假设x=18,也就是 0001 0010,一起看下结果:
    对 2的n次方 取模: 18 % 8 = 2
    对 2的n次方-1 按位与: 0001 0010 & 0111 = 0000 0010 = 2
    

    4.3 HashCode在equals方法中的作用

    为什么重写 equals 时必须重写 hashCode 方法?

    hashCode()的默认行为是对堆上的对象产生独特值。如果没有重写 hashCode(),则该 class 的两个对象无论如何都不会相等(即使这两个对象指向相同的数据)

    hashCode 方法的常规协定声明 相等对象必须具有相等的哈希码 。

    equals()既然已能对比的功能了,为什么还要hashCode()呢?因为重写的equals()里一般比较的比较全面比较复杂,这样效率就比较低,而利用hashCode()进行对比,则只要生成一个hash值进行比较就可以了,效率很高。

    hashcode 只是用来缩小查找成本

    hashCode()既然效率这么高为什么还要equals()呢?因为hashCode()并不是完全可靠,有时候不同的对象他们生成的hashcode也会一样(生成hash值得公式可能存在的问题),所以hashCode()只能说是大部分时候可靠,并不是绝对可靠,

    5. RESIZE(扩容)

    5.1 为什么扩容

    1. hashMap是懒加载的,第一次用之前,Table都是空的,第一次用需要扩容

    2. 当容量(size属性 * )达到阈值threshold减少hash冲突

      • size属性就是指所有的<k,v>数量,不管在不在同一个链表内都算;下文是putValue()源码,这个++size是在所有if else之外的,所以不管新节点放到哪size都会增加。

        if (++size > threshold)
            resize();
        

    5.2 扩容到多少?

    • 自动扩容的容量为当前 HashMap 总容量的两倍,

    • 如果是指定了容量,tableSizeFor函数会根据传入的参数,寻找距离最近的2的N次方;传75,容量就是128.

      https://www.jianshu.com/p/f86191afd918 (tableSizeFor函数源码解析)

    5.3 为什么扩容是2倍?

    1. 容量是2的次幂,结合计算索引的位运算,可以比较大程度分散元素,散列效果更好

    2. 计算索引会更高效(尤其是扩容时,用容量n和hash按位与很方便高效,(实际上是查看最高位是0/1))

      计算索引本质是取模运算,当容量是2的次幂时,取模可以转化为快速的位运算。

    HashMap为了存取高效,要尽量较少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同,这个实现就在把数据存到哪个链表中的算法;

    这个算法实际就是取模,hash%length。 但是,大家都知道这种运算不如位移运算快

    Jdk1.8中,用的是hash&(length-1),如果容量是2的n次方,在计算索引时,(length - 1)的二进制形式上每一位都是1,这样与hash进行按位与会更大程度的分散元素,

    例如长度为8时候,3&(8-1)=3 2&(8-1)=2 ,不同位置上,不碰撞。 而长度为5的时候,3&(5-1)=0 2&(5-1)=0,都在0上,出现碰撞了

    5.3 什么时候扩容

    A:两个时候

    1. HashMap实行了懒加载, 新建HashMap时不会对table进行赋值, 而是到第一次插入时, 进行resize时构建table;当新建时, 如果没有指定HashMap.table的初始长度, 就用默认值16, 否则就是指定的值; 然后不管是第一次构建还是后续扩容, threshold = capacity * loadFactor(比如往HashMap里面放了一对值,threshold就会更新)
    2. 当HashMap.size 大于 threshold时, 会进行resize;threshold的值:

    5.4 loadFactor为什么是0.75

    Node[] table,即哈希桶数组,哈希桶数组table的长度length大小必须为2的n次方

    0.75 * 2^n 得到的都是整数。

    5.5 HashCode的计算在扩容上的好处

    把bucket扩充为2倍,之后重新计算index,把节点再放到新的bucket中

    hashCode是很长的一串数字,<font color = orange>(换成二进制,此元素的位置就是后四位组成的 (数组的长度为16,即4位))</font>

    扩容 = 把作为索引的数字范围往前一个

    eg.

    <font color = gray>1111 1111 1111 1111 0000 1111 0001</font> 1111 (原索引是后面四个,索引是15)

    扩容后:
    <font color = gray>1111 1111 1111 1111 0000 1111 000</font>1 1111 (新的索引多了一位)(多出来这个,或1或0 随机,完全看hash)

    因此,我们在扩充HashMap的时候,不需要重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”。

    if ((e.hash & oldCap) == 0) {// oldCap == 1000 按位与就是看第一位是不是1
        if (loTail == null)
            loHead = e;
        else
            loTail.next = e;
        loTail = e;
    }
    else {
        if (hiTail == null)
            hiHead = e;
        else
            hiTail.next = e;
        hiTail = e;
    }
    

    这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的(hashCode里被作为索引的数往前走了一个,走的这个可能是0,也可能是1),因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。

    6. HashMap使用

    6.1 遍历

    用Iterator有两种方式,分别把迭代器放到entry和keyset上,第一种更推荐,因为不需要再get(key)

    Iterator<Map.Entry<String, Integer>> entryIterator = map.entrySet().iterator();
    while (entryIterator.hasNext()) {
        Map.Entry<String, Integer> next = entryIterator.next();
        System.out.println("key=" + next.getKey() + " value=" + next.getValue());
    }
            
    Iterator<String> iterator = map.keySet().iterator();
    while (iterator.hasNext()){
        String key = iterator.next();
        System.out.println("key=" + key + " value=" + map.get(key));
    }
    

    7. 常见问题

    7.1 树形化阈值为何是8?

    1. 如果选择6和8(如果链表小于等于6树还原转为链表,大于等于8转为树),中间有个差值7可以有效防止链表和树频繁转换。假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。
    2. 还有一点重要的就是由于treeNodes的大小大约是常规节点的两倍,因此我们仅在容器包含足够的节点以保证使用时才使用它们,当它们变得太小(由于移除或调整大小)时,它们会被转换回普通的node节点,容器中节点分布在hash桶中的频率遵循泊松分布,桶的长度超过8的概率非常非常小。所以作者应该是根据概率统计而选择了8作为阀值

    7.2 key的选取

    7.2.1 为什么Integer String等包装类适合作为key

    可减少哈希碰撞

    1. 因为 String、Integer 等包装类是 final 类型的,具有不可变性,不可变性保证了计算 hashCode() 后键值的唯一性和缓存特性,不会出现放入和获取时哈希码不同的情况
    2. 包装类已经重写了 equals() 和 hashCode() 方法,而这些方法是在存取时都会用到的。
      读取哈希值高效,此外官方实现的 equals() 和 hashCode() 都是严格遵守相关规范的,不会出现错误。

    7.2.2 Null可以做key吗

    可以,null的索引被设置为0,也就是Table[0]位置
    在JDK7中,调用了putForNullKey()方法,处理空值

    JDK8中,则修改了hash函数,在hash函数中直接把key==null的元素hash值设为0,

    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
    //如果key为Null,直接把hash设为0
    

    再通过计算索引的步骤

    //(JDK11,函数:putVal())
    if ((p = tab[i = (n - 1) & hash]) == null)
                tab[i] = newNode(hash, key, value, null);
    //索引:i = (n - 1) & hash
    

    得到索引为0;

    7.2.3 如何设计一个class作为key

    1. 重写equals方法,equals方法与hashCode()的关系保证正确(hashCode相同不一定equals,equals一定hashCode相同)
    2. 让这个类不可变
      1. 添加final修饰符,保证类不被继承
      2. 保证所有成员变量必须私有,并且加上final修饰
      3. 不提供改变成员变量的方法,包括setter
      4. 通过构造器初始化所有成员,进行深拷贝(deep copy) ,传入的构造器参数也复制一份,而不是直接用引用
      5. 在getter方法中,不要直接返回对象本身,而是克隆对象,并返回对象的拷贝

    7.3 为什么使用数组+链表的结构

    1. 数组根据索引取值效率高,用来根据key确定桶的位置
    2. 链表用来解决hash冲突,索引值一样时,就在链表上增加一个节点。

    7.3.1 为什么不用linkedList或者ArrayList代替数组

    1. 因为有索引,数组取值比LinkedList快;
    2. 数组是基本的数据结构,操作更方便(扩容机制可以自定义,ArrayList扩容是变成1.5倍容量)

    7.4 hashmap中get元素的过程?

    对key的hashCode()做hash运算,计算index; 如果在bucket里的第一个节点里直接命中,则直接返回; 如果有冲突,则通过key.equals(k)去查找对应的Entry;

    • 若为树,则在树中通过key.equals(k)查找,O(logn);
    • 若为链表,则在链表中通过key.equals(k)查找,O(n)。

    7.5 hashmap中put元素的过程?

    调用putValue:

    1. 查看当前Table是否为空,为空就resize
    2. 查看对应索引处是否为空,是就直接newNode()放到Table[i]

    7.5 说说String中hashcode的实现?

    public int hashCode() {
        int h = hash;
        if (h == 0 && value.length > 0) {
            char val[] = value;
    
            for (int i = 0; i < value.length; i++) {
                h = 31 * h + val[i];
            }
            hash = h;
        }
        return h;
    }
    

    是以31为权,每一位为字符的ASCII值进行运算,用int的自然溢出来等效取模。

    假设String是ABC,

    ((A + 0*31) * 31  + 'B' )*31 + 'C' 
    

    7.5.1 为什么每次乘31?

    1. 31是个比较大的质数,适合用于hash
    2. 31 == 2^5 - 1,很适合改成位运算,i* 31 == (i << 5) - i

    7.6 为什么不直接用红黑树

    1. 空间资源考虑:红黑树节点大小比链表节点大,占空间更多
    2. 时间资源:虽然查询快,但是需要左右旋转,调整颜色等保持红黑树的性质,在节点比较少的情况下,节省下来的查询时间开销并不值。

    7.7 HashMap在并发时有什么问题?1.8还有吗?如何解决

    1. 多线程扩容后,取元素时的死循环(1.8解决)

    2. 多线程put可能导致元素丢失(未解决)

      元素为什么会丢失?

      1. 元素put时是一种“先查后改“的机制,即先计算得到索引,然后再往桶里面放;查询可以同步进行(假设有两个索引相同的元素需要put,查询出前一个节点都是A,那么插入时,后插入的C可能就会覆盖先插入的B)先插入的B就丢失了。
      2. put函数中有一句++size表示HashMap当前的Entry数量,但该操作不是原子的,源码设计时就没有考虑其并发安全性。在多线程执行这句++size时,结果很可能出错,这导致元素个数是错的
    3. put非null元素get出来却是null(未解决)

    可以使用ConcurrentHashmap,Hashtable等线程安全等集合类。

    7.9 map中的对象能否修改

    Divenier总结:

    要看作为key的元素的类如何实现的,如果修改的部分导致其hashcode变化,则修改后不能get()到;

    如修改部分对hashcode无影响,则可以修改。

    因为 key 更新后 hashCode 也更新了,(这里是因为重写了 hashcode 的原因)而 HashMap 里面的对象是我们原来哈希值的对象,在 get 时由于哈希值已经变了,原来的对象不会被索引到了,所以结果为 null

    因此当把对象放到 HashMap 后就不要尝试对 key 进行修改操作,谨防出现哈希值变化或者 equals 比较不等的情况导致无法索引。

    7.10 解决Hash冲突有几个办法?HashMap用的是什么办法?

    开放定址法、链地址法(拉链法)、再Hash法

    • HashMap用的是拉链法。

    • ThreadLocalMap是开放定址法

    之所以采用不同的方式主要是因为:在 ThreadLocalMap中的散列值分散得十分均匀,很少会出现冲突。并且 ThreadLocalMap经常需要清除无用的对象,使用纯数组更加方便。

    8. 并发安全

    8.1 HashMap和HashTable

    • 都实现了map接口,HashMap继承自AbstractMap ,实现此接口比较简单, HashTable继承自Dictionary类(已废弃),都是键值对存储方式
    • HashMap可以有Null键(位置是0),HashTable则不可以(直接返回 NullPointerException)
    • HashMap线程不安全(非synchronized) Table安全(几乎所有的 public方法都加了synchronized,所以线程安全)

    在 Collections 类中存在一个静态方法:synchronizedMap(),该方法创建了一个线程安全的 Map 对象,并把它作为一个封装的对象来返回。

    部分参考资料:

    https://tech.meituan.com/2016/06/24/java-hashmap.html

    https://zhuanlan.zhihu.com/p/76735726

    https://zhuanlan.zhihu.com/p/111501405

    相关文章

      网友评论

        本文标题:HashMap面试问题整理

        本文链接:https://www.haomeiwen.com/subject/yuvcoktx.html