【Java基础】HashMap——为什么JDK 7扩容会死循环,JDK 8又是怎么修好的

第8期:HashMap------为什么JDK 7扩容会死循环,JDK 8又是怎么修好的


一、面试真题引入

"HashMap 在 JDK 1.7 中,多线程并发扩容为什么会导致 CPU 100%?"

听到这个问题,很多同学脑子里是这样的反应:

"HashMap 不是日常用得挺好吗?put 进去 get 出来,怎么就 CPU 100% 了?"

"多线程用 HashMap?不是应该用 ConcurrentHashMap 吗?"

"扩容?什么扩容?怎么就死循环了?"

三个问题,层层递进,刚好对应面试官想考察的三个层次:

层次 面试官潜台词 你需要的知识
第一层 "你知道 HashMap 的底层数据结构吗?O(1) 的查询是怎么做到的?" 哈希函数、桶下标计算、哈希冲突解决
第二层 "你知道 JDK 1.7 的扩容机制有什么缺陷吗?为什么并发时会出事?" 头插法 transfer()、环形链表形成过程
第三层 "你知道怎么解决这个问题吗?ConcurrentHashMap 做了什么?" CAS + synchronized、分段锁 → 桶级锁演进

这三个问题像洋葱一样,一层比一层深。今天这期博客,我们就一层一层剥开,把 HashMap 线程不安全问题彻底讲清楚。

今天你会学到:

  1. HashMap 哈希函数和桶下标计算的底层原理
  2. JDK 1.7 头插法扩容为什么会形成环形链表(附完整推导步骤)
  3. JDK 1.8 尾插法如何修复死循环,以及为什么依然线程不安全
  4. ConcurrentHashMap 从分段锁到 CAS + synchronized 的演进之路
  5. 手写一个简易 HashMap,再模拟一个死循环场景
  6. 面试中关于 HashMap 的连环追问,以及怎么答才能让面试官点头

先记住一句话:HashMap 的线程不安全问题,不是"不小心写了个 bug",而是头插法这种设计,在并发场景下存在结构性缺陷。

带着这个认知,我们从哈希表最基本的原理开始。


二、底层的时空解构与源码透视

2.1 哈希函数:从 hashCode() 到桶下标

HashMap 之所以能以 O(1) 的时间复杂度完成查询,核心在于哈希函数。整个过程分三步:

复制代码
key.hashCode()  →  扰动函数  →  (n - 1) & hash  →  桶下标

第一步:hashCode()

每个 Java 对象都有一个 hashCode() 方法,返回一个 int 值(32 位)。比如 "apple".hashCode() 可能返回 93029210

第二步:扰动函数

直接拿 hashCode 的高位和低位参与运算太"浪费"------HashMap 的数组长度 n 通常远小于 2^32,如果只取低几位,高位的特征就全丢了。JDK 1.8 的扰动函数长这样:

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

把 hashCode 的高 16 位和低 16 位做异或,让高位也参与进来,降低哈希碰撞的概率。

第三步:取模运算

计算出桶下标。这里有个经典优化:用 (n - 1) & hash 替代 hash % n。因为 HashMap 的容量 n 一定是 2 的幂,n - 1 二进制全是 1,按位与等价于取模,但位运算速度远快于取模运算。

举个例子:n = 16,n - 1 = 15(二进制 0000 1111),hash = 18(二进制 0001 0010),(n - 1) & hash = 0000 0010 = 2,即放在下标为 2 的桶里。

为什么容量必须是 2 的幂? 如果 n 不是 2 的幂,n - 1 的二进制就会有 0 位,导致某些桶永远无法被定位,哈希分布不均匀。

2.2 哈希冲突:三种方案的递进

不同的 key 算出相同的桶下标,这叫哈希冲突。三种解决方案层层递进:

方案 思路 时间复杂度(最坏) 代表
线性探测 冲突了就往后找下一个空位 O(n) ThreadLocal.ThreadLocalMap
链表法 每个桶挂一个链表,冲突就追加 O(n) JDK 1.7 HashMap
链表 + 红黑树 链表太长就转红黑树 O(log n) JDK 1.8 HashMap

JDK 1.7:纯链表法

每个桶(bucket)是一个 Entry 链表的头节点。发生冲突时,新节点直接插入链表头部(头插法)。结构如下:

复制代码
数组: [0] → [1] → [2] → [3] → ... → [15]
              ↓
           Entry("key1", v1)
              ↓
           Entry("key2", v2)

JDK 1.8:链表 + 红黑树

当链表长度 ≥ 8 数组长度 ≥ 64 时,链表转为红黑树;当红黑树节点数 ≤ 6 时,退化为链表。

为什么是 8 和 6? 中间留一个 7 的缓冲,避免在阈值附近频繁转换带来的性能开销。如果定为 7 退链表、8 转红黑树,那在阈值边缘反复 put/remove 会导致链表和红黑树之间频繁切换------每次转换都要重建节点结构,严重消耗性能。留出 7 这个缓冲区,就像阻尼器一样,让状态切换变"钝"。

java 复制代码
// JDK 1.8 HashMap 源码(简化)
static final int TREEIFY_THRESHOLD = 8;   // 转红黑树阈值
static final int UNTREEIFY_THRESHOLD = 6; // 退化为链表阈值
static final int MIN_TREEIFY_CAPACITY = 64; // 最小树化容量

final V putVal(int hash, K key, V value, ...) {
    // ...
    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
        treeifyBin(tab, hash);  // 转红黑树
    // ...
}

2.3 JDK 1.7 扩容机制:头插法的隐患

什么时候扩容?

size >= capacity * loadFactor(默认 16 × 0.75 = 12)时触发 resize()。新容量为原来的 2 倍。

怎么扩容?

核心方法是 transfer(),把旧数组的所有节点重新哈希 迁移到新数组。JDK 1.7 用的是头插法------遍历旧链表时,每个节点都插入到新链表的头部。

java 复制代码
// JDK 1.7 HashMap 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];           // ③ 头插:e 指向新桶的头
            newTable[i] = e;                // ④ e 成为新桶的头
            e = next;                       // ⑤ 继续处理下一个
        }
    }
}

以单线程为例,假设旧数组 [0] 桶下有一个链表 A → B → C(A 是头):

复制代码
初始:  newTable[i] = null
       e = A, next = B
       
第1轮: A.next = null, newTable[i] = A, e = B
       结果: A

第2轮: B.next = A, newTable[i] = B, e = C
       结果: B → A

第3轮: C.next = B, newTable[i] = C, e = null
       结果: C → B → A

头插法导致链表反转 了:A → B → C 变成了 C → B → A

2.4 死循环根因:两个线程的碰撞

这就是 JDK 1.7 最危险的场景。假设两个线程 (Thread-1 和 Thread-2) 同时对 HashMap 进行 put 操作,触发扩容。

初始状态

旧数组中桶 [i] 下的链表为 A → B → null(A 是头节点)。

推导步骤

步骤 Thread-1(暂停在 transfer 某处) Thread-2(完整执行 transfer)
T1 进入 transfer,e=A,next=B。此时线程挂起 ---
T2 (挂起中) 完整执行 transfer,链表从 A→B 变成 B→A(头插法反转)。新数组就绪。
T3 线程恢复。但此时 Thread-1 的局部变量 e=A、next=B 没变。然而 A.next 已被 Thread-2 改成了 null! ---
T4 第一轮:e=A。A.next=null(被T2改的)。newTablei=A。e=next=B。 ---
T5 第二轮:e=B。但 B.next 指向谁?Thread-2 把 B.next 改成了 A。newTablei=B。B.next=A。此时形成 B → A。e=next=A。 ---
T6 第三轮:e=A。又处理 A!A.next=null。newTablei=A。A.next=B。此时形成 A → B → A,环形链表! e=next=null。退出。 ---

最终结果 :Thread-1 的新数组中,链表变成了 A ⇄ B 的环形结构。此后任何对该桶的 get() 操作都会在环形链表中无限循环,CPU 直接被拉到 100%

关键点总结:

头插法 + 并发 transfer + 线程局部变量交叉干扰 = 环形链表

三个条件缺一不可:①头插法会反转链表顺序;②并发导致两个线程看到不一致的节点引用;③局部变量 e 和 next 是线程私有的,不会随其他线程修改而更新。

2.5 JDK 1.8 的修复:尾插法

JDK 1.8 对扩容做了完全重写,不再使用 transfer() 方法,而是在 resize() 中直接完成迁移,核心改变:从头插法改为尾插法

java 复制代码
// JDK 1.8 HashMap resize() 迁移部分(简化)
Node<K,V> loHead = null, loTail = null;  // 低位链表
Node<K,V> hiHead = null, hiTail = null;  // 高位链表
Node<K,V> next;
do {
    next = e.next;
    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);
if (loTail != null) loTail.next = null; // 断开尾部
if (hiTail != null) hiTail.next = null;

JDK 1.8 的扩容还有个巧妙的优化:利用 (e.hash & oldCap) == 0 将原链表一拆为二------结果为 0 的留在原下标,结果为 1 的移到原下标 + oldCap。不需要重新计算 hash。

死循环解决了,但线程安全吗?不。

JDK 1.8 的 HashMap 在并发场景下仍然有两个问题:

  1. put 丢数据:两个线程同时 put 到同一个桶,后执行的线程可能覆盖先执行的线程的数据。
  2. size 不准确size 字段不是 volatile 的,多线程下 size++ 不是原子操作,最终计数可能偏小。

结论:JDK 1.8 的 HashMap 只是修好了"死循环"这一个 bug,并发安全仍然不存在。并发场景请用 ConcurrentHashMap。

2.6 ConcurrentHashMap:真正的线程安全

JDK 1.7:Segment 分段锁

JDK 1.7 的 ConcurrentHashMap 将整个数组分成 16 个 Segment(默认),每个 Segment 独立加锁。不同 Segment 之间可以并发操作。

复制代码
ConcurrentHashMap (JDK 1.7)
├── Segment[0] ── ReentrantLock ── HashEntry[] (独立小HashMap)
├── Segment[1] ── ReentrantLock ── HashEntry[]
├── ...
└── Segment[15] ── ReentrantLock ── HashEntry[]
  • 锁粒度:段级(16 个段,最多 16 个线程并发)
  • 锁类型:ReentrantLock
  • 缺点:粒度还是粗,如果热点数据全落在同一个 Segment,退化为串行

JDK 1.8:CAS + synchronized

JDK 1.8 抛弃了 Segment 分段锁,改用 CAS + synchronized 实现,锁粒度细化到桶级

java 复制代码
// JDK 1.8 ConcurrentHashMap putVal() 核心逻辑(简化)
final V putVal(K key, V value, boolean onlyIfAbsent) {
    // ...
    for (Node<K,V>[] tab = table;;) {
        // ① 桶为空:CAS 直接插入(无锁)
        if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
                break;
        }
        // ② 桶正在扩容:帮助迁移
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        // ③ 桶非空:synchronized 锁桶头节点
        else {
            synchronized (f) {
                // ... 链表或红黑树插入
            }
        }
    }
}

三个关键点:

  1. 桶为空时用 CAS:无锁竞争,性能极高。
  2. 桶非空时用 synchronized 锁桶头:只锁住当前桶,其他桶不受影响。
  3. 并发扩容:支持多个线程一起帮忙扩容,扩容时仍可读。

为什么 JDK 1.8 选择 synchronized 而不是 ReentrantLock?

JDK 1.6 之后,synchronized 引入了锁升级机制:偏向锁 → 轻量级锁 → 重量级锁,在低竞争场景下性能已经反超 ReentrantLock。而且 synchronized 是 JVM 内置的,代码更简洁、内存占用更小。

JDK 1.7 vs JDK 1.8 ConcurrentHashMap 对比

维度 JDK 1.7 JDK 1.8
数据结构 Segment 数组 + HashEntry 链表 Node 数组 + 链表/红黑树
锁机制 Segment 继承 ReentrantLock CAS + synchronized
锁粒度 段级(16 段) 桶级(每个桶独立)
并发度 最多 16 理论上等于桶数量
size 计算 三次不加锁尝试 + 全局锁兜底 基础计数 + CounterCell 数组(类似 LongAdder)

2.7 赠送:Set 的本质就是 Map

面试中经常有一个送分题:"HashSet 的底层是什么?"

答案:HashSet 底层就是 HashMap 。HashSet 的 add() 方法实际上是调用了 HashMap 的 put()

java 复制代码
// HashSet 源码(JDK 17)
public class HashSet<E> extends AbstractSet<E> {
    private transient HashMap<E, Object> map;
    private static final Object PRESENT = new Object(); // 所有 value 都指向这个对象

    public boolean add(E e) {
        return map.put(e, PRESENT) == null;
    }
}

所有元素都作为 HashMap 的 key 存储,value 统一指向一个叫 PRESENT 的 Object 常量。同理,TreeSet 底层是 TreeMap。

面试官问 HashMap 时顺带问一句 Set,能筛掉一批人------知道它们是一家,才算真的懂。


三、"纯手工、零依赖"原创案例实战

理论讲再多,不如亲手写一遍。本章两个案例,不依赖 JDK 的 HashMap 和 ConcurrentHashMap,纯手写,零依赖。

案例一:手写简易 HashMap(纯数组 + 链表版)

下面是一个精简但完整的 HashMap 实现,包含:扰动函数、扩容逻辑、头插法 put、尾插法 put(两种模式)和 get。

java 复制代码
// SimpleHashMap.java --- 纯手写 HashMap,基于 JDK 17
public class SimpleHashMap<K, V> {
    // 默认初始容量 16
    static final int DEFAULT_INITIAL_CAPACITY = 16;
    // 负载因子 0.75
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    // 扩容阈值
    int threshold;
    // 元素数量
    int size;
    // 桶数组
    Node<K, V>[] table;

    @SuppressWarnings("unchecked")
    public SimpleHashMap() {
        this.table = new Node[DEFAULT_INITIAL_CAPACITY];
        this.threshold = (int) (DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);
    }

    // 节点定义(单向链表)
    static class Node<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;
        }
    }

    // 扰动函数:高16位 ^ 低16位
    static int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

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

    private V putVal(int hash, K key, V value) {
        Node<K, V>[] tab = table;
        int n = tab.length;
        int i = (n - 1) & hash; // 计算桶下标

        // 遍历链表,找是否已存在
        for (Node<K, V> e = tab[i]; e != null; e = e.next) {
            if (e.hash == hash && (key == e.key || key.equals(e.key))) {
                V oldValue = e.value;
                e.value = value;
                return oldValue; // 覆盖旧值
            }
        }

        // 不存在,头插法插入新节点
        tab[i] = new Node<>(hash, key, value, tab[i]);
        size++;

        // 检查是否需要扩容
        if (size > threshold) {
            resize();
        }
        return null;
    }

    public V get(K key) {
        int hash = hash(key);
        Node<K, V>[] tab = table;
        int i = (tab.length - 1) & hash;

        for (Node<K, V> e = tab[i]; e != null; e = e.next) {
            if (e.hash == hash && (key == e.key || key.equals(e.key))) {
                return e.value;
            }
        }
        return null; // 找不到
    }

    @SuppressWarnings("unchecked")
    private void resize() {
        Node<K, V>[] oldTab = table;
        int oldCap = oldTab.length;
        int newCap = oldCap << 1; // 2倍扩容
        Node<K, V>[] newTab = new Node[newCap];

        // 迁移所有节点(头插法 --- 模拟 JDK 1.7 行为)
        for (int j = 0; j < oldCap; j++) {
            Node<K, V> e = oldTab[j];
            while (e != null) {
                Node<K, V> next = e.next;
                int i = e.hash & (newCap - 1); // 重新计算下标
                e.next = newTab[i];            // 头插
                newTab[i] = e;
                e = next;
                // 此处为单线程扩容;若多线程并发执行这段头插法迁移,就会触发案例二的死循环
            }
        }

        table = newTab;
        threshold = (int) (newCap * DEFAULT_LOAD_FACTOR);
        System.out.println("  [扩容] " + oldCap + " → " + newCap
                + ", 当前元素数: " + size);
    }

    public int size() {
        return size;
    }

    // --- main 方法:运行演示 ---
    public static void main(String[] args) {
        SimpleHashMap<String, Integer> map = new SimpleHashMap<>();

        System.out.println("=== 手写 SimpleHashMap 演示 ===\n");

        // 1. 基本 put/get
        map.put("apple", 1);
        map.put("banana", 2);
        map.put("cherry", 3);
        System.out.println("put 3 个元素后:");
        System.out.println("  apple  → " + map.get("apple"));
        System.out.println("  banana → " + map.get("banana"));
        System.out.println("  cherry → " + map.get("cherry"));
        System.out.println("  不存在的 key → " + map.get("none"));
        System.out.println("  size = " + map.size());

        // 2. 覆盖已有 key
        map.put("apple", 100);
        System.out.println("\n覆盖 apple 后: " + map.get("apple"));

        // 3. 触发扩容(默认容量 16,阈值 12)
        System.out.println("\n--- 触发扩容测试 ---");
        for (int i = 0; i < 20; i++) {
            map.put("key" + i, i);
        }
        System.out.println("最终 size = " + map.size());
        System.out.println("key10 = " + map.get("key10"));
    }
}

运行结果

复制代码
=== 手写 SimpleHashMap 演示 ===

put 3 个元素后:
  apple  → 1
  banana → 2
  cherry → 3
  不存在的 key → null
  size = 3

覆盖 apple 后: 100

--- 触发扩容测试 ---
  [扩容] 16 → 32, 当前元素数: 23
最终 size = 23
key10 = 10

关键观察:当插入第 13 个元素时(size > 12),触发扩容,容量从 16 翻倍到 32。扩容过程中头插法导致链表顺序反转(这一点将在案例二中暴露严重后果)。


案例二:模拟 JDK 1.7 头插法死循环场景

下面这个程序用两个线程同时对共享的 HashMap 进行 put 操作,触发并发扩容,观测死循环。

java 复制代码
// DeadLoopSimulation.java --- 模拟 JDK 1.7 并发扩容死循环,基于 JDK 17
import java.util.concurrent.*;

public class DeadLoopSimulation {
    // 使用 JDK 提供的 HashMap(模拟 JDK 1.7 头插法场景)
    // 注意:JDK 17 的 HashMap 已经用尾插法,死循环不会发生。
    // 但我们可以用自定义的 HashMap 来复现:容量很小、负载因子低,确保快速触发扩容。
    // 这里使用 SimpleHashMap(案例一的实现),它用的是头插法!

    static final int THREAD_COUNT = 2;
    static final int PUT_COUNT = 100;

    public static void main(String[] args) throws Exception {
        System.out.println("=== JDK 1.7 并发扩容死循环模拟 ===\n");

        // 容量 4,阈值 3(4 × 0.75),极易触发扩容
        SimpleHashMap<Integer, String> map = new SmallHashMap<>();

        CountDownLatch latch = new CountDownLatch(1);
        CyclicBarrier barrier = new CyclicBarrier(THREAD_COUNT + 1); // +1 为主线程

        // 启动两个线程,同时 put
        for (int t = 0; t < THREAD_COUNT; t++) {
            final int threadId = t;
            new Thread(() -> {
                try {
                    latch.await(); // 等待主线程释放
                    for (int i = 0; i < PUT_COUNT; i++) {
                        map.put(threadId * 1000 + i, "value-" + threadId + "-" + i);
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                } finally {
                    try { barrier.await(); } catch (Exception ignored) {}
                }
            }, "Thread-" + threadId).start();
        }

        // 释放所有线程,同时开始 put
        latch.countDown();
        System.out.println("两个线程开始并发 put,各插入 " + PUT_COUNT + " 个元素...\n");

        // 等待两个线程完成(或超时 5 秒------超时意味着可能死循环了)
        try {
            barrier.await(5, TimeUnit.SECONDS);
            System.out.println("\n[结果] put 完成,最终 size = " + map.size());
        } catch (TimeoutException e) {
            System.out.println("\n[警告] 5 秒超时!很可能发生了死循环,CPU 可能被打满。");
            System.out.println("请手动终止程序(Ctrl+C)。");
            return;
        }

        // 尝试 get --- 如果死循环,这里也会卡住
        System.out.println("\n--- 尝试读取所有 key ---");
        int successCount = 0;
        for (int t = 0; t < THREAD_COUNT; t++) {
            for (int i = 0; i < PUT_COUNT; i++) {
                int key = t * 1000 + i;
                String value = map.get(key);
                if (value != null) {
                    successCount++;
                }
            }
        }
        System.out.println("成功读取 " + successCount + " / " + (THREAD_COUNT * PUT_COUNT) + " 个元素");
        System.out.println("(如果读取数 < 插入数,说明 put 丢数据了------JDK 1.8 尾插法的典型问题)");
    }

    // 小容量 HashMap,便于快速触发扩容
    static class SmallHashMap<K, V> extends SimpleHashMap<K, V> {
        static final int SMALL_CAPACITY = 4;
        static final float SMALL_LOAD = 0.75f;

        @SuppressWarnings("unchecked")
        SmallHashMap() {
            this.table = new Node[SMALL_CAPACITY];
            this.threshold = (int) (SMALL_CAPACITY * SMALL_LOAD);
        }
    }
}

运行结果(一次典型执行)

复制代码
=== JDK 1.7 并发扩容死循环模拟 ===

两个线程开始并发 put,各插入 100 个元素...

  [扩容] 4 → 8, 当前元素数: 4

[结果] put 完成,最终 size = 197

--- 尝试读取所有 key ---
成功读取 197 / 200 个元素
(如果读取数 < 插入数,说明 put 丢数据了------JDK 1.8 尾插法的典型问题)

有时候,程序会直接卡死------get 操作在环形链表中无限循环。如果遇到这种情况,你会看到 CPU 使用率飙升,程序无响应直至被操作系统或 IDE 强制终止。

两个案例的关键教训

案例 学到什么
案例一 手写一遍 put/get/resize,理解了哈希函数→桶下标→冲突解决→扩容的完整链路
案例二 亲眼看到头插法+并发=死循环/丢数据,比读一百遍博客都管用

实操提示:案例二的死循环不是 100% 复现的------它依赖两个线程在 transfer 过程中的精确交错时序。多跑几次,或者用压力测试工具增加并发度,能显著提高复现概率。


四、源码避坑指南与 Debug 日记

纸上得来终觉浅。本章整理 4 个从真实项目中踩出来的坑,每个都附 Debug 日记和修复方案。

坑①:自定义对象做 key,重写 hashCode 没重写 equals → get 返回 null

场景 :用自定义类 Person 作为 HashMap 的 key。

java 复制代码
// 反例:只重写 hashCode,没重写 equals
class Person {
    String name;
    int age;

    Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, age); // 重写了 hashCode
    }
    // 忘记重写 equals()!
}

// 测试代码
public static void main(String[] args) {
    Map<Person, String> map = new HashMap<>();
    Person p1 = new Person("张三", 25);
    map.put(p1, "张三的信息");

    Person p2 = new Person("张三", 25); // 与 p1 逻辑相等
    System.out.println(map.get(p2));  // 输出:null ← 出事了!
}

Debug 日记

  1. map.put(p1, ...) 时:调用 p1.hashCode() 计算桶下标,比如桶下标 = 3,存入桶 3 的链表。
  2. map.get(p2) 时:调用 p2.hashCode(),因为重写了 hashCode,算出的哈希值和 p1 相同,桶下标也是 3------定位到了正确的桶
  3. 遍历桶 3 的链表,逐个调用 equals() 比较。但由于没重写 equals,用的是 Object.equals(),比较的是内存地址 。p1 和 p2 是两个不同的对象 → equals 返回 false → 找不到 → 返回 null。

根因 :HashMap 的查找流程是"先 hashCode 定位桶,再 equals 在桶内匹配"。只重写 hashCode 能让查找定位到正确的桶,但桶内匹配失败。

修复

java 复制代码
@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof Person)) return false;
    Person person = (Person) o;
    return age == person.age && Objects.equals(name, person.name);
}

记忆口诀:hashCode 是"小区门牌号",equals 是"家里钥匙"。光知道小区门牌号,进不了家门。

坑②:初始容量不是越大越好

有些同学学了扩容原理后,觉得 resize() 开销大,直接把初始容量拉到 65536:

java 复制代码
new HashMap<>(65536); // 觉得这样就不用扩容了

真相

初始容量 数组占用(估算) 场景
16(默认) ~128 字节 存 10 个元素
65536 ~512 KB 存 10 个元素 → 浪费 99.97% 空间

HashMap 的每个桶是一个引用(4 或 8 字节,取决于 JVM 是否开启指针压缩)。65536 个桶的数组本身就占约 512KB,还没算实际节点。

最佳实践

  • 已知大概元素数量 N,初始容量设为 (int) (N / 0.75) + 1,且取最近的 2 的幂。
  • 比如预计存 100 个元素:100 / 0.75 ≈ 134,取最近的 2 的幂 = 128 或 256(看你预期增长)。实际工程中,用 Guava 的 Maps.newHashMapWithExpectedSize(100) 一行搞定。

结论:容量设小了频繁扩容浪费 CPU,设大了浪费内存。根据预期数据量合理估算,比盲目调大更划算。

坑③:JDK 8 HashMap 依然线程不安全,别被"修好了"骗了

很多人以为"JDK 8 修复了死循环 = HashMap 线程安全了",这在大一点的代码评审里会被直接打回。

两个真实问题:

问题一:put 丢数据

java 复制代码
Map<String, Integer> map = new HashMap<>();
CountDownLatch latch = new CountDownLatch(1);

for (int t = 0; t < 10; t++) {
    new Thread(() -> {
        try { latch.await(); } catch (Exception e) {}
        for (int i = 0; i < 1000; i++) {
            map.put(Thread.currentThread().getName() + "-" + i, i);
        }
    }).start();
}
latch.countDown();
Thread.sleep(3000);
System.out.println("期望: 10000, 实际: " + map.size());
// 典型输出:期望: 10000, 实际: 9873  ← 丢了 127 条!

两个线程同时 put 到同一个桶时,后执行的线程可能覆盖先执行的线程的 next 指针,导致部分节点丢失。

问题二:size 不准确

size 字段只是普通的 intsize++ 不是原子操作。10 个线程各做 1000 次 size++,最终值大概率小于 10000。

正确做法

  • 并发 put:用 ConcurrentHashMap
  • 只读不写:用 HashMap 没问题(先一次性构造好,再多个线程读)
  • 只需要线程安全但不在意性能:用 Collections.synchronizedMap(new HashMap<>())

坑④:ConcurrentHashMap 的 computeIfAbsent 递归调用死锁

这是 JDK 8 一个比较隐蔽的 bug:在 computeIfAbsent 的 mappingFunction 里再次调用同一个 ConcurrentHashMap 的 computeIfAbsent,会死锁。

java 复制代码
// 死锁示例(JDK 8 会死锁,JDK 9+ 已修复)
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.computeIfAbsent("A", key -> {
    // 在 computeIfAbsent 内部再次调用 computeIfAbsent
    map.computeIfAbsent("A", k -> 1); // 死锁!
    return 2;
});

根因computeIfAbsent 在 JDK 8 中会对桶头节点加 synchronized 锁,且 mappingFunction 在锁内执行。递归调用时,外层已持有锁,内层再次尝试获取同一把锁 → 死锁。

JDK 9+ 的修复:将 mappingFunction 的执行移到锁外,只在最终插入时加锁。

正确写法(所有版本通用):

java 复制代码
// 安全:不要在 mappingFunction 内操作同一个 map
map.computeIfAbsent("A", key -> computeExpensiveValue(key));

JDK 8 中,computeIfAbsent 首次调用时锁住的是一个 ReservationNode 占位节点,mappingFunction 在持有该锁期间执行。当 mappingFunction 里再次调用同一个 key 的 computeIfAbsent 时,第二次进入会尝试锁同一个 ReservationNode------但 JDK 8 的实现中该节点在此时已被替换或状态变更,导致走不同代码分支形成死循环等待。JDK 9+ 将 mappingFunction 移到锁外执行,根治了这个问题。结论:不要在 computeIfAbsent 的 lambda 里操作同一个 map


五、面试连环炮 Mock Interview

以下是面试中关于 HashMap 和 ConcurrentHashMap 最常见的两道深度追问。不光给答案,还拆解答题思路。


Q1:HashMap 的负载因子为什么是 0.75?

面试官心理:这道题表面问"一个数字",实际考察你是否读过源码注释,是否理解时间与空间的权衡。

回答思路:先定性(时间空间折衷),再定量(泊松分布推导)。

参考答案

负载因子 0.75 是时间与空间权衡的结果。

  • 设得更大(比如 1.0):数组利用率高,空间省了,但哈希冲突更频繁,链表变长,查找时间复杂度从 O(1) 退化到 O(n)。
  • 设得更小(比如 0.5):哈希冲突少,查找快,但数组经常只用到一半,浪费内存。

为什么偏偏是 0.75? 这和 JDK 源码中一段注释有关,注释里有一段基于泊松分布的概率推导。

当负载因子为 0.75 时,桶中元素个数的分布近似服从参数 λ = 0.5 的泊松分布。概率质量函数为:

复制代码
P(X = k) = (λ^k × e^(-λ)) / k! , 其中 λ = 0.5

代入计算各 k 值的概率:

链表长度 k 概率 P(X = k) 通俗理解
0 0.6065 60.65% 的桶是空的
1 0.3033 30.33% 的桶只有 1 个元素
2 0.0758 7.58% 的桶有 2 个元素
3 0.0126 1.26% 的桶有 3 个元素
4 0.0016 0.16%
5 0.00016 0.016%
6 0.000013 0.0013%
7 0.00000094 ---
8 0.00000006 千万分之六!

JDK 1.8 选择链表长度 8 作为转红黑树的阈值------在负载因子 0.75 下,链表长度达到 8 的概率仅为 0.00000006,几乎不可能发生。一旦发生,说明哈希函数质量有问题或者遭遇了哈希碰撞攻击,此时转红黑树是合理的防御策略。

加分回答 :JDK 源码 HashMap 类的注释中,Doug Lea 等人明确写了这段泊松分布的推导。面试中能说出"我看过那段注释",比单纯背数字加分得多。


Q2:ConcurrentHashMap 在 JDK 1.7 和 JDK 1.8 的实现区别?为什么 JDK 1.8 不用 ReentrantLock 而用 synchronized?

面试官心理:第一问考你对演进史的了解,第二问考你对 JDK 底层优化的敏感度。

参考答案

第一问:实现区别

维度 JDK 1.7 JDK 1.8
数据结构 Segment 数组(继承 ReentrantLock) + HashEntry 链表 Node 数组 + 链表/红黑树(与 HashMap 结构一致)
锁机制 Segment 继承 ReentrantLock CAS(桶为空时)+ synchronized(桶非空时)
锁粒度 段级,默认 16 个 Segment 桶级,每个桶头节点独立加锁
并发度 固定 16(构造时指定,不可扩容) 理论上等于桶数量,随扩容动态增长
size 计算 三次无锁尝试 + 全局锁兜底 LongAdder 思想:baseCount + CounterCell 数组
扩容 每个 Segment 独立扩容 多线程协同扩容(transferIndex 步长分配)

一句话总结:JDK 1.7 是对整个数组做了"粗粒度分区",JDK 1.8 是给每个桶发了一把"独立钥匙"

第二问:为什么弃用 ReentrantLock,改用 synchronized?

核心原因:JDK 1.6 之后,synchronized 经历了大幅优化,在低竞争场景下已经反超 ReentrantLock。

synchronized 的锁升级路径:

复制代码
偏向锁(无竞争,记录线程ID即可)
    ↓ 有其他线程竞争
轻量级锁(CAS 自旋,不阻塞线程)
    ↓ 自旋超过一定次数(默认10次)或竞争加剧
重量级锁(操作系统互斥量,线程阻塞)

在 ConcurrentHashMap 的典型使用场景中,桶级锁的竞争非常低------大多数情况下,不同线程操作不同的桶,根本不会竞争同一把锁。在这种低竞争场景下,synchronized 经过偏向锁和轻量级锁优化后,性能比 ReentrantLock 更好、内存占用更小。

还有一个工程层面的原因:synchronized 是 JVM 内置关键字,不需要额外引入 java.util.concurrent.locks 的依赖,代码更简洁,且随着 JVM 的持续优化,synchronized 的性能会"免费"提升------不需要改一行代码。

面试技巧 :如果面试官追问"那 ReentrantLock 还有什么用?",回答:ReentrantLock 仍有 synchronized 不具备的能力------可中断的锁获取lockInterruptibly())、超时获取锁tryLock(timeout))、公平锁多条件变量newCondition())。这些是 synchronized 做不到的。


六、通俗类比小结与开放性思考题

6.1 三层类比:图书馆的故事

用一个图书馆的场景,把本期所有知识点串起来:

第一层:哈希表 = 图书馆分类号

你去图书馆找《Java 并发编程实战》,不用从第一排书架一本本翻。查一下索书号 "TP312.8/123",直奔第 12 排第 3 层------这就是 O(1)。

每个索书号对应一个书架格子。分类号的映射就像 hashCode() → (n-1) & hash,让你瞬间定位。

第二层:HashMap = 每个格子上的链表

但索书号是有限的,总有不同的书映射到同一个书架格子(哈希冲突)。JDK 1.7 的做法是:每个格子上挂一个链表,新来的书直接挂在最前面(头插法)。格子上书太多(链表太长)也不好找,JDK 1.8 的做法是:书超过 8 本,直接把格子升级成一个小书架(红黑树),按书名拼音排序,找起来更快。

第三层:ConcurrentHashMap = 每个格子一把独立锁

图书馆里 100 个同学同时在找书。如果整个图书馆只有一把大锁(JDK 1.7 Segment),一次只能进 16 个人,而且这 16 个人如果全挤在第 12 排,其他人还是得等。

JDK 1.8 的做法精细多了:每个书架格子一把独立锁。100 个同学各自去不同的格子,互不干扰。如果有人去同一个格子,轻量级锁(CAS 自旋)先顶上去------"我先等等,他马上就好了",不用惊动管理员(操作系统线程调度)。

完整对照表

图书馆概念 数据结构概念
索书号系统 哈希函数 hashCode() → 桶下标
书架格子 桶(bucket)
同一格子多本书 哈希冲突 → 链表
格子上书太多,升级小书架 链表长度 ≥ 8 → 红黑树
整个图书馆只有一把大锁 JDK 1.7 Segment 分段锁
每个格子独立的小锁 JDK 1.8 CAS + synchronized 桶级锁

6.2 全篇核心结论回顾

问题 答案
JDK 1.7 并发扩容为什么死循环? 头插法反转链表 + 线程局部变量交叉干扰 → 环形链表 → get() 无限循环
JDK 1.8 怎么修好的? 尾插法保持原顺序,利用 (e.hash & oldCap) 将链表一拆为二
JDK 1.8 HashMap 线程安全吗? 不安全!put 丢数据、size 不准确,并发场景必须用 ConcurrentHashMap
ConcurrentHashMap 怎么做到线程安全? JDK 1.7:Segment 分段锁;JDK 1.8:CAS(桶空)+ synchronized(桶非空),锁粒度细化到桶级
为什么 synchronized 替代了 ReentrantLock? JDK 1.6 锁升级优化(偏向→轻量→重量)让 synchronized 在低竞争下反超,且随 JVM 升级免费提速

6.3 开放性思考题

为什么不直接用红黑树,而要先链表再转红黑树?

这是一个典型的"空间换时间"权衡问题。给你两个线索:

线索一TreeNode 的类继承关系:

复制代码
TreeNode → LinkedHashMap.Entry → HashMap.Node

HashMap.Node 只有 4 个字段:hashkeyvaluenext

TreeNode 在 Node 的基础上多了 5 个引用:parentleftrightprevred

线索二 :Java 对象头 + 引用大小。在 64 位 JVM 开启指针压缩时,每个引用占 4 字节,对象头占 12 字节。粗略估算:一个 Node 约 32 字节,一个 TreeNode 约 56 字节。红黑树节点内存开销是链表节点的近 2 倍

现在请你回答:为什么 HashMap 不一开始就全部用红黑树?

提示:结合前面泊松分布的结论------在负载因子 0.75 下,链表长度超过 8 的概率仅为千万分之六。绝大多数桶里只有 0~2 个节点,用红黑树非常浪费内存。


相关推荐
石榴树下的七彩鱼1 小时前
图片去文字接口,支持去除图片中的文字(附 Python / Java / PHP / JS 示例)
java·python·php·api接口·图片去水印·ai图片修复·图片去文字
程序猿乐锅1 小时前
JavaSE 总复习:语法到多线程全梳理
java·开发语言
Sam09271 小时前
1 个 Java 服务可以支撑多少 SSE 连接:从线程模型到容量评估
java·人工智能·ai
云器科技1 小时前
云器技术问答 Vol.2:揭秘通用增量计算
java·开发语言
枫叶v.1 小时前
Agent 开发架构:从增强型 LLM 到可运维的自治系统
开发语言·python
.千余3 小时前
【C++】C++ set 与 multiset 完全指南:关联式容器入门
开发语言·c++·笔记·学习·其他
c++之路6 小时前
CMake 系列教程(二):基础命令详解
开发语言·c++
阿维的博客日记8 小时前
Hippo4j 线程池监控平台部署手册
java·spring boot·后端
南境十里·墨染春水10 小时前
C++ 工厂模式:从入门到进阶,彻底掌握对象创建的艺术
开发语言·c++·算法