HashMap 是 Java 开发者最常用的集合类之一,但很多人只知其然不知其所以然。今天我们就来深入剖析 JDK7 中 HashMap 的源码,用最通俗的方式讲清楚它的底层原理。
一、HashMap 的整体结构
JDK7 中的 HashMap 采用 数组 + 链表 的结构,俗称"散列表"或"哈希表"。
┌─────┬─────┬─────┬─────┬─────┐
│ 0 │ 1 │ 2 │ 3 │ 4 │ ... ← 数组 (Entry[])
└─────┴─────┴─────┴─────┴─────┘
│
↓
┌──────┐ ┌──────┐ ┌──────┐
│Entry │───→│Entry │───→│Entry │ ← 链表
└──────┘ └──────┘ └──────┘
形象理解:数组就像一排储物柜,每个柜子里可以放一个物品。如果多个物品要放进同一个柜子,就用链条把它们串起来。
二、核心数据结构:Entry
static class Entry<K,V> implements Map.Entry<K,V> {
final K key; // 键
V value; // 值
Entry<K,V> next; // 指向下一个节点的指针(形成链表)
int hash; // 哈希值(缓存,避免重复计算)
}
每个 Entry 对象就是一个"键值对",同时它还持有下一个 Entry 的引用,这就是链表的实现方式。
三、put 方法的完整流程
这是 HashMap 最核心的方法,我们一步步拆解:
3.1 整体流程图
put(key, value)
│
▼
┌─────────────────┐
│ key == null ? │── 是 ──→ putForNullKey(value)
└─────────────────┘
│ 否
▼
┌─────────────────┐
│ 计算 hash 值 │
└─────────────────┘
│
▼
┌─────────────────┐
│ 计算数组索引 index │ = hash & (length-1)
└─────────────────┘
│
▼
┌─────────────────┐
│ 遍历该位置的链表 │
└─────────────────┘
│
├── 找到相同key ──→ 覆盖旧值,返回旧值
│
└── 未找到 ──→ addEntry() 头插法添加新节点
3.2 第一步:处理 key = null 的情况
public V put(K key, V value) {
if (key == null)
return putForNullKey(value);
// ...
}
HashMap 允许 key 为 null,它会放在数组的第 0 个位置(table[0])。如果有多个 null key?放心,后续的会覆盖前面的。
private V putForNullKey(V value) {
// 遍历 table[0] 处的链表,看是否已经有 null key
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value; // 覆盖
return oldValue;
}
}
// 没有则添加新节点
addEntry(0, null, value, 0);
return null;
}
3.3 第二步:计算哈希值
int hash = hash(key);
这里的 `hash()` 方法不是直接调用 `key.hashCode()`,而是做了进一步处理:
final int hash(Object k) {
int h = 0;
h ^= k.hashCode();
// 让高位参与低位运算,减少哈希碰撞
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
为什么要这样做?
因为计算数组下标时只用了哈希值的低位(后面会讲),如果直接使用原始 hashCode,很容易产生冲突。让高位参与低位运算,能让哈希值的分布更均匀。
3.4 第三步:计算数组下标
int i = indexFor(hash, table.length);
static int indexFor(int h, int length) {
return h & (length - 1);
}
重点理解:这里用 `&` 运算代替了取模 `%` 运算,效率更高。
但有个前提:数组长度必须是 2 的幂次方(16、32、64...)。
举例说明(假设 length = 16,length-1 = 15 = 1111 二进制):
hashCode: 1101 1010 0011 1110 1010 1100 1010 1101
& 15 (1111): ................................................
结果: 只有最后4位参与运算 → 0101 = 5
这就是为什么 HashMap 要求容量是 2 的幂次方 ------ 为了让 `h & (length-1)` 等价于 `h % length`,同时位运算更快。
3.5 第四步:遍历链表,查找相同 key
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value; // 覆盖旧值
e.recordAccess(this);
return oldValue; // 返回旧值
}
}
判断相同的条件:hash 相等 && (key 地址相同 或 key 内容相等)
这里用 `e.hash == hash` 先判断哈希值,可以快速过滤掉大部分不相同的 key,提高效率。
3.6 第五步:未找到时添加新节点(头插法)
addEntry(hash, key, value, i);
void addEntry(int hash, K key, V value, int bucketIndex) {
// 先检查是否需要扩容
if (size >= threshold && null != table[bucketIndex]) {
resize(2 * table.length); // 扩容为原来的2倍
hash = (key != null) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
关键点:头插法
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex]; // 取出原来在数组位置上的节点
table[bucketIndex] = new Entry<>(hash, key, value, e); // 新节点指向旧节点,并放入数组
size++;
}
这就是 头插法:新来的节点直接插入到链表头部,原来的头节点变成它的下一个节点。
插入前: 数组[index] → NodeA → NodeB → null
插入新节点New:
步骤1:New.next = NodeA
步骤2:数组[index] = New
插入后: 数组[index] → New → NodeA → NodeB → null
为什么用头插法?因为新插入的元素往往被访问的概率更高(局部性原理),放在头部可以更快被找到。
四、get 方法的流程
理解了 put,get 就简单多了:
public V get(Object key) {
if (key == null)
return getForNullKey(); // 去 table[0] 找
int hash = hash(key);
int i = indexFor(hash, table.length);
// 遍历链表
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
return e.value;
}
return null;
}
步骤:
-
处理 null key
-
计算 hash
-
计算数组下标
-
遍历链表找匹配的 key
-
找到返回 value,找不到返回 null
五、扩容机制
当 `size >= threshold`(容量 × 加载因子,默认 0.75)时触发扩容:
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity)); // 重新分配
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
transfer 方法:遍历所有旧数组的节点,重新计算新数组下标,迁移过去。
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while (null != e) {
Entry<K,V> next = e.next; // 先保存下一个节点(重要!)
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i]; // 头插法插入新数组
newTable[i] = e;
e = next; // 继续处理下一个
}
}
}
注意:扩容后链表的顺序会反转(因为头插法)。这在多线程环境下会形成循环链表,导致死循环 ------ 这也是 JDK8 改为尾插法的原因之一。
六、完整示例演示
假设我们执行以下代码:
HashMap<String, Integer> map = new HashMap<>(8);
map.put("张三", 18);
map.put("李四", 20);
map.put("王五", 22);
执行过程:
-
创建容量为 8 的数组(2 的 3 次方)
-
put("张三",18):hash=123456,index = 123456 & 7 = 4,table[4] 为空,直接放入
-
put("李四",20):hash=654321,index = 654321 & 7 = 1,table[1] 为空,直接放入
-
put("王五",22):hash=123456(假设与张三碰撞),index = 4,发现 table[4] 已有张三
-
遍历链表:key 不同,继续
-
链表结束,头插法插入:新节点.next = 张三,table[4] = 新节点
最终结构:
table[0] → null
table[1] → 李四
table[2] → null
table[3] → null
table[4] → 王五 → 张三 → null
table[5] → null
table[6] → null
table[7] → null
七、JDK7 HashMap 的优缺点
优点
-
增删查改平均时间复杂度 O(1)
-
允许 null key 和 null value
-
位运算优化,性能较好
缺点
-
线程不安全:多线程下扩容可能产生循环链表
-
头插法导致扩容时链表反转,多线程环境下容易死循环
-
哈希碰撞严重时退化为 O(n) 链表查询
JDK8 的改进
-
链表长度超过 8 时转为红黑树,最坏复杂度降为 O(log n)
-
头插法改为尾插法,避免多线程死循环
-
优化 hash 算法
八、面试常见问题
Q1:为什么 HashMap 容量必须是 2 的幂次方?
A:为了用 `h & (length-1)` 替代 `h % length`,位运算更快,且能保证下标均匀分布。
Q2:加载因子为什么是 0.75?
A:时间和空间的权衡。太小浪费空间,太大增加哈希碰撞概率。0.75 是经验最优值。
Q3:JDK7 和 JDK8 的 HashMap 有什么区别?
A:主要区别:数据结构(数组+链表 vs 数组+链表+红黑树)、插入方式(头插 vs 尾插)、扩容优化等。
Q4:HashMap 在多线程下有什么问题?
A:JDK7 中多线程扩容可能导致循环链表,造成 CPU 100%;另外多线程 put 可能导致数据丢失。
结语
JDK7 的 HashMap 虽然已经被 JDK8 的版本超越,但它的设计思想依然值得我们学习。理解它,你就理解了哈希表的核心原理:哈希函数、冲突解决、动态扩容。