0
点赞
收藏
分享

微信扫一扫

HashMap面试问题整理

闲云困兽 2021-09-21 阅读 95

1. HashMap的实现

1.1 Hash的实现

1.2. 底层实现

1.2.1. JDK1.8之前

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

1.2.2. JDK1.8之后

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

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

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的实现吗?为什么要这样实现?

高低位异或,避免高位不同,低位相同的两个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);
    //这里为什么用&位运算??(下文)
}
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 方法?

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

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的次幂时,取模可以转化为快速的位运算。

5.3 什么时候扩容

A:两个时候

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可能导致元素丢失(未解决)

  3. put非null元素get出来却是null(未解决)

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

7.9 map中的对象能否修改

Divenier总结:

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

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

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

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

  • HashMap用的是拉链法。

  • ThreadLocalMap是开放定址法

8. 并发安全

8.1 HashMap和HashTable

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

部分参考资料:

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

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

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

举报

相关推荐

0 条评论