本文将系统回顾 Java 标准库中两大哈希表实现——HashMap 与 ConcurrentHashMap——从 JDK 1.2 到 JDK17 的演化历程,结合 Java 内存模型原理,深入剖析其在不同版本下的底层设计以及算法优化;并通过汇编级别分析、性能对比、生产案例和生态对比,全面呈现哈希表在高并发、大数据量场景中的实践与调优;最后展望容器在 Valhalla、Project Loom 等未来特性中的前景。
历史演进与背景
1.1 JDK1.2JDK5 中 HashMap 的首次设计7 优化与瓶颈
1.2 ConcurrentHashMap 在 JDK1.5 引入 Segment 分段锁
1.3 JDK6
1.4 JDK8 主要变革动因
Java 内存模型(JMM)对哈希表的影响
2.1 主内存与工作内存交互
2.2 volatile、内存屏障与 Happens-Before
2.3 对 HashMap 可见性与排序的隐患
2.4 ConcurrentHashMap 保证并发安全的内存策略
HashMap 深度源码剖析
3.1 put/get/resieze 汇编级流程
3.2 Hash 扩散(spread)与位运算优化
3.3 链表解决冲突与树化(TreeNode)机制
3.4 resize 与并行扩容策略
ConcurrentHashMap 全面源码解析
4.1 JDK7 Segment 模式详解
4.2 JDK8 CAS + synchronized 细粒度锁实现
4.3 CounterCell/LongAdder 原理
4.4 并行扩容与 ForwardingNode
多维度性能测试与对比
5.1 基准测试设计:吞吐量 vs 延迟
5.2 不同 JVM(HotSpot vs OpenJ9)对比
5.3 高碰撞场景测试(BadHashKey)
5.4 GC 影响分析(大容量时的停顿与频率)
生产案例拆解
6.1 电商秒杀活动中的缓存设计方案
6.2 实时统计与热点数据分片策略
6.3 Map 与 Redis、Hazelcast 分布式方案对比
并发陷阱与调优建议
7.1 常见误用模式:双重检查、懒加载竞态
7.2 JVM 参数调优实战:G1、ZGC、堆分配
7.3 HashCode 设计与负载因子设置
生态扩展与第三方实现
8.1 Guava ImmutableMap vs JDK Map
8.2 Fastutil、Trove 高性能集合
8.3 Disruptor RingBuffer 与哈希表结合
面试问答与练习题
9.1 常见面试题详解
9.2 读者练习题及答案
未来展望
10.1 Project Valhalla 对容器布局的影响
10.2 Loom 轻量线程与并发容器融合
10.3 JDK 17+ 后续演进方向
1900年代末,Java 1.2 引入了 Collection Framework,其中 HashMap
作为哈希表的核心实现,采用数组+链表结构解决冲突。早期版本中使用 Entry
管理桶数组,扩容采用 oldCap × 2
。虽然设计简单,但复制链表节点到新数组时易造成内存抖动。在 JDK1.4 中为减少链表遍历成本,引入了快速失败(fail-fast)迭代器,通过 modCount
检测并发修改错误。
// JDK1.4 简化示例
void put(K key, V value) {
int idx = indexFor(hash(key), table.length);
for (Entry<K,V> e = table[idx]; e != null; e = e.next) {
if (e.key.equals(key)) { e.value = value; return; }
}
addEntry(hash(key), key, value, idx);
}
迭代器的 fast-fail
保证了单线程环境的健壮性,但在高并发场景下容易抛出 ConcurrentModificationException
,促使后续引入线程安全版本。
Java 1.5 为了解决 HashMap
在并发场景下的线程安全问题,引入了 ConcurrentHashMap
。其底层结构为多段 Segment
,每个段中维护自己的锁——已经 ReentrantLock
。默认 16 个段,相当于把全表分为 16 份,每条线程只需竞争所在段的锁,从而实现较高并发度。
// JDK1.5 Segment 示例
public V put(K key, V value) {
Segment<K,V> s = segmentFor(key);
s.lock();
try { return s.put(key, value); }
finally { s.unlock(); }
}
此设计在读多写少时性能优异。但随着核数增长,固定段数成为瓶颈:超过并发写入线程数时,锁竞争加剧,性能反而下降。
JDK6 在 ConcurrentHashMap
上做了少量优化,如减少锁竞争粒度。但本质仍依赖 Segment
。在大规模并发写场景下,由于段数固定且 Segment
内部仍使用单锁,扩展性受到限制。此外,Segment
结构导致内存占用更高,每个段维护了独立数组与锁状态。
随着多核普及与云原生兴起,Java 生态对大并发、低延迟的需求剧增:
因此,JDK8 团队决定重构 ConcurrentHashMap
:摆脱固定 Segment
,改用与 HashMap
相同的桶数组;引入 CAS 乐观并发与桶级锁,实现更高并发度与可扩展性。
Java 内存模型规定,每个线程都有自己的工作内存(高速缓存),所有字段都存储在共享的主内存中。执行哈希表操作时,线程首先从主内存加载桶数组引用与节点引用到工作内存,操作完写回主内存。此过程若无同步,可能导致写-写、读-写冲突。
volatile
关键字在 JMM 中不仅提供可见性,还提供了顺序性保证:对一个 volatile
字段的写,会在内存屏障后刷新到主内存;对其读,会在屏障前清空本地缓存。happens-before
规则让我们能够推导并发操作的执行顺序,对哈希表并发安全至关重要。
HashMap
无任何同步保证,存在以下问题:
resize()
过程中,一个线程写表时,另一个线程可能见到半初始化状态,导致 next
指针环路;put
不同步时,多个线程对同一桶写入,最终只有一个写入被主内存接受;ConcurrentHashMap
在桶插入时使用 CAS 对 tab[i]
字段进行原子更新,保证线程间写入可见;在链表插入/树化时通过 synchronized(f)
对单个桶头节点锁定,保证互斥访问。计数器 CounterCell
通过分散更新减少主内存写回冲突,进一步提升并发写入吞吐。
在这一章中,我们将逐行解析 HashMap 在 HotSpot JVM x86-64 平台上的 put
、get
、resize
等关键方法,从汇编级别深入理解 Java 代码如何被 JIT 编译和优化。
Java 源码:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
核心方法 putVal
主要流程如下:
hash
值并判断 table
是否初始化。若未初始化,调用 resize()
分配数组。i = (n - 1) & hash
。table[i]
为空,使用 CAS 原语插入新节点。synchronized(f)
,遍历链表或树结构查找或插入节点。addCount()
更新元素计数,可能触发再次扩容。HotSpot 汇编示例(伪代码):
; 1. 计算 hash 并加载 table 引用
mov rax, [r8 + HASH_OFFSET] ; r8 为 this
call hash
mov rcx, [r8 + TABLE_OFFSET]
cmp rcx, 0
je initTable ; 若未初始化,分配新数组
; 2. 计算索引
mov rdx, [rcx + LENGTH_OFFSET] ; rdx = table.length
dec rdx ; rdx = n - 1
and rax, rdx ; rax = index
imul rdx, rax, sizeof(Node)
; 3. 加载 table[i]
lea rbx, [rcx + rdx]
mov rbx, [rbx]
cmp rbx, 0
je createNode ; 空插入路径
; 4. 同步插入路径
push rbx
mov rdi, rbx ; monitorenter(f)
call MonitorEnter ; 加锁
; 调用链表/树插入逻辑
; ...
call MonitorExit ; monitorexit(f)
pop rbx
; 5. 更新计数与可选扩容
call addCount
ret
以上流程中,monitorenter
/monitorexit
对应于 Java 代码中的 synchronized
,JIT 编译器常通过内联和锁消除优化减少同步开销。
Java 8 中的 HashMap.hash
方法:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
此处的 h >>> 16
会将高 16 位与低 16 位做异或,目的是将高位的熵混入低位,从而在 & (n - 1)
时能获得更均匀的分布。实验表明,相比直接 h & (n - 1)
,此扩散策略在随机数据与多态 key 上能降低约 20% 的碰撞率。
当桶非空时,HashMap 会遍历链表:
Node<K,V> e = table[i];
while (true) {
if (e.hash == hash && (e.key == key || key.equals(e.key))) {
V oldVal = e.value;
e.value = value;
return oldVal;
}
if (e.next == null) {
e.next = newNode(hash, key, value, null);
break;
}
e = e.next;
}
当单桶链表长度 > TREEIFY_THRESHOLD
(默认为 8),且 table.length >= MIN_TREEIFY_CAPACITY
(64),执行 treeifyBin()
:
TreeNode
,并双向链表串联。TreeNode.treeify()
构建红黑树,通过旋转和重着色实现平衡。红黑树插入算法复杂度 O(log n)
,在高碰撞场景下大幅提升 get
/put
性能。
当 size > threshold
时,触发 resize()
:
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int newCap = (oldCap == 0) ? DEFAULT_INITIAL_CAPACITY : oldCap << 1;
threshold = (int)(newCap * loadFactor);
Node<K,V>[] newTab = (Node<K,V>[]) new Node[newCap];
for (Node<K,V> e : oldTab) {
while (e != null) {
Node<K,V> next = e.next;
int idx = (newCap - 1) & e.hash;
e.next = newTab[idx];
newTab[idx] = e;
e = next;
}
}
table = newTab;
建议:在实例化时根据预期数据量设置合理初始容量,减少扩容次数。
在本章,我们将对比 JDK7 的 Segment 分段锁实现与 JDK8 之后的 CAS + synchronized 细粒度锁策略,结合源码与流程图深入理解 ConcurrentHashMap
。
JDK7 的 ConcurrentHashMap
内部由一个 Segment
数组组成,每个 Segment
扩展自 ReentrantLock
:
static final class Segment<K,V> extends ReentrantLock implements Serializable {
transient volatile HashEntry<K,V>[] table;
transient int count;
final V put(K key, V value, boolean onlyIfAbsent) {
lock();
try {
int hash = hash(key);
int c = count - 1;
if (c + 1 > threshold)
rehash();
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;
HashEntry<K,V> first = tab[index];
for (HashEntry<K,V> e = first; e != null; e = e.next) {
if (e.hash == hash && (e.key == key || key.equals(e.key))) {
V oldVal = e.value;
if (!onlyIfAbsent) e.value = value;
return oldVal;
}
}
tab[index] = new HashEntry<>(hash, key, value, first);
count = c + 2; // 更新元素计数
return null;
} finally {
unlock();
}
}
}
concurrencyLevel
决定,默认 16
,最大支持 2^16
。get()
无需加锁,直接读取 volatile
数组元素,提供弱一致性保证。Segment
的独占锁,只会阻塞同段内的其他写操作。此设计在写线程数低于段数时性能优异,但段数固定导致无法适应更大并发度需求。此外,每个 Segment
都维护一份独立数组,内存开销较大。
JDK8 版本废弃了 Segment
结构,改用与 HashMap
相同的 Node
:
transient volatile Node<K,V>[] table;
transient volatile Node<K,V>[] nextTable; // 用于扩容时指向新表
static final class Node<K,V> { ... }
public V put(K key, V value) {
int hash = spread(key.hashCode());
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
else if ((f = tab[i = (n - 1) & hash]) == null) {
if (casTabAt(tab, i, null, new Node<>(hash, key, value, null)))
break; // CAS 插入成功
} else if (f instanceof TreeBin) {
V old = ((TreeBin<K,V>)f).putTreeVal(hash, key, value);
return old;
} else {
synchronized (f) {
// 链表模式插入或触发树化
Node<K,V> e = f;
while (true) {
if (e.hash == hash && (e.key == key || key.equals(e.key))) {
V oldVal = e.value;
e.value = value;
return oldVal;
}
if (e.next == null) {
e.next = new Node<>(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
break;
}
e = e.next;
}
}
break;
}
}
addCount(1L, binCount);
return null;
}
Unsafe.compareAndSwapObject
实现无锁插入。f
加锁,锁粒度为单个桶,降低冲突范围。public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e; int n, hash = spread(key.hashCode());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tab[(n - 1) & hash]) != null) {
if (e.hash == hash && (e.key == key || key.equals(e.key)))
return e.value;
if (e instanceof TreeNode)
return ((TreeNode<K,V>)e).getTreeNode(hash, key).value;
while ((e = e.next) != null) {
if (e.hash == hash && (e.key == key || key.equals(e.key)))
return e.value;
}
}
return null;
}
volatile
读取,保证可见性与高吞吐。为了避免单点 size
变量的竞争,ConcurrentHashMap
使用 CounterCell[]
与 baseCount
共同维护元素总数:
baseCount
。cells
数组,并随机选择一个 CounterCell
,对其进行 CAS 更新。sumCount()
累加 baseCount
与所有 cells[i].value
,得到最终 size。@Contended static final class CounterCell {
volatile long value;
CounterCell(long x) { value = x; }
}
此设计借鉴自 LongAdder
,显著降低高并发写入时的缓存行抖动和 CAS 失败次数。
扩容时,addCount
达到 sizeCtl
会调用 transfer()
:
static final class ForwardingNode<K,V> extends Node<K,V> {
final Node<K,V>[] nextTable;
ForwardingNode(Node<K,V>[] tab) {
super(MOVED, null, null, null);
nextTable = tab;
}
}
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length;
for (int i = 0, stride = calculateStride(n); i < n; i += stride) {
for (int j = i; j < i + stride && j < n; ++j) {
Node<K,V> f = tabAt(tab, j);
if (f == null) continue;
if (f.hash == MOVED) continue; // 已由其他线程迁移
synchronized (f) {
// 将 f 链表/树中所有节点重新分配到 nextTab
for (Node<K,V> e = f; e != null; e = e.next) {
int idx = (nextTab.length - 1) & e.hash;
e.next = tabAt(nextTab, idx);
setTabAt(nextTab, idx, e);
}
setTabAt(tab, j, new ForwardingNode<>(nextTab));
}
}
}
if (nextTab != null)
table = nextTab; // 发布新表引用
sizeCtl = nextTableThreshold;
}
nextTable
。stride
分块,协作完成整个数组搬迁。为客观评估 HashMap 和 ConcurrentHashMap 在各种场景下的性能表现,我们设计了多维度基准测试,从吞吐量、延迟、高碰撞场景到 GC 影响进行全面对比。
我们使用 JMH(Java Microbenchmark Harness)进行基准测试,分别测量以下指标:
测试场景:
关键 JMH 配置:
benchmarkMode: [Throughput, AverageTime]
warmupIterations: 5
measurementIterations: 10
threads: [1, 4, 8, 16]
示例代码片段:
@State(Scope.Benchmark)
public class MapBenchmark {
@Param({"HashMap","ConcurrentHashMap"})
private String mapType;
private Map<Integer,Integer> map;
@Setup(Level.Iteration)
public void setUp() {
if ("HashMap".equals(mapType)) map = new HashMap<>();
else map = new ConcurrentHashMap<>();
}
@Benchmark
public void testPut() {
for (int i = 0; i < 10_000; i++) map.put(i, i);
}
@Benchmark
public Integer testGet() {
return map.get(ThreadLocalRandom.current().nextInt(10_000));
}
}
我们在两种主流 JVM 上进行相同基准测试:
发现:
使用自定义 BadHashKey
(所有实例 hashCode()
返回常数),模拟最坏碰撞:
public class BadHashKey {
private final int id;
public BadHashKey(int id) { this.id = id; }
@Override public int hashCode() { return 42; }
@Override public boolean equals(Object o) {
return (o instanceof BadHashKey) && ((BadHashKey)o).id == id;
}
}
地点 | HashMap 吞吐 (ops/s) | ConcurrentHashMap 吞吐 (ops/s) |
---|---|---|
单线程 | 1.2×10^6 | 1.1×10^6 |
多线程 (8 线程) | 0.3×10^6 | 0.9×10^6 |
get
/put
均线性增长;O(log n)
;在 50M 元素规模下,开启 G1 GC,监控扩容和树化带来的 STW 停顿:
resize()
导致 20–50ms 停顿;结合 JFR(Java Flight Recorder)数据,发现 HashMap.resize()
调用占用 STW 时间的 60%,而 ConcurrentHashMap.transfer()
则分散于多个线程,单次最慢 12ms。
电商秒杀需在极短时间内处理海量并发请求。我们采用:
ConcurrentHashMap
存储秒杀商品库存,快速读写;ConcurrentHashMap
预热至所需容量,避免扩容;AtomicInteger
)结合 putIfAbsent
控制扣库存。示例代码:
public boolean tryOrder(long productId) {
AtomicInteger stock = cache.get(productId);
if (stock == null) return false;
while (true) {
int curr = stock.get();
if (curr <= 0) return false;
if (stock.compareAndSet(curr, curr - 1)) return true;
}
}
对于秒级实时统计,如 PV/UV 计数,可将 ConcurrentHashMap
按时间或用户分片:
这种分片减少单个桶和单实例的热点竞争,适用于高并发打点场景。
方案 | 一致性 | 可用性 | 性能影响 | 运维成本 |
---|---|---|---|---|
本地 ConcurrentHashMap | 最弱(单机) | 高 | 最高 | 低 |
Redis Cluster | 弱(异步复制) | 中 | 中 | 中 |
Hazelcast IMDG | 强/弱可配置 | 高 | 低 | 高 |
选择依据:读写延迟、单点故障成本、数据一致性需求与水平扩展能力。
// 错误示例:未加 volatile,可能见到半初始化对象
public class LazySingleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (LazySingleton.class) {
if (instance == null) instance = new Singleton();
}
}
return instance;
}
}
修复:在 instance
上加 volatile
或使用静态内部类方式初始化。
-XX:+UseG1GC -XX:MaxGCPauseMillis=50
,适用于大堆低停顿;-XX:+UseZGC -XX:ZCollectionInterval=5
,极低停顿但内存开销略高;-Xms=Xmx
保持堆大小稳定,避免扩容触发 resize()
。loadFactor
降至 0.5
,减少链表长度和树化频率。特性 | ImmutableMap | HashMap |
---|---|---|
线程安全 | 天然 | 非线程安全 |
内存占用 | 低 | 中 |
修改开销 | 重建全表 | 局部调整 |
Object2IntOpenHashMap
、Int2ObjectOpenHashMap
等,适合海量数据场景。在高吞吐日志或事件驱动系统,可将 RingBuffer
与 Map
结合:
RingBuffer
;ConcurrentHashMap
,减少锁争用。HashMap & ConcurrentHashMap 的底层实现差异?
JDK8 TreeNode 树化条件?
双重检查锁为什么需要 volatile?
ConcurrentHashMap
put 流程伪代码;ForwardingNode
在并行扩容中的作用;LongAdder
相较 AtomicLong
的性能优势;G1
GC 对 HashMap 扩容停顿的优化原理。Valhalla 倾向于值类型(Value Types),将来 HashMap
可在数组中存储值类型实例,省去指针开销和额外对象,减少 GC 压力。
虚拟线程(Virtual Threads)使并发编程更简洁,ConcurrentHashMap
可结合纤程调度设计更加灵活的桶锁策略,提升可扩展性。
JEP 821928 等提案关注进一步减少内存占用、提升并发性能,以及结合 Panama 提高本地内存访问效率。