← 返回题目列表

HashMap、Hashtable、ConcurrentHashMap 有什么区别?

高频 中等 第 10 / 30 题 更新于 2026/07/25
HashMapHashtableConcurrentHashMap线程安全

简化版

三者都是 Map,核心差别在线程安全和性能HashMap 不安全但最快,单线程用它;Hashtable 用一把大锁锁全表、安全但慢,已过时;ConcurrentHashMap 用细粒度锁(JDK 8 锁单个桶)+ 无锁读,既安全又高并发,是并发场景的正解。所以并发下别用 Hashtable,用 ConcurrentHashMap

详细版

维度HashMapHashtableConcurrentHashMap
线程安全❌ 不安全✅ 安全✅ 安全
加锁方式无锁整表一把 synchronized 大锁JDK8:CAS + 锁单个桶;读无锁
并发性能最高(但不安全)最差(全表串行)
允许 null 键值✅ 一个 null 键、多个 null 值❌ 都不允许❌ 都不允许
null 原因早期设计并发下 null 有歧义
现状常用已淘汰并发首选
  • HashMap:数组 + 链表/红黑树,性能最好,但并发读写会数据错乱甚至(JDK7)死循环,只能单线程或加外部同步用。
  • Hashtable:几乎每个方法都加 synchronized,锁的是整个表,任何时刻只有一个线程能操作,并发度等于 1,性能很差,是「遗留类」(Legacy),新代码不该用。
  • ConcurrentHashMap:JDK 8 用「CAS 写空桶 + synchronized 锁单桶头节点」把锁粒度细化到桶级,读操作靠 volatile 无锁,并发能力远超 Hashtable。

完整版教学

一、一条主线:锁粒度决定并发性能

三者的并发能力差异,本质是锁粒度不同:

  • Hashtable:一把锁锁整张表 → 并发度 = 1 → 最慢。
  • ConcurrentHashMap(JDK8):一把锁只锁一个桶 → 并发度 ≈ 桶数(可能上千)→ 快得多。
  • HashMap不加锁 → 最快但不安全。

从 Hashtable 到 ConcurrentHashMap,就是把「锁整张表」拆成「锁一个桶」,冲突概率骤降,这就是它高并发的根本(ConcurrentHashMap 的细节见「ConcurrentHashMap 如何保证线程安全」那道题)。

二、为什么 HashMap 允许 null,另外两个不允许

  • HashMap:单线程使用,允许一个 null 键(存在下标 0 的桶)和多个 null 值,没有歧义。
  • Hashtable:早期设计就禁止 null(put 时会对 value 判空抛 NPE,key 为 null 时 hashCode() 也会 NPE)。
  • ConcurrentHashMap:并发下 null 有二义性——map.get(k) 返回 null,你分不清是「key 不存在」还是「key 存在但值就是 null」。单线程可以再 containsKey 确认,但并发下这两步之间值可能被改,无法区分。所以干脆禁止 null 消除歧义。

记忆点:并发容器禁止 null,是为了避免「值为 null」和「键不存在」在并发下无法区分。

三、为什么 Hashtable 被淘汰而不是改进

Hashtable 的问题不只是慢,还有设计过时:它继承自古老的 Dictionary 类(不是标准的 Map 体系的自然成员),命名不规范(Hashtable 的 t 该大写),加锁方式粗暴。JDK 没去改进它,而是提供了两个替代:

  • 单线程/需要外部控制同步 → HashMap
  • 并发 → ConcurrentHashMap

如果只是想要一个「同步的 HashMap」,还可以用 Collections.synchronizedMap(new HashMap<>())——但它和 Hashtable 一样是全表锁,并发性能同样差,一般也不推荐。并发就用 ConcurrentHashMap,这是标准答案。

四、迭代与复合操作的语义差异

线程安全不只是“put 会不会把结构写坏”。Hashtable 和 synchronizedMap 的单方法由全表锁保护,但 if (!map.containsKey(k)) map.put(k,v) 仍是两个分离临界区;两个线程可能都通过检查。ConcurrentHashMap 提供 putIfAbsentcomputemerge,把常见读改写合并到容器内部原子执行。

迭代语义也不同:HashMap 迭代器 fail-fast,发现结构变化会尽力抛 CME;ConcurrentHashMap 迭代器弱一致,允许并发更新但不提供单一时刻快照。Hashtable 的老式 Enumeration 与现代 Iterator 行为也不同,这正体现了遗留 API 带来的认知成本。

// 两步,不是整体原子
if (!map.containsKey("count")) map.put("count", 1);

// ConcurrentHashMap 原子表达初始化
map.putIfAbsent("count", 1);
map.merge("count", 1, Integer::sum);

五、按使用场景选,而不是只看“安全”标签

假设 32 个线程同时访问不同 key:Hashtable 的 32 个操作仍要排队抢同一把对象锁;ConcurrentHashMap 大部分操作可落入不同桶并行推进;HashMap 虽没有锁成本,却可能产生错误结果。性能比较必须以正确性为前提,“无锁所以快”不能成为共享可变数据选 HashMap 的理由。

需求推荐选择关键边界
方法内临时映射、单线程构建HashMap最简单,允许 null
高并发共享映射ConcurrentHashMap禁止 null,使用原子复合 API
构建后只读并安全发布Map.copyOf 等不可变 Map后续不能修改
必须兼容遗留接口Hashtable只为兼容,不用于新设计
低并发且需同步包装synchronizedMap遍历需在包装对象上加锁

六、常见误区与追问

  • 误区:Hashtable 线程安全,所以适合新的并发系统。 它以整表串行换安全,API 遗留且并发度低,新代码通常用 ConcurrentHashMap。
  • 误区:ConcurrentHashMap 完全无锁。 空桶尝试 CAS,冲突更新仍会进行桶级同步,读路径主要不加互斥锁。
  • 误区:synchronizedMap 能让任意组合逻辑自动原子。 包装只同步单次方法,复合操作和遍历仍需显式覆盖整个临界区。
  • 追问:为什么 ConcurrentHashMap 不允许 null? null 被用作“不存在”的明确返回信号,避免并发下 get 与 containsKey 组合判断的竞态。
  • 追问:只读 HashMap 能否多线程共享? 构建完成后通过 final、锁、volatile 等安全发布且不再修改时可以;并发构建或发布不安全仍有风险。
  • 追问:何时选择不可变 Map? 配置、路由等整体替换且读多的场景,可构造新快照后原子替换引用,减少细粒度更新复杂度。

记忆钩子:先问“谁会写、是否复合更新、需要什么迭代一致性”,再选 Map;线程安全不是类名旁边一个勾就结束。

原子更新还要关注回调执行环境。下面用 merge 做计数时,容器会围绕目标 key 协调读改写,比 getOrDefault + put 更可靠;但回调应短小,不能把远程调用或长事务塞进去,否则同桶更新会被拖慢。

ConcurrentHashMap<String, Long> counts = new ConcurrentHashMap<>();
counts.merge("java", 1L, Long::sum);

若统计热点 key 的高频增量,ConcurrentHashMap<K, LongAdder> 往往比每次替换 Long 更能分散竞争;这也说明选对 Map 只是第一层,value 的并发更新模型同样重要。

freq.computeIfAbsent("java", k -> new LongAdder()).increment();

容器保护的是节点结构和所承诺的原子操作,不会自动让存入的可变 List、User 或计数对象线程安全。

七、加强记忆

HashMap 面向无同步的普通场景,允许 null 且开销低;Hashtable 用对象级大锁保护方法,安全却全表串行,是兼容遗留;ConcurrentHashMap 通过 CAS、桶级同步、volatile 读和原子复合 API 支撑高并发。选择时同时考虑 null 语义、迭代一致性和多步操作,不能把“单方法线程安全”误当成整段业务原子性。