实现原理
Q:HashMap 的实现原理/底层数据结构?
HashMap 底层是数组 + 链表,JDK 1.8 后增加了红黑树:
-
数组中存储着 Node 对象,本质是一个键值对,内部包含 key、value、hash、next 属性;
-
有时两个 key 会定位到相同的位置,表示发生哈希冲突;解决方法是采用链地址法,在这个位置挂一个链表,放入冲突的键值对;
-
当链表长度大于 8 时转换为红黑树,来提高 put、get 效率到 O(logN)。
Q:为什么是8,不是16,32甚至是7 ?(为什么先用链表,再转红黑树?)
因为经过计算,元素小于 8 时,链表可以保证查询性能,增删修改 Entry 引用即可,而红黑树需要左旋、右旋、变色等操作保持平衡,大于 8 时,链表查询性能下降,就需要转为红黑树来加快查询速度。
hash
Q:HashMap 的哈希函数如何设计?
哈希算法分三步:
// JDK 1.8
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
(n - 1) & hash
-
取 key 的 hashCode 值;
-
高位运算:
1.7 做了四次位移和异或,比较多余;
1.8 进行了优化,改成一次,让其高 16 位异或低 16 位,使得后续寻址时,即使 length 比较小,也能保证高低位都参与哈希计算,减少哈希冲突,同时不会有太大的开销;
-
取模运算:因为 length 始终为 2 的 n 次方,所以 (length - 1) & hash 可以等同于对 length 取模,但性能更好。
Q:为什么这么设计?
- 尽可能降低哈希碰撞;
- 使用位运算可以提高性能。
Q:为什么不直接用 key 的 hashCode 值就行,还要高位和取模?
key 的 hashCode 值 是一个 int 型整数,范围在 -231到 231,很明显我们不可能建一个这么大的数组,所以需要取模;
Q:为什么不直接取 key 的 hashCode 值,而是高位后再进行取模?
高位运算可以使得后续寻址时,即使 length 比较小,也能保证高低位都参与哈希计算,减少哈希冲突;
put
Q:HashMap 的 put 方法的执行过程 / 数据插入原理?
- hash(key):计算 key 的 hash 值;
- 判断数组是否为空,为空调用 resize,进行初始化;
- (n - 1) & hash:确定数组下标 i;
- 判断 tab[i] 是否为空,为空直接插入;
- 不为空,表示发生哈希冲突,如果和首节点 hash、key 值相等,就覆盖 value;
- 若不等,判断是否为树节点,是就插入红黑树中;
- 不是就遍历链表,若 key 值存在,就覆盖 value,最后在尾部插入新节点,如果链表长度大于 8,就转换成红黑树;
- 插入成功后,如果 ++size > threshold,就要 resize,进行扩容。
get
Q:HashMap 的 get 方法的执行过程?
- hash(key):计算 key 的 hash 值;
- 判断数组以及 tab[(n - 1) & hash]) 是否为空,为空返回 null;
- 不为空时,如果和首节点 hash 值、key 值相等,返回首节点;
- 如果不止一个节点,判断是否为树节点,是就遍历红黑树取数,否就遍历链表取数;
注意不能根据 get 的返回值,判断 key 是否存在,要用 containsKey,
因为 get 返回空,可能是 key 不存在,也可能是 value 为空。
resize
Q:HashMap 的 resize 方法的执行过程?
-
判断 oldTable 是否为空,为空就进行初始化并返回;
-
不为空,则左移 1 位,扩容为原来(原数组长度)的两倍;
-
遍历 oldTable,
JDK 1.7 要 rehash,用 (n - 1) & hash 重新确定每个元素的数组下标;
JDK 1.8 采用更简单的判断逻辑,在原位置或原位置 + 旧容量;因为每次扩容都为原来的两倍,所以 n - 1 的二进制会比原来高位多一个 1,而 1 & 任何数都是该数本身,所以只需看原 hash 值该 bit 是 0 还是 1,0 在原位置,1 为原位置 + 旧容量。
源码中用 hash & oldCap == 0,true 原位置,false 原位置 + oldCap
oldCap 二进制该 bit 为 1,1 & 任何数都是该数本身,所以可以用来检查 hash 该 bit 是 0 还是 1
有两种情况会调用 resize:
- 懒加载,首次调用 put 时,用 resize 进行初始化,默认大小为 16,也可以在构造时指定值,实际大小为大于该值的 2 的 n 次方;
- size 超过 threshold 时,用 resize 进行扩容。
扩容是一个特别耗性能的操作,所以实际使用 HashMap 时,最好估算 map 大小,初始化给一个大致数值,避免频繁的扩容。
长度为 2 的整数次方
Q:为什么 HashMap 的数组长度是 2 的整数次方?
主要为了优化寻址,当 length 为 2 的 n 次方时, (length - 1) & hash 等同于对 length 取模,但性能更好。
1.8 的优化
Q:1.8 做了哪些优化?为什么这么做?
-
增加了红黑树:当链表长度大于 8 时转换为红黑树,来提高 put、get 效率到 O(logN);
-
优化高位运算:改成一次,让其高 16 位异或低 16 位,使得后续寻址时,即使 length 比较小,也能保证高低位都参与哈希计算,减少哈希冲突;
-
链表插入方式改为尾插法;
-
扩容时,采用更简单的判断逻辑,在原位置或原位置 + 旧容量,这结合尾插法,解决了多线程死循环问题。
Hashtable
Q:HashMap 与 Hashtable 的区别?
Hashtable:基于哈希表实现,不支持 null 键和值,由于同步导致性能较差,不推荐使用;推荐使用基于分段锁实现的 ConcurrentHashMap 来支持线程安全,性能更好。
HashMap:与 Hashtable 类似,基于哈希表实现,但支持 null 键和值,并且不是线程安全的,性能更好;散列正常时,能提供常数时间的 put / get 操作,但不保证有序;如果需要满足线程安全,可以用 Collections.synchronizedMap(),或者直接用性能更好的 ConcurrentHashMap。
线程安全性
Q:HashMap 在并发下有什么问题?(HashMap 是线程安全的吗?)
HashMap 不是线程安全的,所以在并发下:
-
1.7 会因头插法和 rehash 形成一个循环链表,造成死循环问题,以及多线程 put 会造成数据丢失和覆盖的问题;
-
1.8 做了优化,改用尾插法,以及扩容后元素在原位置或原位置+旧容量,解决了死循环问题,但仍有数据丢失和覆盖的问题。
Q:你平常怎么解决这个线程不安全的问题?
想线程安全的使用 HashMap,有三种方式:
- Hashtable:直接在方法上加 synchronized,锁住整个数组,性能很低;
- Collections.synchronizedMap():使用对象锁,性能也很低;
- 推荐使用 ConcurrentHashMap:使用分段锁,降低了锁粒度,大大提高了性能。
节点无序
Q:HashMap 内部节点是有序的吗?
是无序的,根据 hash 值随机插入
Q:那有没有有序的 Map ?
LinkedHashMap 和 TreeMap
-
LinkedHashMap:HashMap 的子类,底层使用双向链表维护元素插入的顺序,也可以构造时设置 accessOrder 为 true,转换成访问顺序;同时能提供常数时间的 put / get 操作,但性能略低于 HashMap,因为有需要维护链表的开销。
-
TreeMap:基于红黑树实现,支持顺序访问,默认按键值升序,也可以由指定的 Comparator 来决定,但 put / get 效率为 O(logN);使用时,key 必须实现 Comparable 接口或者在构造时传入指定的 Comparator,否则运行时会抛出 ClassCastException。