乐于分享
好东西不私藏

JDK源码阅读(3) — HashMap:面试之王,一文打透

JDK源码阅读(3) — HashMap:面试之王,一文打透

JDK源码阅读(3) — HashMap:面试之王,一文打透

一、HashMap 的地位

Java 面试里如果只能选一个必问的类,那一定是 HashMap。没有之一。

为什么?因为它太巧妙了——数组 + 链表 + 红黑树,三种数据结构的组合拳。而且从 JDK 7 到 JDK 8 有了一次大重构,每次版本升级也跟着微调,深度足够问到怀疑人生。

java.lang.Object
  └── java.util.AbstractMap<K,V>
        └── java.util.HashMap<K,V>
              implements Map<K,V>, Cloneable, Serializable

二、核心数据结构——数组 + 链表 + 红黑树

2.1 静态常量——调优的起点

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {

    // 默认初始容量——16,必须是2的幂
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4// 16

    // 最大容量
    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 时优先扩容,不树化
    static final int MIN_TREEIFY_CAPACITY = 64;
}

这几个常量的数字不是瞎拍的,背后都有门道:

  • 1 << 4 而不是写 16:位运算的意图,让人一眼看出是 2 的幂
  • 0.75 的负载因子:这是空间和时间的折中。太大(比如1)则冲突多、查询慢;太小(比如0.5)则数组很多空位、内存浪费。0.75 是泊松分布下经验最优值
  • 阈值 8 和 6 不一样:中间留了 1 的缓冲,避免链表和红黑树在边界频繁来回切换

2.2 字段——核心状态

// 哈希桶数组——第一次 put 时才创建(懒加载)
transient Node<K,V>[] table;

// 缓存的 entrySet
transient Set<Map.Entry<K,V>> entrySet;

// 元素个数(不是 table 长度)
transient int size;

// 结构修改计数器——用于 fail-fast
transient int modCount;

// 扩容阈值 = table.length * loadFactor
int threshold;

// 负载因子
final float loadFactor;

2.3 节点结构——链表节点 vs 树节点

链表节点:

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;     // 哈希值(经过扰动处理)
    final K key;
    V value;
    Node<K,V> next;     // 链表下一个节点

    Node(int hash, K key, V value, Node<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }

    public final int hashCode() {
        return Objects.hashCode(key) ^ Objects.hashCode(value);
    }

    public final boolean equals(Object o) {
        if (o == thisreturn true;
        if (o instanceof Map.Entry) {
            Map.Entry<?,?> e = (Map.Entry<?,?>)o;
            // key 和 value 都相等才算相等
            if (Objects.equals(key, e.getKey()) &&
                Objects.equals(value, e.getValue()))
                return true;
        }
        return false;
    }
}

红黑树节点(只展示结构):

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;            // 红黑树颜色标记

    TreeNode(int hash, K key, V val, Node<K,V> next) {
        super(hash, key, val, next);
    }
}

TreeNode 继承自 LinkedHashMap.Entry(多了 before/after),LinkedHashMap.Entry 继承自 Node。这个继承链在后面写 LinkedHashMap 时会展开,现在先记住 TreeNode 有了 parent/left/right/red 这四个红黑树必备字段就够了。

三、核心方法深度拆解

3.1 hash()——高16位参与运算

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

这行代码太经典了。为什么要把高16位异或到低16位?

因为 HashMap 定位桶的算法是 (n - 1) & hash,n 是数组长度。当 n 比较小(比如默认16),n-1 的二进制只有低几位是1,高位全是0。如果对象的 hashCode 差异在高位而不是低位,没有扰动就会疯狂碰撞。

举个栗子,假设有两个对象的 hashCode 分别是 0xA00000000xB0000000

// n = 16 = 0x0F
// 无扰动:
0xA0000000 & 0x0F = 0x00  // 碰撞!
0xB0000000 & 0x0F = 0x00  // 碰撞!同一个桶

// 扰动后(高16位参与):
h = 0xA0000000
h >>> 16 = 0x0000A000
h ^ (h >>> 16) = 0xA000A000
0xA000A000 & 0x0F = 0x00   // 还是0,但后面会好很多

h = 0xB0000000
h >>> 16 = 0x0000B000
h ^ (h >>> 16) = 0xB000B000
0xB000B000 & 0x0F = 0x00   // 嗯...这个例子不太好,但道理就是这样

其实当 n=16 时,低位只有4位,再怎么扰动也只能影响这4位,改善有限。真正起作用的是 resize 之后,当 n 变大,原来被掩码掩盖的高位信息通过扰动参与进来,分布就更均匀了。

3.2 put()——一切的入口

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

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. 桶第一个节点就是——覆盖
        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) {
                    // 尾插法——JDK 8 改的,JDK 7 是头插
                    p.next = newNode(hash, key, value, null);
                    // 达到树化阈值时转红黑树
                    if (binCount >= TREEIFY_THRESHOLD - 1// -1 是因为 binCount 从0开始
                        treeifyBin(tab, hash);
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        // 6. 找到相同 key——替换 value
        if (e != null) {
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);  // LinkedHashMap 用到的回调
            return oldValue;
        }
    }
    ++modCount;
    // 7. 超出阈值——扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);  // LinkedHashMap 回调
    return null;
}

执行流程用一张图概括:

put(key, value)
   │
   ├── table == null? ──YES──→ resize()
   │
   └── (n-1) & hash 定位桶
         │
         ├── 桶为空 ──→ 直接放 Node
         │
         └── 桶不为空
               │
               ├── 是同一个 key(hash 相等 && equals)──→ 替换 value
               │
               ├── 桶节点是 TreeNode ──→ 红黑树插入
               │
               └── 桶节点是 Node ──→ 链表遍历
                     │
                     ├── 找到相同 key ──→ 替换 value
                     │
                     └── 到达链表尾部 ──→ 尾插
                           │
                           └── 链表长度 ≥ 8 ──→ treeifyBin()
                                 │
                                 ├── table.length ≥ 64 ──→ 树化
                                 └── table.length < 64 ──→ resize() 扩容

3.3 resize()——扩容的学问

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. 计算新容量和阈值
    if (oldCap > 0) {
        // 已达最大值,不再扩容
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 翻倍扩容——左移1位
        else if ((newCap = oldCap << 1) <= MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1;  // 阈值也翻倍
    }
    // 2. 容量为0但阈值>0(初次 put 时,带初始容量构造)
    else if (oldThr > 0)
        newCap = oldThr;
    // 3. 默认初始化
    else {
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }

    // 4. 重新计算阈值(第一次构造时)
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY
                  ? (int)ft : Integer.MAX_VALUE);
    }

    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;

    // 5. 搬数据——最关键的部分
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;  // 帮助 GC
                // 5.1 只有一个节点——直接 rehash
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                // 5.2 红黑树搬移
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                // 5.3 链表搬移——JDK 8 的高明之处
                else {
                    // 低位链表(索引不变)
                    Node<K,V> loHead = null, loTail = null;
                    // 高位链表(索引 + oldCap)
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        // 核心判断:hash & oldCap == 0
                        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;
                        }
                        e = next;
                    } while (e != null);
                    // 低位放原位,高位放 j + oldCap
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

扩容的妙处在于 hash & oldCap == 0 这个判断。因为容量翻倍,n-1 相当于多了一位 1。原来在位置 j 的元素,新的位置要么不变,要么变成 j + oldCap

举例:oldCap = 16 = 0x10000
     newCap = 32 = 0x100000

原来的 n-1 = 0x1111(低4位)
新的 n-1 = 0x11111(低5位)

对于 hash = 5 (0b00101):
  5 & oldCap(0x10000) = 0 → 留在原位(低位)
    
对于 hash = 21(0b10101):
  21 & oldCap(0x10000) = 1 → 移到 j + 16(高位)

因为 oldCap 是 2 的幂,正好是新的那一位的权重。

这样就不需要重新计算全部的 hash,只通过一次位运算就拆成了两条链。相比 JDK 7 逐个 rehash + 头插法可能产生死循环,JDK 8 这个设计安全且高效。

3.4 get()——查找

public V get(Object key) {
    Node<K,V> e;
    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) {
        // 1. 检查第一个节点
        if (first.hash == hash &&
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        // 2. 遍历下一个
        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 的逻辑对称于 put,只是不修改结构。

四、红黑树化——treeifyBin()

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    // 桶数组长度 < 64 时优先扩容
    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);  // 真正的树化操作
    }
}

注意这里:不是链表长度 8 就一定会树化。如果数组长度还不到 64,就先扩容。因为扩容可以让链表变短,比费劲搞红黑树更划算。这种权衡在实际开发中特别常见——"能不能不做复杂操作,用一种更简单的替代方案?"

五、构造方法——几个细节

// 默认构造——容量16,负载因子0.75
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
}

// 指定初始容量——会帮你对齐到2的幂
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

// 最完整的构造
public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
    this.loadFactor = loadFactor;
    // 关键是这个——tableSizeFor!
    this.threshold = tableSizeFor(initialCapacity);
}

// 把传入的容量对齐到 >= cap 的最小2的幂
// 比如 cap=7 返回 8,cap=9 返回 16
static final int tableSizeFor(int cap) {
    int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1);
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

tableSizeFor 这方法改一下写法可能更好理解:

// 等效写法:
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 + 1;
}

原理就是把最高位1后面所有的位都变成1,然后 +1 翻到下一个2的幂。这里用 Integer.numberOfLeadingZeros 只是更简洁的写法。

六、完整示例

import java.util.*;

public class HashMapDemo {
    public static void main(String[] args) {
        // 1. 基础操作
        HashMap<String, Integer> map = new HashMap<>();
        map.put("Java"1);
        map.put("Python"2);
        map.put("Go"3);
        System.out.println("基础操作: " + map);
        System.out.println("get('Java'): " + map.get("Java"));       // 1
        System.out.println("containsKey('Go'): " + map.containsKey("Go"));  // true
        
        // 2. null key——HashMap 允许一个 null key
        map.put(null0);
        System.out.println("null key: " + map.get(null));            // 0
        
        // 3. 遍历方式
        System.out.println("\n=== 遍历方式 ===");
        // 3.1 entrySet
        for (Map.Entry<String, Integer> entry : map.entrySet()) {
            System.out.println(entry.getKey() + " -> " + entry.getValue());
        }
        // 3.2 keySet + get(不推荐,每次都 O(1) 但多了次查找)
        for (String key : map.keySet()) {
            System.out.println(key + " -> " + map.get(key));
        }
        
        // 4. 指定初始容量——避免频繁扩容
        // 已知要放100个元素,负载因子0.75,100/0.75≈134,对齐到2的幂=256
        HashMap<String, String> bigMap = new HashMap<>(256);
        for (int i = 0; i < 100; i++) {
            bigMap.put("key-" + i, "value-" + i);
        }
        System.out.println("\nbigMap size: " + bigMap.size());
        
        // 5. 自定义对象作为 key——必须重写 hashCode 和 equals!
        class Person {
            String name;
            int age;
            Person(String name, int age) {
                this.name = name; this.age = age;
            }
            @Override
            public boolean equals(Object o) {
                if (this == o) return true;
                if (!(o instanceof Person)) return false;
                Person p = (Person) o;
                return age == p.age && Objects.equals(name, p.name);
            }
            @Override
            public int hashCode() {
                return Objects.hash(name, age);
            }
            @Override
            public String toString() {
                return name + "(" + age + ")";
            }
        }
        
        HashMap<Person, String> people = new HashMap<>();
        people.put(new Person("Alice"25), "Engineer");
        people.put(new Person("Bob"30), "Designer");
        // 同一个逻辑对象必须能查到
        System.out.println("Alice: " + people.get(new Person("Alice"25)));  // Engineer
    }
}

七、常见坑

7.1 自定义 key 不重写 hashCode

这是最经典的坑。不重写 hashCode 会导致两个相等的对象被分配到不同的桶,get() 永远返回 null。

class BadKey {
    String id;
    BadKey(String id) { this.id = id; }
    @Override public boolean equals(Object o) {
        return o instanceof BadKey && Objects.equals(id, ((BadKey)o).id);
    }
    // ❌ 忘记重写 hashCode
}

BadKey k1 = new BadKey("123");
BadKey k2 = new BadKey("123");
map.put(k1, "value");
map.get(k2);  // → null!因为 k1 和 k2 的 hashCode 不同!

7.2 并发修改——ConcurrentModificationException

// ❌ 遍历时修改
for (String key : map.keySet()) {
    if ("Python".equals(key)) {
        map.remove(key);  // → ConcurrentModificationException!
    }
}

// ✅ 用 Iterator
Iterator<String> it = map.keySet().iterator();
while (it.hasNext()) {
    String key = it.next();
    if ("Python".equals(key)) {
        it.remove();  // OK
    }
}

// ✅ 或者用 removeIf(Java 8+)
map.keySet().removeIf(k -> "Python".equals(k));

7.3 线程安全

HashMap 不是线程安全的。多线程并发 put 可能导致:

  • 数据丢失(两个线程同时 put 到同一个桶,一个被覆盖)
  • JDK 7 下 resize 死循环(头插法导致环形链表)
  • JDK 8 改了尾插,但数据丢失问题依然存在

替代方案:

场景 推荐
读多写少 Collections.synchronizedMap()
高并发 ConcurrentHashMap(下一篇就写)
需要额外特性 ConcurrentSkipListMap

7.4 初始容量预估

// ❌ 要放 1000 个元素
HashMap<String, String> bad = new HashMap<>();    // 默认 16
for (...) { bad.put(...); }  // 触发多次 resize——性能浪费

// ✅ 预估容量:1000 / 0.75 ≈ 1334 → 对齐到 2048
HashMap<String, String> good = new HashMap<>(2048);

简化写法:new HashMap<>(initialSize / 0.75f + 1)

八、JDK 7 vs JDK 8 对比

维度 JDK 7 JDK 8
数据结构 数组 + 链表 数组 + 链表 + 红黑树
插入方式 头插(简单) 尾插(避免死循环)
hash 扰动 4次位运算+5次异或 1次异或
扩容后搬移 逐个 rehash hash & oldCap 分高低位链
树化 链表 ≥ 8 且数组 ≥ 64
节点类型 Entry(内部类) Node + TreeNode

JDK 8 的改动主要是为了解决两个问题:

  1. 哈希碰撞导致链表过长时性能骤降 → 红黑树把 O(n) 降为 O(log n)
  2. 并发 resize 死循环 → 尾插 + 高低位链拆分

当然,HashMap 本来就没承诺线程安全,JDK 8 只是顺手修了 JDK 7 在非并发情况下的头插坑(JDK 7 非并发下 get 也可能造成死循环,因为扩容时头插改成尾插了)。

九、面试问答

Q:HashMap 的容量为什么必须是 2 的幂? A:因为 (n - 1) & hash 等效于 hash % n,但位运算比取模快得多。当 n 是 2 的幂时,n-1 的二进制全是1,位运算不会丢失信息。

Q:为什么负载因子是 0.75? A:泊松分布下的经验值。JDK 源码注释里提到,在负载因子 0.75 下,链表长度达到 8 的概率约为 0.00000006,几乎不可能发生。这是空间和时间的平衡点。

Q:什么时候触发扩容? A:size > threshold 时触发,即元素个数超过 容量 × 负载因子。扩容后容量翻倍,阈值也翻倍。

Q:为什么树化阈值是 8? A:根据泊松分布计算,在理想随机 hashCode 下,链表长度达到 8 的概率极低(千万分之六)。所以链表达不到 8 时性能可以接受,到了 8 说明 hashCode 分布很差,树化来兜底。