深入Java集合框架:HashMap源码解析(JDK 8)

前言

在 Java 后端开发工程师的面试中,或者日常的高性能代码编写里,HashMap 永远是一个绕不开的话题。JDK 8 对 HashMap 进行了大刀阔斧的改造,引入了红黑树,彻底解决了 JDK 7 及以前版本中链表过长导致的性能瓶颈。

今天,我们就抛开表象,深入 java.util.HashMap 的源码,一层一层剥开它的设计精髓。


一、 核心数据结构:数组 + 链表 + 红黑树

在 JDK 8 之前,HashMap 的底层结构是 数组 + 链表。而在 JDK 8 中,变成了 数组 + 链表 + 红黑树

// 核心数组,也称为哈希桶(Hash Bucket)
transient Node<K,V>[] table;

// 链表节点(当哈希冲突时,元素以链表形式挂载)
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next; // 指向下一个节点
}

// 红黑树节点(当链表长度达到一定阈值时转换)
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    TreeNode<K,V> parent;
    TreeNode<K,V> left;
    TreeNode<K,V> right;
    TreeNode<K,V> prev;
    boolean red;
}

为什么要引入红黑树?

在极端情况下(比如大量 Key 的 hashCode 都相同),链表会变得非常长。链表的查询时间复杂度是 O(n),而红黑树是 O(logn)。引入红黑树后,即使遭遇恶意构造的哈希碰撞攻击,HashMap 的性能也不会断崖式下跌。


二、 关键参数与阈值

在阅读源码前,必须先记住这几个核心常量,它们是 HashMap 运作的“灵魂”:
image

面试考点:为什么链表转红黑树的阈值是 8?

根据泊松分布,在负载因子 0.75 的情况下,单个桶中节点数量达到 8 的概率小于千万分之一。也就是说,正常情况下链表不会变红黑树,一旦变了,说明数据分布极不均匀,红黑树能提供更好的查询性能。


三、 核心方法深度解析

1. 哈希函数:hash(Object key)

HashMap 没有直接使用 key.hashCode(),而是进行了一次扰动函数(Perturbation function)处理:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

为什么要高 16 位异或低 16 位?

因为 HashMap 计算数组下标时用的是 (n - 1) & hash。当数组容量 n 较小时(比如 16),只有低 4 位参与了运算,高位的变化被忽略了。通过右移 16 位并与原哈希值异或,可以将高位的特征混合到低位,从而减少哈希冲突。


2. 存入元素:put(K key, V value)

put 方法是 HashMap 最核心的逻辑,我们顺着源码梳理它的执行链路:

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

putVal 方法中,主要做了以下几件事:

  1. 计算下标i = (n - 1) & hash

  2. 桶为空:直接新建 Node 放入。

  3. 桶不为空

    • 如果第一个节点就命中(key 相同),直接更新 value。

    • 如果是红黑树节点,调用红黑树的插入方法。

    • 如果是链表,遍历链表。如果找到相同的 key 则更新;如果没找到,在尾部插入新节点。插入后如果链表长度 >= 8,触发树化逻辑(treeifyBin

  4. 扩容检查:如果 size > threshold,调用 resize()


3. 扩容机制:resize()

扩容是 HashMap 中最消耗性能的操作。JDK 8 对扩容进行了巧妙的优化:

final Node<K,V>[] resize() {
    // 1. 计算新容量和新阈值(通常是旧的两倍)
    // 2. 创建新数组
    // 3. 数据迁移
}

JDK 8 扩容的精髓:高位迁移

在 JDK 7 中,扩容时需要重新计算每个元素的 hash % length。而在 JDK 8 中,因为容量永远是 2 的幂,扩容后元素的新位置要么在原位置,要么在原位置 + 旧容量

// 判断原哈希值新增参与运算的位是 0 还是 1
if ((e.hash & oldCap) == 0) {
    // 低位,留在原索引
} else {
    // 高位,移动到 原索引 + oldCap
}

这种位运算的优化,让扩容效率提升了数倍。


4. 获取元素:get(Object key)

get 的逻辑相对简单:

  1. 计算 hash

  2. 定位到数组下标。

  3. 如果第一个节点命中,直接返回。

  4. 如果是红黑树,调用 getTreeNode 查找。

  5. 如果是链表,遍历查找。


四、 面试常问的“坑点”

1. HashMap 是线程安全的吗?

绝对不是。​ 在多线程环境下,JDK 8 的 HashMap 可能会出现死循环(虽然 JDK 8 修复了 JDK 7 头插法导致的死循环,但数据覆盖问题依然存在)。高并发请使用 ConcurrentHashMap

2. 为什么 String、Integer 适合作为 Key?

因为它们是不可变类(Immutable)。如果 Key 是可变的,对象被放入 Map 后,其 hashCode 发生变化,就再也找不到原来的值了。

3. 初始容量设为 1000,实际是多少?

HashMap 会将其调整为大于等于该值的最小的 2 的幂,即 1024


五、 总结

JDK 8 的 HashMap 是一次非常优秀的架构升级:

  1. 数据结构:数组 + 链表 + 红黑树,兼顾了性能和内存。

  2. 哈希算法:扰动函数减少冲突。

  3. 扩容机制:高位运算优化迁移效率。

  4. 树化策略:基于泊松分布的阈值设定,既保证了极端情况下的性能,又避免了不必要的结构转换。

理解 HashMap 的源码,不仅能帮你从容应对面试,更能让你在设计高并发、高性能系统时,对“哈希”这一基础数据结构有更深刻的敬畏和运用。


写在最后

以上就是这次关于HashMap底层源码解析的学习记录。

如果这篇笔记对你有帮助,点个赞鼓励一下吧~ 有任何疑问也欢迎在评论区一起讨论交流!

posted @ 2026-08-16 17:52  李冰然  阅读(1)  评论(0)    收藏  举报