HashMap的源码学习

学习 Java 中 HashMap 的源码是深入理解哈希表数据结构、Java 集合框架设计思想的重要途径。下面从核心原理、关键属性、核心方法实现等方面,带你逐步剖析 JDK 8 及以上版本的 HashMap 源码(注:JDK 8 是 HashMap 实现的重要转折点,引入了红黑树优化,以下分析以 JDK 8 为基础)。

一、HashMap 核心原理概览

HashMap 是基于 哈希表 实现的 Map 接口,存储键值对(key-value),允许 keyvaluenullkey 仅允许一个 null),且无序(插入顺序与遍历顺序不一致)。

其核心思想是:

  1. 哈希函数 :通过 keyhashCode() 计算哈希值,确定元素在数组中的存储位置(桶索引)。
  2. 解决哈希冲突 :当多个 key 计算出相同的桶索引时,JDK 8 采用 链表 + 红黑树 结合的方式:
    • 当链表长度 <= 8 时,用链表存储(查询时间复杂度 O(n))。
    • 当链表长度 > 8 时,转为红黑树(查询时间复杂度 O(log n))。
  3. 动态扩容 :当元素数量(size)超过 负载因子(loadFactor)× 数组长度(capacity) 时,触发扩容(数组长度翻倍),重新计算所有元素的存储位置(rehash)。

二、关键属性与常量

先看 HashMap 类中定义的核心属性和常量,理解其底层存储结构的基础:

java 复制代码
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
    // 1. 常量
    // 默认初始容量(必须是 2 的幂):16
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 16
    // 最大容量(2^30)
    static final int MAXIMUM_CAPACITY = 1 << 30;
    // 默认负载因子:0.75(减少哈希冲突的概率)
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    // 链表转红黑树的阈值:当链表长度 > 8 时转树
    static final int TREEIFY_THRESHOLD = 8;
    // 红黑树转链表的阈值:当树节点数 < 6 时转链表(避免频繁转换)
    static final int UNTREEIFY_THRESHOLD = 6;
    // 最小树化容量:当数组长度 < 64 时,即使链表长度超 8,也先扩容而非转树
    static final int MIN_TREEIFY_CAPACITY = 64;

    // 2. 核心存储结构:哈希桶数组(数组 + 链表/红黑树)
    // 类型是 Node<K,V>[],Node 是链表节点;若转树则为 TreeNode(继承 Node)
    transient Node<K,V>[] table;

    // 3. 其他关键属性
    // 元素数量(key-value 对的个数)
    transient int size;
    // 结构修改次数(用于迭代器的快速失败机制)
    transient int modCount;
    // 扩容阈值(= capacity × loadFactor),当 size 超过此值时触发扩容
    int threshold;
    // 负载因子(可在构造方法中指定)
    final float loadFactor;
}
  • table 数组HashMap 的核心存储容器,每个元素是一个 Node 节点(链表节点)或 TreeNode 节点(红黑树节点)。
  • 容量(capacity)table 数组的长度,必须是 2 的幂(原因后面解释),默认 16。
  • 负载因子(loadFactor):控制哈希表的疏密程度,默认 0.75(平衡空间和时间效率)。
  • 阈值(threshold) :触发扩容的临界值,初始为 DEFAULT_INITIAL_CAPACITY × DEFAULT_LOAD_FACTOR = 12

三、哈希函数与桶索引计算

HashMap 中,key 的存储位置(桶索引)由两步计算得出,目的是减少哈希冲突:

1. 计算 key 的哈希值(hash() 方法)
java 复制代码
static final int hash(Object key) {
    int h;
    // 若 key 为 null,哈希值为 0;否则取 key 的 hashCode(),并将高 16 位与低 16 位异或
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
  • 作用:将 keyhashCode()(32 位整数)的高 16 位与低 16 位进行异或,混合哈希值的高位和低位,减少因低位重复导致的哈希冲突(尤其在数组长度较小时,高位无法参与索引计算,通过异或让高位信息融入低位)。
2. 计算桶索引(i = (n - 1) & hash)

通过哈希值计算数组索引时,HashMap 没有用取模运算(hash % n),而是用:

java 复制代码
int index = (table.length - 1) & hash;
  • 原因:当 n(数组长度)是 2 的幂时,n - 1 的二进制是全 1(如 n=16 时,n-1=151111),此时 (n-1) & hash 等价于 hash % n,但位运算效率更高。
  • 这也解释了为什么 capacity 必须是 2 的幂:保证索引计算的均匀性,避免某些位置永远无法被使用。

四、核心方法解析

1. 构造方法

HashMap 有三个主要构造方法,用于初始化容量和负载因子:

java 复制代码
// 1. 无参构造:使用默认容量(16)和默认负载因子(0.75)
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // 其他属性默认初始化
}

// 2. 指定初始容量:使用默认负载因子
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

// 3. 指定初始容量和负载因子
public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
    // 初始容量最大不超过 MAXIMUM_CAPACITY(2^30)
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
    this.loadFactor = loadFactor;
    // 计算初始阈值:将 initialCapacity 向上调整为最接近的 2 的幂(如 initialCapacity=10 → 16)
    this.threshold = tableSizeFor(initialCapacity);
}

// 辅助方法:返回 >= 给定值的最小 2 的幂(用于初始化容量)
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;
}
  • 注意:table 数组(哈希桶)并非在构造方法中初始化,而是在第一次插入元素时(put 方法)才真正初始化(延迟初始化,节省空间)。
2. put() 方法(核心中的核心)

put 方法用于添加键值对,流程较复杂,可分为以下步骤:

java 复制代码
public V put(K key, V value) {
    // 调用 putVal 方法,传入 key 的哈希值、key、value,其他参数默认
    return putVal(hash(key), key, value, false, true);
}

/**
 * 真正执行插入的核心方法
 * @param hash key 的哈希值
 * @param key 键
 * @param value 值
 * @param onlyIfAbsent 若为 true,则仅当 key 不存在时才插入(不覆盖已有值)
 * @param evict 用于 LinkedHashMap 的标志,HashMap 中无实际意义
 * @return 旧值(若 key 已存在)或 null
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 步骤 1:若 table 未初始化(null 或长度 0),则初始化 table(resize())
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 步骤 2:计算桶索引 i = (n-1) & hash,若桶为空(p == null),直接新建节点放入
    if ((p = tab[i]) == null)
        tab[i] = newNode(hash, key, value, null);
    else { // 步骤 3:桶不为空(存在哈希冲突)
        Node<K,V> e; K k;
        // 3.1 若桶中第一个节点的 hash 和 key 与当前 key 相同,记录该节点(e = p)
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 3.2 若桶是红黑树(p 是 TreeNode),则调用树的插入方法
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 3.3 若桶是链表,遍历链表
        else {
            for (int binCount = 0; ; ++binCount) {
                // 3.3.1 若遍历到链表尾部(e = p.next == null),新建节点插入尾部
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // 若链表长度超过 TREEIFY_THRESHOLD(8),则尝试转红黑树(treeifyBin)
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 因为从 0 开始计数
                        treeifyBin(tab, hash);
                    break;
                }
                // 3.3.2 若链表中存在相同 key,跳出循环(e 已记录该节点)
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e; // 移动到下一个节点
            }
        }
        // 步骤 4:若存在相同 key 的节点(e != null),则覆盖旧值
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e); // 空方法,供 LinkedHashMap 重写
            return oldValue;
        }
    }
    // 步骤 5:若插入了新节点,修改次数 +1,检查是否需要扩容
    ++modCount;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict); // 空方法,供 LinkedHashMap 重写
    return null;
}

put 方法核心流程总结

  1. 若哈希桶数组未初始化,先调用 resize() 初始化。
  2. 计算桶索引,若桶为空,直接插入新节点。
  3. 若桶不为空:
    • 若第一个节点与当前 key 相同,直接覆盖。
    • 若桶是红黑树,调用树的插入方法。
    • 若桶是链表,遍历链表:
      • 若找到相同 key,覆盖旧值。
      • 若未找到,插入链表尾部,若长度超 8 则尝试转红黑树(转树前会检查数组长度,若 <64 则先扩容)。
  4. 插入新节点后,若 size 超过阈值,调用 resize() 扩容。
3. resize() 方法(扩容)

resizeHashMap 中最复杂的方法之一,负责初始化哈希桶或在容量不足时扩容(数组长度翻倍),并重新计算所有元素的存储位置(rehash)。

java 复制代码
final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;

    // 情况 1:旧数组已初始化(oldCap > 0)
    if (oldCap > 0) {
        // 若旧容量已达最大值(2^30),则阈值设为 Integer.MAX_VALUE,不再扩容
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 否则,新容量 = 旧容量 × 2(仍为 2 的幂),新阈值 = 旧阈值 × 2
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    // 情况 2:旧数组未初始化,但旧阈值 > 0(通常是通过带参构造方法指定了初始容量)
    else if (oldThr > 0)
        newCap = oldThr;
    // 情况 3:旧数组未初始化,且旧阈值 = 0(无参构造),使用默认值
    else {
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }

    // 计算新阈值(若上面未设置)
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;

    // 创建新的哈希桶数组(容量为 newCap)
    @SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;

    // 若旧数组不为 null,将旧数组中的元素转移到新数组中
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null; // 帮助 GC
                // 若桶中只有一个节点,直接计算新索引并放入新数组
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                // 若桶是红黑树,拆分树节点到新数组(可能转链表)
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                // 若桶是链表,拆分链表到新数组(优化:无需重新计算所有节点的哈希,利用容量翻倍的特性)
                else { // preserve order
                    Node<K,V> loHead = null, loTail = null; // 索引不变的链表(low 链)
                    Node<K,V> hiHead = null, hiTail = null; // 索引 = 旧索引 + 旧容量的链表(high 链)
                    Node<K,V> next;
                    do {
                        next = e.next;
                        // 核心:通过 hash & oldCap 判断新索引(旧容量是 2 的幂,二进制只有一位为 1)
                        // 若结果为 0,新索引 = 旧索引;否则新索引 = 旧索引 + oldCap
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        } else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 将 low 链放入新数组的旧索引位置
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    // 将 high 链放入新数组的(旧索引 + oldCap)位置
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

resize 核心逻辑

  • 扩容后容量为原来的 2 倍(保证仍是 2 的幂)。
  • 转移旧元素到新数组时,利用 hash & oldCap 判断新索引:
    • 若结果为 0,新索引 = 旧索引(low 链)。
    • 若结果非 0,新索引 = 旧索引 + 旧容量(high 链)。
    • 无需重新计算所有节点的哈希,效率更高。
  • 红黑树在转移时可能拆分为两个链表或保持树结构(若节点数不足则转链表)。
4. get() 方法

get 方法用于根据 key 获取对应的 value,流程相对简单:

java 复制代码
public V get(Object key) {
    Node<K,V> e;
    // 调用 getNode 方法,传入 key 的哈希值和 key
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    // 若数组不为空且桶索引对应的桶不为空
    if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) {
        // 检查桶中第一个节点是否匹配
        if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        // 若有后续节点
        if ((e = first.next) != null) {
            // 若桶是红黑树,调用树的查找方法
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            // 若桶是链表,遍历查找
            do {
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null; // 未找到
}

get 流程

  1. 计算 key 的哈希值和桶索引。
  2. 若桶为空,返回 null
  3. 若桶不为空:
    • 检查第一个节点是否匹配,匹配则返回。
    • 若为红黑树,调用树的查找方法。
    • 若为链表,遍历链表查找匹配节点。

五、红黑树相关(TreeNode)

JDK 8 中,TreeNode 继承自 Node,并实现了红黑树的结构(平衡二叉树的一种),用于优化长链表的查询效率。核心方法包括:

  • putTreeVal:红黑树的插入。
  • getTreeNode:红黑树的查找。
  • split:扩容时红黑树的拆分。
  • 红黑树的旋转(rotateLeftrotateRight)和着色调整,保证树的平衡。

红黑树的实现较为复杂,核心是通过 节点颜色(红/黑)旋转操作 维持树的平衡,确保查询、插入、删除的时间复杂度为 O(log n)。

六、总结与注意事项

  1. 线程不安全HashMap 是非线程安全的,多线程环境下可能导致死循环(扩容时)或数据不一致,需使用 ConcurrentHashMap 替代。
  2. 迭代器快速失败modCount 记录结构修改次数,迭代过程中若结构被修改,会抛出 ConcurrentModificationException
  3. key 的哈希值不可变 :若 key 是自定义对象,需重写 hashCode()equals(),且保证 key 的哈希值在存储期间不变(否则可能无法找到元素)。
  4. 性能影响因素:初始容量和负载因子影响性能,容量过小会导致频繁扩容,过大则浪费空间;负载因子过高会增加哈希冲突概率,过低则空间利用率低。

通过阅读源码,不仅能理解 HashMap 的工作原理,还能学习到哈希表设计、冲突解决、动态扩容等经典算法思想,以及 Java 中的优化技巧(如位运算、延迟初始化等)。建议结合调试工具,跟踪 putresize 等方法的执行过程,加深理解。

相关推荐
2501_938791226 小时前
服务器恶意进程排查:从 top 命令定位到病毒文件删除的实战步骤
java·linux·服务器
星光一影6 小时前
基于Jdk17+SpringBoot3AI智慧教育平台,告别低效学习,AI精准导学 + 新架构稳跑
java·学习·mysql
SimonKing6 小时前
Spring Boot全局异常处理的背后的故事
java·后端·程序员
ac.char6 小时前
编辑 JAR 包内嵌套的 TXT 文件(Vim 操作)
java·pycharm·vim·jar
我想进大厂6 小时前
Mybatis中# 和 $的区别
java·sql·tomcat
命运之光6 小时前
让 Jar 程序在云服务器上持续后台运行,不受终端界面关闭的影响
java·服务器·jar
乐维_lwops6 小时前
zabbix进阶教程:Jmx用户认证监控tomcat
java·tomcat·zabbix
my一阁6 小时前
tomcat web实测
java·前端·nginx·tomcat·负载均衡
m0_748231316 小时前
从企业开发到AI时代:Java的新征程与技术蜕变
java·开发语言·人工智能