此文会记录我对 HashMap的一些理解,源码均基于JDK8
tableSizeFor(int cap)
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;
}tableSizeFor这个方法入参是一个int类型的capacity,用于传递hashmap的期望容量,并把“期望容量 cap 向上调整为最近的 2 的幂”作为返回值(举个例子,期望容量是19,则向上最近的2的幂为32)
1.代码一开始有 n = cap - 1,这一步的作用是?
如果 cap 本身已经是 2 的幂,比如 16(二进制 10000),减一得到 01111。后续把这些 1 再“扩散”一遍,最终 n+1 又回到 10000,不会把容量错误地提升到下一个 2 的幂。
2.从二进制角度,如何理解这回事呢
假设 cap = 19,先 n=cap-1=18,二进制 0001 0010
那么如果需要获取容量 cap 向上调整为最近的 2 的幂,则只需要将00010010 -〉00011111,即将最高位1后面的每一位都变为1即可
3.为什么这些移位宽度为1、2、4、8、16?
因为 int 是 32 位,1+2+4+8+16 ≥ 31,足以让最高位的 1 通过多轮扩散覆盖到其右侧的所有位。
每一步都把已有的 1 成倍“传染”到更远的低位,最终形成形如 0b0…0111…11 的数:从最高的那一个 1 开始,右侧全是 1。
4.直观例子
同样假设 cap = 19,先 n=cap-1=18,二进制 0001 0010
n |= n>>>1: 0001 0010 | 0000 1001 = 0001 1011
n |= n>>>2: 0001 1011 | 0000 0110 = 0001 1111
n |= n>>>4: 0001 1111 | 0000 0001 = 0001 1111
后续 >>>8、>>>16 不再改变
结果 n = 0001 1111
n + 1 = 0010 0000 = 32,即大于等于 19 的最小 2 的幂
HashMap(int initialCapacity,float loadFactor)
在讨论完tableSizeFor之后,我们接着来讨论HashMap的这个有参构造方法,之所以只讨论这个构造方法,是因为这个方法中调用到了tableSizeFor方法,并且可以更好的了解到HashMap的设计思路,先来看下代码:
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}1. initialCapacity & loadFactor
initialCapacity
期望元素容量
loadFactor
加载因子
最大容量=期望元素容量/加载因子
2.threshold
在正常情况下,threshold代表的是HashMap的扩容阈值,但是在这里,使用了tableSizeFor方法来进行了赋值,代表的却是最大容量,乍一看好像是把 threshold 当作容量(capacity)用了,难道是逻辑出现了混乱?
其实并不是,需要注意的是,在 JDK8 的 HashMap 里,底层数组 table 并没有在构造函数里创建。因此在构造函数里,threshold 并不是真正的“扩容阈值”,而是暂存的初始容量大小,等到第一次 put 时再真正初始化数组。JDK8 为了节省资源,采用了懒加载策略:只有当第一次执行 put() 时才创建底层数组。
初见resize()
没错,虽然上面你知道了第一次 put 时才会真正初始化数组,但是接下来我要先讲讲resize方法,因为在第一次调用put时,table为空,我们会默认调用的其实就是resize,那就不如直接跳转吧,由于原代码有些多,我会略过一些和第一次put流程无关的内容:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
……
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
……
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
……
}
return newTab;
}让我简单概述一下这个代码讲了什么:
1.定义了旧table的最大容量、扩容阈值,新table的最大容量、扩容阈值
2.将新table的最大容量设置为oldThr,即通过第一步tableSizeFor得出的值
3.通过“新table的最大容量 * loadFactor”计算新table的threshold
4.为新table分配内存并返回
putVal
putVal应该算得上整个HashMap逻辑集成度最高的地方了,代码相对较多,而且都是核心逻辑,这里就着代码贴图讲几个关键点:
resize的调用位置
桶数组下标计算
putTreeVal(往树里插节点)
treeifyBin(链表结构转树结构)
afterNodeAccess和afterNodeInsertion(LinkedHashMap)

1.resize的调用位置
初见resize中讲到的内容,就是在这个位置的调用,我们知道HashMap构造函数中并不会对table进行任何操作,而是在putVal时使用resize来初始化table,这里也不难理解,因此就不过多展开了。
2.桶数组下标计算——模运算VS与运算
HashMap 的核心是“根据 key 计算出桶数组(table)下标”,在根据key经过hash算法得到hash以后,通常情况下,要判断这个key要落在哪个桶里,就是做一个取模操作:
int index = hash % n;但是在JDK8中,却是这么做的:
int index = (n - 1) & hash;我们来进行一个简单的推理,假设:
n = 8 (二进制 1000)
hash = 10110100(十进制180)我们知道一个数除以2的k幂次,相当于将这个数的二进制数向右移动k位,右移后的值为:00010110
加下来是套公式环节:
n = 2^k
index = hash % n
q = hash / n = hash >>> k
hash = n * q + index想要从二进制的角度去获取到index,只需要做一件事情:
index = hash - ((hash >>> k) <<< k )
#代入示例的结果:
index = 10110100 - 10110000 = 00000100 = 4看着很复杂是吧,但是一句话来说:其实取模的结果就是hash的低k位。
那么继续看int index = (n - 1) & hash这个操作,由于我们知道n必须是2次幂,因此n-1对应的二进制全是连续的 k 个 1。
还是拿 n=8 来举例,n-1的二进制表达式为 00000111,那么(n - 1) & hash 的结果,其实也是hash的低k位,因此hash % n和(n - 1) & hash在n是2次幂的时候其实是等价的,但是为什么选择与运算,而不是模运算呢?下面是几个原因:
更快的效率
CPU 执行一次除法的耗时 ≈ 几十个时钟周期;执行一次位与只需 1 个时钟周期。
简化扩容计算
当桶扩容时, 桶容量从n变为2n,桶数组下标的计算从(n-1) & hash变为(2n-1)&hash,从二进制层面来说,当哈希值重新分桶时, 不需要重新取模,只需判断哈希的新增加的那一位是 0 还是 1。
还是举上面的例子:
(n-1) & hash = 00000111 & 10110100
(2n-1)&hash = 00001111 & 10110100
也就是说:元素要么“留在原桶”,要么“移动到原桶 + n”的位置。(运气不错,这个情况下留在了原桶)
3.putTreeVal
我们知道HashMap会对桶中元素进行链表和红黑树的转换,假如在插入一个元素时,桶中的元素保存的数据结构已经是树形了(p instanceof TreeNode),那么就会调用putTreeVal,主要的功能是往红黑树插入节点,并没有太多需要关注的,不过由于putTreeVal的源码比较晦涩,我在其中加上了一些注释。
final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,
int h, K k, V v) {
Class<?> kc = null;
boolean searched = false;
// 若本节点有父,则取真正root;否则this就是root
TreeNode<K,V> root = (parent != null) ? root() : this;
// 从根开始向下找插入位置或已存在的key
for (TreeNode<K,V> p = root;;) {
int dir, ph; K pk;
// 1) 先比 hash,决定左右
if ((ph = p.hash) > h)
dir = -1; // 往左
else if (ph < h)
dir = 1; // 往右
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
return p; // 同一key,返回旧节点
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0) {
// hash相等且key不可比较(或比较结果为0)
if (!searched) {
// 只做一次:尝试在左右子树里再找同key
TreeNode<K,V> q, ch;
searched = true;
if (((ch = p.left) != null &&
(q = ch.find(h, k, kc)) != null) ||
((ch = p.right) != null &&
(q = ch.find(h, k, kc)) != null))
return q; // 找到了就直接返回
}
// 仍找不到,用平局打破策略确定左右
dir = tieBreakOrder(k, pk);
}
TreeNode<K,V> xp = p;
// 2) 若对应子树为空,落位插入
if ((p = (dir <= 0) ? p.left : p.right) == null) {
Node<K,V> xpn = xp.next; // 桶内链表的下一个
// 同时创建树结点,并预置链表next指向xpn
TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);
// 接到树上
if (dir <= 0) xp.left = x;
else xp.right = x;
// 接到桶内双向链表上
xp.next = x;
x.parent = x.prev = xp;
if (xpn != null)
((TreeNode<K,V>)xpn).prev = x;
// 红黑树平衡 & 把根移到桶表头
moveRootToFront(tab, balanceInsertion(root, x));
return null; // 新插入,返回null
}
// 否则继续向下
}
}4.treeifyBin
treeifyBin的工作其实就是将桶中的元素链表转换为红黑树,这里重点需要看的是套在treeifyBin外面的这个for循环,首先理解一下两个变量
binCount:表示当前正在遍历的桶中链表的节点位置(从头算起)。
p:一个指向当前桶中元素链表的指针(默认指向第0个元素)。
然后理解for循环中干了什么:
如果p是最后一个元素了,那么将需要插入的节点插入到p.next
如果此时链表长度大于等于TREEIFY_THRESHOLD,则需要将链表转红黑树
插入完毕,break
p不是最后一个元素,but发现p.next的key正好和我们要插入的元素key相同,break
移动p指针指向下一个链表中的元素
5.afterNodeAccess和afterNodeInsertion
afterNodeAccess(e) 和 afterNodeInsertion(evict) 这两个方法在普通 HashMap 中几乎没存在感,但它们的设计意义非常重要 —— 是为子类扩展(如 LinkedHashMap)预留的“钩子(hook)”。
👉 在 HashMap 中,这两个方法是 空实现(do nothing)。
👉 但它们被设计成模板方法(template methods),供子类(比如 LinkedHashMap)重写(override)。
例如LinkedHashMap, 它是 HashMap 的子类,维护了一个 双向链表 来记录插入/访问顺序。
void afterNodeAccess(Node<K,V> e) { // move node to last
LinkedHashMap.Entry<K,V> last;
if (accessOrder && (last = tail) != e) {
// 把被访问的节点移到链表尾部,保持“最近访问的在后面”
LinkedHashMap.Entry<K,V> p = e.prev, n = e.next;
if (p != null) p.next = n;
if (n != null) n.prev = p;
if (tail == e) tail = p;
if (head == e) head = n;
e.next = null;
e.prev = tail;
if (tail != null)
tail.next = e;
else
head = e;
tail = e;
}
}
void afterNodeInsertion(boolean evict) { // possibly remove eldest
LinkedHashMap.Entry<K,V> first;
if (evict && (first = head) != null && removeEldestEntry(first))
removeNode(first.hash, first.key, null, false, true);
}需要注意的是,这两个方法在 LinkedHashMap 里被重写成维护访问顺序与LRU 淘汰策略的关键机制。在本篇文章中不会展开,但这是一个很有趣的东西,所以值得提及。
写在最后
本文是近几天我在回顾HashMap内容时做的一些笔记,由于篇幅和时间限制,仅记录了我认为值得学习借鉴和比较有意思的内容,如果后面有新的想法和理解,将会开一篇新的文章继续记录。