题目:HashMap 用在并发场景中有什么问题?
难度:medium
标签:java/collections, general/knowledge
评分:⭐⭐⭐☆☆(3/5)
HashMap 在并发场景中,由于它没有做锁的操作,多个线程同时进行操作的时候,会导致插入的是插入同一位置的对象出现冲突。在 JDK 1.8 之前,有比较严重的问题,就是它原本是用头插法,如果前一个头断开了,然后再重新指向的时候指向了自己,就会导致整个链路锁死。JDK 1.8 之后修复了这个问题,改成了尾插法,但是并不能弥补并发下插入内容插错的问题。多线程下,size 是同时插入,size 增加由于不是原子性的,也没有锁,会导致 size 错误,然后插入的 put 的内容被覆盖丢失掉。处理这个问题,核心其实在加锁。对应的主流的、主要的还是使用 ConcurrentHashMap 去进行线程安全的 HashMap 处理,或者是使用对应的那个工具类中 ConcurrentNavigableMap 方法来创建一个线程安全的线程安全的 HashMap。
优点:
不足:
if (map.get(k) == null) map.put(k, v) 这类复合操作不是原子的,两个线程可能同时 putCollections.synchronizedMap()补充(JDK8 HashMap 并发问题全景):
++size 非原子(read-modify-write),并发 put 会丢失更新替代方案对比:
ConcurrentHashMap:默认选择,桶级 synchronized + CAS,读几乎无锁Collections.synchronizedMap(new HashMap<>()):包一层全表锁,简单但性能差,迭代仍需外层加锁ConcurrentSkipListMap:需要有序时用(TreeMap 的并发版)工程实践:优先 ConcurrentHashMap;复合操作用 computeIfAbsent;不要用 HashMap 存共享统计计数(用 LongAdder);不可变场景用 Collections.unmodifiableMap
核心考点定位:验证对 HashMap 并发缺陷的全面认识,尤其是 JDK8 尾插法修复的边界——它只修了一个 bug,不代表线程安全。
知识点拆解:
size++ 非原子:两个线程同时 put,size 可能少加 1if (!map.containsKey(k)) map.put(k, v) 之间可能被其他线程插入按「底层原理 → 设计权衡 → 工程实践」递进排列。
考察方向:
延伸递进面试题:
computeIfAbsent 在 ConcurrentHashMap 上为什么能保证原子?Collections.synchronizedMap 的迭代要外层加锁吗?为什么?synchronizedMap 只同步单方法调用,迭代器本身不是同步的,遍历时可能被修改,需要手动在 synchronized(map) 块内迭代。面试官您好,HashMap 在并发场景下有多个层面的问题。
第一个是 JDK7 的经典问题——头插法导致的环形链表。并发扩容时两个线程同时搬运链表,A 遍历到 e→f,B 遍历到 f→e,A 完成后变成 e→f→e 环,任何 get 命中这个桶都会死循环、CPU 100%。JDK8 改成了尾插法,这个问题修复了,但只是修复了这一个 bug,不代表 HashMap 就线程安全了。
第二个是 size 计数不准。++size 是 read-modify-write 的复合操作,两个线程同时 put 时可能都读到同一个 size 值,加 1 后写入,导致实际 count 少 1。
第三个是 put 覆盖丢失。HashMap 的 put 逻辑是"先检查 key 是否存在,存在则覆盖 value,不存在则插入",这个 check-then-act 中间没有锁保护。两个线程同时 put 不同 key 到同一个桶,其中一个可能会被另一个的写入覆盖。
第四个是 get 读到中间状态。并发扩容时,节点可能已经从旧桶摘除但还没挂到新桶,此时 get 命中该桶会返回 null。
第五个是迭代器的问题。HashMap 是 fail-fast 的,遍历过程中被修改会抛 ConcurrentModificationException。
第六个是内存可见性。HashMap 内部变量都不是 volatile,线程 A 修改后线程 B 可能读不到最新值。
解决方案上,主流就是用 ConcurrentHashMap——JDK8 用桶级 synchronized + CAS,读几乎无锁,并发写入也只锁一个桶。低并发简单场景可以用 Collections.synchronizedMap(new HashMap<>()) 包一层全表锁,但要注意迭代时仍需外层加锁。需要有序的场景用 ConcurrentSkipListMap 代替 TreeMap。复合操作(如"不存在则创建")直接用 computeIfAbsent 或 merge,它们在 ConcurrentHashMap 上能保证原子。共享计数器不要用 Map 存 AtomicLong,直接用 LongAdder 分片累加性能更好。