深入浅出 JDK7 HashMap 源码分析

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;
}

步骤:

  1. 处理 null key

  2. 计算 hash

  3. 计算数组下标

  4. 遍历链表找匹配的 key

  5. 找到返回 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);

执行过程:

  1. 创建容量为 8 的数组(2 的 3 次方)

  2. put("张三",18):hash=123456,index = 123456 & 7 = 4,table[4] 为空,直接放入

  3. put("李四",20):hash=654321,index = 654321 & 7 = 1,table[1] 为空,直接放入

  4. 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 的版本超越,但它的设计思想依然值得我们学习。理解它,你就理解了哈希表的核心原理:哈希函数、冲突解决、动态扩容。

相关推荐
君义_noip2 小时前
信息学奥赛一本通 4150:【GESP2509七级】⾦币收集 | 洛谷 P14078 [GESP202509 七级] 金币收集
c++·算法·gesp·信息学奥赛·csp-s
摸个小yu2 小时前
【力扣LeetCode热题h100】链表、二叉树
算法·leetcode·链表
汀、人工智能2 小时前
[特殊字符] 第93课:太平洋大西洋水流问题
数据结构·算法·数据库架构·图论·bfs·太平洋大西洋水流问题
ZPC82102 小时前
rviz2 仿真控制器与真实机器人切换
人工智能·算法·机器人
澈2073 小时前
双指针,数组去重
c++·算法
小辉同志3 小时前
207. 课程表
c++·算法·力扣·图论
CheerWWW3 小时前
深入理解计算机系统——位运算、树状数组
笔记·学习·算法·计算机系统
锅挤4 小时前
数据结构复习(第一章):绪论
数据结构·算法
skywalker_114 小时前
力扣hot100-5(盛最多水的容器),6(三数之和)
算法·leetcode·职场和发展