数据结构
Node
HashMap 将键值对封装成 Node 进行存储,底层维护一个 Node<K,V>[] table。
TreeNode
Node 的子类。当 key 不相等,但哈希冲突时,节点会以链表的形式,挂在数组上。
一旦链表通过添加节点,长度大于等于 TREEIFY_THRESHOLD(8) 时,在 resize() 时会触发 treeifyBin(Node<K,V>[] tab, int hash),链表可能会转成红黑树。
红黑树通过移除节点,长度小于等于 UNTREEIFY_THRESHOLD(6) 时,在 resize() 时会触发 untreeify(HashMap<K,V> map),红黑树会转成链表。
table
HashMap 的底层数据结构。
1 |
|
初始化
初始化时,仅仅是对大小和负载因子进行设置,底层数组并不分配空间。只有在第一次插入元素时,才会对数组进行初始化 resize()。
1 |
|
关键属性
DEFAULT_INITIAL_CAPACITY
默认的初始化大小 16,必须是 2 的幂数。
-
为什么必须是 2 的幂数?
HashMap 在存储时,会使用 hash(Object key) 根据 Key 的 hashcode 计算出最终的哈希值。最后使用 i = (n - 1) & hash 计算出索引值。(n - 1) 的结果正好相当于一个低位掩码,在进行 &(与) 时,可以让索引尽可能地分布均匀。
1 |
|
DEFAULT_LOAD_FACTOR
默认负载因子 0.75f,当 HashMap 的大小达到 capacity * factory 时,会使用 resize() 进行扩容。
-
为什么需要负载因子?
如果没有负载因子,数组应该在什么时候进行扩容?随着键值对不断增加,在不进行扩容的情况下,会频繁哈希冲突,一个桶位的节点越来越多,查找效率会变低。
或者说,负载因子过于大,扩容不及时,也会引起上述问题;而如果负载因子过于小,频繁扩容导致,扩容性能消耗大,且内存利用不充分。
1 |
|
MIN_TREEIFY_CAPACITY
链转树最小容量 64,当触发 treeifyBin(Node<K,V>[] tab, int hash),会先判断数组长度是否达到 64,再决定链转树还是扩容。
1 |
|
TREEIFY_THRESHOLD
链转树链长度的阈值 8。当链表长度大于等于 8,会触发 treeifyBin(Node<K,V>[] tab, int hash)。
1 |
|
UNTREEIFY_THRESHOLD
树转链树大小的阈值 6。在 split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) 时,当遇到树大小小于等于 6,会触发 untreeify(HashMap<K,V> map)。
1 |
|
关键方法
hash(Object key)
扰动函数,高 16 位与低 16 位 ^(异或),计算 key 对应的哈希值。
1 |
|
-
为什么不直接使用 key 的 hashcode ?
首先,map 存储的键值对总数一般不会大于 216(65536)。在使用 i = (n - 1) & hash 计算索引时,你的 hashcode 设计不合理的情况下,一旦低位出现大量一致,就会哈希冲突。使用高位特征与低位特征,计算出来的结果尽可能地让每一位的变化都能够对最终结果产生影响。
-
为什么不用 & 或 | 而用 ^ ?
& 容易让结果偏向 0;| 容易让结果偏向 1
-
为什么 i = (n - 1) & hash 要 n - 1 ?
map 的容量都是 2 的幂数,这种情况下的二进制形式,举 24(16) 为例:
1
0000 0000 0000 0000 0000 0000 0001 0000
与这样的结果进行 &(与) ,只能得到的两种结果(即使前面的哈希值已结合高低位特征,尽可能的散列)。
putVal()
添加键值对(key, value 均可为 null),返回被覆盖的值。
1 |
|
resize()
初始化或双倍扩容数组。扩容之后,将原来 Node 插入新数组中(原索引位置或 2 的幂数偏移量索引位)。最后返回新数组。
1 |
|
split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit)
将红黑树分为高低位两棵树,插入到对应的桶。
1 |
|
-
为什么要分高低位?
节点插入到新的索引位的计算规则为 i = (n - 1) & hash,由于双倍扩容,n 是原来的 2 倍。
举个例子(取低 8 位):
原数组长度 16 (0001 0000) -> 新数组长度 32 (0010 0000)
原 n - 1 (0000 1111) -> 新 n - 1 (0001 1111)
key 的 hash 是不变的,由此可见,新的索引位仅取决于多出的那一位(刚好对应原数组长度的二进制)。通过这个 bit,直接分出两个链表,插入到对应的索引位上(高低位索引相差的偏移量就是 bit 的大小)。
treeifyBin(Node<K,V>[] tab, int hash)
对指定 hash 的链表转换成红黑树结构。
1 |
|
相较于 JDK 7 的改进
Entry -> Node
节点从 Entry 改为 Node(TreeNode),链表长度达到阈值时,支持转换成红黑树。
扰动函数
扰动函数在 JDK 7 时的实现:
1 |
|
JDK 8 只做了一次高低位异或,而其实这样已经可以得到一个不错的散列值了,没必要多做几次。
头插法 -> 尾插法
JDK 7 插入新节点时使用 new Entry<>(hash, key, value, e)
1 |
|
由此可见,新节点都会成为链表的首节点。在进行扩容时,JDK 7 的 resize(int newCapacity) 调用 transfer(Entry[] newTable, boolean rehash) 进行元素迁移。在多线程扩容情况下,头插法可能会导致死链,数据丢失(尾插法也救不了)等问题。大多数情况下,直接使用 ConcurrentHashMap 替代 HashMap,性能相差并不大,而且能够保证线程安全。