HashMap在多线程下死循环了——HashMap底层原理和线程安全
本文目录
先说结论
HashMap底层是数组+链表+红黑树(JDK 8+)。默认容量16、负载因子0.75、链表长度超过8转红黑树。HashMap不是线程安全的——多线程同时put可能导致数据丢失、死循环(JDK 7的头插法)或数据覆盖。多线程场景用ConcurrentHashMap。
先把这段讲顺;面试官追问时,再展开下面的原理和细节。
详细讲解
HashMap是Java面试的'保留节目',但大数据面试问HashMap有它的特殊视角——Flink的Operator State内部用了HashMap存储、Spark的广播变量底层也是HashMap、Kafka的元数据管理用了大量ConcurrentHashMap。在某元宇宙社交公司写Flink DataStream时,有一次在processElement里用了一个普通HashMap做本地缓存,多个并行度共享同一个实例(设计错误),结果在高并发下出现了数据丢失。后来改成ConcurrentHashMap才解决。
HashMap底层结构——数组+链表+红黑树
// HashMap的核心结构(简化版)
// 底层是一个Node数组(桶数组)
// 每个桶是一个链表或红黑树
// put(key, value)的过程:
// 1. 计算key的hash值
// int hash = (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
// 高16位和低16位异或,让hash分布更均匀
// 2. 定位桶:index = hash & (capacity - 1)
// 相当于 hash % capacity,但位运算更快
// 这要求capacity必须是2的幂
// 3. 如果桶为空,直接放入
// 如果桶不为空(hash冲突),挂到链表尾部(JDK8尾插法)
// 如果链表长度 >= 8 且数组长度 >= 64,转成红黑树
// 红黑树查找O(logN)比链表O(N)快
// 4. 如果size > capacity * loadFactor(0.75),触发扩容
// 容量翻倍(16 ==> 32),所有元素重新hash分配
为什么HashMap线程不安全?——三个问题
问题一:数据覆盖(JDK 8)
两个线程同时put,hash到同一个桶,都检查到桶为空。线程A写入后线程B也写入,线程A的数据被覆盖了。
问题二:死循环(JDK 7的头插法)
JDK 7扩容时用头插法迁移链表,两个线程同时扩容可能形成环形链表,之后get操作遍历链表进入死循环,CPU 100%。JDK 8改成尾插法修复了这个问题,但数据覆盖的问题仍然存在。
问题三:size不准确
size++不是原子操作(读取-修改-写入三步),多线程同时put可能导致size比实际元素少。
ConcurrentHashMap——线程安全的HashMap
ConcurrentHashMap是多线程场景下的标准选择。JDK 7和JDK 8的实现完全不同:
| 维度 | JDK 7 ConcurrentHashMap | JDK 8 ConcurrentHashMap |
|---|---|---|
| 数据结构 | Segment数组+HashEntry链表 | Node数组+链表+红黑树 |
| 锁粒度 | Segment级别(分段锁) | Node级别(更细粒度) |
| 锁实现 | ReentrantLock | synchronized + CAS |
| 并发度 | 默认16个Segment | 等于桶数(更高并发度) |
| 读操作 | 不加锁(volatile保证可见性) | 不加锁(volatile+CAS) |
// JDK 8 ConcurrentHashMap的put过程(简化版)
// 1. 计算hash定位到桶
// 2. 如果桶为空:
// 用CAS原子操作放入(不加锁,性能最好)
// 3. 如果桶不为空(hash冲突):
// 对桶的头节点加synchronized锁
// 然后在链表/红黑树上操作
// 只锁住当前桶,其他桶不受影响
// 4. size的统计用LongAdder思想
// 分散到多个cell中累加,减少竞争
// 为什么JDK 8不用Segment了?
// Segment锁粒度太粗(默认16个Segment,并发度只有16)
// Node级别的锁粒度更细,并发度等于桶数(可以成百上千)
// synchronized在JDK 8已经做了大量优化(偏向锁/轻量级锁)
// 性能不输ReentrantLock
大数据场景中HashMap的使用
| 场景 | 用的什么Map | 为什么 |
|---|---|---|
| Flink Keyed State | HashMapStateBackend内部 | 状态存储,单线程访问 |
| Spark广播变量 | HashMap(只读) | 广播后只读,不需要线程安全 |
| Kafka元数据管理 | ConcurrentHashMap | Broker多线程访问元数据 |
| Flink processElement本地缓存 | 推荐ConcurrentHashMap | 可能被多个并行度访问 |
| Spark Accumulator | 内部用同步Map | Driver端多线程合并 |
实战踩坑:Flink中HashMap导致数据丢失
在某元宇宙社交公司,我写了一个Flink DataStream的ProcessFunction,在里面用了一个静态的HashMap做用户信息本地缓存(想减少查Redis的次数)。
问题:并行度设为4,4个SubTask共享了同一个静态HashMap实例。高并发下多个SubTask同时put,出现了数据覆盖——某些用户的缓存信息被其他用户的覆盖了,导致下游逻辑出错。
解决方案:① 把静态HashMap改成ConcurrentHashMap;② 更好的方案是不用静态变量,而是用Flink的MapState——它是和Key绑定的,天然线程安全且支持Checkpoint持久化。
教训:Flink的Operator内部代码可能被多个线程调用(比如Async IO),使用共享的可变集合一定要考虑线程安全。最佳实践是用Flink的State API替代自定义集合。
面试高频追问
追问一:HashMap的初始容量为什么是16?为什么必须是2的幂?
16是经验值——不大不小。必须是2的幂是因为定位桶的算法是hash & (capacity - 1),这等价于hash % capacity但比取模快。当capacity是2的幂时,capacity - 1的二进制是全1(比如15=1111),& 运算能均匀分布。如果不是2的幂,某些桶永远不会被命中,分布不均匀。
追问二:链表转红黑树的阈值为什么是8?
基于泊松分布的统计——在良好的hash函数下,链表长度达到8的概率只有千万分之一。所以正常情况下链表很短(1-2个元素),不需要红黑树。转红黑树是为了防御hash攻击(恶意构造大量相同hash的key),避免O(N)退化为极慢的链表遍历。反转阈值是6(避免频繁转换)。
追问三:ConcurrentHashMap的size()是精确的吗?
不是100%精确的——因为没有全局锁,在你调用size()的同时可能有其他线程在put/remove。但误差通常很小(1-2个元素),对大部分场景够用了。如果需要精确值,可以用mappingCount()(返回long)配合适当的同步控制。
总结
HashMap面试讲清楚三层:① 底层结构(数组+链表+红黑树,扩容2倍,链表转树阈值8);② 线程不安全的原因(数据覆盖+JDK 7死循环+size不准);③ ConcurrentHashMap的实现(JDK 8的Node级锁+CAS+synchronized,比JDK 7的分段锁更细粒度)。能结合大数据场景讲HashMap的使用(Flink State、Spark广播变量)和踩坑经历,面试官会觉得你不只是背八股文。
下一篇聊序列化:Flink的State为什么要序列化存储?Java序列化、Kryo、Protobuf怎么选?
你们在大数据代码里用过HashMap踩过坑吗?评论区聊聊~
关注「Wood聊大数据」| 9年大数据老兵,聊技术、聊职场、聊成长