杰瑞科技汇

Java hashmap 存在哪些安全隐患?

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

Java hashmap 存在哪些安全隐患?-图1
(图片来源网络,侵删)
  1. 线程安全:在多线程环境下,HashMap 的操作是否是安全的,会不会出现数据不一致或程序崩溃等问题。
  2. 安全性:从防止恶意攻击(如拒绝服务攻击 DoS)的角度看,HashMap 的设计是否存在漏洞。

下面我们详细分解这两个方面。


线程安全问题

这是 HashMap 最广为人知的安全隐患。

结论先行:HashMap非线程安全的。

在单线程环境下,HashMap 性能优异,但在多线程环境下,直接使用 HashMap 会导致严重问题。

主要的线程不安全场景

主要有两个核心问题:死循环数据覆盖/丢失

Java hashmap 存在哪些安全隐患?-图2
(图片来源网络,侵删)

1 死循环问题(发生在 JDK 1.7 及之前)

这个问题在 JDK 1.7 中尤为经典,源于其头插法扩容机制。

  • 触发条件:当多个线程同时 put 操作,导致 HashMap 的元素数量超过 扩容阈值loadFactor * capacity),从而触发 resize()(扩容)操作时。
  • 根本原因
    1. 头插法:JDK 1.7 在扩容时,采用头插法将旧链表中的元素迁移到新数组,新节点会放在链表的头部。
    2. 并发执行:假设线程 A 和线程 B 同时进行 put 操作,都触发了扩容。
    3. 交叉执行:线程 A 暂停,线程 B 完成了扩容,并将新数组的引用赋给了 table,线程 A 恢复执行,它还在使用旧数组进行扩容。
    4. 形成环形链表:在迁移节点时,由于两个线程操作的是同一个链表,但一个在旧数组,一个在新数组,它们的 next 指针会互相指向,形成一个环。
    5. 死循环:当后续有 get 操作尝试遍历这个环形链表时,程序会陷入无限循环,CPU 占用率飙升至 100%。

JDK 1.8 的改进: JDK 1.8 改用了尾插法来扩容,并且在 put 的第一个节点时会加锁(synchronized),这极大地降低了发生死循环的概率,但并未完全解决线程安全问题。

2 数据覆盖/丢失问题(在 JDK 1.8 中依然存在)

这个问题的触发场景更多,也更隐蔽。

  • 触发条件:多个线程同时调用 put() 方法,并且发生了哈希冲突(即计算出的索引位置相同)。
  • 根本原因
    1. 无锁操作HashMapput 操作整体上是无锁的。
    2. 执行交叉:我们来看 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);
      }
    3. 竞态条件:假设线程 A 和线程 B 都计算出了同一个 index,并且都发现 tab[index]null
      • 线程 A 执行到 tab[index] = newNode(...) 之前被挂起。
      • 线程 B 执行完毕,成功将一个新节点放入了 tab[index]
      • 线程 A 恢复,它仍然认为 tab[index]null,于是也执行 tab[index] = newNode(...)覆盖了线程 B 的数据
    4. 结果:线程 B 的 put 操作被“无声”地覆盖掉了,数据丢失。

线程安全的替代方案

既然 HashMap 不安全,那么在多线程环境下应该使用什么?

Java hashmap 存在哪些安全隐患?-图3
(图片来源网络,侵删)
集合类 线程安全机制 特点 适用场景
Hashtable 方法级锁 (synchronized) 效率低,所有操作都串行执行,性能差。 极少使用,基本被淘汰。
Collections.synchronizedMap(new HashMap<>()) 对象级锁 对整个 Map 对象加锁,并发性能依然很差。 需要一个线程安全的 Map,但又不希望引入 ConcurrentHashMap 的复杂性时。
ConcurrentHashMap CAS + synchronized (分段锁/锁桶) 推荐,高并发下性能卓越,它只对链表/红黑树的头节点加锁,不影响其他桶的操作。 高并发场景下的首选。

在多线程环境中,绝对不要直接使用 HashMap,应优先选择 ConcurrentHashMap


安全性问题(防止 DoS 攻击)

这个问题主要关注 HashMap 在面对恶意构造的输入时是否健壮。

结论先行:HashMap 在设计上存在哈希碰撞攻击的风险,可能导致性能急剧下降(拒绝服务攻击)。

攻击原理

HashMap 的性能依赖于哈希函数的均匀分布,理想情况下,不同的 key 应该均匀地散布到数组的各个桶中。

  • 攻击方式:攻击者可以精心构造一系列 key,使得这些 key 经过 hashCode() 方法计算后,都返回相同的值(或非常接近的值,导致落在同一个桶中)。
  • 攻击后果
    1. 所有这些 key 都会存储在 HashMap同一个桶中。
    2. 这个桶会从链表(JDK 1.7)或红黑树(JDK 1.8)的形式退化成一个超长的链表
    3. 当程序对这个 HashMap 进行 getputremove 等操作时,时间复杂度会从平均的 O(1) 退化到最坏的 O(n)
    4. 程序响应变得极其缓慢,CPU 资源被耗尽,从而无法为正常用户提供服务,这就是拒绝服务攻击

JDK 的应对措施

Java 官方意识到了这个问题,并从 JDK 1.8 开始进行了一系列优化来缓解攻击:

  1. 引入 java.util.HashMapTreeNode

    • 当链表的长度超过 TREEIFY_THRESHOLD(默认为 8)时,HashMap 会自动将链表转换成红黑树
    • 红黑树的增删改查时间复杂度为 O(log n),这比 O(n) 好得多,大大缓解了链表过长导致的性能问题。
  2. 引入 java.util.HashMap$TreeNodesplit 方法

    • 在扩容时,如果一个桶中的元素过多(比如红黑树节点数超过 UNTREEIFY_THRESHOLD,默认为 6),扩容后如果仍然很长,会尝试将红黑树拆分到不同的桶中,避免所有恶意数据都集中在一个桶里。
  3. 引入 java.util.HashMap$TreeNodeuntreeify 方法

    • 如果在扩容后,某个桶中的元素数量又变得很少(比如小于 6),红黑树会退化回链表,以节省空间。
  4. String.hashCode() 的防御性设计

    • Java 对 StringhashCode() 方法进行了特殊处理,其哈希值计算过程中会引入一个随机种子,这个种子在每次 JVM 启动时都会变化。
    • 这意味着,即使攻击者知道 String 的哈希算法,也无法在不知道当前 JVM 实例的种子的情况下,精确地构造出哈希碰撞的字符串,这对于针对 String 作为 key 的攻击是致命的。

局限性: 这些措施大大缓解了攻击,但不能完全消除,攻击者仍然可以针对非 String 类型的 key(比如自定义的类)来构造碰撞,只要该类的 hashCode() 方法设计得不够健壮。


总结与最佳实践

安全类型 原因 解决方案
线程安全 不安全 resize 可能导致死循环;并发 put 可能导致数据覆盖。 多线程环境下使用 ConcurrentHashMap
防 DoS 攻击 存在风险,但已缓解 恶意输入可导致哈希碰撞,使链表过长,性能降为 O(n)。 JDK 1.8+ 引入红黑树优化,String.hashCode() 使用随机种子,但仍需警惕自定义 key

最佳实践建议

  1. 明确使用场景

    • 单线程:放心使用 HashMap,性能最优。
    • 多线程必须使用 ConcurrentHashMap,避免使用 HashtablesynchronizedMap
  2. 谨慎自定义 keyhashCode()

    • 如果你要创建一个类作为 HashMapkey,请务必正确地实现 equals()hashCode() 方法。
    • 一个好的 hashCode() 算法应该尽可能均匀地分布哈希值,避免所有实例返回相同的哈希码。
    • 如果无法保证 key 的哈希质量,可以考虑使用 java.util.IdentityHashMap,它使用 System.identityHashCode(),基于对象的内存地址,能更好地抵御哈希碰撞攻击(但代价是失去了基于值的 equals 语义)。
  3. 预估容量

    • 如果大致知道 HashMap 中会存放多少数据,在初始化时指定一个合理的初始容量(new HashMap<>(initialCapacity)),可以避免因多次扩容带来的性能损耗。
  4. 保持警惕

    • 永远不要假设 HashMap 的操作是原子性的,即使在看起来简单的场景下(如 if (!map.containsKey(key)) map.put(key, value);),在多线程下也可能出现问题,应使用 ConcurrentHashMapputIfAbsent 方法。
分享:
扫描分享到社交APP
上一篇
下一篇