一、为什么会有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()比较顺序:
1、hashCode()比较结果不相同,则说明是不同对象;
2、hashCode()比较结果相同,则需要再进行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) & hash 。2的幂次方减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)
(length−1)
即:当长度为
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过程

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)链表插入。头插改变原本元素顺序,并发场景会导致链表成环,而尾插不会。

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

[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()?
答:
- 哈希table为null或长度为0;
- Map中存储的k-v对数量超过了阈值threshold;
- 链表中的长度超过了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
| HashMap | JDK1.7 | JDK1.8 |
|---|---|---|
| 底层结构 | 数组+链表 | 数组+链表/红黑树 |
| 插扩顺序 | 先扩容再插值 | 先插值再扩容 |
| 插入顺序 | 表头插入法 | 表尾插入法 |
| 扩容后的索引计算 | 扩容时需要重新计算哈希值和索引位置 | 不需要重新计算 |
| 并发问题 | 改变原有顺序,并发时引起链表闭环 | 保持原有顺序,不会出现闭环 |
追问1:HashMap/HashTable/ConcurrentHashMap数据结构,底层(*4),如何保证线程安全,怎么实现(*3)
……
追问1:为什么HashMap线程不安全?
1.7中transfer()链表使用头插法,多线程情况下,会成环;
1.8中putVal()若桶为空,多线程操作,值会出现覆盖情况。
2、你平常怎么解决这个线程不安全的问题?
HashTable、Collections.synchronizedMap及ConcurrentHashMap都是实现线程安全的Map。
- 1)
HashTable:直接在方法上加synchronized来锁住整个数组,粒度比较大。 - 2)
Collections.synchronizedMap:Collections集合工具的内部类,通过传入Map封装出一个SynchronizedMap对象,内部定义了一个对象锁,方法内通过对象锁实现。 - 3)
ConcurrentHashMap:使用分段锁,降低锁粒度,让并发度大大提高。
3、那你知道ConcurrentHashMap的分段锁的实现原理吗?
ConcurrentHashMap成员变量使用volatile修饰,免除指令重排序,同时保证内存可见性,另外使用CAS操作和synchronized结合实现赋值操作,多线程操作只会锁住当前操作索引的节点。
如下图,线程A锁住A节点所在链表,线程B锁住B节点所在链表,互不干涉。

五、参考
-
My
1、【Java】HashMap详解 -
Other
1、07 | 深入浅出HashMap的设计与优化
2、一个 HashMap 能跟面试官扯上半个小时
3、到底什么是 HashMap?
4、jdk 源码系列之 HashMap
5、HashMap 源码分析(JDK1.8)
6、由HashMap哈希算法引出的求余%和与运算&转换问题
7、【1】JDK8 HashMap扩容优化
8、HashMap 链表插入方式 → 头插为何改成尾插 ?
9、HashMap1.7 vs 1.8 -
HashMap线程不安全
1、HashMap的transfer()方法(jdk1.7)
2、Hashmap头插法死循环
3、HashMap线程不安全的体现
4、深入解读HashMap线程安全性问题

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

889

被折叠的 条评论
为什么被折叠?



