前言

HashMap 是 Java 面试的必考题。"HashMap 底层原理"这个问题,10 个面试 9 个会问。

但很多人只知道"数组+链表+红黑树",追问细节就答不上来:

  • 为什么负载因子是 0.75?

  • 链表转红黑树的阈值为什么是 8?

  • resize 到底做了什么?

  • put 流程你能完整说一遍吗?

本文从 JDK 1.8 源码出发,逐行拆解 HashMap 的核心逻辑。

核心数据结构

组件类型作用类比
tableNode[] 数组存储所有键值对书架的格子
Node链表节点处理哈希冲突格子里的书
TreeNode红黑树节点链表过长时优化查询格子里的索引卡
sizeint当前元素数量书的数量
thresholdint扩容阈值 = capacity × loadFactor书架满了要换大的临界点
loadFactorfloat负载因子,默认 0.75书架多满就该换
// JDK 1.8 HashMap 核心结构
public class HashMap<K,V> {
​
    // 默认初始容量:16(必须是 2 的幂)
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
​
    // 最大容量:2^30
    static final int MAXIMUM_CAPACITY = 1 << 30;
​
    // 默认负载因子:0.75
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
​
    // 链表转红黑树的阈值:链表长度 >= 8 且数组长度 >= 64
    static final int TREEIFY_THRESHOLD = 8;
​
    // 红黑树退化为链表的阈值:节点数 <= 6
    static final int UNTREEIFY_THRESHOLD = 6;
​
    // 最小树形化容量:64
    static final int MIN_TREEIFY_CAPACITY = 64;
​
    // 实际存储数组
    transient Node<K,V>[] table;
​
    // 元素数量
    transient int size;
​
    // 扩容阈值
    int threshold;
}

put 流程(完整版)

这是 HashMap 最核心的流程,面试必背:

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}
​
/**
 * put 完整流程:
 * 1. 计算 key 的 hash 值
 * 2. 如果 table 为空,调用 resize() 初始化
 * 3. 用 hash & (n-1) 定位数组下标
 * 4. 如果该位置为空,直接插入新 Node
 * 5. 如果该位置有元素:
 *    a. key 相同 → 覆盖 value
 *    b. 是 TreeNode → 红黑树插入
 *    c. 是链表 → 尾插法插入,链表长度 >= 8 且数组 >= 64 → 转红黑树
 * 6. 插入后 size++,如果超过 threshold → resize() 扩容
 */
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 为空则初始化
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
​
    // 步骤 2:定位下标,如果为空直接插入
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
​
        // 步骤 3:key 相同,覆盖
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
​
        // 步骤 4:如果是红黑树节点
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
​
        // 步骤 5:链表遍历
        else {
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);  // 尾插法
                    if (binCount >= TREEIFY_THRESHOLD - 1)
                        treeifyBin(tab, hash);  // 链表长度 >= 8,尝试转红黑树
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
​
        // 覆盖旧值
        if (e != null) {
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            return oldValue;
        }
    }
​
    ++modCount;
​
    // 步骤 6:超过阈值则扩容
    if (++size > threshold)
        resize();
​
    return null;
}

💡 关键区别:JDK 1.7 用头插法(多线程死循环),JDK 1.8 改为尾插法,解决了并发扩容的死循环问题。

hash 计算原理

HashMap 不直接用 hashCode(),而是做二次扰动,减少哈希冲突:

/**
 * hash 扰动函数
 * 高 16 位与低 16 位异或
 * 让高位也参与下标计算,减少冲突
 */
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
​
// 下标计算:hash & (n-1)
// 等价于 hash % n(但位运算更快)
// 前提:n 必须是 2 的幂

为什么容量必须是 2 的幂?

容量n-1(二进制)& 运算效果
160000 1111取 hash 低 4 位
320001 1111取 hash 低 5 位
640011 1111取 hash 低 6 位

2 的幂 - 1 的二进制全是 1,这样 hash & (n-1) 等价于取模,且分布均匀。

扩容机制(resize)

/**
 * 扩容核心逻辑:
 * 1. 新容量 = 旧容量 × 2
 * 2. 新阈值 = 新容量 × loadFactor
 * 3. 创建新数组
 * 4. 重新分配所有元素(核心优化:不用重新 hash)
 */
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) {
        newCap = oldCap << 1;  // 容量翻倍
        newThr = oldThr << 1;  // 阈值翻倍
    } else {
        newCap = DEFAULT_INITIAL_CAPACITY;  // 16
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);  // 12
    }
​
    Node<K,V>[] newTab = new Node[newCap];
​
    // 重新分配元素(JDK 1.8 优化)
    // 不用重新 hash,只需要 hash & oldCap 判断新位置
    // 要么在原位置,要么在原位置 + oldCap
    for (int j = 0; j < oldCap; ++j) {
        Node<K,V> e;
        if ((e = oldTab[j]) != null) {
            oldTab[j] = null;
            if (e.next == null)
                newTab[e.hash & (newCap - 1)] = e;  // 单节点直接放
            else {
                // 链表/红黑树拆分到两个位置
                // ...(省略详细代码)
            }
        }
    }
​
    table = newTab;
    threshold = newThr;
    return newTab;
}

💡 扩容优化:JDK 1.8 不重新计算 hash,而是通过 hash & oldCap 判断元素在新数组的位置,要么原位,要么偏移 oldCap,效率翻倍。

红黑树转换

条件动作
链表长度 >= 8 数组长度 >= 64链表 → 红黑树
链表长度 >= 8 数组长度 < 64先扩容,不转树
红黑树节点数 <= 6红黑树 → 链表

为什么阈值是 8?

泊松分布计算:在负载因子 0.75 的情况下,一个桶中链表长度达到 8 的概率约为 0.00000006(千万分之六)。超过 8 的概率更低,所以转红黑树是极少数情况下的优化。

/**
 * 链表转红黑树
 * 前提:tab.length >= 64,否则只是扩容
 */
final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
​
    // 数组太小先扩容,不急着转树
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        TreeNode<K,V> hd = null, tl = null;
        // 链表 → 红黑树
        do {
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }
}

get 流程

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}
​
/**
 * get 流程:
 * 1. 计算 hash
 * 2. 定位数组下标
 * 3. 如果第一个节点匹配 → 返回
 * 4. 如果是红黑树 → 树查找 O(logN)
 * 5. 如果是链表 → 遍历查找 O(N)
 */
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;
}

面试高频问题

Q: HashMap 和 HashTable 的区别?

A:

对比项HashMapHashTable
线程安全❌ 不安全✅ synchronized
null 键值✅ 允许 1 个 null key❌ 不允许
初始容量1611
扩容方式×2×2 + 1
推荐✅ 单线程/配合 ConcurrentHashMap❌ 已过时

Q: 为什么 HashMap 线程不安全?

A: JDK 1.7 头插法导致并发扩容死循环。JDK 1.8 虽然改为尾插法,但 put 操作本身不是原子的,并发仍会丢数据。多线程用 ConcurrentHashMap。

Q: 负载因子为什么是 0.75?

A: 时间和空间的折中。太小(如 0.5)→ 浪费空间;太大(如 1.0)→ 冲突增多,查询变慢。0.75 是经过大量测试得出的最优值。

Q: 容量为什么建议设为 2 的幂?

A: hash & (n-1) 等价于 hash % n,但位运算更快。如果不是 2 的幂,hash 分布不均匀,冲突增多。

Q: HashMap 的 key 可以是自定义对象吗?

A: 可以,但必须同时重写 hashCode()equals()。否则两个"相同"的对象会被当作不同的 key。

总结

  • 数据结构:数组 + 链表 + 红黑树(JDK 1.8)

  • put 流程:hash → 定位 → 空则插入 → 非空则判断 key/树/链表 → 扩容

  • 扩容:容量翻倍,元素要么原位要么偏移 oldCap

  • 红黑树:链表 >= 8 且数组 >= 64 时转换,概率极低

  • 线程不安全:多线程用 ConcurrentHashMap

  • 面试关键:能手写 put 流程 + 说清扩容机制 = 稳了

  • 实战建议:预估元素数量,提前指定初始容量(如 new HashMap<>(1000)),避免频繁扩容

参考

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐