Skip to content

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 BitwiseOperatorsBasicsDemo

1. 实验目标与工程要点

本实验对比 Java 常考 Map 家族HashMapHashtableCollections.synchronizedMapConcurrentHashMap)的线程安全边界与底层差异,并用可运行代码验证:

  1. HashMap 不能当共享字典——并发 put 可能丢数据甚至结构损坏。
  2. ConcurrentHashMap 保证的是单次 API 调用的线程安全if (get == null) put() 这类复合逻辑仍会竞态
  3. computeIfAbsent / putIfAbsent 才是「检查并初始化」的正确方向。
  4. synchronizedMap vs ConcurrentHashMap——前者整表一把锁,后者桶级并发。
  5. 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)同 HashMapHashMap + 双向链表维护顺序⚡ 单线程高📉 略增链表维护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 起:单桶链表长度 > 8table.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=16n-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]

  1. 办身份证 hashCode() 每个 key 一张 32 位号码,例如 "hello" 每次都是同一个数。
  2. 拌指纹 h ^ (h>>>16) 选柜子时主要看号码末尾几位。若很多人末尾相似,会挤进同一柜。
    扰动 = 把号码前半段拌进后半段,让柜子用得均匀。
    Demo 里 keyA、keyB 低位都是 …00AB,不扰动都进 11 号柜;扰动后分到 153
  3. 选柜号 (n-1) & hashn=16n-1=1500001111,像筛子只保留 hash 最后 4 位 → 得到 0~15
    "hello"11 号柜"world"3 号柜
  4. 放满就换大柜 size > capacity × 0.75 16 个柜、负载 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 速查              扩容再看 & oldCap

2.3.4 何时扩容?扩容做什么?

概念公式 / 行为比喻
loadFactor默认 0.75柜子用到 75% 就换更大的
thresholdcapacity × loadFactor16 柜 → 红线 12 个元素
触发putsize > threshold第 13 个元素进来 → 扩容
新容量oldCap × 216 → 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) 流程(简化)

  1. 算 hash → 定位桶 table[i]
  2. 若桶头节点 key 匹配(hash 相等且 equals)→ 返回值。
  3. 否则沿链表或红黑树查找。
  4. 找不到返回 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 内部不被别的线程打断到一半HashtablesynchronizedMapConcurrentHashMap
② 复合操作原子性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 数组引用、Nodeval/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 成功谁挂上,失败者重试——像抢空柜的「占位牌」
写非空柜 putsynchronized 锁柜头柜里已有东西了:给这一柜上小锁,只在这个柜里接链子/改树;别的柜照常读写
扩容 resize多线程协助搬家柜子排不够:换一排更大的柜,每个旧柜只搬一次,多个人可以分工搬不同柜号

对比 §2.1:

  • synchronizedMap = 整个储物间门口一把总锁,看一眼也要登记排队
  • CHM = 只有动到同一柜的人才短暂互斥;读的人、改别的柜的人大多不受影响。

2.5.3 底层三条机制(原理对照)

机制解决什么问题JDK 8+ 里大致用在哪
volatile 可见性线程 A 改完结构,线程 B 别一直读缓存里的旧柜布局table 数组、Nodeval / next 等;读 get 多数无锁但能看见已发布节点
CAS(Compare-And-Swap)空桶「第一个节点」只能有一个人插成功,避免两个线程同时认为桶是空的桶为 nullput: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 ConcurrentHashMapJDK 8+ ConcurrentHashMap
结构Segment 数组(分段锁),每段是一把 ReentrantLockNode 数组 + 链表/树,与 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.synchronizedMapConcurrentHashMap
锁范围整表一把锁(mutex单桶
读多写少读也要抢同一把锁(若实现上同步了方法)读通常无锁
迭代必须 synchronized(map) { iterate }弱一致,不抛 ConcurrentModificationException
适用低并发、代码极简高并发缓存、去重

本 lab 场景 4 对比两者批量写入耗时。

口头汇报版(30 秒)

CHM 线程安全靠三点:volatile 让别的线程看得见新柜子布局空桶 CAS 抢第一个挂钩非空桶只锁这一柜的头。所以是桶级并发,不是整表一把锁。但它只保证单次 API,我自己 get 再 put 还是要用 computeIfAbsent


2.6 面试高频题清单(Map + 并发)

基础原理

  1. HashMap 底层结构? 数组 + 链表 + 红黑树;JDK 8 树化/退化阈值?
  2. HashMap 的 hash 怎么算?为什么扰动? hashCode ^ (hashCode >>> 16);让高位参与下标计算。
  3. HashMap 扩容条件与过程? size > cap * 0.75;容量 2 倍;rehash。
  4. HashMap 为什么线程不安全? 并发 put/resize 丢数据、JDK7 成环;本 lab 场景 1。
  5. HashMap 允许 null 键吗? 允许一个 null 键、多个 null 值;ConcurrentHashMap 不允许。

对比选型

  1. HashMap vs Hashtable vs ConcurrentHashMap? 见 §2.2 表格。
  2. ConcurrentHashMap 是线程安全的吗? 单次操作是;复合逻辑不一定;见 §2.4。
  3. JDK 7 和 JDK 8 的 ConcurrentHashMap 区别? 见 §2.5.6。
  4. CHM 为什么线程安全? volatile + 空桶 CAS + 桶头锁;见 §2.5.1~§2.5.4。
  5. synchronizedMap ConcurrentHashMap 怎么选? 见 §2.5.9。

复合操作(极易挂)

  1. 如何实现线程安全的「不存在则放入」? putIfAbsent / computeIfAbsent,不要 get + put
  2. size() / isEmpty() 在 CHM 里准确吗? 弱一致快照,高并发下是近似值,不要当精确计数用。
  3. 遍历 CHM 会抛 ConcurrentModificationException 吗? 通常不会(弱一致迭代器);HashMap 并发修改会。

延伸(加分)

  1. LinkedHashMap 如何实现≈? 访问顺序链表 + removeEldestEntry
  2. TreeMap 底层? 红黑树;O(log n);key 必须可比较。
  3. Android 里哪里会用 CHM? in-flight 请求去重、全局缓存索引;见 §3.1。
  4. WeakHashMap 键是弱引用,GC 后条目自动清理;适合做辅助缓存。

标准答题模板:get-then-put 竞态

ConcurrentHashMapgetput 各自线程安全,但组合成 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 常见误配

  1. ⚠️ 多线程共享 HashMap → size 不对、偶现崩溃或脏读。
  2. ⚠️ ConcurrentHashMap + if (get == null) put() → 重复创建昂贵对象(本 lab 场景 2)。
  3. ⚠️ containsKey + put 当成原子 → 仍有竞态窗口。
  4. ⚠️ 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. 对应知识库文档