面试_java_HashMap

82 阅读12分钟

介绍下 HashMap 的底层数据结构吧。

HashMap 底层是由 “数组+链表/红黑树” 组成( JDK 1.8 之前是 “数组+链表” )。

每个节点用Node表示,Node是HashMap的一个内部类,实现了Map.Entry接口。每个Node都包含一个key-value键值对,还有hash值和下一个元素的引用next总共4个属性。

static class Node<K,V> implements Map.Entry<K,V> {
   final int hash;
   final K key;
   V value;
   Node<K,V> next;

其中 key是唯一不重复的、无序的,key和value都允许为null。但只允许一条记录的key为null,允许多条记录的value为null。




为什么使用“数组+链表”?

散列表可能存在哈希冲突,数组+链表就是可以解决此问题的链地址法。

链地址法,简单来说,就是在每个数组元素上都挂载一个链表结构,经过哈希运算得到数组下标后,若没有冲突,放到下标位置,如果发生冲突,就放到该下标位置挂载的链表中。

数组的特点是查询效率高但插入删除效率低;链表则刚好相反。所以结合二者既解决了冲突,又能发挥两者各自的优势。




为什么要改成“数组+链表/红黑树”?

“数组+链表” 有自己的优势,但是仍然存在问题:当哈希冲突很严重的时候,链表会非常长,查找性能降低,为O(N)。而红黑树能保持为O(logN)




那在什么时候用链表?什么时候用红黑树?

对于插入,如果此下标上挂载的Node结点在新增后超过8个,继续判断当前数组长度,若大于等于 64,则会将该数组元素对应的链表转化为红黑树(treeifyBin);若小于64,对数组扩容。

对于移除,当此下标上挂载的Node结点在移除后达到 6 个,并且此时是红黑树,就将红黑树转化为链表(untreeify)。




为什么阈值是8和6?为什么转回链表节点是用的6而不是复用8?

多次试验后,时间和空间上权衡的结果。

如果我们设置节点多于8个转红黑树,少于8个就马上转链表,当节点个数在8徘徊时,就会频繁进行红黑树和链表的转换,造成性能的损耗。




HashMap 的初始容量是多少?最大容量是多少?重要性?

在 jdk1.8 中,在调用HashMap的构造函数时就会对集合进行初始化,默认情况下容量为16.

static final int MAXIMUM_CAPACITY = 1 << 30;

因此最大容量为即 2 ^ 30 。容量很重要,如果哈希桶数组很大,即使较差的Hash算法也会比较分散,如果哈希桶数组数组很小,即使好的Hash算法也会出现较多碰撞,所以容量可以在空间允许的范围内尽可能大一些。




为什么最大容量是2的30次方,而非2的32次方(int四个字节,32位)?

定义最大容量的 MAXIMUM_CAPACITY 变量用int修饰,在java中int占4字节,表示范围是 -2^31 ~ 2^31-1 。我们需要正数,所以范围变成了 0 ~ 2^31-1。但不能用 2^31-1 来初始化,因为“2^31 ”这一部分已经溢出了(因为int能表示的最大值为2^31-1),所以只能再除以2,初始化为 2^30 .




HashMap 的容量(数组长度)为什么必须是 2 的 N 次方?

这是一种非常规的设计,常规的设计是把桶的大小设计为素数,因为相对来说素数作为模数导致冲突的概率要小于合数。 HashMap采用这种非常规设计,主要是为了运算时的优化:

取模时:将取模运算转化为位运算,提升速度

我们知道哈希计算就是取模 hash函数(key) % 数组长度 ,但取模的计算速度很慢,组合数学中有一个公式可以将模运算转化成了位运算:x mod 2^n = x & (2^n - 1) ,位运算在计算机中是非常快的,将长度设为 2^n 就可以利用这个公式,从而加快元素位置计算的速度。

同时为了减少冲突,在哈希函数中让高位参加运算,使得到的结果更加分散。




如果自定义容量,参数不是2 的 N 次方,会怎么样?

HashMap 会根据我们传入的容量计算一个“大于等于该容量的最小的 2 的 N 次方”。计算方法如下:

static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

解释???




HashMap的哈希算法是怎样设计的?

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

首先获取key的hashCode()值,返回int类型的h(普通的hash函数到这里就结束了),将h无符号右移16位得到h'hh' 做异或运算,得到最终的hash值。

要注意:key为null时,hash值为0




为什么要这样?不能直接拿hashCode去和数组长度求模吗

通常数组的长度不会太大,那么原始的hashcode中的“高位”对取模后的余数的影响就会很小(直接做模运算不好理解,可以转化为&运算来看,原hashcode & 数组长度减1,写出运算公式可以看到数组的长度值高位补了很多的0,相当于忽略了hashcode 的高位部分),参与取模的位数越少,余数相同的可能性就越大,从而发生碰撞的概率就越大。

总结:让hashcode的高位也参与取模运算,使得每一位都尽量能对取模结果产生影响,从而让取模的结果更加“零散”、“均匀”,发生碰撞的几率降低。




怎样取key的hashCode值?

key是字符串String类型,String类有自带的 hashCode() 函数,返回值为int,计算方法:

s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]   // s[i] 的值是字符的ASCII值;n是字符串长度

问题:为什么用 31 ?

答:31 有个很好的数学特性,即可以用移位和减法来代替乘法: i * 31== (i << 5)- i, 加快运算速度。




为什么重写equals必须重写hashCode

这个规定主要出现在散列集合中,比如HashMap、HashSet、HashTable、ConcurrentHashMap。

假设现在有两个对象地址不同但值相同。在插入数据时,规定是先hashCode计算下标,再equals比较key。如果只重写equals不重写hashcode,hashCode只关注地址,两个对象的hash值会不同,从而下标会不同,他们就会被放到两个哈希桶里面。但下一步只关注值的equals认为他俩key相同,是同一个对象,应该在同一个桶里做新value替换旧value的操作,但现在被放到两个桶里面了,就错乱了。

equals 和 hashCode 方法之间的关系

  • 若equals 为 true , hashCode 必须相等
  • 若hashCode 相等 , equals 可以不用为 true (也就是hash碰撞的时候)



你清楚HashMap的数据插入原理吗? put方法

1、判断数组table是否为空,为空进行初始化; 2、不为空,计算 key 的 hash 值,再通过 (数组长度 - 1) & hash 得出数组下标 index; 3、查看 table[index] 是否存在数据,没有数据就构造一个包含key-value的Node节点存放在这里; 4、若已存在数据,说明发生了冲突(即hash值一样),用equals判断key是否相等,若相等,比较value,若不同就用新value替换旧value,若相同就不添加了,break; 5、如果key不相等,判断当前节点类型是不是树型节点,如果是树型节点,就构造树型节点插入红黑树中; 6、如果不是树型节点,就构造普通Node加入到链表中;加入后判断链表长度是否大于等于 8,如果大于等于8且当前数组长度大于等于64,就将链表转换为红黑树;如果大于等于8但当前数组长度小于64,则扩容; 7、插入完成之后判断当前数组长度是否大于阈值(数组最大容量*负载因子),如果大于就扩容。

同一个链表或红黑树中,hash值肯定是一样的,但是key不一样。




你清楚HashMap的扩容原理吗? resize方法

扩容时机:

  • 在链表中加入某节点后,如果链表长度大于等于8但当前数组长度小于64,则扩容
  • 加入某节点后,数组长度大于阈值(数组最大容量*负载因子),则扩容

jdk1.7 HashMap扩容原理:

创建一个2倍容量的新数组,重新计算数组中每个元素的下标并且进行迁移。jdk1.7中还没有红黑树,只有链表。链表中的元素移动后仍然在这个下标上,但因为采用了头插入,整个链表会倒置。

【缺点】: (1)扩容后数组中每个元素需要重新计算位置,这个对性能的损耗比较大。 (2)头插法导致的倒置可能会使链表出现死循环。


jdk1.8 HashMap扩容原理:

仍然是扩容了两倍,但不需要像1.7中那样重新计算数组中每个元素的位置。

具体方案:因为扩容了两倍,所以容量n在二进制上表现为左面新增了一个1,因为求下标的操作是hash & (n-1) ,里面的n-1在二进制表示上也是左面多了一个1。任何数与1做&运算都是它本身,所以关键就看原hash值(二进制)相应的那一位,是1还是0 。如果是0,&运算后那一位结果为0 ,说明下标index的二进制中那一位也是0,省略(因为再往左都是0),所以得到的数组位置不变;如果是1,&运算后那一位结果为1 ,说明下标index的二进制中那一位也是1,不能省略,相当于十进制中加上一个扩容前数组容量,因此新的数组下标为“oldCap+原index”

便捷办法:用十进制,看原哈希值是否大于原数组容量,若小于,下标不变;若大于,下标加上旧容量大小。即:即扩容后的位置=【原位置】 or 【原位置 + 旧容量】




HashMap内部节点是有序的吗?哪些Map是有序的?

HashMap是无序的,根据hash函数计算出的位置随机插入。LinkedHashMap 和 TreeMap是有序的。

  • LinkedHashMap的Entry继承了HashMap的Node,另外增设了before和after两个属性,可用来实现排序。另外,LinkedHashMap是天然存储有序的,记录了插入顺序。
  • TreeMap是按照Key的自然顺序或者Comprator的顺序进行排序。后者的话,要么key所属的类实现Comparable接口,要么自定义一个实现了Comparator接口的比较器



HashMap 是线程安全的吗?哪里不安全?

不安全。JDK1.7 中并发put会造成循环链表,导致get时出现死循环。

死循环发生在 HashMap 的扩容函数内调用的 transfer 函数中,扩容时需要迁移链表,因为是头插法,链表的顺序会翻转,并发条件下可能造成链表的死循环。

1.8中改成尾插法,但是在多线程的情况下仍然不安全。举例,当A线程判断index位置为空后正好挂起,B线程开始往index位置写入数据时,这时A线程恢复,执行写入操作,这样B数据就被覆盖了。




那hashmap是怎样解决线程不安全的?

  • 给 hashMap 直接加锁,来保证线程安全
  • 使用 hashTable,其实就是在其方法上加了 synchronized 关键字,锁住整个数组,粒度比较大,效率也低。
  • 使用 concurrentHashMap , 不管是其 1.7 还是 1.8 版本,本质都是减小了锁的粒度,减少线程竞争来保证高效



JDK 1.8改动

  • 存储结构:JDK1.7的底层数据结构是数组加链表;JDK1.8的底层数据结构是数组+链表/红黑树。

  • 节点区别:JDK1.7时是Entry;JDK1.8时是Node (Node 继承了Entry)

  • 节点插入方式:JDK1.7时是头插法;JDK1.8时是尾插法

  • 哈希函数:jdk1.7中的hash函数是直接计算key的hashCode值,而1.8中让高位参与了运算。

  • 扩容时机:JDK1.7是插入前扩容;JDK1.8是插入后扩容

  • 扩容时数组元素新位置的计算方式




HashMap 有哪些重要参数?分别用于做什么的?

  • DEFAULT_INITIAL_CAPACITY 默认初始容量,16。

  • MAXIMUM_CAPACITY 规定最大容量,2^30。

  • DEFAULT_LOAD_FACTOR 默认装载因子为0.75。 这个值可以修改,可以大于1

  • TREEIFY_THRESHOLD 树化阈值。默认为8。

  • UNTREEIFY_THRESHOLD 树还原链表阈值。默认为6,该值应比TREEIFY_THRESHOLD小,以免频繁转换。

  • MIN_TREEIFY_CAPACITY 树化时最小数组长度,默认为64。为了避免进行扩容和树化之间选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD。

  • size指HashMap中实际存在的键值对数量,包括链表或红黑树中的;length指当前数组长度

  • modCount用来记录HashMap内部结构发生变化的次数,主要用于迭代的快速失败。(value值覆盖不属于结构变化)




你觉得设计巧妙的地方

  1. 要求容量为2的n次方
  2. 哈希函数设计



HashMap怎样实现迭代删除

map的迭代删除,和我们常见的list,set不太一样,不能直接获取Iteraotr对象,提供的删除方法也是单个的,根据key进行删除。如果我们有个需求,将map中满足某些条件的元素删除掉,要怎么做呢?

1、ForEach遍历,先把符合删除条件的键存起来,之后再统一按键删除

Map<String, Integer> map = new HashMap<>();
map.put("a", 1);
map.put("b", 2);
map.put("c", 3);
map.put("d", 4);

List<String> removeKey = new ArrayList<>();
for (Map.Entry<String, Integer> e: map.entrySet()) {
	if (e.getValue() % 2== 0) {
	    removeKey.add(e.getKey());   // 不允许在遍历HashMap的同时直接按键删除,会报错。这里先把符合删除条件的键存起来,之后再统一按键删除
	}
}
removeKey.forEach(map::remove);  // 按键批量删除

2、使用带泛型的迭代器进行遍历

Map<String, Integer> map = new HashMap<>();
map.put("a", 1);
map.put("b", 2);
map.put("c", 3);
map.put("d", 4);

Iterator<Map.Entry<String, Integer>> iterator = map.entrySet().iterator();
Map.Entry<String, Integer> entry;
while (iterator.hasNext()) {
    entry = iterator.next();
    if (entry.getValue() % 2 == 0) {
        iterator.remove();
    }
}
System.out.println(map);

3、Lambda表达式删除

Map<String, Integer> map = new HashMap<>();
map.put("a", 1);
map.put("b", 2);
map.put("c", 3);
map.put("d", 4);
map.entrySet().removeIf(entry -> entry.getValue() % 2 == 0);






zhuanlan.zhihu.com/p/344716821 juejin.cn/post/684490… zhuanlan.zhihu.com/p/366604122 forthe77.github.io/2019/07/04/… segmentfault.com/a/119000002… tech.meituan.com/2016/06/24/… www.geek-share.com/detail/2810… bbs.huaweicloud.com/blogs/detai… www.cnblogs.com/liang1101/p…