Java 集合与并发控制 —— 从红黑树到 HashMap 到 ConcurrentHashMap
10 题 · 平均 2.7/5 · low 2.25 · medium 2.75 · high 3.5 · 第 1 轮
**核心考点定位**:验证是否理解红黑树作为一种「工程折中」的自平衡 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 更快。
面试官您好,红黑树是一种自平衡的二叉搜索树,本质上是在普通 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),避免了极端哈希碰撞下链表退化带来的性能陷阱。
**核心考点定位**:验证是否掌握三种 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`;继续维护投入产出比低。
面试官您好,这三个 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 时才见到。
**核心考点定位**:验证对 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) 复制开销爆炸。
面试官您好,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"。
**核心考点定位**:验证对 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 尾插法保留了插入顺序,不会形成环。
面试官您好,我以 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`。
**核心考点定位**:验证对 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 更适合)。
面试官您好,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`,它有协助扩容机制让多线程一起迁移。
**核心考点定位**:验证对 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,不是全面并发安全。
面试官您好,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%。
**核心考点定位**:验证对 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() 时才合并,写多场景吞吐高得多。
面试官您好,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 分片累加性能更好。
**核心考点定位**:验证对 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 兜底返回。
面试官您好,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,语义更简洁。
**核心考点定位**:验证是否理解 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 的简洁性正好匹配。
面试官您好,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。
**核心考点定位**:验证对享元模式本质的理解——用"不可变共享对象"换内存,以及能否准确识别 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 敏感(享元池长期持有对象增加内存压力)。
面试官您好,享元模式是 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 存类元数据。
享元模式的适用条件是"对象数量大 + 内蕴状态占比高 + 内蕴状态可不可变"。这三个条件不满足时不建议用,管理开销会超过节省开销。