HashMap 灵魂 30 问:从数据结构到红黑树,从扩容到 ConcurrentHashMap,一篇彻底通关

HashMap 灵魂 30 问:从数据结构到红黑树,从扩容到 ConcurrentHashMap,一篇彻底通关

别再死记硬背了!从哈希碰撞到红黑树,从扩容死链到 CAS,这篇带你真正理解 Map

HashMap 是 Java 中最常用的集合类之一,也是面试中必考、深挖、连环问的绝对核心。

而它的"兄弟姐妹"们------HashSet(底层就是 HashMap)、LinkedHashMap(双向链表 + HashMap)和 ConcurrentHashMap(并发安全的 HashMap)------则是构建高阶知识体系的拼图。

今天这篇文章,我们从底层数据结构哈希算法扩容机制Java 8+ 红黑树优化三种遍历顺序并发演进六个维度,把这四个类彻底讲透。

一、先上结论(一张表看懂四兄弟)

对比维度 HashMap HashSet LinkedHashMap ConcurrentHashMap
底层数据结构 数组 + 链表 + 红黑树(Java 8+) 就是 HashMap (值存占位符 PRESENT HashMap + 双向链表(维护顺序) 数组 + 链表 + 红黑树(CAS + synchronized 实现并发)
存储内容 Key-Value 键值对 只存 Key(去重) Key-Value 键值对 Key-Value 键值对
是否允许 null Key 允许(存在数组第 0 位) 允许(一个) ✅ 允许 禁止(会 NPE)
是否允许 null Value ✅ 允许 N/A(存的是 PRESENT,非 null ✅ 允许 禁止(会 NPE)
线程安全 ❌ 不安全(Fast-Fail 机制) ❌ 不安全 ❌ 不安全 安全(并发读写)
顺序性 无序(插入顺序不保证) 无序 有序(插入顺序 或 访问顺序) 无序(与 HashMap 一致)
适用场景 单线程通用存储 单线程去重集合 需要可预测顺序的 Map 多线程并发场景

🚀 黄金选型原则

  • 单线程存 Key-Value → HashMap
  • 单线程存 Key 去重 → HashSet
  • 需要保持插入顺序或做 LRU 缓存 → LinkedHashMap
  • 多线程并发读写 → ConcurrentHashMap (千万别用 HashTable,已淘汰)

二、HashMap:底层结构与核心原理(源码级)

1. 底层数据结构(Java 8+)

java 复制代码
// JDK 1.8 源码核心字段
public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {
    
    // 1. 核心数组(Node 数组,即哈希桶)
    transient Node<K,V>[] table;
    
    // 2. 实际元素个数
    transient int size;
    
    // 3. 扩容阈值(capacity * loadFactor)
    int threshold;
    
    // 4. 负载因子(默认 0.75)
    final float loadFactor;
    
    // 5. 静态内部类 Node(链表节点)
    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;  // 指向下一个节点(链表指针)
    }
    
    // 6. 树化阈值(链表长度 >= 8 且数组长度 >= 64 时转红黑树)
    static final int TREEIFY_THRESHOLD = 8;
    
    // 7. 退树化阈值(红黑树节点数 <= 6 时退化为链表)
    static final int UNTREEIFY_THRESHOLD = 6;
    
    // 8. 最小树化容量
    static final int MIN_TREEIFY_CAPACITY = 64;
}

2. 数据结构示意图

复制代码
HashMap 底层结构(Java 8+)
┌─────────────────────────────────────────────────────────────────┐
│                         table 数组(哈希桶)                   │
│  ┌────┬────┬────┬────┬────┬────┬────┬────┬────┬────┐        │
│  │ 0  │ 1  │ 2  │ 3  │ 4  │ 5  │ 6  │ 7  │ 8  │ 9  │ ...   │
│  └────┴────┴────┴────┴────┴────┴────┴────┴────┴────┘        │
│    │                  │                                       │
│    ↓                  ↓                                       │
│  Node(链表)       红黑树(TreeNode)                            │
│  ┌─────┐         ┌─────┐                                    │
│  │Key1  │──→      │Key4  │  (红黑节点,自平衡)                │
│  ├─────┤   next  ├─────┤                                    │
│  │Key2  │──→      │Key5  │                                    │
│  ├─────┤   next  ├─────┤                                    │
│  │Key3  │         │Key6  │                                    │
│  └─────┘         └─────┘                                    │
└─────────────────────────────────────────────────────────────────┘

3. put() 方法的执行流程(面试必画流程图)

java 复制代码
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 为空则初始化(resize)
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 2. 计算数组下标 (n - 1) & hash,若该位置为空则直接放入
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        // 3. 如果 key 相同(hash 相同 && (引用相同 || equals 为 true))→ 覆盖
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 4. 如果已经是红黑树节点 → 走红黑树的 putTreeVal
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            // 5. 链表情况:遍历链表
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // 5a. 如果链表长度 >= 8 → 尝试转红黑树
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        // 6. 覆盖旧值
        if (e != null) {
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            return oldValue;
        }
    }
    ++modCount;  // 记录修改次数(用于 Fast-Fail)
    if (++size > threshold)  // 7. 超过阈值则扩容
        resize();
    return null;
}

4. 哈希算法(hash() 方法------扰动函数)

java 复制代码
static final int hash(Object key) {
    int h;
    // 让 hashCode 的高 16 位参与低 16 位的运算(混合扰动),减少哈希碰撞
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

计算数组下标的公式(n - 1) & hash(n 是数组长度,必须是 2 的幂次方)。

5. 扩容机制(resize()------最耗时的操作)

  • 触发条件size >= threshold(容量 × 负载因子),默认 16 × 0.75 = 12
  • 新容量翻倍oldCap << 1),所以容量始终是 2 的幂。
  • 重哈希(Rehash) :扩容后,每个元素要么留在原位置 i,要么移动到 i + oldCap 位置(利用 2 的幂取模特性,效率极高)。

🚨 扩容性能提示 :如果能预知元素数量,请在构造时指定 initialCapacity,避免频繁扩容带来的性能损耗。

6. 为什么链表长度 ≥ 8 时转红黑树?

  • 理想情况下 ,链表的平均长度为 0.75(负载因子),出现 8 个碰撞的概率低于千万分之一(泊松分布)。
  • 如果大于等于 8,说明哈希函数有严重问题或数据量极大,此时转为红黑树(时间复杂度从 O(n) 降为 O(log n)),保证性能。
  • 退树化:红黑树节点数 ≤ 6 时,退化为链表(节点数少时,树维护开销反而更大)。

三、HashSet:披着 Set 外衣的 HashMap

HashSet 的底层非常简单------直接复用 HashMap ,元素作为 KeyValue 统一用一个虚拟占位对象 PRESENT

java 复制代码
// JDK 源码
public class HashSet<E> extends AbstractSet<E>
    implements Set<E>, Cloneable, java.io.Serializable {
    
    private transient HashMap<E,Object> map;  // 底层就是 HashMap!
    
    // 虚拟占位值(所有 Value 都指向它)
    private static final Object PRESENT = new Object();
    
    // 构造方法(直接 new HashMap)
    public HashSet() {
        map = new HashMap<>();
    }
    
    // 添加元素:存到 map 的 Key 位置
    public boolean add(E e) {
        return map.put(e, PRESENT) == null;
    }
    
    // 删除元素
    public boolean remove(Object o) {
        return map.remove(o) == PRESENT;
    }
}

📌 结论HashSet所有操作 都是委托给内部的 HashMap 完成的,它的特性和 HashMap 完全一致(线程不安全、允许 null、无序)。

四、LinkedHashMap:有序的 HashMap(LRU 缓存的基础)

LinkedHashMapHashMap 的基础上,额外维护了一个双向链表来记录元素的顺序。

1. 两种顺序模式

模式 构造参数 accessOrder 顺序逻辑 典型用途
插入顺序(默认) false(默认) 按照元素插入的顺序遍历 需要可预测迭代顺序的普通 Map
访问顺序 true 按照元素最近被访问(get/put) 的顺序遍历 LRU 缓存(最近最少使用淘汰)
java 复制代码
// 1. 插入顺序(默认)
LinkedHashMap<String, String> map1 = new LinkedHashMap<>();
map1.put("A", "1");
map1.put("B", "2");
map1.put("C", "3");
// 遍历输出:A, B, C(按插入顺序)

// 2. 访问顺序(accessOrder = true)
LinkedHashMap<String, String> map2 = new LinkedHashMap<>(16, 0.75f, true);
map2.put("A", "1");
map2.put("B", "2");
map2.put("C", "3");
map2.get("A");  // 访问 A,A 会被移动到链表尾部
// 遍历输出:B, C, A(最近访问的放在最后)

2. 实现 LRU 缓存的经典写法(面试加分项)

java 复制代码
// 实现一个固定大小的 LRU 缓存
class LRUCache<K, V> extends LinkedHashMap<K, V> {
    private final int maxSize;
    
    public LRUCache(int maxSize) {
        super(maxSize, 0.75f, true);  // accessOrder = true
        this.maxSize = maxSize;
    }
    
    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        // 当元素数量超过最大容量时,自动移除最老的元素(链表头部)
        return size() > maxSize;
    }
}

// 使用示例
LRUCache<String, Integer> cache = new LRUCache<>(3);
cache.put("A", 1);
cache.put("B", 2);
cache.put("C", 3);
cache.get("A");  // 访问 A
cache.put("D", 4);  // 触发淘汰,淘汰最久未使用的 B
// 缓存中:C, A, D

五、ConcurrentHashMap:并发安全的终极方案

1. 为什么不用 HashTable?(面试送命题)

对比维度 HashTable ConcurrentHashMap
锁粒度 整张表(所有操作锁住整个对象) 分段锁(Java 7)/ 节点锁(Java 8+)
并发度 极低(同一时刻只有一个线程能操作) 极高(多线程可以同时操作不同段/不同节点)
性能 差(已被淘汰) 优(并发王者
null 支持 不支持(会 NPE) 不支持(会 NPE)

💡 结论HashTable 已被官方标记为"遗留类",新代码中绝不要使用

2. Java 7 ConcurrentHashMap(分段锁,已过时)

  • 将数据分成 16 个 Segment(段) ,每个 Segment 独立加锁(继承 ReentrantLock)。
  • 并发度 = 16。
  • 缺点:Segment 数量固定,无法动态调整,且 16 个锁的并发度在大规模并发下不够高。

3. Java 8+ ConcurrentHashMap(CAS + synchronized------当前的实现)

Java 8 彻底抛弃了 Segment 分段锁,采用了更细粒度的锁机制:

  • put 操作
    1. 如果该数组位置为空 → CAS(Compare And Swap) 自旋插入,无锁。
    2. 如果该位置不为空 → synchronized(头节点),只锁住当前链表的头节点或红黑树的根节点。
  • get 操作完全无锁 (通过 volatile 保证可见性),读取效率极高。
  • 扩容(transfer并发扩容,多个线程可以同时参与迁移数据(把一个大任务拆成小任务)。
java 复制代码
// JDK 8+ 源码核心(putVal 方法片段)
final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();  // 禁止 null
    int hash = spread(key.hashCode());
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            // 1. 位置为空 → CAS 尝试插入(无锁)
            if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
                break;
        } else if ((fh = f.hash) == MOVED)
            // 2. 正在扩容 → 帮助迁移
            tab = helpTransfer(tab, f);
        else {
            V oldVal = null;
            // 3. 位置不为空 → synchronized 锁住头节点
            synchronized (f) {
                // ... 插入或覆盖逻辑
            }
            break;
        }
    }
    addCount(1L, binCount);
    return null;
}

4. 为什么 ConcurrentHashMap 不允许 null

  • 设计歧义性 :在并发环境下,map.get(key) 返回 null,无法区分是"这个 key 不存在"还是"这个 key 存在,但 Value 就是 null"。
  • 在非并发的 HashMap 中,可以通过 containsKey() 区分,但在并发环境下,两次调用之间状态可能已经改变,区分变得毫无意义。因此 JDK 设计者直接禁止了 null Key 和 null Value。

六、四兄弟对比总结(面试速记版)

特性 HashMap HashSet LinkedHashMap ConcurrentHashMap
底层 哈希表 + 红黑树 就是 HashMap HashMap + 双向链表 CAS + synchronized + 红黑树
是否有序 ✅(插入或访问顺序)
线程安全
允许 null Key
允许 null Value N/A
适用场景 单线程通用 Map 单线程去重 有序 Map / LRU 缓存 多线程并发读写

七、高频面试连环追问

Q1:HashMaphashCode()equals() 的关系?

  • 两个对象 equals()true,则 hashCode() 必须相等。
  • 两个对象 hashCode() 相等,equals() 不一定true(哈希碰撞)。
  • 实践 :重写 equals() 必须重写 hashCode()

Q2:HashMap 的容量为什么是 2 的幂次方?

  • 方便 (n - 1) & hash 快速取模(位运算比 % 快)。
  • 扩容时,元素要么在原位,要么在 i + oldCap,迁移逻辑简单高效。

Q3:ConcurrentHashMap 在 Java 7 和 Java 8 的区别?

  • Java 7Segment(分段锁),一个锁管一个 Segment。
  • Java 8+CAS + synchronized(锁头节点),并发度更高,锁粒度更细。

Q4:HashMap 在多线程下有什么问题?

  • JDK 1.7 :并发扩容可能产生死链(环形链表),导致 CPU 100%。
  • JDK 1.8 :扩容算法优化,不会死链,但仍存在数据覆盖 问题(两个 put 同时操作同一个数组位置,后覆盖前),不建议多线程使用

八、思考题(检验是否真的懂了)

java 复制代码
// 问题 1:下面代码输出什么?为什么?
public static void main(String[] args) {
    HashSet<String> set = new HashSet<>();
    String s1 = new String("Hello");
    String s2 = new String("Hello");
    set.add(s1);
    set.add(s2);
    System.out.println(set.size());  // A
}
java 复制代码
// 问题 2:下面代码在多线程环境下会有什么问题?
public class Test {
    private static final Map<String, String> map = new HashMap<>();
    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                map.put(Thread.currentThread().getName(), "value");
            }).start();
        }
    }
}
java 复制代码
// 问题 3:下面哪个 Map 最合适用来实现一个带有过期淘汰策略的缓存?
// A. HashMap   B. LinkedHashMap   C. ConcurrentHashMap   D. HashTable

答案(选中下方空白区域查看):

  1. 输出 1 。因为 HashSet 底层是 HashMap,通过 equals()hashCode() 去重。s1s2 虽然是不同对象,但 String 重写了 equals()hashCode(),它们内容相同,所以只能存 1 个。
  2. 数据覆盖、脏读,甚至无限循环或 ConcurrentModificationExceptionHashMap 线程不安全,多线程并发 put 可能导致内部数组结构被破坏(如 size 不准确、键值覆盖等)。
  3. B. LinkedHashMap 。因为它的 accessOrder=true 配合 removeEldestEntry() 方法,可以非常轻松地实现 LRU 缓存淘汰策略。

总结(终极速查表)

知识点 一句话记忆
HashMap 底层 数组 + 链表 + 红黑树(Java 8)
扩容因子 默认 0.75(时间和空间的权衡)
树化阈值 链表长度 ≥ 8 且数组 ≥ 64 时转红黑树
HashSet 底层就是 HashMap,只存 Key
LinkedHashMap HashMap + 双向链表,可以按插入/访问顺序迭代
ConcurrentHashMap(Java 8) CAS + synchronized(锁头节点),读操作无锁
线程安全选择 多线程 → ConcurrentHashMap (绝不用 HashTable
null 限制 ConcurrentHashMapHashTable 禁止 null

💬 互动话题 :你在面试中遇到过关于 HashMap 的"变态"追问吗?比如为什么负载因子是 0.75?或者有没有多线程使用 HashMap 导致死循环或数据丢失的血泪史?欢迎评论区分享!🚀


如果觉得有收获,别忘了点赞收藏转发,让更多 Javaer 彻底搞懂 Map 家族的底层原理!我们下篇见!👋

发布日期:2026-08-28

相关推荐
_Narcissus_3 小时前
分治&递归
数据结构·c++·笔记·算法·leetcode·递归·分治
不灭的黄金瞳1236 小时前
C语言实现单链表
c语言·数据结构
这是程序猿7 小时前
深入剖析Java HashMap:底层原理、数据结构与核心机制全解析
java·开发语言·数据结构
HanhahnaH9 小时前
各数据结构操作的时间复杂度汇总
数据结构·算法
_wxd6669 小时前
排序-堆排序(Heap Sort)
数据结构
间歇性努力持续性发呆的野生快乐选手10 小时前
二分模板(整型,浮点型)
数据结构·c++·算法
evans在进步10 小时前
LeetCode 189 轮转数组:三次反转为什么能实现右旋?
数据结构·算法·leetcode
五_谷_丰_登11 小时前
平衡二叉搜索树讲解
数据结构·c++