HashMap面试问题整理

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属性就是指所有的数量,不管在不在同一个链表内都算;下文是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是很长的一串数字,(换成二进制,此元素的位置就是后四位组成的 (数组的长度为16,即4位))

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

eg.

1111 1111 1111 1111 0000 1111 0001 1111 (原索引是后面四个,索引是15)

扩容后:
1111 1111 1111 1111 0000 1111 0001 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> entryIterator = map.entrySet().iterator();
while (entryIterator.hasNext()) {
    Map.Entry next = entryIterator.next();
    System.out.println("key=" + next.getKey() + " value=" + next.getValue());
}
        
Iterator 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面试问题整理)