← 学习门户 每日面试题 学习看板

2026-10-09

Java 集合与并发控制 —— 从红黑树到 HashMap 到 ConcurrentHashMap

10 题 · 平均 2.7/5 · low 2.25 · medium 2.75 · high 3.5 · 第 1 轮

#1 什么是红黑树? low algorithm

题目

什么是红黑树?

我的回答

红黑树是 Java 中相对来说比较熟悉的一个数据结构,它在 JDK1.8 中将这个结构引入到 HashMap 中。在 HashMap 的节点长度超过 8,并且单个节点长度超过 8,并且它的数组长度超过 64,还是 16、64 的时候,才会将当前节点的链表升级为红黑树。通过红黑树对链表的链表进行改造,将原有链表改造为红黑树的结构,实现快速的搜索。而红黑树本身是一种自平衡的二叉树,它可以比较更,对比于链表,它可以更快的进行搜索,找到目标的值。红黑树本身是通过红节点与黑节点构成,所有叶子节点都是黑节点,整个树高不超过,平均树高是相等的。并且红黑树是自平衡的,搜索二叉树。

点评

评分:⭐⭐☆☆☆
优点:抓住和 HashMap 关联、能说出转树条件(8+64)、提到自平衡和叶子黑
不足:5 条性质只说 2 条;概念错位("平均树高等"错,是黑高相等);未量化复杂度;未说应用场景
补充:5 条性质("根黑叶黑红隔、黑高相等")、AVL 对比、TreeMap/TreeSet/Linux CFS/`std::map`

完整讲解

第一部分:知识重点剖析

**核心考点定位**:验证是否理解红黑树作为一种「工程折中」的自平衡 BST——既要能说出 5 条性质,又要能解释为什么工程上偏爱它而非 AVL。

**知识点拆解**:
1. **底层原理**:红黑树是自平衡二叉搜索树,通过「颜色约束」代替 AVL 的「严格高度差 ≤ 1」约束,允许最长路径不超过最短路径的 2 倍,本质上是把平衡的严格度换成了实现复杂度和写性能的收益。
2. **5 条性质**:①节点是红或黑;②根是黑色;③所有 NIL 叶子是黑色;④红节点的子节点必须是黑色(红不邻红);⑤任一节点到其所有叶子路径上的黑节点数相同(黑高相等)。
3. **复杂度**:查找/插入/删除均 O(log n);由于黑高 h ≥ 树高/2,树高 ≤ 2·log₂(n+1),写操作至多 2~3 次旋转。
4. **设计权衡**:相比 AVL 严格平衡(最多 O(log log n) 次旋转但更慢),红黑树牺牲少量查询效率换取更快的写入;相比跳表,红黑树内存紧凑、遍历有序。
5. **工程实践**:`TreeMap`/`TreeSet` 直接用红黑树;`ConcurrentHashMap` JDK8 桶内链表超过 8 且桶数超过 64 时转红黑树(避免扩容抖动);Linux CFS 调度器用红黑树管理任务;`std::map` 基于红黑树。

> 按「底层原理 → 设计权衡 → 工程实践」递进排列。

第二部分:考察方向与延伸递进

**考察方向**:
- **深度**:是否真正理解 5 条性质及其约束关系(比如"红不邻红"如何保证最长路径 ≤ 2 倍最短路径)
- **广度**:能否对比 AVL、B+树、跳表、哈希表在不同场景的选型
- **应用**:能否说清楚 JDK8 HashMap 为什么选红黑树(不是跳表),以及并发环境下的处理

**延伸递进面试题**:
1. **为什么 HashMap JDK8 选红黑树而不是跳表?**
- 解答要点:红黑树节点内存紧凑(无跳层层层指针),空间 O(n) vs 跳表平均 O(n) 但常数更大;且 HashMap 节点本身是数组/链表结构,转树改造侵入性小;跳表更适合需要范围查询或并发友好(Redis zset)。
2. **红黑树插入一个红色节点后如何修复?**
- 解答要点:分三种情况——叔叔红则父叔改黑、爷爷改红向上递归;叔叔黑则判断是左左/左右/右左/右右,做旋转(LL/RR 单旋转,LR/RL 双旋转),最后可能一路冒到根,根改黑。
3. **HashMap 为什么链表长度超过 8 才转树、桶数超过 64 才转树?**
- 解答要点:泊松分布下自然概率链长达到 8 已经极小(约 0.00000006),说明大概率是哈希冲突而非随机分布,此时转树更划算;桶数少于 64 时优先扩容解决冲突比转树更便宜(摊平冲突)。
4. **TreeMap 和 HashMap 在什么场景下该选哪个?**
- 解答要点:需要有序遍历、范围查询、K 值最近查询、排序取最大值 → TreeMap;只做 O(1) 键值映射、无顺序需求 → HashMap。写多场景 HashMap 更快。

第三部分:面试者参考回答(300-600 字口语化)

面试官您好,红黑树是一种自平衡的二叉搜索树,本质上是在普通 BST 上加了 5 条颜色约束。这 5 条性质是:第一,节点非红即黑;第二,根节点一定是黑色;第三,所有 NIL 叶子节点是黑色;第四,红节点的两个子节点必须是黑色,也就是"红不邻红";第五,从任一节点到它所有叶子路径上的黑节点数量必须相同,也就是"黑高相等"。正是第 5 条性质,配合"红不邻红",保证了最长路径不会超过最短路径的 2 倍,从而整个树的查找、插入、删除复杂度都是 O(log n)。

它相比 AVL 树的优势在于不追求严格平衡,只约束红黑比例,所以插入删除时最多 2 到 3 次旋转就能修复,写操作比 AVL 更快。工程上我们更关心"读多还是写多":读多严格平衡用 AVL,写多折中用红黑树,Java 生态里几乎所有标准有序结构都选了红黑树——比如 `TreeMap`、`TreeSet`,Linux 的 CFS 调度器,还有 C++ 的 `std::map`。

回到我们熟悉的 JDK 8 HashMap:当一个桶里的链表长度超过 8、并且整个 HashMap 的桶数组长度超过 64 时,链表会转换成红黑树。这里有两个门槛,第一个 8 来自泊松分布,随机情况下链长达到 8 的概率极低,说明大概率是哈希冲突而非分布问题;第二个 64 是保证扩容比转树更划算。转成红黑树之后,查找从 O(n) 降到 O(log n),避免了极端哈希碰撞下链表退化带来的性能陷阱。

#2 HashMap、Hashtable 和 ConcurrentHashMap 的区别? low java

题目

HashMap、Hashtable 和 ConcurrentHashMap 的区别?

我的回答

Hashmap,Hashtable,ConcurrentHashMap 三者核心底层都是 HashMap,都是用来存储 key value 类型的内容。最大的差异其实是对于并发的支持。HashMap 本身不支持并发,多线程并发,它是线程多线程下不安全的。而 Hashtable 跟 ConcurrentHashMap 都是多线程下线程安全的,但是它们两个的机制不一样,Hashtable 是在整体上整个方法上就添加了 synchronized 锁。所冲突的,在进行对应的数组上添加数据的时候才会添加锁。也就是说 ConcurrentHashMap 的加锁力度要比 Hashtable 的要少。目前 JDK 已经不推荐使用 Hashtable,因为它加锁的力度太大,效率太低了。常规正常情况下还是使用 HashMap,然后只有在存在并发的场景使用 ConcurrentHashMap。

点评

评分:⭐⭐⭐☆☆
优点:抓准并发主线、对比准 Hashtable 方法级锁 vs ConcurrentHashMap 桶级锁、工程选型正确
不足:漏 null 键值差异、默认容量、迭代器行为、ConcurrentHashMap JDK7→8 演进细节
补充:Hashtable 淘汰原因、CHM JDK7 Segment → JDK8 桶级 synchronized + CAS、弱一致迭代器

完整讲解

第一部分:知识重点剖析

**核心考点定位**:验证是否掌握三种 Map 的线程安全模型差异、JDK 演进、以及并发场景的正确选型。

**知识点拆解**:
1. **底层原理**:三者本质上都是哈希表实现,差异主要在锁模型和空值策略。`HashMap` 单线程裸奔,`Hashtable` 全表锁 + 拒绝 null,`ConcurrentHashMap` 分段→桶级细粒度锁 + 允许更高并发。
2. **并发模型差异**:`Hashtable` 把每个方法都 `synchronized`,锁的是 `this`(整个表),任何读/写都串行化;`ConcurrentHashMap` JDK7 用 16 个 Segment,每个 Segment 是独立的锁对象,JDK8 进一步细化到桶头节点,读操作几乎无锁(volatile 读 + CAS),只有同桶写入才竞争。
3. **设计权衡**:全表锁简单但吞吐瓶颈;分段锁粒度可控但 1.7 时代有 Segment 上限;桶级锁配合 CAS 是 JDK8+ 的甜蜜点。选型的本质是"锁粒度 vs 正确性成本"的权衡。
4. **工程实践**:默认用 `HashMap`;单线程转并发优先 `ConcurrentHashMap`;只需要"包一层同步"的低并发场景可用 `Collections.synchronizedMap`;`Hashtable` 只在对接遗留 API 时保留。
5. **迭代器语义**:`HashMap` fail-fast(`ConcurrentModificationException`);`Hashtable` 事实上弱一致但被官方标注为不可靠;`ConcurrentHashMap` 弱一致迭代器是设计保证,不抛 CME。

> 按「底层原理 → 设计权衡 → 工程实践」递进排列。

第二部分:考察方向与延伸递进

**考察方向**:
- **深度**:是否清楚 `ConcurrentHashMap` 从 1.7 分段锁到 1.8 桶级 `synchronized` + CAS 的演进原因
- **广度**:能否联系 `ConcurrentLinkedQueue`、`CopyOnWriteArrayList`、`Collections.synchronizedMap` 等并发容器形成选型谱系
- **应用**:能否根据 QPS、读多写少 vs 写多场景给出选型建议

**延伸递进面试题**:
1. **`ConcurrentHashMap` JDK7 和 JDK8 锁结构有什么变化?为什么这样改?**
- 解答要点:JDK7 Segment(默认 16 个)→ JDK8 桶头 `synchronized`。JDK8 取消 Segment 的原因:写多场景 Segment 仍是瓶颈,桶级锁更细;配合 CAS 处理桶为空场景,扩容时协助迁移。
2. **`ConcurrentHashMap` 如何保证 `size()` 是准的?**
- 解答要点:JDK8 用 `baseCount` + `counterCells[]`(类似 LongAdder)分片计数,`size()` 遍历累加,返回值可能是"某个瞬间的近似值",不保证强一致。
3. **什么时候用 `Collections.synchronizedMap`,什么时候用 `ConcurrentHashMap`?**
- 解答要点:低并发/整表加锁能接受时用 `synchronizedMap`;高并发读多写少/需要迭代时选 `ConcurrentHashMap`。前者需要自己处理迭代器外层加锁,后者不需要。
4. **为什么 `HashMap` 在 JDK8 引入红黑树但 `Hashtable` 一直没有?**
- 解答要点:`Hashtable` 是 JDK1.0 遗留物,早期未维护,JDK5 后官方推荐迁移到 `ConcurrentHashMap` 或 `Collections.synchronizedMap`;继续维护投入产出比低。

第三部分:面试者参考回答(300-600 字口语化)

面试官您好,这三个 Map 底层都是哈希表结构,最大差异在并发模型和空值策略。

`HashMap` 单线程裸奔,不保证线程安全,但性能最好;允许 1 个 null key 和多个 null value,默认容量 16,JDK8 里当桶内链表长度超过 8 且整个表容量超过 64 时会转成红黑树。

`Hashtable` 是 JDK1.0 的遗留物,把每个公共方法都加了 `synchronized`,锁的是整张表,读也串行化;同时它不接受 null key 或 null value,会抛 NPE。JDK5 之后官方就不推荐使用它了。

`ConcurrentHashMap` 是并发场景的默认选择。它的演进很有意思:JDK1.7 用 16 个 Segment 分段锁,每个 Segment 内部还有一张小 HashMap,一次加锁只锁一个 Segment;JDK1.8 进一步去掉 Segment,改用桶头节点的 `synchronized` 加 `CAS`——空桶写入用 CAS,非空桶写入锁桶头,扩容时用 `sizeCtl` 协助多线程迁移节点。这样写冲突的粒度细化到桶级,读操作几乎无锁。

迭代器也有差异:`HashMap` 和 `Hashtable` 都是 fail-fast,遍历时被修改会抛 `ConcurrentModificationException`;`ConcurrentHashMap` 是弱一致迭代器,遍历时允许修改,不抛异常,但可能读到修改前后的任意快照——这在读多写少的高并发统计场景非常合适。

选型上,单线程用 `HashMap`;只在极少数低并发场景需要线程安全时可以用 `Collections.synchronizedMap` 包一层;一旦进入真正并发场景,直接用 `ConcurrentHashMap`;`Hashtable` 只在对接历史 API 时才见到。

#3 Java 中的集合类有哪些?如何分类的? low java

题目

Java 中的集合类有哪些?如何分类的?

我的回答

Java 集合核心的其实也就是两项,一个是 List 系的,一个是 Map 系的。也就是链表跟,也就是存储一系列的 List,然后另外一个以及 Key-Value 类型的 Map。然后 List 衍生下来包括,Arraylist、LinkedList,然后还有相关的 LinkedList 衍生出来的栈跟队列。另外就是 HashMap 衍生出来类队列的 Set 集合,基本上就这几种类型。然后常用的就是 Arraylist,然后用来做日常的数据存储。然后 Linked List 可以做类似链表或者栈的方面的操作。Set 用来去重,存储常规数据集合。Map 方面就存储 KV,KV 映射。一般可以拿 MySQL 做缓存,然后保存一些日常的缓存数据。Redis 也可以当做一个大的 Map

点评

评分:⭐⭐☆☆☆
优点:说出 List+Map 两大主线、举了主要实现类
不足:关系搞错(Set 非 Map 派生);Queue 位置错;漏 TreeSet/LinkedHashSet/ConcurrentHashMap/PriorityQueue;未区分 Collection vs Map 顶层;概念混淆(MySQL/Redis 不是集合类)
补充:集合框架全景图、选型口诀、过时 API

完整讲解

第一部分:知识重点剖析

**核心考点定位**:验证对 Java 集合框架顶层结构的整体认知,能否画出完整体系图并说清每种容器的选型场景。

**知识点拆解**:
1. **底层原理**:集合框架由两大顶层接口构成——`Collection`(元素集合)和 `Map`(键值对),Map 不在 Collection 之下。`Collection` 之下按语义分三支:`List`(有序可重复)、`Set`(无序不重复)、`Queue`(队列语义)。
2. **关键设计**:几乎每个抽象语义都有三种实现——基础版(ArrayList/HashSet/HashMap)、保插入序版(LinkedList/LinkedHashSet/LinkedHashMap)、排序版(—/TreeSet/TreeMap),以及并发版(CopyOnWriteArrayList/ConcurrentSkipListSet/ConcurrentHashMap)。
3. **设计权衡**:不同容器是不同复杂度曲线的权衡:ArrayList 数组下标 O(1) 但插删要搬;LinkedList 双端增删 O(1) 但随机访问 O(n);TreeSet 有序但每操作 O(log n);HashSet O(1) 但乱序。
4. **工程实践**:日常业务代码 90% 用 ArrayList + HashMap;排序场景上 TreeMap/TreeSet;去重且保插入序用 LinkedHashSet;并发计数用 ConcurrentHashMap 或 LongAdder。
5. **过时 API**:`Vector`、`Stack` 在 JDK5 后被 `Collections.synchronizedList`、`Deque` 全面替代;`Dictionary` 已被 Map 替代。

> 按「底层原理 → 设计权衡 → 工程实践」递进排列。

第二部分:考察方向与延伸递进

**考察方向**:
- **深度**:能否说清接口继承关系(`Deque extends Queue extends Collection`)
- **广度**:能否把 `java.util` 和 `java.util.concurrent` 下的集合形成选型谱系
- **应用**:能否针对"去重 + 保插入序 + 并发写"这种复合需求给出方案(例如 `ConcurrentLinkedQueue` + 去重逻辑,或自建 `ConcurrentHashSet`)

**延伸递进面试题**:
1. **为什么 Map 不是 Collection 的子接口?**
- 解答要点:Map 的语义是"键→值"的映射,不是"元素集合";且 Map 的遍历返回的是 EntrySet 而非 Map 自身。Java 团队把它作为独立顶层接口,避免语义混淆。
2. **`Deque` 和 `Queue` 什么关系?为什么推荐用 `Deque`?**
- 解答要点:`Deque` 是 `Queue` 的子接口,扩展了双端操作(push/pop/addFirst/addLast)。用 `Deque` 而非 `Stack`,因为 Stack 是 Vector 派生、全方法 synchronized 且过时。
3. **`ArrayList` 和 `LinkedList` 到底谁快?**
- 解答要点:90% 场景 ArrayList 更快(内存局部性 + 缓存友好),只有在极端两端高频增删时才用 LinkedList。
4. **`CopyOnWriteArrayList` 适合什么场景?为什么不适合写多?**
- 解答要点:适合"读远多于写、通知类场景"(如订阅者列表)。每次写都会复制整个数组,写多时 O(n) 复制开销爆炸。

第三部分:面试者参考回答(300-600 字口语化)

面试官您好,Java 集合框架的顶层结构是两根主干——`Collection` 和 `Map`,两者并列,Map 并不属于 Collection。

`Collection` 下面分三支:`List` 是有序可重复的,比如 `ArrayList`(数组,随机访问快)、`LinkedList`(双向链表,两端增删快)、`CopyOnWriteArrayList`(并发读);`Set` 是元素不重复的,有 `HashSet`(无序 O(1) 查重)、`LinkedHashSet`(保插入序)、`TreeSet`(自然排序);`Queue` 是队列语义,包含 `PriorityQueue`(堆)、`ArrayDeque`(双端队列)、`ConcurrentLinkedQueue`(无锁并发队列)。这里要注意 `Deque` 是 `Queue` 的子接口,扩展了双端操作,推荐用它替代过时的 `Stack`。

`Map` 下面主要有 `HashMap`(无序 O(1))、`LinkedHashMap`(保插入序、可当 LRU 缓存)、`TreeMap`(键自然排序)、`ConcurrentHashMap`(并发读写)和 `WeakHashMap`(弱引用清理)。选型上,日常业务 90% 是 ArrayList + HashMap;需要去重且保插入序用 LinkedHashSet 或 LinkedHashMap;需要排序用 TreeSet 或 TreeMap;并发场景直接上 ConcurrentHashMap。

另外提醒下,`Vector` 和 `Stack` 在 JDK5 之后已经被标记过时,用 `Collections.synchronizedList` 和 `Deque` 替代即可;`Dictionary` 也早已让位给 Map。真正并发容器都在 `java.util.concurrent` 包下,比如 `ConcurrentSkipListSet`、`BlockingQueue` 家族等。整体上选型口诀就是"要序用 Linked,要排用 Tree,要并发上 Concurrent,要快访问用 Array"。

#4 HashMap 在 get 和 put 时经过哪些步骤? medium java

题目

HashMap 在 get 和 put 时经过哪些步骤?

我的回答

Put,Put 先把 Key 转化成 Hash Code,然后去进行确认存储位置,确认存储位置之后进行比对,看当前位置是否已经存储了元素。如果没有存储元素就放进去,如果有存储元素就对比进行链表叠加,或者是转化成红黑树。然后另外 Put 要判定队列的数量,然后一定比例考虑是否扩容等一系列事件。插入的时候选择,当下 JDK 后期版本使用的是尾插法,不是最开始的头插法。头插法虽然更快,但是会有风险安全问题。尾插法的话不会有出现断链以及循环等极端严重的风险。是有对 key value 的判定,key 不能为空,只能有一个空,在 HashMap 中。在比对 key 的时候,使用,在 get 的时候将 key 也是转化成 hash code,然后先去找到对应位置,位置之后再依次作比,找到对应的是否是同一个,然后从链表中,如果是在对比链表中,当前桶下的链表中的对象,对比到是哪一个,通过 equals,如果有就去除,如果没有就返回 null。

点评

评分:⭐⭐⭐☆☆
优点:抓准"头插改尾插"这一 JDK8 关键改动;put/get 主线正确
不足:hash 位运算缺失;空键约束说错;转树条件不完整;get 树形分支漏了;put 覆盖 vs 新增没说清;put 与 get 讲混了
补充:`hash(key) = key.hashCode() ^ (key.hashCode() >>> 16)`、`(n-1) & hash`、JDK8 resize 位运算优化、头插法并发翻车

完整讲解

第一部分:知识重点剖析

**核心考点定位**:验证对 HashMap 内部实现的完整掌握——hash 计算、桶定位、冲突处理、扩容机制、以及 JDK7/8 关键差异(头插法 → 尾插法、引入红黑树)。

**知识点拆解**:
1. **底层原理**:HashMap 是「数组 + 链表 + 红黑树」的复合结构。数组是桶,桶内是链表(碰撞时挂接),链表过长转红黑树降低查找成本。JDK8 用尾插法替代头插法,避免并发扩容形成环形链表。
2. **put 流程**:①`hash(key) = key.hashCode() ^ (key.hashCode() >>> 16)` 高位参与运算;②`(n - 1) & hash` 定位桶(容量是 2 的幂,等价于模运算);③桶为空直接放,桶非空遍历:`key.equals()` 相同则覆盖 value,不同则尾插;④若链长 > 8 且容量 ≥ 64 → 转红黑树;⑤`++size > threshold` 触发 `resize()`。
3. **get 流程**:①同样算 hash;②定位桶;③桶首个元素 hash+equals 命中返回;④若为红黑树走 `getTreeNode` 树形查找 O(log n);⑤否则遍历链表逐一 equals 比对;⑥未命中返回 null。
4. **设计权衡**:容量必须 2 的幂——(n-1) & hash 等价于取模,且扩容时判断旧 hash 的第 n 位就能决定新位置;负载因子 0.75 是"空间 vs 时间"的经典折中;链表阈值 8 来自泊松分布,自然碰撞下概率极低,超过就是哈希冲突。
5. **工程实践**:预估容量时 `new HashMap<>(expectedSize / 0.75f + 1)` 避免扩容;自定义 key 类必须同时重写 `hashCode` 和 `equals`(违背会让 get 失效);不要在多线程中用 HashMap 做共享状态。

> 按「底层原理 → 设计权衡 → 工程实践」递进排列。

第二部分:考察方向与延伸递进

**考察方向**:
- **深度**:是否理解 `(n-1) & hash` 为什么能替代 `%n`(容量为 2 的幂时才等价)
- **广度**:能否对比 `ConcurrentHashMap` 的 put 流程(多了 CAS + synchronized)
- **应用**:能否根据 put 流程解释"为什么自定义 key 不重写 hashCode 会导致 Map 异常"

**延伸递进面试题**:
1. **为什么 HashMap 容量必须为 2 的幂?**
- 解答要点:容量 2^n 时,`n-1` 是低位全是 1,`(n-1) & hash` 等价于 `hash % n`;扩容时 `e.hash & oldCap` 一个 bit 就能判断新桶是原位还是 +oldCap;且低位散列均匀。
2. **`hashCode` 和 `equals` 契约是什么?打破会怎样?**
- 解答要点:契约是"equals 相等则 hashCode 必相等"(反过来不成立)。破坏后 put 时 A/B 的 hash 不同,equals 无法触发覆盖,出现"两个 equals 相等的 key 各占一个桶",get 时可能找不到刚 put 的值。
3. **HashMap 为什么默认容量 16、负载因子 0.75?**
- 解答要点:16 是内存开销和扩容频率的折中;0.75 是泊松分布下的经验值,冲突概率低但空间利用率合理。
4. **JDK7 头插法为什么会导致 CPU 100%?**
- 解答要点:并发扩容时两线程同时迁移链表,节点指针互相覆盖形成循环 e→f→e,get 命中该桶会死循环;JDK8 尾插法保留了插入顺序,不会形成环。

第三部分:面试者参考回答(300-600 字口语化)

面试官您好,我以 JDK8 为例说 put 和 get 的完整流程。

put 分四步:第一步算 hash,代码是 `hash(key) = key.hashCode() ^ (key.hashCode() >>> 16)`,把 hashCode 的高 16 位异或到低 16 位,让高位也参与计算,减少高位碰撞;第二步定位桶,`index = (n - 1) & hash`,因为容量 n 是 2 的幂,`n-1` 低位全 1,这一步等价于取模但更快;第三步桶处理,桶为空直接 new 一个 Node 放进去,桶非空就遍历:如果头节点 key 和当前 key 用 `==` 或 `equals()` 相等就覆盖 value,否则尾插一个新 Node 到链表末尾——这里请注意是尾插法,JDK7 用头插法在并发扩容时会形成环形链表导致 CPU 100%;第四步检查扩容条件,如果链长 > 8 且表容量 ≥ 64 就转红黑树,如果 `size > threshold`(默认容量 × 0.75)就调用 `resize()` 扩容到 2 倍。

扩容有个巧妙设计:对每个旧桶元素判断 `e.hash & oldCap`,如果结果为 0 就留在原 index,否则搬到 `index + oldCap`。因为容量翻倍后低位 bit 不变,新位置只会在这两个候选值之间。

get 流程类似:先算 hash 定位桶;桶头命中直接返回;如果桶是红黑树走 `getTreeNode` 走树形查找 O(log n);如果是链表就逐一 equals 比对;都没命中返回 null。

几个易踩的坑:容量一定要选 2 的幂;自定义 key 类必须同时重写 `hashCode` 和 `equals`,违背契约会导致 get 找不到刚 put 的值;预估容量时用 `new HashMap<>(expectedSize / 0.75f + 1)` 避免扩容抖动;并发场景直接换 `ConcurrentHashMap`。

#5 HashMap 是如何扩容的? medium java

题目

HashMap 是如何扩容的?

我的回答

hashMap 的扩容,首先是要判断节点数,有多少节点了,达到多大量级进行扩容。扩容的触发条件应该是 75%,我记得有一个参数可以设置扩容比例默认是 0.75,当前容量达到了这个量级之后进行扩容。

点评

评分:⭐⭐☆☆☆
优点:说对触发条件(loadFactor 0.75)
不足:只答了触发条件、未说扩容过程(核心 60% 缺失);未提扩容倍数、JDK7 vs JDK8 差异、位运算优化、红黑树退化
补充:JDK8 resize 完整流程、`e.hash & oldCap` 位运算、JDK7/8 对比表

完整讲解

第一部分:知识重点剖析

**核心考点定位**:验证对 HashMap 扩容机制的完整掌握,尤其是 JDK8 相对 JDK7 的位运算优化,以及红黑树退化规则。

**知识点拆解**:
1. **底层原理**:HashMap 用负载因子控制"空间 vs 时间"折中。当 `size > threshold` 时扩容,避免链表过长导致 O(n) 查找。默认 loadFactor=0.75 是泊松分布下的经验值。
2. **扩容流程(JDK8 resize)**:①判断是否首次初始化(table==null)→ newCap=initialCapacity;②否则 newCap=oldCap<<1(最大 1<<30);③若达 MAX_CAP 只增大 threshold;④重算 threshold=(int)(newCap*loadFactor);⑤遍历旧表迁移所有元素到新表。
3. **JDK8 迁移优化**:对每个节点 e,只需判断 `e.hash & oldCap`:为 0 则留在原 index(因为新表容量翻倍后 index 位不变),非 0 则搬到 `index+oldCap`。这是利用"容量是 2 的幂"这一约束——新旧 index 只在旧低位置的基础上多了一位。
4. **红黑树处理**:树形桶扩容时调用 `treeifyBin → split`,若树 size < 6(TREEIFY_THRESHOLD-1)退化回链表,因为扩容后冲突已减少。
5. **性能成本**:每次扩容 O(n) 遍历全部元素;多次小步扩容累积开销大,所以预估容量时主动传入 `new HashMap<>(expected/0.75f+1)` 可显著减少 GC 与 CPU 抖动。

> 按「底层原理 → 设计权衡 → 工程实践」递进排列。

第二部分:考察方向与延伸递进

**考察方向**:
- **深度**:能否推导出 `e.hash & oldCap` 判定新 index 的数学原理
- **广度**:能否对比 `ConcurrentHashMap` 的协助扩容(多线程迁移)
- **应用**:能否根据业务负载预估初始容量,规避扩容抖动

**延伸递进面试题**:
1. **`e.hash & oldCap == 0` 为什么就留在原 index?**
- 解答要点:newCap = 2*oldCap,所以 (newCap-1) 和 (oldCap-1) 相比,多出来一个 bit(第 n 位)。当 e.hash 的第 n 位为 0 时,(newCap-1) & hash 的低 n 位与 (oldCap-1) & hash 相同,index 不变;为 1 时 index 增加 oldCap。
2. **ConcurrentHashMap 如何协助扩容?**
- 解答要点:JDK8 用 `transferIndex` 变量协调多线程,每个线程负责迁移一段桶(步长 16);用 `FORWARD_NODE` 占位表示已迁移;读操作遇到正在迁移的桶会 `helpTransfer` 或走 `find` 兜底。
3. **为什么负载因子默认 0.75 而不是 1.0 或 0.5?**
- 解答要点:泊松分布下,0.75 时平均链长期望约 0.5,冲突概率极低;1.0 内存浪费小但冲突高,0.5 冲突低但内存翻倍。0.75 是空间和时间成本的经验最优点。
4. **HashMap 大量扩容时怎么优化?**
- 解答要点:预估容量传入构造器;使用 `computeIfAbsent` 减少重哈希;批量导入用 `Collectors.toMap` 后合并;避免高并发扩容(ConcurrentHashMap 更适合)。

第三部分:面试者参考回答(300-600 字口语化)

面试官您好,HashMap 的扩容由 `resize()` 方法完成,触发条件是 `size > threshold`,threshold 默认是容量 × 0.75。默认容量 16、loadFactor 0.75,所以第一次扩容阈值是 12。

扩容的具体过程 JDK7 和 JDK8 差别很大。JDK7 每次扩容都重新计算每个元素的 hash 值定位到新桶,然后用头插法挂接,这在并发场景下会形成环形链表导致 CPU 100%。

JDK8 做了两个关键优化。第一个是尾插法,保留了原链表的顺序,不再形成环。第二个是位运算迁移——不用重新算 hash,只需判断 `e.hash & oldCap`:如果结果是 0,说明元素 hash 的第 n 位是 0,扩容后新 index 等于旧 index,元素留在原位;如果结果非 0,说明第 n 位是 1,元素要搬到 `index + oldCap` 位置。这个优化的原理是容量是 2 的幂,新旧桶号只有在第 n 位上有差别,所以一个 bit 判断就能决定新位置。

扩容时如果桶是红黑树,会调用 `split()` 把树节点分拆到新旧两个桶;扩容后如果树 size 小于 6(`TREEIFY_THRESHOLD - 1`),会退化回链表,因为桶分散后冲突降低。

最大容量是 `1 << 30`,达到后不再扩容,只把 threshold 设为 `Integer.MAX_VALUE` 避免溢出。

工程上的最佳实践是预估容量时主动传入构造器,比如预估 1000 个元素就 `new HashMap<>(1334)`(1000/0.75+1 向上取 2 的幂前值),这样避免多次扩容抖动,能省掉大约 50% 的初始化时间开销。如果场景是并发写多,直接选 `ConcurrentHashMap`,它有协助扩容机制让多线程一起迁移。

#6 JDK1.8 中 HashMap 有哪些改变? medium java

题目

JDK1.8 中 HashMap 有哪些改变?

我的回答

JDK 1.8 中 HashMap 的改动,首先核心是处理了并发下它的死循环问题,把原本的头插法改成了尾插法。头插法原本头插法出现,可能会出现,在断头的过程中,其他线程的插入,导致出现头指尾、尾指头,整个变成了死循环,产生导致程序崩溃的问题。然后其次在 JDK 1.8 中引入了,当单条链哈希冲突到 8 个以上的时候,会进行红黑树扩容。引入了红黑树这个架构数据结构,替换了原本的链表,提升了对于数据检索的效率。然后再然后是关于扩容方式,原本是通过扩容之后,每个元素进行哈希重新分配,修改后使用位运算,当新旧值对比位运算是的值是位运算是 0 的时候就不用移,如果是 1 的话就进行迁移到新的位置,扩容后的新的位置,只用移一部分元素,不用全部元素再进行迁移。

点评

评分:⭐⭐⭐☆☆
优点:抓准三大核心改动(头插→尾插、红黑树、位运算扩容)
不足:术语错位(转树不是扩容);遗漏 hash 算法改造、Node 类拆分、树退化;位运算表述小瑕疵
补充:JDK8 HashMap 完整改动清单 7 项

完整讲解

第一部分:知识重点剖析

**核心考点定位**:验证对 HashMap JDK7 → JDK8 演进的理解,尤其每个改动背后的性能与正确性权衡。

**知识点拆解**:
1. **底层原理**:JDK7 的 HashMap 是"数组 + 链表",性能瓶颈在长链表的 O(n) 查找和并发扩容的头插法循环。JDK8 引入红黑树(O(log n))+ 尾插法 + 位运算扩容优化,从"数据结构 → 冲突处理 → 扩容效率"三个维度同时优化。
2. **改动 1:头插→尾插**:JDK7 头插法在并发扩容时会反转链表,两个线程同时扩容时 A 遍历 (e→f),B 遍历 (f→e),A 完成后 e.next=f.next=e,形成 e→f→e 环,get 命中该桶 CPU 100%。JDK8 尾插法保持插入顺序,避免此问题——但 HashMap 本身仍非线程安全,只是这个特定 bug 修复了。
3. **改动 2:hash 算法**:`hash(key) = (h = key.hashCode()) ^ (h >>> 16)`,把高 16 位异或到低 16 位。目的:让高位也参与桶下标计算,减少低位分布均匀时的高位碰撞。
4. **改动 3:红黑树**:链表长度 > 8 且容量 ≥ 64 时转红黑树;扩容时树桶 size < 6 退化回链表。8 来自泊松分布——自然概率链长到 8 的概率约 0.00000006,超过就是哈希冲突。
5. **改动 4:扩容位运算**:`e.hash & oldCap` 判断元素留原位还是搬到 index+oldCap。原理:容量是 2 的幂,扩容后新旧 index 只差第 n 位。避免重新 hash,节省约一半运算。
6. **改动 5:TreeNode**:JDK8 把 Node 拆分为 Node(链表)和 TreeNode(红黑树),TreeNode 继承 LinkedHashMap.Entry 保留双向链表语义,方便退化回链表时快速组装。

> 按「底层原理 → 设计权衡 → 工程实践」递进排列。

第二部分:考察方向与延伸递进

**考察方向**:
- **深度**:能否解释为什么 8 是泊松分布下的合适阈值
- **广度**:能否对比 `ConcurrentHashMap` JDK8 的类似改动(也引入红黑树、桶级 synchronized)
- **应用**:能否根据改动点解释为什么 JDK8 HashMap 内存占用略高于 JDK7

**延伸递进面试题**:
1. **为什么链表阈值选 8,而不是 4 或 16?**
- 解答要点:泊松分布下,负载因子 0.75 时自然概率链长到 8 的概率约 6×10⁻⁸。这意味着链长到 8 几乎必然是哈希冲突,不是随机分布,此时红黑树的 O(log n) 优势才体现。若阈值更低(如 4)容易误转树浪费内存。
2. **为什么 JDK8 引入红黑树后 HashMap 内存开销变大?**
- 解答要点:TreeNode 除了 key/value/next,还要存 parent/left/right 指针和红黑树颜色标记,每个节点比 Node 大约多 32 字节。为极端的冲突场景付出额外内存代价。
3. **`computeIfAbsent` 相比手写 if-null-then-put 有什么优势?**
- 解答要点:语义更清晰;在 `ConcurrentHashMap` 上能保证原子性(同桶内计算+插入);配合函数式编程减少样板代码。但注意 `computeIfAbsent` 里若调用会导致死锁的操作(如又调回同 Map 的方法)会出问题。
4. **为什么即使 JDK8 修复了头插法循环,HashMap 仍然非线程安全?**
- 解答要点:还有其他并发问题:size 计数不原子(丢失更新)、get 读到中间状态、put 覆盖丢失、遍历中修改抛 CME。HashMap 只是修了头插法的极端 bug,不是全面并发安全。

第三部分:面试者参考回答(300-600 字口语化)

面试官您好,JDK8 对 HashMap 的改动主要集中在四个层面。

第一是数据结构,从"数组+链表"扩展为"数组+链表+红黑树"。当某个桶的链表长度超过 8 且整个 HashMap 的容量超过 64 时,链表会转换成红黑树,把该桶内的查找从 O(n) 降到 O(log n)。阈值 8 来自泊松分布,自然概率下链长到 8 几乎为零,超过说明是哈希冲突而非分布问题。扩容时如果树桶 size 小于 6 会退化回链表。

第二是 hash 算法的优化。JDK8 的 hash 函数是 `hash(key) = key.hashCode() ^ (key.hashCode() >>> 16)`,把 hashCode 的高 16 位异或到低 16 位参与运算,让高位也参与桶下标的计算,减少碰撞。

第三是头插法改尾插法。JDK7 用头插法扩容时,两个线程并发操作会形成环形链表,get 命中该桶 CPU 会 100%。JDK8 用尾插法保留了插入顺序,避免这个问题。要注意这只是修复了特定的并发 bug,HashMap 本身仍然不是线程安全的,size 计数、并发 put 等都有问题,并发场景还是要用 ConcurrentHashMap。

第四是扩容的效率优化。JDK7 扩容时对每个元素重新计算 hash 定位新桶;JDK8 用 `e.hash & oldCap` 判断——如果结果非 0 说明元素 hash 的第 n 位是 1,新位置是 index + oldCap;如果为 0 说明第 n 位是 0,元素留在原位。原理是容量是 2 的幂,新旧 index 只在第 n 位有差别,一个 bit 判断就能决定迁移方向,省去了重新 hash 的开销。

此外还引入了 TreeNode 类(继承 LinkedHashMap.Entry,同时含树指针和双向链表指针),以及一批新的函数式 API:compute、computeIfAbsent、computeIfPresent、merge,配合 lambda 表达式的场景。总的来说 JDK8 从数据结构、hash 算法、并发正确性、扩容效率四个维度做了系统性优化,性能提升了大约 30%-50%。

#7 HashMap 用在并发场景中有什么问题? medium java

题目

HashMap 用在并发场景中有什么问题?

我的回答

HashMap 在并发场景中,由于它没有做锁的操作,多个线程同时进行操作的时候,会导致插入的是插入同一位置的对象出现冲突。在 JDK 1.8 之前,有比较严重的问题,就是它原本是用头插法,如果前一个头断开了,然后再重新指向的时候指向了自己,就会导致整个链路锁死。JDK 1.8 之后修复了这个问题,改成了尾插法,但是并不能弥补并发下插入内容插错的问题。多线程下,size 是同时插入,size 增加由于不是原子性的,也没有锁,会导致 size 错误,然后插入的 put 的内容被覆盖丢失掉。处理这个问题,核心其实在加锁。对应的主流的、主要的还是使用 ConcurrentHashMap 去进行线程安全的 HashMap 处理,或者是使用对应的那个工具类中 ConcurrentNavigableMap 方法来创建一个线程安全的线程安全的 HashMap。

点评

评分:⭐⭐⭐☆☆
优点:主线正确(JDK7 头插 → JDK8 尾插 → 仍非线程安全);size 非原子、put 覆盖丢失点抓准
不足:遗漏 get 中间状态、迭代 CME、check-then-act 竞态;工具类说法不准(ConcurrentNavigableMap 是 TreeMap 的替代)
补充:JDK8 HashMap 并发问题全景表、替代方案对比、工程实践

完整讲解

第一部分:知识重点剖析

**核心考点定位**:验证对 HashMap 并发缺陷的全面认识,尤其是 JDK8 尾插法修复的边界——它只修了一个 bug,不代表线程安全。

**知识点拆解**:
1. **底层原理**:HashMap 的所有操作(put/get/resize)都依赖非原子读-改-写,无内存可见性保证。JDK7 头插法在并发扩容时会翻转链表,形成 e→f→e 环形链表导致 CPU 100%。JDK8 改尾插法修复了这个特定 bug,但其他并发问题依然存在。
2. **JDK8 遗留的并发问题**:
- `size++` 非原子:两个线程同时 put,size 可能少加 1
- put 覆盖丢失:无锁检查 key 是否存在再写入,两个线程可能同时写入同一 key
- get 中间状态:并发 resize 期间,节点已从旧桶摘除但尚未挂到新桶时读到 null
- 迭代器 CME:modCount 检测发现修改后抛 ConcurrentModificationException
- check-then-act 竞态:`if (!map.containsKey(k)) map.put(k, v)` 之间可能被其他线程插入
- 内存可见性缺失:普通变量无 volatile,其他线程可能读到 CPU 缓存的旧值
3. **设计权衡**:HashMap 追求单线程性能,把所有并发正确性成本转嫁给调用者。选型时应根据"是否并发"直接决定用哪个类,而非在运行时加锁补救。
4. **工程实践**:默认用 ConcurrentHashMap;低并发场景用 Collections.synchronizedMap;复合操作用 computeIfAbsent/merge;不可变场景用 unmodifiableMap;共享计数器用 LongAdder/AtomicLong。

> 按「底层原理 → 设计权衡 → 工程实践」递进排列。

第二部分:考察方向与延伸递进

**考察方向**:
- **深度**:能否画出并发 put 时的 read-modify-write 时序图,说明丢失更新的机理
- **广度**:能否对比 ConcurrentHashMap 是如何针对性修复每个问题的(CAS、桶级锁、弱一致迭代)
- **应用**:能否根据并发特征(读多/写多/复合操作)选择合适的并发容器

**延伸递进面试题**:
1. **JDK8 尾插法为什么不能完全修复并发问题?**
- 解答要点:只解决了环形链表这一个 bug,其他并发问题(size、覆盖、可见性、迭代)依然存在。HashMap 的设计目标不是并发安全,尾插法只是顺手修了一个 bug。
2. **`computeIfAbsent` 在 ConcurrentHashMap 上为什么能保证原子?**
- 解答要点:桶为空时 CAS 写入;桶非空时锁住桶头节点,计算+写入在同一个锁内完成,避免 check-then-act 竞态。
3. **`Collections.synchronizedMap` 的迭代要外层加锁吗?为什么?**
- 解答要点:要。`synchronizedMap` 只同步单方法调用,迭代器本身不是同步的,遍历时可能被修改,需要手动在 synchronized(map) 块内迭代。
4. **为什么共享计数器推荐 LongAdder 而不是 AtomicLong?**
- 解答要点:AtomicLong 单变量 CAS,高并发下自旋竞争激烈;LongAdder 分片累加(多个 Cell),只有 sum() 时才合并,写多场景吞吐高得多。

第三部分:面试者参考回答(300-600 字口语化)

面试官您好,HashMap 在并发场景下有多个层面的问题。

第一个是 JDK7 的经典问题——头插法导致的环形链表。并发扩容时两个线程同时搬运链表,A 遍历到 e→f,B 遍历到 f→e,A 完成后变成 e→f→e 环,任何 get 命中这个桶都会死循环、CPU 100%。JDK8 改成了尾插法,这个问题修复了,但只是修复了这一个 bug,不代表 HashMap 就线程安全了。

第二个是 size 计数不准。`++size` 是 read-modify-write 的复合操作,两个线程同时 put 时可能都读到同一个 size 值,加 1 后写入,导致实际 count 少 1。

第三个是 put 覆盖丢失。HashMap 的 put 逻辑是"先检查 key 是否存在,存在则覆盖 value,不存在则插入",这个 check-then-act 中间没有锁保护。两个线程同时 put 不同 key 到同一个桶,其中一个可能会被另一个的写入覆盖。

第四个是 get 读到中间状态。并发扩容时,节点可能已经从旧桶摘除但还没挂到新桶,此时 get 命中该桶会返回 null。

第五个是迭代器的问题。HashMap 是 fail-fast 的,遍历过程中被修改会抛 ConcurrentModificationException。

第六个是内存可见性。HashMap 内部变量都不是 volatile,线程 A 修改后线程 B 可能读不到最新值。

解决方案上,主流就是用 ConcurrentHashMap——JDK8 用桶级 synchronized + CAS,读几乎无锁,并发写入也只锁一个桶。低并发简单场景可以用 `Collections.synchronizedMap(new HashMap<>())` 包一层全表锁,但要注意迭代时仍需外层加锁。需要有序的场景用 `ConcurrentSkipListMap` 代替 `TreeMap`。复合操作(如"不存在则创建")直接用 `computeIfAbsent` 或 `merge`,它们在 ConcurrentHashMap 上能保证原子。共享计数器不要用 Map 存 AtomicLong,直接用 LongAdder 分片累加性能更好。

#8 ConcurrentHashMap 是如何保证线程安全的? high java

题目

ConcurrentHashMap 是如何保证线程安全的?

我的回答

ConcurrentHashMap 保证线程安全还是使用的锁,synchronized 的关键字。在 JDK1.8 之前,它使用的是从区域分段锁。它的结构在 1.8 之前是用的分段,然后分桶,然后链表的模式。它锁一次是锁一个段的内容,主要还是为了控制锁的力度。在 JDK1.8 之后,把 synchronized 的移动到桶上了,也就是说只有出现哈希冲突,往对应的桶中添加元素的时候才会上锁,这时候它的力度更低。它在添加元素的时候如果出现哈西冲突,才走相关于锁的那一套东西。核心还是通过锁去保证线程安全的,只不过它的力度对比 HashMap 要小得多。HashMap 直接在 put 方法上加锁,而 ConcurrentHashMap 是在添加元素对应的桶上加锁,只有当冲突出现的时候才会有锁竞争、锁的问题、线程安全,否则是不会触发对应的锁的。

点评

评分:⭐⭐⭐☆☆
优点:抓准主线(JDK7 分段 → JDK8 桶级 synchronized);提到"只有冲突才加锁"
不足:**完全没提 CAS**(关键缺失);完全没提 volatile;完全没提 size 统计(baseCount+counterCells);对比错(HashMap 根本不加锁!);未提协助扩容、红黑树、弱一致迭代器
补充:JDK8 putVal 完整流程、协助扩容 helpTransfer、LongAdder 分片计数

完整讲解

第一部分:知识重点剖析

**核心考点定位**:验证对 ConcurrentHashMap JDK8 完整实现的理解,尤其是 CAS + synchronized 混用、协助扩容、分片计数三大机制。

**知识点拆解**:
1. **底层原理**:ConcurrentHashMap 是"数组 + 链表 + 红黑树"结构,JDK8 把 JDK7 的 Segment 分段锁拆掉,改用桶头节点的 synchronized + CAS 混用。桶为空用 CAS 无锁写入;桶非空 synchronized 桶头节点,锁粒度到单桶。
2. **putVal 完整流程**:①hash 定位 index;②table[index]==null → CAS 写入空桶(无锁);③桶头是 FORWARD_NODE → 迁移中,调 helpTransfer 或等迁移;④桶头 key 相同 → synchronized 覆盖 value;⑤TREEBIN → synchronized(treebin) 走树内 put;⑥链表尾插 → synchronized(node) 尾插;⑦count++ 检查扩容阈值;⑧触发 resize 时可能协作扩容。
3. **size 统计(LongAdder 思路)**:baseCount 单变量 CAS 累加,竞争激烈时降级到 counterCells[] 数组分片累加;size() 遍历累加返回值是"某个瞬间的近似值",不保证强一致。
4. **协助扩容 helpTransfer**:JDK8 用 transferIndex 变量记录迁移进度(从右向左),每个线程迁移 16 个桶(STRIDE 步长);已迁移桶用 FORWARD_NODE 标记;其他线程遇到迁移中的桶会主动 helpTransfer,读操作可 helpTransfer 或走 find 兜底。
5. **弱一致迭代器**:迭代器基于桶数组快照,遍历时允许修改,不抛 CME;可能读到修改前后的任意快照——非常适合读多写少的高并发统计场景。
6. **设计权衡**:synchronized 替代 ReentrantLock 的原因——JDK6+ 优化后性能相当;粒度小更容易在 JIT 层优化;语义简单。CAS 用于空桶快速写入,避免不必要的锁开销。

> 按「底层原理 → 设计权衡 → 工程实践」递进排列。

第二部分:考察方向与延伸递进

**考察方向**:
- **深度**:能否画出 putVal 完整流程图,说清每个分支的锁/CAS 策略
- **广度**:能否对比 `LongAdder`、`AtomicLong`、`ReentrantLock` 在类似场景的选型
- **应用**:能否根据业务 QPS/读写比/内存占用给出选型建议

**延伸递进面试题**:
1. **为什么 JDK8 取消 Segment 改用桶级 synchronized?**
- 解答要点:Segment 默认 16 个,写入冲突上限 16;桶级锁粒度可以做到 table.length 个(默认 16 但可扩到千万级),写冲突概率大幅下降。JDK6+ synchronized 已优化(偏向锁→轻量级锁→重量级锁),性能和 ReentrantLock 相当,且 JVM 更好优化。
2. **ConcurrentHashMap 的 size() 为什么可能不准?**
- 解答要点:baseCount + counterCells[] 分片累加,size() 遍历各分片时其他线程可能正在更新;返回值是"某个瞬间的近似值",不保证强一致。如果需要强一致,用 `getMap().values()` 之类的全量快照,但性能差。
3. **`computeIfAbsent` 在 ConcurrentHashMap 上有什么特殊行为?**
- 解答要点:桶为空 CAS 写入;桶非空锁住桶头,计算+插入在同一锁内完成。注意:在 computeIfAbsent 的函数中不能再调用同一个 map 的其他方法(会死锁),也不能返回 null。
4. **JDK8 扩容时多线程如何协作?**
- 解答要点:transferIndex 从 table.length-1 递减,每次减 STRIDE=16;每个线程从 transferIndex 处开始迁移 16 个桶到 transferIndex-16;已迁移桶用 FORWARD_NODE(hash 特殊值)标记;其他线程调用 put 时如果发现 FORWARD_NODE,调 helpTransfer 协助迁移或 find 兜底返回。

第三部分:面试者参考回答(300-600 字口语化)

面试官您好,ConcurrentHashMap 的线程安全机制在 JDK7 和 JDK8 差异很大。

JDK7 用分段锁。整个 Map 分成 16 个 Segment,每个 Segment 是一个独立的锁对象(ReentrantLock)+ 一个小 HashEntry 数组。加锁只锁一个 Segment,所以并发度上限是 16——16 个线程可以同时写不同的 Segment。

JDK8 大改,去掉了 Segment,把锁直接下放到桶头节点。整体结构变成"数组 + 链表 + 红黑树"。put 的时候分几种情况:如果目标桶为空,用 Unsafe 的 CAS 操作直接写入,完全不需要加锁;如果桶非空,synchronized 锁住桶头节点,然后在链表或红黑树里做操作。这样锁的粒度就是单个桶,表多大并发度就多大。

读操作几乎完全无锁。桶头节点用 volatile 修饰,读线程直接 volatile 读就能看到最新值,不需要加锁。所以读多写少的场景性能特别好。

size 统计用了类似 LongAdder 的分片思路。有个 baseCount 用 CAS 累加,竞争激烈的时候降级到 counterCells 数组分片累加,避免单点 CAS 竞争。size() 方法遍历累加返回值是"某个瞬间的近似值",不保证强一致。

扩容有个特别巧妙的设计——协助扩容。用一个 transferIndex 变量记录迁移进度(从右向左),每个线程迁移 16 个桶;已迁移的桶用 FORWARD_NODE 特殊标记。其他线程调用 put 时如果发现 FORWARD_NODE,会主动调 helpTransfer 协助迁移;如果只想读,会走 find 兜底查新旧两个位置。

迭代器是弱一致的,基于桶数组快照,遍历时允许修改,不抛 ConcurrentModificationException,可能读到修改前后的任意快照。

对比起来,JDK7 靠"分段锁 + ReentrantLock",并发度受 Segment 数限制;JDK8 靠"桶级 synchronized + CAS 混合",空桶写入走 CAS 完全无锁,非空桶走 synchronized 锁桶头,读走 volatile 无锁,并发度可以无限扩展。JDK6 之后 synchronized 经过锁升级优化,性能和 ReentrantLock 相当,所以 JDK8 干脆用回 synchronized,语义更简洁。

#9 ConcurrentHashMap 为什么用 `synchronized` 而不是 `ReentrantLock`? high java

题目

ConcurrentHashMap 为什么用 `synchronized` 而不是 `ReentrantLock`?

我的回答

ConcurrentHashMap 在 JDK1.8 中使用新的 synchronized 而不是 ReentrantLock,核心原因是在 JDK1.6 的时候,新的 synchronized 被 JVM 进行了一次升级,升级后它的性能跟效果其实是相对来说不输于 ReentrantLock 的,甚至比它更好。而有这个说法,为什么不用它?原因在于 JDK1.6 之前的版本使用的新的 synchronized,它的性能相对很低,它一直是加全量的锁。重量级锁,导致它性能比较低。但是在 JDK1.6 之后,它开始使用锁升级的一系列操作,包括从无锁到偏向锁到轻量级锁到重量级锁这一套升级过程,使得它本身的性能在不同的环境下有不同的选择,使得性能有一定一定程度提高。对比 ReentrantLock 来说,它的性能和使用方法更简单。而 ReentrantLock 它的使用,首先你需要创建锁对象,然后还需要进行人工手动的加锁跟解锁,它使用上在操作上会更繁琐。综合语义、内核和自身的考虑,GM 最终在 ConcurrentHashMap 中使用 synchronized 作为自身的锁,进行锁处理。

点评

评分:⭐⭐⭐⭐☆
优点:抓准核心原因(JDK6 synchronized 锁升级)、说对语义简单性、提到 JIT 优化、逻辑清晰
不足:表述小瑕疵("全量锁"应为"重量级锁");未提 JDK7 为什么用 ReentrantLock(关键上下文);未提锁粒度匹配、AQS 复杂度、JIT 优化
补充:完整决策树(技术演进+场景匹配+实现简洁+JIT+内存)、JDK7 用 ReentrantLock 的原因

完整讲解

第一部分:知识重点剖析

**核心考点定位**:验证是否理解 JDK6 synchronized 演进的技术决策,以及"什么场景选什么锁"的权衡思维。

**知识点拆解**:
1. **底层原理**:JDK5 及以前 synchronized 只有重量级锁(OS 内核 mutex,用户态到内核态切换 10μs+),性能比 ReentrantLock 差 10 倍。JDK6 引入锁升级机制:无锁(Mark Word 存储 hashCode)→ 偏向锁(无竞争时 CPU 直接标记线程 ID,零开销)→ 轻量级锁(CAS 尝试加锁,无自旋升级)→ 重量级锁(真正的 mutex)。锁升级基于竞争激烈程度自动迁移,避免了重量级锁的一次性成本。
2. **ReentrantLock 的代价**:基于 AQS(AbstractQueuedSynchronizer),需要 volatile int state + CLH 队列维护;每次加锁要 CAS state、失败则入队 park、唤醒后 unpark。每次操作都要读写共享变量,缓存行乒乓开销大。
3. **设计权衡**:
- **性能**:JDK6+ synchronized 在无竞争/低竞争场景比 ReentrantLock 快;竞争激烈时二者相当
- **内存**:synchronized 用 Mark Word 内联存储,0 额外对象;ReentrantLock 要 new 对象 + AQS state + 队列
- **语义**:synchronized 自动解锁(异常也解锁),ReentrantLock 必须手动 unlock,try-finally 包起来防漏
- **JIT 优化**:synchronized 是内建指令,JIT 可做逃逸分析消除锁、锁粗化、锁消除;ReentrantLock 是库调用 JIT 无法穿透
4. **JDK7 为什么用 ReentrantLock**:Segment 需要 `tryLock` 非阻塞尝试(避免死等)、`lockInterruptibly` 可中断、公平锁选项。这些是 ReentrantLock 独有特性。JDK8 桶级锁粒度更细、竞争更短,不需要这些高级特性,切回 synchronized 更合适。
5. **工程实践**:现代代码默认用 synchronized(简洁);需要可中断/tryLock/超时/条件变量时用 ReentrantLock;读多写少用 ReadWriteLock;无锁场景用 CAS/AtomicXxx。

> 按「底层原理 → 设计权衡 → 工程实践」递进排列。

第二部分:考察方向与延伸递进

**考察方向**:
- **深度**:能否画出 JDK6 synchronized 锁升级路径,说清每一步的开销差异
- **广度**:能否对比 synchronized、ReentrantLock、ReadWriteLock、StampedLock、CLH 队列的选型
- **应用**:能否根据具体锁竞争特征(竞争持续时间、竞争次数)选择合适锁

**延伸递进面试题**:
1. **synchronized 锁升级的四种状态?**
- 解答要点:无锁(Mark Word 存 hashCode)→ 偏向锁(存线程 ID,无竞争 CPU 直返)→ 轻量级锁(CAS 尝试,失败自旋最多 10 次)→ 重量级锁(真实 mutex,线程 park)。升级单向不可逆。
2. **偏向锁在 JDK15 后被默认禁用?**
- 解答要点:JDK15 JEP 374 默认禁用偏向锁。原因:偏向锁撤销成本高(全局 safepoint + 重偏向),现代 JIT 优化已能更好地处理无竞争锁。可通过 `-XX:-UseBiasedLocking` 手动禁用。
3. **什么时候必须用 ReentrantLock 而不是 synchronized?**
- 解答要点:需要 `tryLock()` 非阻塞加锁(如避免死等);需要 `lockInterruptibly()` 可中断;需要公平锁;需要多个 Condition 条件变量;需要 `lock(long, TimeUnit)` 超时加锁。
4. **为什么 ConcurrentHashMap 桶级锁不需要 ReentrantLock 的高级特性?**
- 解答要点:桶级锁竞争极短(微秒级),无阻塞死等风险;不需要可中断(迁移是短任务);不需要公平锁(吞吐优先);单个桶一个条件就够了。synchronized 的简洁性正好匹配。

第三部分:面试者参考回答(300-600 字口语化)

面试官您好,JDK8 ConcurrentHashMap 从 JDK7 的 ReentrantLock 切回 synchronized,核心是 JDK6 之后 synchronized 的演进让它在性能和语义上都更优。

JDK6 之前,synchronized 只有重量级锁,走的是操作系统 mutex,用户态到内核态切换开销非常大,性能比 ReentrantLock 差 10 倍左右。所以 JDK7 的 ConcurrentHashMap 只能用 ReentrantLock。

JDK6 之后,JVM 对 synchronized 做了大幅优化——锁升级机制:一开始是无锁状态,Mark Word 里存 hashCode;第一次加锁进偏向锁,Mark Word 直接写入线程 ID,无竞争时 CPU 直接返回,几乎零开销;第二次被其他线程竞争转轻量级锁,用 CAS 尝试,失败则自旋;再竞争升级到重量级锁,才走真正的 OS mutex。整个升级路径基于竞争激烈程度自动迁移。同时 JIT 编译器对 synchronized 有专门优化:逃逸分析可以消除不必要的锁、锁粗化可以合并相邻小锁、锁消除可以干掉死锁代码。

对比 ReentrantLock,JDK8 的场景下 synchronized 有四个优势。第一,性能——JDK6 之后无竞争和低竞争场景 synchronized 更快,因为 ReentrantLock 每次加锁都要 CAS volatile state、维护 CLH 队列,缓存行乒乓开销更大。第二,内存——synchronized 用 Mark Word 内联存储锁状态,0 额外对象;ReentrantLock 要 new 一个对象加 AQS state 加队列,内存占用明显。第三,语义——synchronized 是 JVM 内建语法,自动加解锁,即使抛异常也会解锁;ReentrantLock 必须手动 lock/unlock,还要 try-finally 包起来防漏。第四,JIT 友好——synchronized 可以被 JIT 逃逸分析消除、锁粗化,ReentrantLock 是库调用 JIT 无法穿透。

那为什么 JDK7 用 ReentrantLock?因为 Segment 需要 `tryLock()` 非阻塞尝试加锁、`lockInterruptibly()` 可中断、公平锁这些高级特性,都是 ReentrantLock 独有的。JDK8 桶级锁粒度细到单个桶,竞争极短、无死等风险、无中断需求,synchronized 的简洁性正好匹配。

所以 JDK8 的这次切换,本质是"JVM 技术演进 + 场景简化"的必然结果。现代代码里默认用 synchronized,只在需要 tryLock、可中断、公平锁、多个 Condition 时才用 ReentrantLock。

#10 什么是享元模式,有哪些具体应用? low system-design

题目

什么是享元模式,有哪些具体应用?

我的回答

享元模式。享元模式其实有点类似于单例模式,但是它是单例模式的一个延伸,相当于把单例模式的单个对象进行了类似于池化的操作,进行重复使用。比如 Spring 中对对象的管理,统一进行对象的统一管理,然后分配处理,其实就是一种享元模式的延伸使用。还有还有 Java 中 String 的,把 String 的那个元空间,把 String 对象,字符串对象进行持久化,然后不是反复的创建相同的字符串,这种都是享元模式的使用。

点评

评分:⭐⭐☆☆☆
优点:类比单例延伸,说出"池化共享"直觉;举了 String 常量池
不足:Spring IoC 类比错误(作用域管理≠享元);术语错位(字符串池≠Metaspace);未讲核心(内蕴/外蕴状态);未讲典型应用(Integer/Boolean 缓存);未讲工程风险(intern() OOM)
补充:本质定义、经典应用清单、String 天生享元的原因、String 5 种设计模式 ---

完整讲解

第一部分:知识重点剖析

**核心考点定位**:验证对享元模式本质的理解——用"不可变共享对象"换内存,以及能否准确识别 JDK 中的享元应用。

**知识点拆解**:
1. **底层原理**:享元模式(Flyweight Pattern)是 GoF 创建型设计模式之一,通过把对象拆成"内蕴状态(intrinsic state,可共享的不变量)"和"外蕴状态(extrinsic state,调用者自己持有)",让多个对象共享同一份内蕴状态,减少对象总数、节省内存。共享的前提是内蕴状态必须不可变(immutable)。
2. **经典应用**:
- **JDK 自动装箱缓存**:`Integer.valueOf(-128, 127)` 缓存小整数;`Boolean.TRUE/FALSE`;`Byte.valueOf(-128, 127)`;`Character.valueOf(0, 127)`
- **String 字符串常量池**:JVM 把字符串内容作为内蕴状态存到常量池,`"abc" == "abc"` 为 true
- **String.intern()**:显式把运行期字符串放入常量池,节省内存但可能撑爆池(OOM)
- **游戏引擎精灵**:精灵的渲染图作为内蕴状态共享,位置/旋转/动画帧作为外蕴状态
- **文本编辑器字符**:字符字体样式共享,位置和上下文由调用者持有
- **数据库行对象**:行结构模板共享,具体数据作为外蕴状态
3. **设计权衡**:享元用运行时共享逻辑换取了内存效率,但代价是查找共享对象的开销(通常用 Map);共享对象必须不可变,否则多线程下会相互干扰;不适合"对象数量不多"的场景(管理开销超过节省开销)。
4. **工程实践**:优先复用 JDK 内置享元(Integer/Boolean/String);自己实现享元时配套 FlyweightFactory 管理共享池;避免对可能无界的字符串做 intern();游戏和渲染引擎是享元模式的经典战场。
5. **常见误区**:
- Spring singleton scope ≠ 享元模式(Spring Bean 携带业务状态,不是内蕴共享)
- 字符串池 ≠ Metaspace(字符串池在 Heap,Metaspace 存类元数据)
- 缓存 ≠ 享元(缓存关注"暂时存",享元关注"共享同一份")

> 按「底层原理 → 设计权衡 → 工程实践」递进排列。

第二部分:考察方向与延伸递进

**考察方向**:
- **深度**:能否说清内蕴/外蕴状态的划分,以及"不可变"为什么是前提
- **广度**:能否列举至少 3 个 JDK 内置享元实现(Integer/Boolean/String)
- **应用**:能否判断业务对象是否适合享元(可变字段比例、数量级)

**延伸递进面试题**:
1. **`Integer a = 128; Integer b = 128; a == b` 结果?**
- 解答要点:false。Integer 缓存范围是 [-128, 127],128 超出范围会 new 新对象;改 127 则为 true(享元池命中)。
2. **为什么 String 天然是享元?**
- 解答要点:String 是不可变类(final class + final 字段 + 私有构造),字符串常量池只存一份相同内容,相同字符串引用同一内存。
3. **String.intern() 什么时候会 OOM?**
- 解答要点:把运行期生成的字符串显式加入常量池,若循环中每个字符串都是 unique(如 UUID、时间戳),常量池会持续增长撑爆内存。生产环境应避免对无界字符串做 intern()。
4. **享元模式什么时候不该用?**
- 解答要点:对象数量小(节省效果 < 管理开销);对象有大量可变字段(内蕴状态占比低);性能对 GC 敏感(享元池长期持有对象增加内存压力)。

第三部分:面试者参考回答(300-600 字口语化)

面试官您好,享元模式是 GoF 的创建型设计模式之一,本质是"用共享换内存"。

核心思想是把一个对象拆成两部分——内蕴状态(intrinsic state)和外蕴状态(extrinsic state)。内蕴状态是"多个对象都一样的、不随使用变化的",比如同一个精灵的渲染图;外蕴状态是"每个使用点独有的、可变的",比如这个精灵在屏幕上的位置和旋转角度。把内蕴状态共享,多个对象复用同一份,只让每个使用点持有自己的外蕴状态,就能大幅减少对象数量和内存占用。前提是内蕴状态必须不可变,否则共享对象之间会相互干扰。

Java 中最经典的享元应用有几个。第一是字符串常量池——JVM 把字符串内容作为内蕴状态存到 Heap 的字符串池中,`"abc" == "abc"` 是同一个引用;String.intern() 可以显式把运行期字符串加入池。要注意 intern() 有个陷阱:如果循环中对大量 unique 字符串(如 UUID、时间戳)调用 intern,会撑爆常量池引发 OOM。

第二是 JDK 的自动装箱缓存。Integer.valueOf() 会缓存 -128 到 127 的整数对象,所以 `Integer a = 127; Integer b = 127; a == b` 是 true;如果换成 128 就 false 了,因为超出了缓存范围要 new 新对象。同样原理 Boolean.TRUE/FALSE、Byte.valueOf(-128, 127)、Character.valueOf(0, 127) 都是享元。

第三是引擎和游戏场景。文本编辑器里同一个字体的字符渲染可以共享,位置和外蕴信息单独存;游戏里同一精灵的多实例共享渲染图;数据库行对象模板共享。这些场景对象数量大、内蕴状态占比高,是享元模式的经典战场。

常见的误区有两个。第一,Spring 的 singleton scope 不是享元模式——Spring Bean 通常携带业务状态,可变字段多,不是享元需要的"不变量共享"。第二,字符串池和 Metaspace 不是同一个东西——字符串池在 Heap,Metaspace 存类元数据。

享元模式的适用条件是"对象数量大 + 内蕴状态占比高 + 内蕴状态可不可变"。这三个条件不满足时不建议用,管理开销会超过节省开销。

📚 查看全部面试记录