Appearance
map-concurrency-compare
0. 前置速查(建议先读)
| 资料 | 说明 |
|---|---|
| 位运算基础(Java / Android 复习用) | 位运算(& ^ >>>) |
| 树与红黑树基础 | 树 / 红黑树(桶内树化) |
| CHM 是什么?(ConcurrentHashMap 速览) | CHM 是什么(Java 速览) |
| LRU 缓存与淘汰策略(含 LruCache) | LRU / LruCache(与 CHM 分工) |
| bitwise-operators-demo | 可运行:每个符号单独演示 |
| BitwiseOperatorsBasicsDemo.java | 位运算 Demo 源码 |
bash
cd labs/foundations/bitwise-operators-demo
javac -d out src/BitwiseOperatorsBasicsDemo.java && java -cp out BitwiseOperatorsBasicsDemo1. 实验目标与工程要点
本实验对比 Java 常考 Map 家族(HashMap、Hashtable、Collections.synchronizedMap、ConcurrentHashMap)的线程安全边界与底层差异,并用可运行代码验证:
HashMap不能当共享字典——并发put可能丢数据甚至结构损坏。ConcurrentHashMap保证的是单次 API 调用的线程安全,if (get == null) put()这类复合逻辑仍会竞态。computeIfAbsent/putIfAbsent才是「检查并初始化」的正确方向。synchronizedMapvsConcurrentHashMap——前者整表一把锁,后者桶级并发。- HashMap 桶下标算法——
hashCode→ 扰动 →(n-1) & hash(见HashMapIndexAlgorithmDemo.java);位运算前置见 §0。
2. 深入原理与生活浅显比喻
2.1 生活浅显比喻:公共布告栏 vs 分格储物柜
HashMap:办公室只有一块公共布告栏,多人同时贴纸条没人管顺序——可能覆盖、丢失、撕坏(非线程安全)。Hashtable/synchronizedMap:布告栏门口站一个管理员,每次只允许一个人靠近(整表串行,安全但慢)。ConcurrentHashMap:布告栏分成很多格子(桶 bin),大多数人只读不用排队;只有改同一格时才短暂加锁或用 CAS(细粒度并发)。
2.2 常考 Map 对比矩阵(面试速记)
| 实现 | 线程安全 | null 键/值 | 底层结构(JDK 8+) | 并发粒度 | 吞吐性能 | 运行开销 | 典型场景 |
|---|---|---|---|---|---|---|---|
HashMap | ❌ | 允许 1 个 null 键、多个 null 值 | 数组 + 链表/红黑树 | 无 | ⚡ 单线程极高 | 📉 低 | 单线程字典、局部变量 |
Hashtable | ✅ 方法级 synchronized | ❌ 不允许 | 数组 + 链表(遗留) | 整表 | 🐢 低 | 📈 高 | 老代码,新工程避免 |
Collections.synchronizedMap | ✅ 包装器同步 | 取决于内层 map | 内层 HashMap + 同一把锁 | 整表 | 🐢 较低 | 📈 较高 | 低并发简单包装 |
ConcurrentHashMap | ✅ 单次操作安全 | ❌ 不允许 | 数组 + 链表/红黑树 + CAS/桶锁 | 桶级 | 🚀 高并发高 | 📊 中等 | 本地缓存、in-flight 合并 |
LinkedHashMap | ❌(同 HashMap) | 同 HashMap | HashMap + 双向链表维护顺序 | 无 | ⚡ 单线程高 | 📉 略增链表维护 | LRU、有序遍历 |
TreeMap | ❌ | ❌ null 键 | 红黑树 | 无 | 🐢 O(log n) | 📈 树旋转 | 需要排序的 key |
2.3 HashMap 到底如何存储?(JDK 8+)
2.3.1 核心数据结构
HashMap 本质是:数组(桶 table)+ 每个桶上的链表或红黑树。
text
table (Node<K,V>[])
┌─────┬─────┬─────┬─────┬─────┐
│ bin0│ bin1│ bin2│ ... │ binN│ ← 数组下标 = 桶 (bucket / bin)
└──┬──┴──┬──┴─────┴─────┴─────┘
│ │
▼ ▼
Node TreeNode (红黑树,链表过长时)
k,v k,v
next left / right每个节点(Node)至少包含:
| 字段 | 含义 |
|---|---|
hash | 键的扰动后 hash 值 |
key | 键 |
value | 值 |
next | 链表下一个节点(冲突时拉链表) |
JDK 8 起:单桶链表长度 > 8 且 table.length >= 64 时,链表转 红黑树(TreeNode);树节点 ≤ 6 时退化为链表。红黑树概念见 树与红黑树基础。
2.3.2 如何定位到桶?(hash 与下标)
三步把 key 映射到 table[index]:
① hashCode() — 对象自带的 32 位整数指纹。
② 扰动 h ^ (h >>> 16)
>>>:无符号右移 16 位,把高 16 位搬到低 16 位。^:异或,把高位信息「拌」进低位。- 目的:下标只看 hash 的低几位;若不做扰动,很多
hashCode()低位相似会挤进同一桶。扰动让高位也影响下标,减少碰撞。
③ 桶下标 (n - 1) & hash
n为 table 长度,且始终是 2 的幂(8、16、32…)。- 此时
n - 1的二进制是 低位全 1 的掩码,例如n=16→n-1=15→…00001111。 &只保留 hash 的低 log₂(n) 位,等价于hash % n,但位运算更快。
| 步骤 | 公式 | 在做什么 |
|---|---|---|
| ① | h = key.hashCode() | 取 32 位哈希 |
| ② | hash = h ^ (h >>> 16) | 高低位混合,打散下标 |
| ③ | index = (n - 1) & hash | 用低位掩码定位桶 |
可运行逐步打印:见 HashMapIndexAlgorithmDemo.java(与 JDK 8
HashMap.hash()逻辑一致)。
扩容 rehash(面试加分):容量从 oldCap 扩到 2 * oldCap 时,节点新下标要么是原 index,要么是 index + oldCap,取决于 (hash & oldCap) 是否为 0——因为只多用了 hash 的一位。Demo 场景 4 会打印该过程。
面试常问:为什么长度是 2 的幂?——
(n-1)是低位掩码;取模可写成位与;扩容 rehash 可只看多出来的一位。
位运算符号不熟 → 先读 位运算基础(Java / Android 复习用),可跑 bitwise-operators-demo。
2.3.3 口头汇报版:从 key 到柜子(形象串讲)
把 HashMap 想成 一排编号储物柜 table[0..n-1]:
- 办身份证
hashCode()每个 key 一张 32 位号码,例如"hello"每次都是同一个数。 - 拌指纹
h ^ (h>>>16)选柜子时主要看号码末尾几位。若很多人末尾相似,会挤进同一柜。
扰动 = 把号码前半段拌进后半段,让柜子用得均匀。
Demo 里 keyA、keyB 低位都是…00AB,不扰动都进 11 号柜;扰动后分到 15 和 3。 - 选柜号
(n-1) & hashn=16时n-1=15是00001111,像筛子只保留 hash 最后 4 位 → 得到0~15。"hello"→ 11 号柜,"world"→ 3 号柜。 - 放满就换大柜
size > capacity × 0.7516 个柜、负载 0.75 → 放进第 13 个元素时扩容到 32 个柜。
迁移时多看 hash 一位:hash & oldCap == 0留原地,否则搬到原编号 + oldCap。 为什么只看这一位? 容量从 16 扩到 32 时,掩码从01111(保留低 4 位)变成11111(保留低 5 位),多出来的正是10000(即oldCap = 16)这一位:
hash & oldCap == 0→ 新位为 0,下标仍在 0~15,不用搬;hash & oldCap != 0→ 新位为 1,下标落在 16~31,等于 原编号 + 16。
text
hashCode → 扰动(>>>与^) → (n-1)& → table[i] → 链表/树
↑ ↑
见 00 速查 扩容再看 & oldCap2.3.4 何时扩容?扩容做什么?
| 概念 | 公式 / 行为 | 比喻 |
|---|---|---|
| loadFactor | 默认 0.75 | 柜子用到 75% 就换更大的 |
| threshold | capacity × loadFactor | 16 柜 → 红线 12 个元素 |
| 触发 | put 后 size > threshold | 第 13 个元素进来 → 扩容 |
| 新容量 | oldCap × 2 | 16 → 32,仍保持 2 的幂 |
| 迁移 | hash & oldCap 为 0 留原桶,否则 index + oldCap | 只看「多出来的一位」,不用重算整套 hash |
可运行打印:HashMapIndexAlgorithmDemo 场景 5(threshold)、场景 6(为何 2 的幂)。
2.3.5 put(key, value) 流程(简化)
要点:
- 冲突解决:同一桶内先链表(尾插,JDK 8 改为尾插减少成环风险),过长则红黑树。
- 负载因子
loadFactor = 0.75:元素数size > capacity * 0.75时扩容为 2 倍,并对每个元素 rehash 到新桶。 - 时间复杂度:平均 O(1);最坏(全碰撞)O(log n)(树)或 O(n)(链表)。
2.3.6 get(key) 流程(简化)
- 算 hash → 定位桶
table[i]。 - 若桶头节点
key匹配(hash相等且equals)→ 返回值。 - 否则沿链表或红黑树查找。
- 找不到返回
null。
2.3.7 为什么 HashMap 线程不安全?
HashMap 没有对 table、链表、size 的并发访问做任何同步。多线程同时 put 时可能:
| 问题 | 后果 |
|---|---|
| 同时写入同一桶 | 覆盖 next 指针、丢节点 |
同时扩容 resize | 重复迁移、丢数据;JDK 7 曾出现链表成环导致 get 死循环 |
| 一个读一个写 | 读到未完成发布的节点(脏读) |
size++ 非原子 | size() 不准 |
本 lab 场景 1 用「期望 10000 条、实际 size 偏小」直观展示丢更新。
2.4 「线程安全」对 Map 到底指什么?
面试里要分清 三个层次,不要混为一谈:
| 层次 | 含义 | 谁提供 |
|---|---|---|
| ① 单次 API 原子性 | 单次 put/get/remove 内部不被别的线程打断到一半 | Hashtable、synchronizedMap、ConcurrentHashMap |
| ② 复合操作原子性 | if (!containsKey(k)) put(k,v) 整段逻辑原子 | 只有 computeIfAbsent 等复合 API,或你自己加锁 |
| ③ 迭代一致性 | 遍历过程中别人改了 map,迭代器行为如何 | ConcurrentHashMap 弱一致迭代;synchronizedMap 需手动 synchronized(map) 包住遍历 |
text
❌ 错误认知:用了 ConcurrentHashMap,我的整个业务流程就线程安全了
✅ 正确认知:CHM 保证的是「单次 put/get」安全;check-then-act 要自己用 computeIfAbsent 或锁本 lab 场景 2 vs 3 专门验证层次 ① 与 ② 的差别。
2.5 ConcurrentHashMap 原理(面试重点,JDK 8+)
概念速览:CHM 是什么?(ConcurrentHashMap 速览)
CAS 前置:CAS 与无锁原子基础
2.5.1 线程安全到底「保」什么?(先立边界)
ConcurrentHashMap 不是给整张表套一把大锁的「线程安全版 HashMap」。它保证的是:
| 层次 | 含义 | 例子 |
|---|---|---|
| 单次 API 内部完整 | 一次 put / get / remove / computeIfAbsent 在执行过程中,别的线程看不到这次调用的半截中间态(不会把链表/树结构改到一半就暴露给你) | 两个线程同时 put 不同 key,通常各改各的桶,互不拆台 |
| 同桶写入互斥 | 改同一个桶(同一链表/树头)时,会排队或 CAS 重试,不会两个人同时往同一格链表头插节点把结构插坏 | 两个线程 put 到同一 hash 桶,后到的会等或重试 |
| 可见性 | table 数组引用、Node 的 val/next 等关键字段带 volatile 语义,一个线程写完,别的线程最终能读到新结构(在 happens-before 规则下) | 线程 A put 后,线程 B get 能命中(不是永远读旧快照) |
明确不保:
- 你自己写的
get判断 +put两段代码(§2.4 层次 ②) size()在极高并发下是近似值(分段计数,非全局强一致快照)- 弱一致迭代:遍历的是某一时刻的「快照感」视图,不保证遍历期间 map 不变
text
✅ CHM 保证:一次 put/get 这条「单趟班车」不会开到一半散架
❌ CHM 不保证:你先下车看票、再上车补票 这两步合起来原子2.5.2 形象比喻:带编号的储物柜(承接 §2.1)
把 CHM 想成一排 编号储物柜 table[0..n-1],每个柜子里可以挂一条链子或一棵小树(链表/红黑树)。
| 角色 | 在 CHM 里 | 形象动作 |
|---|---|---|
读 get | 算 hash → 找到柜号 → 沿链/树找 key | 你只打开柜门看一眼,不动里面的挂钩顺序;多数柜没人改时,不用排队 |
写空柜 put(桶为空) | CAS 抢「第一个挂钩」 | 柜里还空着:大家抢第一个挂钩位,谁先 CAS 成功谁挂上,失败者重试——像抢空柜的「占位牌」 |
写非空柜 put | synchronized 锁柜头 | 柜里已有东西了:给这一柜上小锁,只在这个柜里接链子/改树;别的柜照常读写 |
扩容 resize | 多线程协助搬家 | 柜子排不够:换一排更大的柜,每个旧柜只搬一次,多个人可以分工搬不同柜号 |
对比 §2.1:
synchronizedMap= 整个储物间门口一把总锁,看一眼也要登记排队。- CHM = 只有动到同一柜的人才短暂互斥;读的人、改别的柜的人大多不受影响。
2.5.3 底层三条机制(原理对照)
| 机制 | 解决什么问题 | JDK 8+ 里大致用在哪 |
|---|---|---|
volatile 可见性 | 线程 A 改完结构,线程 B 别一直读缓存里的旧柜布局 | table 数组、Node 的 val / next 等;读 get 多数无锁但能看见已发布节点 |
| CAS(Compare-And-Swap) | 空桶「第一个节点」只能有一个人插成功,避免两个线程同时认为桶是空的 | 桶为 null 时 put:CAS 把 null 换成新 Node,失败则重试或改走加锁路径 |
桶头 synchronized | 非空桶上改链表/树、保证同一桶内结构变更串行 | 锁住当前 bin 的头节点(不是锁整表),在锁内插入、删除、树化 |
再加一条工程细节:
| LongAdder 式计数 | size 不能全线程抢一个 size++ 热点 | baseCount + CounterCell[],降低统计冲突 |
和 HashMap 不安全对比(形象):
text
HashMap 并发 put:
线程1、2 同时往同一柜挂第一根钩子 → 两人都认为柜是空的 → 链子断/丢节点/扩容时 table 乱掉
CHM 并发 put(同桶):
空柜 → CAS 只允许一人挂上第一根钩子
非空 → 给柜头上锁,在锁里接好链子再放开2.5.4 读路径 vs 写路径(分场景举例)
场景 A:100 个线程读,10 个线程写,key 分散
- 读:算下标 → 读
volatile table[i]→ 遍历链/树,不加桶锁。 - 写:各自落到不同
i,各 CAS 或锁各柜 → 几乎不堵。 - 面试话术:读多写少、key 分散时 CHM 吞吐高,因为锁竞争面小。
场景 B:两个线程同时 put 同一个 key(同桶)
- 都定位到
table[5],桶非空 → 都要拿 bin 5 的头锁 → 在锁内判断 key 相等则覆盖 value,或链上插入。 - 结果:不会插出两个相同 key 的节点把结构搞坏;后到的要么覆盖要么挂到链上(由实现保证一致性)。
场景 C:两个线程同时 put 不同 key,但 hash 冲突到同一桶
- 与场景 B 类似:同桶串行,不同 key 挂在同一链表/树上。
- 形象:不同人的包裹塞进同一个柜,必须一个一个挂好钩子,不能两人同时拽链头。
场景 D:一个线程 get,一个线程同桶 put
get无锁读当前链/树;put在桶锁内改结构。volatile+ 锁释放语义保证:不会长期读到「永远过时」的值;具体可见性由 JMM happens-before 约束。- 迭代器是弱一致:遍历过程中可能有新
put,迭代器不一定立刻反映,也通常不抛ConcurrentModificationException。
2.5.5 和 HashMap 的关系
- 数据结构类似:也是
Node[] table+ 链表/红黑树(树化阈值同 HashMap,见 树与红黑树基础)。 - 关键区别:对桶级别做 CAS / 细粒度锁,而不是锁整张表,也没有 HashMap 那种非线程安全扩容竞态。
- 不允许 null 键/值:避免
get返回 null 时无法区分「不存在」还是「值为 null」(Doug Lea 的设计取舍)。
2.5.6 JDK 7 vs JDK 8(面试几乎必问「区别」)
| 维度 | JDK 7 ConcurrentHashMap | JDK 8+ ConcurrentHashMap |
|---|---|---|
| 结构 | Segment 数组(分段锁),每段是一把 ReentrantLock | Node 数组 + 链表/树,与 HashMap 更像 |
| 锁粒度 | 段(segment)级,默认 16 段 | 单个 bin(桶头) synchronized 或 CAS |
| 读 | 多数无锁,依赖 segment 可见性 | 无锁读 volatile Node 数组 |
| 扩容 | 每段独立扩容 | 多线程协助迁移(transfer) |
| 面试话术 | 「分段锁降低竞争」 | 「锁细化到桶,读基本无锁,吞吐更高」 |
2.5.7 JDK 8 写入路径(简化)
- 空桶插入:用 CAS 抢桶头,失败则重试。
- 非空桶:只 锁住当前 bin,其他桶仍可并发读写。
- size 统计:JDK 8 用
baseCount+CounterCell[](类似LongAdder)降低热点,不是简单size++。
2.5.8 为什么 computeIfAbsent 能解决重复初始化?
computeIfAbsent 在找到或创建桶的过程中持有 bin 锁(或等价原子路径),保证「判断不存在 → 创建 → 放入」在同一个临界区完成,别的线程进不来插一脚。
而业务代码:
java
if (map.get(k) == null) {
map.put(k, expensive()); // get 与 put 是两次独立 API,中间有竞态窗口
}是两次独立操作,CHM 不保证它们合起来原子。本 lab 场景 2 vs 3 可运行验证。
2.5.9 synchronizedMap vs ConcurrentHashMap
Collections.synchronizedMap | ConcurrentHashMap | |
|---|---|---|
| 锁范围 | 整表一把锁(mutex) | 单桶 |
| 读多写少 | 读也要抢同一把锁(若实现上同步了方法) | 读通常无锁 |
| 迭代 | 必须 synchronized(map) { iterate } | 弱一致,不抛 ConcurrentModificationException |
| 适用 | 低并发、代码极简 | 高并发缓存、去重 |
本 lab 场景 4 对比两者批量写入耗时。
口头汇报版(30 秒):
CHM 线程安全靠三点:volatile 让别的线程看得见新柜子布局;空桶 CAS 抢第一个挂钩;非空桶只锁这一柜的头。所以是桶级并发,不是整表一把锁。但它只保证单次 API,我自己 get 再 put 还是要用
computeIfAbsent。
2.6 面试高频题清单(Map + 并发)
基础原理
- HashMap 底层结构? 数组 + 链表 + 红黑树;JDK 8 树化/退化阈值?
- HashMap 的 hash 怎么算?为什么扰动?
hashCode ^ (hashCode >>> 16);让高位参与下标计算。 - HashMap 扩容条件与过程?
size > cap * 0.75;容量 2 倍;rehash。 - HashMap 为什么线程不安全? 并发 put/resize 丢数据、JDK7 成环;本 lab 场景 1。
- HashMap 允许 null 键吗? 允许一个 null 键、多个 null 值;
ConcurrentHashMap不允许。
对比选型
- HashMap vs Hashtable vs ConcurrentHashMap? 见 §2.2 表格。
ConcurrentHashMap是线程安全的吗? 单次操作是;复合逻辑不一定;见 §2.4。- JDK 7 和 JDK 8 的 ConcurrentHashMap 区别? 见 §2.5.6。
- CHM 为什么线程安全? volatile + 空桶 CAS + 桶头锁;见 §2.5.1~§2.5.4。
synchronizedMap和ConcurrentHashMap怎么选? 见 §2.5.9。
复合操作(极易挂)
- 如何实现线程安全的「不存在则放入」?
putIfAbsent/computeIfAbsent,不要get+put。 size()/isEmpty()在 CHM 里准确吗? 弱一致快照,高并发下是近似值,不要当精确计数用。- 遍历 CHM 会抛
ConcurrentModificationException吗? 通常不会(弱一致迭代器);HashMap并发修改会。
延伸(加分)
- LinkedHashMap 如何实现≈? 访问顺序链表 +
removeEldestEntry。 - TreeMap 底层? 红黑树;
O(log n);key 必须可比较。 - Android 里哪里会用 CHM? in-flight 请求去重、全局缓存索引;见 §3.1。
WeakHashMap? 键是弱引用,GC 后条目自动清理;适合做辅助缓存。
标准答题模板:get-then-put 竞态
ConcurrentHashMap的get和put各自线程安全,但组合成 check-then-act 不是原子操作。多线程可能同时看到 key 不存在,各自创建对象并 put,导致重复初始化。应使用computeIfAbsent在桶锁内完成判断与写入,或由业务层加锁。
3. Android / 移动端实战场景与误配事故
3.1 实战场景
| 场景 | 推荐 | 说明 |
|---|---|---|
内存缓存 LruCache 内部 | 框架自管(见 LRU 缓存与淘汰策略(含 LruCache)) | 不必自己 new HashMap 共享 |
全局 Map<String, OkHttp Call> in-flight 去重 | ConcurrentHashMap + computeIfAbsent | 防止同一 URL 重复建连 |
| 单线程解析后批量建索引 | HashMap | 仅当前线程用时 OK |
| 多线程读配置快照 | 不可变 Map 或 ConcurrentHashMap | 读多写少 |
3.2 常见误配
- ⚠️ 多线程共享
HashMap→ size 不对、偶现崩溃或脏读。 - ⚠️
ConcurrentHashMap+if (get == null) put()→ 重复创建昂贵对象(本 lab 场景 2)。 - ⚠️ 把
containsKey+put当成原子 → 仍有竞态窗口。 - ⚠️ 向
ConcurrentHashMap放 null 键/值 →NullPointerException。
4. 实验源码与运行验证
关键源码
| 文件 | 说明 |
|---|---|
| 位运算基础(Java / Android 复习用) | 前置:位运算理论 |
| bitwise-operators-demo | 前置:位运算可运行 Demo |
| HashMapIndexAlgorithmDemo.java | 桶下标逐步打印、扰动对比、扩容 threshold |
| MapConcurrencyCompare.java | 线程安全、get-then-put vs computeIfAbsent |
运行方式(推荐顺序)
bash
# 位运算前置(从仓库根目录)
cd labs/foundations/bitwise-operators-demo
javac -d out src/BitwiseOperatorsBasicsDemo.java && java -cp out BitwiseOperatorsBasicsDemo
# 本 lab
cd ../../java/runtime-concurrency/map-concurrency-compare
javac -d out src/HashMapIndexAlgorithmDemo.java
java -cp out HashMapIndexAlgorithmDemo
javac -d out src/MapConcurrencyCompare.java
java -cp out MapConcurrencyCompare预期控制台运行输出(HashMapIndexAlgorithmDemo,节选)
text
====== HashMap 桶下标计算三步走 ======
key = "hello", table.length n = 16
① hashCode() = 99162322 (0x05E918D2)
二进制: 00000000 01011110 10011000 11010010
② h >>> 16 = 1513 (0x000005E9)
③ 扰动 h ^ (h>>>16) = 99163835 (0x05E91D3B)
→ 结论:"hello" 应放入 table[11]
====== 扰动有无对下标分布的影响(table 长度 n=16)======
【不做扰动】index = (n-1) & hashCode
keyA → table[11]
keyB → table[11] ← 低位相同,必然同桶碰撞
【JDK8 扰动后】index = (n-1) & (h ^ (h>>>16))
keyA → table[15]
keyB → table[3] ← 高位参与后,下标分开预期控制台运行输出(MapConcurrencyCompare)
text
====== 1. HashMap 并发写入(非线程安全,结果不可预期)======
期望条目数: 10000 | HashMap 实际 size: 5840 (若小于期望值,说明并发 put 已丢数据)
====== 2. ConcurrentHashMap:错误写法 get-then-put(可能重复创建)======
错误写法实际创建次数: 40 (期望 1,常 > 1)
====== 3. ConcurrentHashMap:正确写法 computeIfAbsent ======
computeIfAbsent 创建次数: 1 (恒为 1)
====== 4. Collections.synchronizedMap vs ConcurrentHashMap ======
synchronizedMap 耗时: 16ms | ConcurrentHashMap 耗时: 13ms (并发读写下 CHM 通常更快)场景 1、2 的具体数字每次运行可能略有波动,重点看 HashMap size 偏小 与 get-then-put 创建次数 > 1。
5. 对应知识库文档
- 理论主文档:Java 并发模型总览
- 位运算:位运算基础(Java / Android 复习用)
- 红黑树:树与红黑树基础
- CHM 速览:CHM 是什么?(ConcurrentHashMap 速览)
- LRU / LruCache:LRU 缓存与淘汰策略(含 LruCache)