HashMap、Hashtable、ConcurrentHashMap 有什么区别?
简化版
三者都是 Map,核心差别在线程安全和性能:HashMap 不安全但最快,单线程用它;Hashtable 用一把大锁锁全表、安全但慢,已过时;ConcurrentHashMap 用细粒度锁(JDK 8 锁单个桶)+ 无锁读,既安全又高并发,是并发场景的正解。所以并发下别用 Hashtable,用 ConcurrentHashMap。
详细版
| 维度 | HashMap | Hashtable | ConcurrentHashMap |
|---|---|---|---|
| 线程安全 | ❌ 不安全 | ✅ 安全 | ✅ 安全 |
| 加锁方式 | 无锁 | 整表一把 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 提供 putIfAbsent、compute、merge,把常见读改写合并到容器内部原子执行。
迭代语义也不同: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 语义、迭代一致性和多步操作,不能把“单方法线程安全”误当成整段业务原子性。