你有没有过这种经历——自信满满地去面试,前半段聊项目聊得眉飞色舞,突然面试官轻描淡写来了一句:"聊聊 HashMap 吧。" 你心想这题简单啊,脱口而出"数组+链表+红黑树",正准备展开讲讲,对方直接打断:"那你说说,为什么链表转红黑树的阈值偏偏是 8,不是 7 也不是 9?" 你瞬间愣住,大脑一片空白。

据统计,HashMap 是 Java 面试中出镜率最高的集合类,没有之一。从阿里、美团到字节跳动,从初级到资深,几乎所有 Java 面试都会问到它。更关键的是,面试官问 HashMap,从来不是想听你背八股文,也不是想看你有没有读过源码,他们想要知道的是你到底有没有真正理解设计者的权衡

这篇文章的目的很纯粹:带你从零开始,把 HashMap 的底裤——不对,底层原理——扒个精光。读完你会发现,HashMap 不仅不难,它的设计思路甚至称得上优雅。


一、先别翻源码——搞清楚 Map 到底是干嘛的

在钻进 HashMap 的源码之前,我们先把最基础的概念对齐一下。

1.1 生活中无处不在的"映射"

想象你有一个通讯录,你想根据"姓名"快速找到"电话号码"。你会怎么做?

  • 笨办法:从第一页开始翻,一行一行找,直到找到那个名字。1000 个联系人,最坏情况要翻 1000 次。
  • 聪明办法:通讯录本身就是按姓氏拼音排序的,"张"开头的都在某个范围,你直接翻到那个区域,很快就找到了。

这个"根据姓名找电话"的需求,在 Java 里就叫 Map(映射)。姓名是 key,电话是 value,合在一起就是一个键值对(key-value pair)。

// 生活中:姓名 → 电话号码
// Java 中:key → value
Map<String, String> phoneBook = new HashMap<>();
phoneBook.put("张三", "13800138000");
phoneBook.put("李四", "13900139000");

// 想找张三的电话?一句话搞定
String phone = phoneBook.get("张三");  // "13800138000"

1.2 Map 家族的"族谱"

Java 集合框架里,Map 接口有几个重要的实现类,面试也常问它们的区别:

实现类 底层结构 是否有序 是否允许 null 键 线程安全 一句话
HashMap 数组+链表+红黑树 无序 允许一个 null 键 日常开发首选,面试最爱问
LinkedHashMap HashMap + 双向链表 插入/访问有序 允许 LRU 缓存的实现基础
TreeMap 红黑树 按键自然排序 不允许 需要排序时用它
Hashtable 数组+链表 无序 不允许 上古遗产,基本淘汰

这篇文章主角是 HashMap,但面试官经常接着问"那 LinkedHashMap 和 TreeMap 呢?",上表请刻在 DNA 里。

好了,概念对齐完成。现在让我们正式进入 HashMap 的世界——从最核心的数据结构开始。


二、HashMap 的骨骼:数组 + 链表 + 红黑树

2.1 为什么不能只用数组?

先思考一个最简单的问题:如果让你自己实现"根据 key 找 value",第一反应是什么?

大概率是数组——把所有键值对放进数组,找的时候遍历数组比对 key。这个方法能用,但查找的时间复杂度是 O(n),数据量大了性能直接拉胯。

想要 O(1) 的查找速度,你需要一种能直接根据 key 算出位置的数据结构。这就是哈希表的思路:

key → [哈希函数] → 数组下标 → 直接定位到 value

理想很丰满,现实很骨感。天底下没有不冲突的哈希函数——两个不同的 key 完全可能算出同一个下标,这就是哈希冲突(Hash Collision)

2.2 链表登场:拉链法解决冲突

HashMap 采用的是经典的 拉链法(Separate Chaining) 来处理冲突:

数组:[0]  [1]  [2]  [3]  ...
       ↓    ↓    ↓    ↓
     链表  链表  链表  链表
       ↓         ↓
     节点       节点
       ↓
     节点
  • 数组的每个位置(桶/bucket)存的是一个链表的头节点
  • 当哈希冲突发生时,新节点追加到链表尾部
  • 查找时,先定位桶位置,再沿着链表一个个比对 key

这样一来,只要哈希函数设计得足够均匀,大部分桶里只有一两个节点,查找接近 O(1)。

2.3 红黑树救场:当链表太长时

拉链法有一个致命缺陷:如果运气不好(或者有人恶意构造大量哈希冲突的 key),某个桶的链表可能变得非常长。最坏情况下,HashMap 退化成 LinkedList,查找从 O(1) 变成 O(n)——这可不是开玩笑的。

JDK 8 的解决方案是:当链表长度超过阈值(8),链表自动转成红黑树

链表阶段:                          红黑树阶段:
[桶] → A → B → C → ... → H         [桶] → 红黑树(O(log n) 查找)
查找 O(n),n 越大越慢              查找 O(log n),即使 n 很大也很快

关键认知:红黑树不是为了让 HashMap "更快",而是为了防止 HashMap "变慢"。正常情况下链表根本到不了 8,红黑树是一道"保险丝"。

2.4 一张图看懂 HashMap 的全貌

HashMap 内部结构:

Node<K,V>[] table   ←── 主干数组,每个位置叫"桶(bucket)"
    |
    ├── table[0] → Node → Node → ...(链表)
    |                     ↓
    |               链表长度>8 且 数组长度≥64 时
    |                     ↓
    |                TreeNode ↔ TreeNode ↔ ...(红黑树)
    |
    ├── table[1] → Node → TreeNode ↔ TreeNode(已经是红黑树)
    ├── table[2] → null(空桶)
    ├── table[3] → Node(单节点)
    └── ...

好了,骨架已经搭好。接下来我们进入最刺激的部分——读源码。别怕,我会逐行拆解,保证你看得懂。


三、源码深潜:HashMap 的核心成员变量

翻开 JDK 8 的 HashMap 源码(你可以在 IDE 里直接打开 java.util.HashMap),我们先看几个至关重要的成员变量。面试官问 HashMap,几乎每个问题都跟这些变量有关

3.1 默认容量与负载因子

// HashMap.java 关键常量

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;  // 16,默认初始容量

static final float DEFAULT_LOAD_FACTOR = 0.75f;      // 默认负载因子

static final int MAXIMUM_CAPACITY = 1 << 30;         // 最大容量,2^30

面试高频问题:为什么默认容量是 16?

两个原因:

  1. 16 是 2 的幂,HashMap 用 (n - 1) & hash 代替取模运算(后面会详解),2 的幂能保证这个位运算等价于取模。
  2. 16 是一个经验上的平衡点——太小了频繁扩容,太大了浪费内存。经过大量实战检验,16 作为起手值效果最好。

面试高频问题:为什么负载因子是 0.75?

0.75 是时间与空间的折中点

  • 负载因子 = size / capacity,表示桶的"拥挤程度"
  • 设为 1.0:桶几乎满了才扩容,空间利用率高,但哈希冲突严重,查找效率低
  • 设为 0.5:桶用到一半就扩容,冲突少、查找快,但一半空间被浪费
  • 0.75:两者之间的"甜点",在大多数场景下表现最优

这个 0.75 不是拍脑袋定的。JDK 注释里明确提到,这个值符合泊松分布的数学推导,在时间和空间之间取得了最佳平衡。

3.2 树化的两个阈值

static final int TREEIFY_THRESHOLD = 8;      // 链表转红黑树的阈值

static final int UNTREEIFY_THRESHOLD = 6;     // 红黑树退化为链表的阈值

static final int MIN_TREEIFY_CAPACITY = 64;   // 树化前数组的最小长度
面试高频问题:为什么树化阈值是 8?

这是 HashMap 面试中最经典也最能拉开区分度的问题。答案分两层:

第一层(基础答案):根据泊松分布,链表长度达到 8 的概率极低。

JDK 源码注释里有这样一段话:

* 0:    0.60653066
* 1:    0.30326533
* 2:    0.07581633
* 3:    0.01263606
* 4:    0.00157952
* 5:    0.00015795
* 6:    0.00001316
* 7:    0.00000094
* 8:    0.00000006

在良好的哈希函数下,桶中元素数量的分布遵循泊松分布,链表长度达到 8 的概率约为 0.00000006(亿分之六)——也就是说,正常情况下几乎不可能出现。如果真的出现了,说明要么哈希函数出了问题,要么有人恶意攻击,此时转为红黑树来兜底。

第二层(加分答案):平均查找长度的权衡。

链表长度 平均查找长度(链表) 平均查找长度(红黑树)
7 3.5 ~2.8
8 4.0 ~3.0
9 4.5 ~3.2

在长度为 8 时,链表查找和树查找的差距已经能体现出红黑树的优势(log₂8 = 3 ≈ 4 的一半),此时转换收益明显。同时,8 和泊松分布的极小概率也呼应上了——"正常不会到 8,到了 8 说明该树化了"。

面试高频问题:退化阈值为什么是 6 而不是 8?

如果退化阈值也是 8,会出现什么问题?想象一下:链表长度在 7 和 8 之间反复横跳,红黑树和链表来回转换——每次转换都是昂贵的操作。设置一个 "缓冲区间"(6 到 8),避免了频繁的树化/退化。

这个设计思想叫迟滞(Hysteresis),跟家里空调不会在设定温度上反复开关是一个道理。

3.3 核心内部类 Node

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;    // key 的哈希值(存起来,避免重复计算)
    final K key;       // 键
    V value;           // 值
    Node<K,V> next;    // 指向下一个节点(链表结构)

    // 构造方法、getter、setter 等...
}

每个 Node 就是 HashMap 里存的一个键值对。注意 hash 字段是预先计算并存储的 —— 这是一个重要的性能优化,避免了每次比较时都重新计算哈希值。


四、源码深潜:put() 方法——HashMap 的心脏

put() 是 HashMap 最核心的方法,理解了它,HashMap 就理解了一大半。我们来看看 JDK 8 的 put() 到底做了什么。

4.1 put() 方法的完整调用链

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

put() 本身很简单,就调用了 putVal()。但我们先停一下,看看 hash(key) 这个方法——它是整个 HashMap 的基石

4.2 哈希函数:HashMap 凭什么快

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

就这么两行代码,信息量巨大。

第 1 步:调用 key.hashCode()

由 key 所属的类决定如何生成哈希值。String 的 hashCode 是逐字符计算的,Integer 的 hashCode 直接返回数值本身。

第 2 步:高 16 位与低 16 位做异或(h ^ (h >>> 16)

这是 HashMap 的核心优化。为什么要这样设计?

先看 HashMap 怎么计算数组下标:

// 不是用取模,而是用位运算
index = (n - 1) & hash   // n 是数组长度(2 的幂)

当 n = 16 时,n - 1 = 15,二进制是 0000 0000 0000 0000 0000 0000 0000 1111。任何一个哈希值跟 15 做与运算,只有低 4 位参与计算,高位全部被"丢弃"了。

这就糟了:如果 key 的 hashCode 只在低位有变化(这种情况很常见),高位差异完全被浪费,冲突会很严重。

解决方案:让高位也参与进来。 h >>> 16 把高 16 位移到低 16 位,然后跟原值做异或。这样高 16 位的特征被"混入"到了低 16 位中:

hashCode:  0001 0010 0011 0100 | 0101 0110 0111 1000  (32位)
                               ↓
h >>> 16:  0000 0000 0000 0000 | 0001 0010 0011 0100  (高16位移到低16位)
                               ↓
异或结果:  0001 0010 0011 0100 | 0100 0100 0100 1100  (高位特征混入低位)
                               ↓
& (n-1):   0000 0000 0000 0000 | 0000 0000 0000 1100  (取低4位 = 12)

这个操作被称为扰动函数(Perturbation Function),目的是让哈希值的高位和低位都参与桶下标计算,让最终分布更均匀。

4.3 putVal() 逐行拆解

下面我们逐段拆解 putVal() 方法。别怕长,我会把每一段都讲清楚

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】如果数组为空或长度为 0,先扩容(初始化)
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;

步骤 1 解读:HashMap 是懒加载的——new HashMap() 的时候不会创建数组,第一次 put 才创建。这是一种常见的性能优化,避免你 new 了一个 Map 但从来没使用的情况。

  // 【步骤 2】计算桶下标,如果桶为空,直接放进去
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);

步骤 2 解读:用 (n - 1) & hash 计算桶位置。如果这个位置还没有人占,直接新建一个 Node 放进去。这是最快的情况——O(1)。

    else {
        // 【步骤 3】桶里已经有东西了,需要处理冲突
        Node<K,V> e; K k;

        // 情况 A:桶里第一个节点的 key 就跟我们要插入的一样
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;  // 记录下来,后面统一处理覆盖

步骤 3A 解读:先比较桶的第一个节点。注意这里的比较逻辑非常讲究:先比较 hash,hash 相同再比较 ==(引用相等),还不相等才用 equals()。hash 不同的直接跳过,避免调用代价更高的 equals()——这是一个精巧的性能优化。

        // 情况 B:桶里是红黑树,走红黑树的插入逻辑
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);

步骤 3B 解读:如果当前桶已经树化了,调用红黑树的插入方法。红黑树的复杂度是 O(log n)。

        // 情况 C:桶里是链表,沿链表查找
        else {
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    // 走到链表尾部还没找到,新建节点追加到尾部
                    p.next = newNode(hash, key, value, null);
                    // 插入后链表长度达到 8,树化
                    if (binCount >= TREEIFY_THRESHOLD - 1)  // binCount从0开始,>=7 即第8个
                        treeifyBin(tab, hash);
                    break;
                }
                // 在链表中找到了相同的 key
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }

步骤 3C 解读:这是最复杂的情况——遍历链表。注意两个关键细节:

  1. JDK 8 用的是尾插法(新节点追加到链表尾部),而 JDK 7 是头插法(新节点插入链表头部)。这个差异在并发场景下会导致致命问题,后面会详细讲。
  2. binCount 从 0 开始,当 binCount >= 7 时,链表上已经有 8 个节点(从第 1 个开始算),触发树化。
        // 【步骤 4】找到了已存在的 key,覆盖旧值
        if (e != null) {  // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;   // 新值覆盖旧值
            afterNodeAccess(e);
            return oldValue;       // 返回旧值
        }
    }

    // 【步骤 5】这是新增节点(不是覆盖),modCount++,size++
    ++modCount;
    if (++size > threshold)
        resize();   // 超过阈值,扩容!
    afterNodeInsertion(evict);
    return null;
}

4.4 一张流程图帮你记住 put() 全流程

put(key, value)
    │
    ▼
计算 hash 值:hash = (key == null) ? 0 : key.hashCode() ^ (key.hashCode() >>> 16)
    │
    ▼
数组为空?──是──▶ resize() 初始化
    │
    否
    │
    ▼
计算桶位置:i = (n - 1) & hash
    │
    ▼
table[i] == null?──是──▶ 直接放新节点 ──▶ 结束
    │
    否
    │
    ▼
桶首节点的 key 相同?──是──▶ 记录待覆盖
    │
    否
    │
    ▼
桶首节点是 TreeNode?──是──▶ 红黑树插入/查找
    │
    否
    │
    ▼
遍历链表:
  ├── 找到相同 key → 记录待覆盖
  └── 走到尾部没找到 → 尾部插入新节点
                        └── 链表长度 ≥ 8?──是──▶ treeifyBin() 树化
    │
    ▼
有要覆盖的节点?──是──▶ 新值覆盖旧值,返回旧值
    │
    否
    │
    ▼
size > threshold?──是──▶ resize() 扩容
    │
    ▼
返回 null(新增成功)

五、源码深潜:get() 方法——比 put 简单多了

好消息是,get() 的逻辑比 put() 简单得多——本质就是"先定位桶,再沿结构查找"。

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;

    // 数组不为空且长度>0 且 定位到的桶不为空
    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() 先比较第一个节点是有深意的。在哈希分布良好的情况下,大多数桶里只有一个节点,先查第一个能用最小的代价命中大多数请求。如果第一个不是且 first.next != null,才去判断是链表还是红黑树。

get() 的时间复杂度:理想情况 O(1),链表 O(n),红黑树 O(log n)。


六、扩容机制:HashMap 的性能杀手锏

扩容(resize)是 HashMap 最消耗性能的操作,也是面试官最爱深入追问的地方。

6.1 什么时候扩容?

if (++size > threshold)
    resize();

threshold = capacity * loadFactor。以默认值来说,容量 16、负载因子 0.75,那么 threshold = 12。当 HashMap 中元素个数超过 12 时,触发扩容。

注意:是超过 threshold,不是超过 capacity。负载因子 0.75 相当于给 HashMap 留了 25% 的"缓冲空间"。

6.2 扩容做了什么?

扩容的本质是:创建一个更大的数组(通常是原来的 2 倍),然后把旧数组里的所有元素重新迁移过去

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) {
        if (oldCap >= MAXIMUM_CAPACITY) {
            // 已经达到最大容量,不扩了,把阈值设为 Integer.MAX_VALUE
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 新容量 = 旧容量 * 2
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1;  // 新阈值也 * 2
    }
    // ... 省略初始化的分支 ...

    // 创建新数组
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;

    // 迁移旧数据到新数组
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            // ... 遍历每个桶,把元素迁移到新数组 ...
        }
    }
    return newTab;
}

6.3 数据迁移的核心优化:不用重新 hash

这是 JDK 8 相对于 JDK 7 的一个重大性能优化

在 JDK 7 中,扩容时需要重新计算每个元素在新数组中的位置(hash & (newCapacity - 1)),因为数组长度变了,取模结果自然变了。

在 JDK 8 中,利用了一个巧妙的数学特性:

  • 数组长度始终是 2 的幂,扩容时长度翻倍
  • 对于任意 hash 值,hash & (2n - 1) 的结果,要么等于原位置,要么等于 原位置 + n

为什么?来看个例子:

假设旧容量 n = 16,新容量 2n = 32

旧下标计算:hash & (16 - 1) = hash & 1111  (取低 4 位)
新下标计算:hash & (32 - 1) = hash & 11111 (取低 5 位)

新下标 = 旧下标 + (hash 第5位 == 1 ? 16 : 0)

也就是说,只需要检查 hash 的"第 5 位"是 0 还是 1:

  • 0 → 位置不变
  • 1 → 位置 + oldCap
// JDK 8 的迁移逻辑(简化版)
Node<K,V> loHead = null, loTail = null;  // 位置不变的元素
Node<K,V> hiHead = null, hiTail = null;  // 位置 + oldCap 的元素

Node<K,V> next;
do {
    next = e.next;
    if ((e.hash & oldCap) == 0) {  // hash 的第5位是0
        // 放到 lo 链表(位置不变)
    } else {                        // hash 的第5位是1
        // 放到 hi 链表(位置 + oldCap)
    }
} while ((e = next) != null);

// lo 链表放到 newTab[j](原位置)
// hi 链表放到 newTab[j + oldCap](原位置 + 旧容量)

这个优化避免了大量重新计算 hash 的开销,是 JDK 8 HashMap 性能提升的关键之一。


七、JDK 7 vs JDK 8:两版 HashMap 的关键差异

面试官特别喜欢问这个问题,因为它能看出候选人对 HashMap 理解的深度。

7.1 结构差异

对比维度 JDK 7 JDK 8
底层结构 数组 + 链表 数组 + 链表 + 红黑树
插入方式 头插法 尾插法
哈希函数 多次扰动(4次位运算+5次异或) 一次异或(高16位与低16位)
扩容数据迁移 重新计算 hash 位置 原位置 或 原位置+旧容量
初始化时机 构造时创建数组 懒加载,首次 put 才创建

7.2 头插法 vs 尾插法:为什么 JDK 8 要改?

这是 HashMap 面试中最重要的问题之一。

JDK 7 的头插法在并发场景下会导致死循环(CPU 100%)。这是一个著名的 bug。

复现场景:两个线程同时进行 put 操作触发了扩容,在迁移链表时,头插法会导致链表的顺序反转,在线程切换的特定时序下,链表会形成一个环形链。后续 get() 时,会在这个环里无限循环,CPU 直接飙到 100%。

JDK 8 改用尾插法,扩容时链表元素保持原有顺序,避免了环形链问题。

重要提醒:JDK 8 的 HashMap 依然不是线程安全的。尾插法只是避免了死循环(不会 CPU 100%),但依然可能出现数据覆盖、size 不一致等问题。并发场景请用 ConcurrentHashMap

7.3 哈希函数的简化

JDK 7 的哈希函数相当复杂:

// JDK 7 的 hash()
final int hash(Object k) {
    int h = hashSeed;
    if (0 != h && k instanceof String) {
        return sun.misc.Hashing.stringHash32((String) k);
    }
    h ^= k.hashCode();
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}

JDK 8 简化成一行 (h = key.hashCode()) ^ (h >>> 16)。为什么可以简化?因为:

  1. 红黑树的引入让极端情况下的查找性能有了保障,对哈希函数均匀性的要求可以适当降低
  2. 更少的位运算意味着更高的计算效率——哈希函数每次 put/get 都要执行,能省一点是一点
  3. 实践证明简化的版本在大多数场景下已经足够均匀

八、线程安全:HashMap 的"阿喀琉斯之踵"

前面已经提到了 JDK 7 的死循环问题。但即使在 JDK 8 中,HashMap 依然不是线程安全的。我们来系统梳理一下。

8.1 并发问题演示

数据覆盖问题
// 两个线程同时 put,可能互相覆盖
// 线程A 和 线程B 同时计算到同一个桶位置为空
// 线程A 写入 data1,线程B 写入 data2
// 结果:后写入的覆盖先写入的,一个数据丢失
size 不一致问题
// size++ 不是原子操作
// 线程A:读取 size=10,准备写入 size=11
// 线程B:读取 size=10,准备写入 size=11
// 结果:两个线程都写入 11,但实际应该有 12 个元素

8.2 替代方案

方案 适用场景 原理
Hashtable 基本不用 所有方法加 synchronized,性能差
Collections.synchronizedMap() 小规模并发 包装一层 synchronized
ConcurrentHashMap 推荐 JDK 7 分段锁,JDK 8 CAS + synchronized

如果有人问你"ConcurrentHashMap 和 HashMap 的区别",回答的核心就是两点:线程安全 + 不允许 null 键值。


九、HashMap 常见面试题全梳理

前面已经穿插讲了很多面试考点,这里做一个集中梳理和归纳,方便你面试前快速复习。

Q1:HashMap 的底层数据结构是什么?

JDK 8:数组 + 链表 + 红黑树。具体来说是一个 Node<K,V> 数组,每个位置存链表或红黑树的根节点。

Q2:HashMap 的默认初始容量和负载因子是多少?

  • 默认初始容量:16(2 的 4 次方)
  • 默认负载因子:0.75f
  • 扩容阈值:16 × 0.75 = 12

Q3:HashMap 什么时候扩容?扩容机制是怎样的?

  • 触发条件:size > threshold(元素个数超过扩容阈值)
  • 扩容倍数:2 倍
  • JDK 8 优化:元素的新位置 = 原位置 或 原位置 + 旧容量,不需要重新计算 hash

Q4:HashMap 的 put 方法流程?

见第四章的完整流程图。核心步骤:计算 hash → 定位桶 → 冲突处理(链表遍历/红黑树插入)→ 判断是否覆盖 → 判断是否扩容。

Q5:HashMap 是如何解决哈希冲突的?

拉链法:数组的每个位置存链表,冲突的元素挂在链表上。当链表长度 ≥ 8 且数组长度 ≥ 64 时,链表转为红黑树。

Q6:HashMap 为什么不直接使用 key.hashCode(),而要用 hash() 扰动?

因为计算桶下标时用的是 (n - 1) & hash,只有 hash 值的低位参与运算。如果 hashCode 的高位变化但低位不变,会导致大量冲突。扰动函数将高 16 位的特征混入低 16 位,让分布更均匀。

Q7:HashMap 的 key 一般用什么类型?为什么?

String 和 Integer 最常用。原因:

  1. 不可变性:hashCode 不会变化,put 之后再修改不会导致查找失败
  2. hashCode 和 equals 已经重写好了:无需担心实现质量
  3. 分布均匀:String 的 hashCode 设计经过大量验证,冲突少

Q8:能用自定义对象作为 key 吗?需要注意什么?

能。但必须同时重写 hashCode() 和 equals() 方法,且满足以下约束:

  • equals 相等的对象,hashCode 必须相等
  • hashCode 相等的对象,equals 不一定相等(这是哈希冲突,可以接受)
  • hashCode 应该尽量均匀分布(减少冲突)

Q9:JDK 7 和 JDK 8 的 HashMap 有什么主要区别?

维度 JDK 7 JDK 8
结构 数组+链表 数组+链表+红黑树
插入方式 头插法(扩容时可能死循环) 尾插法
hash 函数 复杂(多次扰动) 简单(一次异或)
扩容迁移 全部重新 hash 原位置或原位置+旧容量
初始化 构造时分配数组 懒加载(首次 put 分配)

Q10:为什么 HashMap 的容量一定要是 2 的幂?

两个原因:

  1. 位运算代替取模(n - 1) & hash 等价于 hash % n(前提是 n 是 2 的幂),位运算比取模快得多
  2. 扩容时数据迁移更简单:旧位置或旧位置+旧容量,无需重新计算 hash

Q11:HashMap 和 Hashtable 的区别?

对比维度 HashMap Hashtable
线程安全 是(synchronized)
效率
null 键/值 允许 不允许
出现时间 JDK 1.2 JDK 1.0
迭代器 fail-fast fail-safe(Enumeration)

Q12:HashMap 和 ConcurrentHashMap 的区别?

  • 线程安全:ConcurrentHashMap 是线程安全的,HashMap 不是
  • null 支持:HashMap 允许 null 键和 null 值,ConcurrentHashMap 不允许
  • 性能:JDK 8 的 ConcurrentHashMap 使用 CAS + synchronized 实现细粒度锁,并发性能远超 Hashtable
  • 数据结构:基本一致,都是数组+链表+红黑树

十、延伸思考:HashMap 的一些"边角料"知识

以下问题不一定每个面试都会问到,但多知道一点总没坏处——说不定就是你跟其他候选人的区分点。

10.1 LinkedHashMap 怎么实现有序?

LinkedHashMap 继承自 HashMap,在 Node 的基础上增加了 before 和 after 两个指针,形成了一个双向链表来维护插入顺序或访问顺序。

// LinkedHashMap.Entry 比 HashMap.Node 多了两个引用
static class Entry<K,V> extends HashMap.Node<K,V> {
    Entry<K,V> before, after;  // 双向链表指针
}

面试中常见的"用 LinkedHashMap 实现 LRU 缓存"就是利用了这个特性——将 accessOrder 设为 true,每次 get/put 时把被访问的元素移到链表尾部,链表头部就是最久未使用的元素。

10.2 为什么实际开发中推荐指定 HashMap 初始容量?

阿里巴巴 Java 开发手册中有这条规范:

// 不推荐:使用默认容量
Map<String, Object> map = new HashMap<>();

// 推荐:根据预期数据量指定初始容量
// expectedSize / 0.75 + 1 避免了不必要的扩容
Map<String, Object> map = new HashMap<>((int) (expectedSize / 0.75 + 1));

如果你已经知道要存大概 100 个元素,就应该指定初始容量为 100 / 0.75 + 1 ≈ 134,向上取最近的 2 的幂(256)。这样避免了在插入过程中多次扩容的性能开销。

10.3 HashMap 的 fail-fast 机制

你也许在遍历 HashMap 时遇到过 ConcurrentModificationException

Map<String, String> map = new HashMap<>();
map.put("a", "1");
map.put("b", "2");

for (String key : map.keySet()) {
    if ("a".equals(key)) {
        map.remove(key);  // 💥 ConcurrentModificationException!
    }
}

这是因为 HashMap 内部有一个 modCount 变量,每次结构性修改(put/remove)都会自增。迭代器在创建时记录当前的 modCount,每次 next() 时检查是否发生了变化——如果变了,说明有并发修改,抛出异常。

正确的删除方式是用迭代器的 remove() 方法,或者用 Java 8 的 removeIf()


写在最后

回到开头那个场景——面试官问"为什么树化阈值是 8"。读完整篇文章的你,现在应该能从容地回答:

"两个原因。第一,根据泊松分布,在良好的哈希函数下,链表长度达到 8 的概率约为亿分之六,正常情况几乎不可能出现。如果真的出现了,说明要么哈希函数设计有问题,要么遭遇了哈希碰撞攻击,此时转为红黑树来兜底。第二,在长度为 8 时,红黑树的平均查找次数约为 3(log₂8=3),而链表的平均查找次数约为 4,红黑树的优势开始显现。另外退化阈值设为 6 而不是 8,是为了形成一个缓冲区间,避免链表在 7 和 8 之间反复横跳导致频繁的树化/退化操作。"

这个回答,从数学原理到工程实践,从正向树化到反向退化,展现了你对 HashMap 设计思想的全方位理解。面试官大概率会在心里给你打个高分。

最后想说一句:HashMap 的源码值得你花一个下午认认真真读一遍。不是背八股文,而是去理解 Doug Lea、Josh Bloch 这些大神们在每一个设计决策背后的考量——为什么这么设计?如果是你会怎么做?当你开始这样思考,你就不是在准备面试,而是在提升自己的工程素养。而这,才是面试官真正想看到的。

Logo

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

更多推荐