HashMap 的“安全”可以从两个层面来理解:

- 线程安全:在多线程环境下,
HashMap的操作是否是安全的,会不会出现数据不一致或程序崩溃等问题。 - 安全性:从防止恶意攻击(如拒绝服务攻击 DoS)的角度看,
HashMap的设计是否存在漏洞。
下面我们详细分解这两个方面。
线程安全问题
这是 HashMap 最广为人知的安全隐患。
结论先行:HashMap 是非线程安全的。
在单线程环境下,HashMap 性能优异,但在多线程环境下,直接使用 HashMap 会导致严重问题。
主要的线程不安全场景
主要有两个核心问题:死循环 和 数据覆盖/丢失。

1 死循环问题(发生在 JDK 1.7 及之前)
这个问题在 JDK 1.7 中尤为经典,源于其头插法扩容机制。
- 触发条件:当多个线程同时
put操作,导致HashMap的元素数量超过扩容阈值(loadFactor * capacity),从而触发resize()(扩容)操作时。 - 根本原因:
- 头插法:JDK 1.7 在扩容时,采用头插法将旧链表中的元素迁移到新数组,新节点会放在链表的头部。
- 并发执行:假设线程 A 和线程 B 同时进行
put操作,都触发了扩容。 - 交叉执行:线程 A 暂停,线程 B 完成了扩容,并将新数组的引用赋给了
table,线程 A 恢复执行,它还在使用旧数组进行扩容。 - 形成环形链表:在迁移节点时,由于两个线程操作的是同一个链表,但一个在旧数组,一个在新数组,它们的
next指针会互相指向,形成一个环。 - 死循环:当后续有
get操作尝试遍历这个环形链表时,程序会陷入无限循环,CPU 占用率飙升至 100%。
JDK 1.8 的改进:
JDK 1.8 改用了尾插法来扩容,并且在 put 的第一个节点时会加锁(synchronized),这极大地降低了发生死循环的概率,但并未完全解决线程安全问题。
2 数据覆盖/丢失问题(在 JDK 1.8 中依然存在)
这个问题的触发场景更多,也更隐蔽。
- 触发条件:多个线程同时调用
put()方法,并且发生了哈希冲突(即计算出的索引位置相同)。 - 根本原因:
- 无锁操作:
HashMap的put操作整体上是无锁的。 - 执行交叉:我们来看
put的核心逻辑(简化版):// 1. 计算索引 int index = hash(key) & (n-1); // 2. 检查第一个节点 Node<K,V> first = tab[index]; if (first == null) { // 3. 如果为空,直接创建新节点放入 tab[index] = newNode(hash, key, value, null); } - 竞态条件:假设线程 A 和线程 B 都计算出了同一个
index,并且都发现tab[index]为null。- 线程 A 执行到
tab[index] = newNode(...)之前被挂起。 - 线程 B 执行完毕,成功将一个新节点放入了
tab[index]。 - 线程 A 恢复,它仍然认为
tab[index]为null,于是也执行tab[index] = newNode(...),覆盖了线程 B 的数据。
- 线程 A 执行到
- 结果:线程 B 的
put操作被“无声”地覆盖掉了,数据丢失。
- 无锁操作:
线程安全的替代方案
既然 HashMap 不安全,那么在多线程环境下应该使用什么?

| 集合类 | 线程安全机制 | 特点 | 适用场景 |
|---|---|---|---|
Hashtable |
方法级锁 (synchronized) |
效率低,所有操作都串行执行,性能差。 | 极少使用,基本被淘汰。 |
Collections.synchronizedMap(new HashMap<>()) |
对象级锁 | 对整个 Map 对象加锁,并发性能依然很差。 |
需要一个线程安全的 Map,但又不希望引入 ConcurrentHashMap 的复杂性时。 |
ConcurrentHashMap |
CAS + synchronized (分段锁/锁桶) |
推荐,高并发下性能卓越,它只对链表/红黑树的头节点加锁,不影响其他桶的操作。 | 高并发场景下的首选。 |
在多线程环境中,绝对不要直接使用 HashMap,应优先选择 ConcurrentHashMap。
安全性问题(防止 DoS 攻击)
这个问题主要关注 HashMap 在面对恶意构造的输入时是否健壮。
结论先行:HashMap 在设计上存在哈希碰撞攻击的风险,可能导致性能急剧下降(拒绝服务攻击)。
攻击原理
HashMap 的性能依赖于哈希函数的均匀分布,理想情况下,不同的 key 应该均匀地散布到数组的各个桶中。
- 攻击方式:攻击者可以精心构造一系列
key,使得这些key经过hashCode()方法计算后,都返回相同的值(或非常接近的值,导致落在同一个桶中)。 - 攻击后果:
- 所有这些
key都会存储在HashMap的同一个桶中。 - 这个桶会从链表(JDK 1.7)或红黑树(JDK 1.8)的形式退化成一个超长的链表。
- 当程序对这个
HashMap进行get、put、remove等操作时,时间复杂度会从平均的 O(1) 退化到最坏的 O(n)。 - 程序响应变得极其缓慢,CPU 资源被耗尽,从而无法为正常用户提供服务,这就是拒绝服务攻击。
- 所有这些
JDK 的应对措施
Java 官方意识到了这个问题,并从 JDK 1.8 开始进行了一系列优化来缓解攻击:
-
引入
java.util.HashMap的TreeNode:- 当链表的长度超过
TREEIFY_THRESHOLD(默认为 8)时,HashMap会自动将链表转换成红黑树。 - 红黑树的增删改查时间复杂度为 O(log n),这比 O(n) 好得多,大大缓解了链表过长导致的性能问题。
- 当链表的长度超过
-
引入
java.util.HashMap$TreeNode的split方法:- 在扩容时,如果一个桶中的元素过多(比如红黑树节点数超过
UNTREEIFY_THRESHOLD,默认为 6),扩容后如果仍然很长,会尝试将红黑树拆分到不同的桶中,避免所有恶意数据都集中在一个桶里。
- 在扩容时,如果一个桶中的元素过多(比如红黑树节点数超过
-
引入
java.util.HashMap$TreeNode的untreeify方法:- 如果在扩容后,某个桶中的元素数量又变得很少(比如小于 6),红黑树会退化回链表,以节省空间。
-
String.hashCode()的防御性设计:- Java 对
String的hashCode()方法进行了特殊处理,其哈希值计算过程中会引入一个随机种子,这个种子在每次 JVM 启动时都会变化。 - 这意味着,即使攻击者知道
String的哈希算法,也无法在不知道当前 JVM 实例的种子的情况下,精确地构造出哈希碰撞的字符串,这对于针对String作为key的攻击是致命的。
- Java 对
局限性:
这些措施大大缓解了攻击,但不能完全消除,攻击者仍然可以针对非 String 类型的 key(比如自定义的类)来构造碰撞,只要该类的 hashCode() 方法设计得不够健壮。
总结与最佳实践
| 安全类型 | 原因 | 解决方案 | |
|---|---|---|---|
| 线程安全 | 不安全 | resize 可能导致死循环;并发 put 可能导致数据覆盖。 |
多线程环境下使用 ConcurrentHashMap。 |
| 防 DoS 攻击 | 存在风险,但已缓解 | 恶意输入可导致哈希碰撞,使链表过长,性能降为 O(n)。 | JDK 1.8+ 引入红黑树优化,String.hashCode() 使用随机种子,但仍需警惕自定义 key。 |
最佳实践建议
-
明确使用场景:
- 单线程:放心使用
HashMap,性能最优。 - 多线程:必须使用
ConcurrentHashMap,避免使用Hashtable或synchronizedMap。
- 单线程:放心使用
-
谨慎自定义
key的hashCode():- 如果你要创建一个类作为
HashMap的key,请务必正确地实现equals()和hashCode()方法。 - 一个好的
hashCode()算法应该尽可能均匀地分布哈希值,避免所有实例返回相同的哈希码。 - 如果无法保证
key的哈希质量,可以考虑使用java.util.IdentityHashMap,它使用System.identityHashCode(),基于对象的内存地址,能更好地抵御哈希碰撞攻击(但代价是失去了基于值的equals语义)。
- 如果你要创建一个类作为
-
预估容量:
- 如果大致知道
HashMap中会存放多少数据,在初始化时指定一个合理的初始容量(new HashMap<>(initialCapacity)),可以避免因多次扩容带来的性能损耗。
- 如果大致知道
-
保持警惕:
- 永远不要假设
HashMap的操作是原子性的,即使在看起来简单的场景下(如if (!map.containsKey(key)) map.put(key, value);),在多线程下也可能出现问题,应使用ConcurrentHashMap的putIfAbsent方法。
- 永远不要假设
