臭名昭著aTrust隔离计划1.臭名昭著aTrust隔离计划2.VPN原理与aTrust隔离网络实践3.docker-easyconnect到底做了什么4.TUN(tunnel-隧道-虚拟网卡)模式
个人项目
personal(个人展示)GitHub Actions 自动部署个人展示项目
基于Quartz搭建的个人博客1.Quartz个人博客使用教程2.使用 rsync 增量部署 Quartz 博客3.使用 GitHub Actions 自动部署 Quartz 博客5.域名绑定6.CDN加速7.Google 搜索收录排查:为什么关键词搜得到别人,却搜不到我的网站?Obsidian、双向链接与知识图谱
架构设计
分布式分布式事务RPC原理
旅行
行程攻略青岛三日游行程攻略
前端
NginxNginx配置与反向代理入门
Nodenpm与npx的区别
数据库
mysql01.MySQL基础语法与约束02.MySQL数据库类型03.MySQL多表查询04.MySQL 索引05.MySQL 事务06.MySQL 隔离级别07.MySQL MVCC08.MySQL 锁09.EXPLAIN10.慢查询与 SQL 优化
mysql面试题01.索引问题02.事务与隔离级别03.锁机制04.存储引擎与性能优化05.用户权限、字符集与备份恢复
mysql实战01.EXPLAIN实战02.测试环境菜单误删排查与恢复03.MySQL IN 详解
网络
网络安全1.无线监听网卡苹果M系列虚拟机安装Kali Linux教程3.kali怎么使用RTL8812AU4.kali基础命令
网络基础进程之间怎样通过网络传递数据
网址常用网址
中间键
esElasticsearch 入门
mq
RabbitMQRabbitMQ高级RabbitMQ基础
1. MQ 基础2. MQ 核心角色与代码对应3. MQ Consumer Group 与消费模式4. SOFAMQ 账户变更代码实践5. MQ 可靠性问题6. 消息幂等
redis缓存击穿、穿透与雪崩Redis 分布式锁Redis 缓存问题Redis 数据结构
AI
使用说明
第三方插件&技能简介Archify使用与安装指南geekgeekRun使用OfferFlow使用指南Ponytail使用指南Skills Manager使用指南
CodexCodex CLI与IDE区别及使用指南Codex Hook单独配置与提交通知Codex MCP安装与使用指南Codex第三方插件安装与使用指南
Agent开发0-学习路线1.LLM、Token、上下文窗口与模型参数2.大模型 API、请求参数与响应结构3.Spring AI ChatClient4.Prompt、System Prompt、Prompt 模板6.结构化输出、JSON Schema7.SSE 流式响应8.会话 ID、聊天记录、Redis9.超时、重试、限流、降级10.*完成可运行聊天接口11.Function Calling、Tool Calling 原理12.用 Java 方法定义 Tool13.工具参数设计和校验14.实现订单查询工具15.实现物流查询工具16.实现库存查询工具17.多个工具自动选择18.工具异常、超时、重试
diy主机
主机零件学习1.CPU2.主板3.内存4.显卡5.硬盘6.散热7.风扇8.机箱热门型号价格表
捡垃圾diy-13600kf+5060diy-14600kf+5070
English高频词汇英文基础语法简单句子音标
GitGit常用命令Sublime Merge 使用指南
Java
工具库Caffeine本地缓存Reactor教程
工作实践如何使用@Async线程池使用与排查线上服务 CPU 使用率过高排查压测下RPC接口大量失败排查记录
基础1.HashMap2.ConcurrentHashMap3.HashSet4.final5.static6.volatile7.AtomicInteger8.String动态代理反射锁与线程同步线程注解
面试题1.HashMap 存储在方法内是线程安全的吗2.ArrayList参数传递与引用重新赋值3.== 和 equals 的区别、hashcode4.重载和重写的区别5.String、StringBuffer、StringBuilder区别及使用场景6.接口和抽象类7.ArrayList 与 LinkedList 的区别和扩容机制8.HashMap 和 Hashtable 的区别及底层实现9.Object 和 Objects 的区别10.ThreadLocal的原理和使用场景11.字节码是什么及作用12.Java类加载器有哪些13.双亲委派模型14.Java异常体系15.GC是什么16.GC如何判断对象可以被回收
面试总结0.HR面试模板1.面试一2.面试二3.面试三笔试
网络与 IO(黑马程序员-纯理论)01-网络编程基础02-IO模型与零拷贝03-BIO04-NIO基础与缓冲区05-NIO通道与选择器06-AIO
juc00-目录01-进程与线程02-线程同步与通信03-并发设计模式04-Java内存模型05-CAS与原子类06-ThreadLocal07-阻塞队列08-线程池使用与原理09-任务调度10-ForkJoin与享元模式11-AQS12-ReentrantLock13-读写锁14-并发协作工具15-ConcurrentHashMap16-CopyOnWrite与同步容器17-SkipListMap18-非阻塞队列
juc(工作常见)Java并发编程三个重点拆解
jvm1. JVM运行时数据区
Spring1.Spring IoC、DI与构造方法注入CGLIB、Byte Buddy 与 Spring AOPSpring 常用注解详解Spring Bean 生命周期
Spring MVC异常处理机制
linux常用命令
mac快捷键终端常用操作
python
基础01.Python是什么,为什么要安装Python02.Python项目中.venv是什么03.Python基础语法
基础实战sql替换
HTTPXHRMS员工详情接口调用(Python HTTPX)
windowswindows激活

面试主线

HashMap 的面试题看起来很多,本质都围绕五个关键词:数组、哈希定位、冲突处理、树化、扩容。

相关笔记:3.HashSet 使用 HashMap 的 Key 唯一性实现元素去重。

00、🧭 阅读前先懂:数组、链表和红黑树是什么?

先记住这句话

数组像一排有编号的柜子,链表像一列用挂钩连接的车厢,红黑树像一本每次都能排除一半范围的分类目录。

0.1 数组:一排有编号的柜子

数组(Array)会保存一组同类型数据。创建后长度固定,每个位置都有从 0 开始的编号,这个编号叫作下标(index)。

下标     0       1       2       3
      ┌──────┬──────┬──────┬──────┐
数组  │ 张三 │ 李四 │ 王五 │ 赵六 │
      └──────┴──────┴──────┴──────┘

想找下标为 2 的数据,可以直接打开 2 号柜子,不需要先查看 0 号和 1 号。因此,根据下标访问数组元素通常非常快,可以理解为 O(1)。

// 创建一个长度固定为 4 的数组。
String[] names = {"张三", "李四", "王五", "赵六"};
 
// 使用下标 2,直接取得第三个元素。
System.out.println(names[2]); // 王五

数组的特点:

优点缺点
知道下标时,可以直接找到元素创建后长度固定
结构简单,访问速度快中间插入或删除时,可能要移动后面的元素

**在 HashMap 中的作用:**数组是第一层目录。HashMap 根据 Key 算出一个下标,先快速定位到对应的数组位置,这个位置叫作“桶(bucket/bin)”。

0.1.1 hash 是什么

hash 可以先理解成:根据 Key 计算出来的数字编号。

例如,我们要保存:

map.put("张三", 18);

这里各部分的含义是:

Key   = “张三”
Value = 18
hash  = 根据“张三”计算出来的一个整数

可以把 hash 想成机器给 Key 生成的“数字身份证”:

张三 ──计算──> 假设得到 hash 21
李四 ──计算──> 假设得到 hash 37

HashMap 为什么需要 hash?因为数组只认识数字下标,不认识“张三”这样的文字。HashMap 先把 Key 转换成数字,后面才能继续计算应该放进哪个数组位置。

现阶段只记这一句

Key 是原始名字,hash 是根据 Key 算出来的数字编号,下标是最终选中的数组位置。

严格来说,Java 会先调用 Key 的 hashCode() 得到一个整数,HashMap 再对这个整数做一次简单处理,得到自己内部使用的 hash。第一次理解时,可以先把它们看成“给 Key 生成数字编号”的前后两步。

需要注意:hash 不是绝对唯一的。两个不同的 Key 可能得到相同的 hash,也可能最终得到相同的数组下标,所以 HashMap 还需要链表或红黑树处理冲突。

0.1.2 HashMap 怎么根据 Key 算出数组下标

先不要看位运算公式。把 HashMap 想成一个只有 16 个抽屉的柜子:

抽屉编号:0、1、2、3……15

现在要保存:

map.put("张三", 18);

HashMap 要解决的问题是:“张三”应该放进哪个抽屉?

它会分两步处理:

第一步:把 Key“张三”转换成一个整数
        假设得到 21
 
第二步:把 21 换算成 0~15 之间的抽屉编号
        21 除以 16,余数是 5
 
结果:“张三”放入 5 号抽屉

为什么不能直接使用 21?因为数组只有 0~15 号位置,没有 21 号位置,所以必须把大数字缩小到数组的合法范围。

现阶段只记这一句

HashMap 先把 Key 变成一个整数,再把这个整数换算成数组范围内的下标。

实际源码为了计算更快,没有真的写“除以 16 取余数”,而是使用下面的位运算:

int index = (数组长度 - 1) & hash;

当数组长度是 2 的幂时,这个运算可以理解成“取余数”。第一次学习时先不用研究二进制,只要知道它最终负责把数字限制到合法数组下标即可。

如果“张三”和“李四”最后都算出 5,它们就都要进入 5 号桶,这叫作哈希冲突。HashMap 会再使用链表或红黑树把它们区分开。

0.2 链表:用指针串起来的车厢

继续使用刚才的抽屉例子。

假设 HashMap 算出:

“张三”应该放进 5 号桶

于是 5 号桶里先放入张三:

5号桶 → 张三

后来又保存“李四”,结果李四也被算到了 5 号桶:

“张三” → 5号桶
“李四” → 5号桶

这就是哈希冲突:两个不同的 Key 想进入同一个桶。

HashMap 不能让李四把张三覆盖掉,于是会把它们像车厢一样连接起来:

5号桶 → 张三 → 李四 → 王五 → null

这串连接起来的节点,就是链表。

每个节点内部可以先简单理解为保存三样东西:

┌───────────────────┐
│ Key:张三          │
│ Value:18          │
│ next:下一个节点   │────→ 李四节点
└───────────────────┘

其中 next 的意思就是:“下一个节点在哪里。”

当 HashMap 查找“李四”时,会这样做:

第一步:算出李四在 5 号桶
第二步:打开 5 号桶,先看到张三
第三步:张三不是李四,沿着 next 继续往后找
第四步:找到李四,返回李四对应的 Value

现阶段只记这一句

链表就是把落在同一个桶里的多个数据,一个接一个串起来,防止它们互相覆盖。

链表的问题也很明显:如果 5 号桶中串了 100 个节点,查找最后一个时,可能要从第一个一路找到第一百个。因此链表太长时,HashMap 才会考虑把它转换成红黑树。

0.3 红黑树:会自动保持平衡的二叉搜索树

继续看刚才的 5 号桶。

如果只有三个数据,链表还不算慢:

5号桶 → 张三 → 李四 → 王五

但是,如果 5 号桶里串了很多数据:

5号桶 → 节点1 → 节点2 → 节点3 → 节点4
       → 节点5 → 节点6 → 节点7 → 节点8……

假设要找最后面的节点,HashMap 只能从第一个开始,一个接一个检查。链表越长,查找越慢。

于是,HashMap 会把这一长串节点重新组织成有分支的结构:

                节点4
              /       \
          节点2       节点6
          /   \       /   \
      节点1  节点3  节点5  节点7

这就是“树”。每个节点最多向下连接两个节点:

左边一个分支 + 右边一个分支

查找节点7时,不再从节点1一路看到节点7,而是沿着分支走:

先看节点4
→ 判断应该走右边
→ 找到节点6
→ 再走右边
→ 找到节点7

原来可能检查 7 次,现在只检查大约 3 次。数据越多,树相对链表的优势越明显。

普通的树也可能因为数据加入顺序不好,慢慢歪成一条链。红黑树会在插入和删除节点后,自动调整节点位置,尽量保持左右两边高度接近。这就是“自动保持平衡”。

为什么叫红黑树

每个节点内部都有一个“红色”或“黑色”标记。它们不是给人看的颜色,而是程序维持树平衡时使用的状态。具体的旋转和变色规则,第一次理解 HashMap 时可以先跳过。

现阶段只记这一句

红黑树就是把过长的链表改造成有分支、较平衡的查找结构,避免每次都从头找到尾。

需要注意:变成红黑树的是某一个冲突严重的桶内部,HashMap 最外层的数组仍然存在。

0.3.1 什么情况下才会转换成红黑树

理解了红黑树的作用后,再来看它什么时候出现。HashMap 不会发现链表变长就立刻转成红黑树,它要同时检查两件事:

条件一:同一个桶里的节点达到树化阈值 8
条件二:整个 HashMap 数组的长度至少是 64

继续用抽屉理解:

桶里的节点数量 = 一个抽屉里塞了多少盒子
数组长度       = 整个柜子一共有多少个抽屉

如果 5 号桶里的节点已经达到阈值,但是整个数组只有 16 个桶:

桶内节点很多
数组长度 16,小于 64
结果:暂时不变成红黑树,先扩容

扩容相当于把柜子从 16 个抽屉增加到 32 个,之后还可能增加到 64 个。抽屉变多后,原来挤在 5 号桶里的节点可能被重新分散到其他桶,链表自然就变短了。

如果同时满足:

桶内节点达到树化阈值 8
数组长度已经达到 64

HashMap 才会把这个桶里的链表转换为红黑树。

记忆口诀

链表到 8,数组到 64,才考虑真正树化;数组不足 64,优先扩容。

0.4 三种结构在 HashMap 中如何配合

数据结构在 HashMap 中负责什么大白话
数组根据 Key 算出的数字编号(hash)快速定位桶先找到哪一排柜子
链表保存少量哈希冲突节点同一个柜子里串几个盒子
红黑树管理大量哈希冲突节点盒子太多时改成分类目录

最小例子

假设数组有 16 个桶,三个 Key 都被算到 5 号桶:数量较少时,它们在 5 号桶里组成链表;冲突节点越来越多并满足树化条件后,5 号桶内部再转换成红黑树。数组本身并不会因此消失。

面试题目录

  1. HashMap 的底层数据结构是什么?
  2. 为什么链表改为红黑树的阈值是 8?
  3. 解决 Hash 冲突的办法有哪些?HashMap 用的是哪种?
  4. 为什么不直接使用红黑树,而是先使用链表?
  5. HashMap 默认加载因子是多少?为什么是 0.75?
  6. HashMap 中 Key 的存储索引怎么计算?
  7. JDK 8 为什么要让 hashCode 异或右移 16 位的值?
  8. 为什么 hash 值要与 length - 1 相与?
  9. HashMap 数组的长度为什么是 2 的幂次方?
  10. HashMap 的 put 方法流程是什么?
  11. HashMap 如何扩容?
  12. 一般使用什么作为 HashMap 的 Key?
  13. HashMap 为什么线程不安全?

01、🧱 HashMap 的底层数据结构是什么?

参考回答

JDK 7 的 HashMap 由“数组 + 链表”组成;JDK 8 开始引入红黑树,底层结构变为“数组 + 链表 + 红黑树”。数组负责定位桶,链表和红黑树负责保存发生哈希冲突的节点。

链表查询的最坏时间复杂度为 O(n);红黑树查询通常为 O(log n)。链表过长时转换成红黑树,可以改善查询性能。

树化需要关注三个常量:

常量值作用
TREEIFY_THRESHOLD8触发树化检查的阈值
UNTREEIFY_THRESHOLD6扩容拆分时可能退化为链表的阈值
MIN_TREEIFY_CAPACITY64允许真正树化的最小数组容量
// 链表达到阈值后,触发树化检查。
if (binCount >= TREEIFY_THRESHOLD - 1) {
    treeifyBin(tab, hash);
}
// 数组容量不足 64 时,优先扩容,而不是立即树化。
final void treeifyBin(Node<K, V>[] tab, int hash) {
    int n;
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) {
        resize();
    }
}

常见误区

“链表长度达到 8 就一定变成红黑树”并不严谨。如果数组容量小于 64,HashMap 会优先扩容,希望通过增加桶的数量分散冲突节点。

02、🌳 为什么链表改为红黑树的阈值是 8?

主要考虑两个因素:

  1. 树节点更重:源码注释指出,树节点占用的空间大约是普通节点的两倍。链表很短时,使用红黑树并不划算。
  2. 长链表很少见:哈希分布良好时,桶内节点数量近似服从泊松分布。源码估算中,桶内出现 8 个节点的概率已经非常低。
桶内节点数源码注释中的近似概率
00.60653066
10.30326533
20.07581633
40.00157952
60.00001316
80.00000006

面试表达

阈值 8 是时间、空间和冲突概率之间的折中,并不是一个脱离实现背景的“神奇数字”。

03、🧩 解决 Hash 冲突的办法有哪些?HashMap 用的是哪种?

方法核心思路特点
开放定址法冲突后继续寻找其他空槽位数据都保存在数组中,删除通常需要特殊标记
再哈希法使用其他哈希函数继续计算降低聚集,但增加计算成本
链地址法同一桶中的冲突元素组成链表或树插入、删除方便,HashMap 采用此方案
公共溢出区冲突元素统一放入额外区域主表和溢出表分开管理

HashMap 使用链地址法。JDK 8 之后,同一个桶内先使用链表;冲突严重且满足条件时,再转换为红黑树。

04、⚖️ 为什么不直接使用红黑树,而是先使用链表?

红黑树需要维护父子引用、节点颜色以及旋转平衡,结构更复杂,占用的内存也更多。

桶内元素较少时,链表创建成本低,顺序查找的实际开销也不大。只有链表较长时,红黑树的查询优势才足以覆盖维护成本。

少量冲突:链表更轻量
大量冲突:红黑树查询更稳定

补充

红黑树的查询通常为 O(log n),链表最坏为 O(n);但红黑树插入时可能需要变色、左旋和右旋,因此不适合从第一个节点起就使用。

05、📊 HashMap 默认加载因子是多少?为什么是 0.75?

默认负载因子是 0.75。它是查询性能、空间占用和扩容频率之间的折中。

负载因子空间占用哈希冲突扩容频率
较小较高较少较高
0.75平衡平衡平衡
较大较低较多较低

扩容阈值通常为:

threshold = capacity × loadFactor

默认容量为 16 时,阈值通常是 16 × 0.75 = 12。加入第 13 个映射后,会触发扩容。

原稿中的延伸阅读

06、🧮 HashMap 中 Key 的存储索引怎么计算?

计算过程分为三步:

对应的核心思路:

// 第一步:获取 Key 的原始 hashCode。
int h = key.hashCode();
 
// 第二步:将高 16 位信息混入低 16 位。
int hash = h ^ (h >>> 16);
 
// 第三步:根据数组长度计算桶下标。
int index = (tableLength - 1) & hash;

一句话回答

先使用 Key 的 hashCode() 计算扰动后的 hash,再通过 hash & (length - 1) 得到桶下标。

原稿中的延伸阅读

07、🔀 JDK 8 为什么要让 hashCode 异或右移 16 位的值?

核心代码:

(h = key.hashCode()) ^ (h >>> 16)

数组长度通常远小于 int 的取值范围,计算桶下标时主要使用哈希值的低位。如果两个 Key 只有高位不同、低位相同,它们很容易落入同一个桶。

把高 16 位信息混入低 16 位,可以改善哈希分布,同时只需要一次移位和一次异或,计算成本较低。

与 JDK 7 更复杂的多次扰动相比,JDK 8 的实现是在速度、效果和计算成本之间做取舍。

08、⚡ 为什么 hash 值要与 length - 1 相与?

当数组长度 length 是 2 的幂时:

hash & (length - 1) 等价于 hash % length

以数组长度 16 为例:

length     = 0001 0000
length - 1 = 0000 1111

与运算会保留哈希值的低 4 位,结果自然落在 0~15 之间。位运算也非常适合 HashMap 的桶定位方式。

09、📐 HashMap 数组的长度为什么是 2 的幂次方?

容量保持为 2 的幂主要有三个好处:

  • length - 1 的低位全是 1,可以更充分地利用哈希值低位,减少不必要的冲突和空间浪费。
  • 可以使用 (length - 1) & hash 快速定位桶。
  • 扩容为原来的两倍后,节点只会留在原下标,或移动到 原下标 + 旧容量。

构造方法传入的容量不是 2 的幂时,HashMap 会把它调整为大于等于该值的最小 2 次幂:

传入容量调整后的容量
88
1016
2032

JDK 8 的容量计算核心代码:

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 < 0) ? 1
            : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY
            : n + 1;
}

先执行 cap - 1,是为了让本来就是 2 的幂的容量保持不变。例如输入 8,如果不先减 1,最终会被调整为 16。

10、🔄 HashMap 的 put 方法流程是什么?

以 JDK 8 为例:

  1. 根据 Key 计算扰动后的 hash。
  2. 如果底层数组尚未初始化,先调用 resize() 创建数组。
  3. 使用 (n - 1) & hash 定位桶。
  4. 桶为空时,直接创建节点。
  5. 桶不为空时,判断首节点是不是相同 Key。
  6. 如果桶已经是红黑树,按树结构插入或更新。
  7. 否则遍历链表:Key 已存在就覆盖 Value;不存在就追加节点。
  8. 链表达到阈值时触发树化检查,容量不足 64 则优先扩容。
  9. 新增映射后,如果 size 超过阈值,再执行扩容。

面试精简版

put 会先计算哈希并定位桶;桶为空就直接插入,Key 已存在就覆盖 Value,冲突时追加到链表或插入红黑树;新增后再判断是否需要树化和扩容。

11、📦 HashMap 如何扩容?

resize() 一句话理解

resize 的字面意思是“重新调整大小”。在 HashMap 中,它负责准备一张容量合适的新数组,并把原有节点重新安排到新数组中。

resize() 是 HashMap 内部的方法,不需要开发者手动调用。它主要做两件事:

  1. HashMap 第一次存数据时,创建底层数组。
  2. 原数组不够用时,创建更大的数组,并迁移原有节点。

11.1 第一次调用:初始化数组

执行下面这行代码时:

Map<String, Integer> map = new HashMap<>();

HashMap 通常还没有真正创建默认长度为 16 的底层数组。等到第一次执行 put() 时,代码发现数组还是空的,才调用 resize() 创建数组:

new HashMap<>()
        ↓
只创建 HashMap 对象,底层数组暂未创建
        ↓
第一次 put("张三", 18)
        ↓
发现 table 为空
        ↓
调用 resize() 创建默认长度为 16 的数组

为什么第一次创建数组也叫 resize()

这里不是把 16 扩大成 32,而是把数组从“还不存在,容量为 0”调整成默认容量 16。源码复用了同一个方法完成初始化和真正扩容。

11.2 后续调用:扩大数组并迁移节点

默认情况下,数组容量是 16,负载因子是 0.75,因此扩容阈值通常是:

threshold = 16 × 0.75 = 12

加入第 13 个映射后,size > threshold,HashMap 会再次调用 resize():

旧数组容量:16                 新数组容量:32
┌──────────────┐               ┌──────────────────────────────┐
│ 0号桶~15号桶 │  ─resize()→  │ 0号桶~31号桶                 │
└──────────────┘               └──────────────────────────────┘
 
旧阈值:12                     新阈值:24

这个过程不是简单地在数组尾部追加 16 个空位置。Java 数组创建后长度不能改变,所以 HashMap 实际会:

  1. 创建一个容量通常为原来 2 倍的新数组。
  2. 重新计算旧节点在新数组中的位置。
  3. 把旧数组中的节点迁移到新数组。
  4. 让 HashMap 的 table 指向新数组。

可以把它想成换柜子:

原来只有 16 个抽屉,东西越来越多
              ↓
换一个有 32 个抽屉的新柜子
              ↓
把旧柜子里的东西重新分配到新柜子

11.3 节点如何迁移?

由于容量始终是 2 的幂,旧桶中的节点只需要检查哈希值与旧容量对应的那一位:

(hash & oldCapacity) == 0  → 留在原下标
(hash & oldCapacity) != 0  → 移到原下标 + oldCapacity

例如,旧数组容量是 16,某个节点原来在 5 号桶。扩容到 32 后,它只有两个去处:

留在 5 号桶
或者
移动到 5 + 16 = 21 号桶

它不需要在 0~31 号桶中重新做一次复杂搜索。

11.4 面试时怎么说?

面试回答

resize() 是 HashMap 内部用于初始化或扩容底层数组的方法。第一次 put 时,如果 table 还没创建,它会创建默认容量为 16 的数组;后续当 size 超过 threshold 时,它通常把容量和阈值扩大为原来的 2 倍,并把旧节点迁移到新数组。JDK 8 中,节点扩容后要么留在原下标,要么移动到“原下标加旧容量”的位置。

实际开发建议

如果能预估元素数量,创建 HashMap 时应设置合适的初始容量,减少扩容和节点迁移的成本。

原稿中的延伸阅读

12、🔑 一般使用什么作为 HashMap 的 Key?

通常优先选择 String、Integer、Long 等不可变类型。

原因包括:

  • 这些类已经正确重写了 equals() 和 hashCode()。
  • 对象创建后状态不会变化,放入 Map 后哈希值保持稳定。
  • String 会缓存计算过的哈希值,多次使用时无需重复计算。

自定义对象作为 Key 时,应使用业务唯一字段同时重写 equals() 和 hashCode(),并尽量把这些字段设计为不可变。

不要修改 Key

Key 放入 HashMap 后,如果参与 equals() 和 hashCode() 的字段发生变化,后续查询会根据新哈希值寻找新桶,但对象仍留在旧桶中,最终可能无法找到。

13、🧵 HashMap 为什么线程不安全?

先记一句大白话

HashMap 像一本没有管理员的共享登记簿。两个线程可以同时“查看旧内容、计算、写回”,它们不会互相等待,所以后写的人可能把先写的结果盖掉。

HashMap 的 put()、resize() 等操作没有使用锁,也没有提供并发修改所需的整体同步保证。问题不只是“多个线程同时运行”,而是一个看似简单的操作,内部其实包含了多个步骤;线程可能恰好在这些步骤之间交叉执行。

13.1 最容易理解的场景:商品数量少加了一次

假设 apple 当前的数量是 0,两个线程同时各增加一次:

Map<String, Integer> countMap = new HashMap<>();
countMap.put("apple", 0);
 
// 线程 A 和线程 B 同时执行这两步。
Integer oldValue = countMap.get("apple"); // 先读取旧值
countMap.put("apple", oldValue + 1);       // 计算后再写回

你期待最终结果是 2,但下面这种执行顺序可能让结果变成 1:

时间线程 A线程 BMap 中的值
1读取到 00
2也读取到 00
3计算 0 + 1,写入 11
4也计算 0 + 1,写入 11

两个线程确实都执行了一次加一,但它们都基于同一个旧值 0 计算,线程 B 写入的 1 覆盖了线程 A 的结果。这叫作丢失更新(lost update)。

正确情况:0 → 1 → 2
并发情况:A 读到 0 ─→ 写 1
          B 读到 0 ─→ 写 1
最终结果:1

这里要分清两层问题

get() 后再 put() 是一个“先读、再改、再写”的组合操作,本身不是原子的;而 HashMap 又没有提供同步保护,所以多个线程可以同时进入这个过程。即使把容器换成 ConcurrentHashMap,也不要继续使用 get() + put() 做并发计数,应使用它提供的原子组合方法。

并发计数可以这样写:

Map<String, Integer> countMap = new ConcurrentHashMap<>();
 
// merge 会把“读取旧值、加一、写回”作为一次针对该 Key 的原子操作处理。
// Key 不存在时写入 1;已经存在时,把旧值和 1 相加。
countMap.merge("apple", 1, Integer::sum);

13.2 更贴近 HashMap 内部的场景:不同 Key 也可能丢失

再假设两个不同的 Key 经过哈希计算后,碰巧落在同一个空桶中:

线程 A:put("张三", 18) ─→ 算出放入 5 号桶
线程 B:put("李四", 20) ─→ 也算出放入 5 号桶

一种可能的交叉过程是:

  1. 线程 A 检查 5 号桶,发现是空的。
  2. 在线程 A 写入前,线程 B 也检查 5 号桶,也认为它是空的。
  3. 线程 A 把“张三”节点写入 5 号桶。
  4. 线程 B 又把“李四”节点写入同一个位置。
  5. 后一次赋值覆盖前一次赋值,“张三”节点可能丢失。
两个线程看到的旧状态:5号桶 → null
 
线程 A 准备写入:      5号桶 → 张三
线程 B 准备写入:      5号桶 → 李四
 
可能的最终结果:       5号桶 → 李四
                                 ↑
                           张三没有被串到链表中

如果单线程执行,线程 B 会看到桶中已经有“张三”,然后把“李四”连接到后面;但并发执行时,两个线程都可能基于“桶为空”这个旧状态做决定。底层没有锁或 CAS 来保证“检查桶并写入节点”只能由一个线程完成,因此可能出现元素丢失,size 等内部状态也可能与实际内容不一致。

CAS 是什么

CAS 可以简单理解为:**写入前再检查一次,只有数据仍然是刚才看到的旧状态,才允许写入;如果已经被其他线程修改,这次写入就失败。**HashMap 在这里没有这种保护。

为什么这个问题有时测试不出来

线程的交叉顺序由系统调度决定。运行一次可能正确,运行很多次才偶尔出错;“这次没出问题”不代表代码是线程安全的。

13.3 还可能出现哪些问题?

问题快速理解
数据覆盖或元素丢失两个线程都根据旧状态写入,后一次写入盖掉前一次结果
可见性问题一个线程修改后,另一个线程没有同步保证,可能不能及时看到最新结果
扩容期间状态不一致一个线程迁移节点时,另一个线程还在读写旧数组或中间状态,结果不可预测
遍历异常遍历期间发生结构修改,可能抛出 ConcurrentModificationException;这个检测只是尽力而为,不能当作线程安全机制
JDK 7 的环形链表JDK 7 使用头插法迁移链表,并发扩容时可能形成环,查询可能死循环;这是经典旧版本问题

版本化回答

不要只回答“HashMap 会形成环形链表”。这个说法主要针对 JDK 7 的经典实现。回答 JDK 8 及之后版本时,更稳妥的重点是:HashMap 没有并发安全保证,并发写入可能导致丢失更新、元素丢失、不可见或内部状态不一致。

13.4 面试时可以直接这样说

30 秒回答

HashMap 线程不安全,是因为它的读写和扩容操作没有锁、CAS 等并发控制。比如两个线程同时往同一个空桶 put 不同的 Key,它们都可能先看到桶为空,然后分别写入,后写的节点就可能覆盖先写的节点,造成元素丢失。再比如用 get 加 put 做计数,两个线程都读到旧值 0,再各自写回 1,最终少加一次。JDK 7 还存在并发扩容形成环形链表的经典问题;JDK 8 以后回答时应把重点放在数据丢失、可见性和内部状态不一致。实际并发场景应使用 ConcurrentHashMap,并使用 merge、compute 等原子组合方法。

如果面试官追问“为什么不能偶尔加个锁”,可以继续回答:

Collections.synchronizedMap(new HashMap<>()) 可以给单次方法调用加互斥保护,但遍历时仍需按它的要求在 Map 上手动同步。并发读写通常优先使用 ConcurrentHashMap;涉及“读取后计算再写回”时,要使用 merge()、compute() 等原子组合方法,不能把几个线程安全的单次调用随意拼在一起。

并发读写场景优先使用 2.ConcurrentHashMap。如果使用 Collections.synchronizedMap(...),遍历时仍要遵循其同步要求。


🧠 十句话快速回顾

  1. JDK 8 的 HashMap 是数组 + 链表 + 红黑树。
  2. 哈希冲突采用链地址法解决。
  3. Key 的哈希会执行 h ^ (h >>> 16) 扰动。
  4. 桶下标通过 (n - 1) & hash 计算。
  5. 数组容量保持为 2 的幂。
  6. 默认容量是 16,默认负载因子是 0.75。
  7. 树化记住 8、6、64,容量不足 64 时优先扩容。
  8. put 遇到相同 Key 会覆盖旧 Value。
  9. Key 最好不可变,并正确重写 equals() 与 hashCode()。
  10. HashMap 不保证顺序,也不保证线程安全。

推荐答题顺序

先给结论 → 说明数据结构 → 描述执行流程 → 补充阈值 → 说明边界与版本。

🔗 参考资料