在 Java 并发编程中,线程安全的 Map 实现是必不可少的组件。Hashtable 作为早期的线程安全容器,曾是开发者的首选;而 ConcurrentHashMap(简称 CHM)则是现代高并发场景下的性能王者。
本文将从底层原理、锁机制、性能表现及实际应用场景,详细解析两者的核心区别。
核心区别概览
为了让你快速抓住重点,我们先通过一张表格总结两者的关键差异:
| 特性 | Hashtable | ConcurrentHashMap (JDK 8+) |
|---|---|---|
| 线程安全 | 是(通过 synchronized 方法) | 是(通过 CAS + synchronized) |
| 锁粒度 | 粗粒度(锁住整个对象) | 细粒度(锁住单个桶/节点) |
| Null 键/值 | 不允许(抛出 NullPointerException) | 不允许(设计初衷是为了避免歧义) |
| 迭代器 | Fail-Fast(遍历时修改抛异常) | 弱一致性(遍历时修改不抛异常) |
| 性能 | 低(并发下串行化,瓶颈明显) | 极高(支持高并发读写) |
| 数据结构 | 数组 + 链表 | 数组 + 链表 + 红黑树 |
锁机制:从"全局锁"到"分段/节点锁"
这是两者最本质的区别,决定了它们在并发环境下的性能天壤之别。
1. Hashtable:暴力且低效的"全局锁"
Hashtable 几乎在所有公共方法(如 put、get、remove)上都加上了 synchronized 关键字。
// Hashtable 源码简化示意
public synchronized V put(K key, V value) {
// ... 写入逻辑
}
public synchronized V get(Object key) {
// ... 读取逻辑
}
这意味着,任意时刻只能有一个线程能操作 Hashtable。如果有 100 个线程在争抢这把锁,其他 99 个线程都必须阻塞等待。这种串行化的处理方式在高并发下会成为系统的严重瓶颈。
2. ConcurrentHashMap:精细化的"节点锁"
ConcurrentHashMap 的设计哲学是"能不加锁就不加锁,必须加锁时只锁最小范围"。
- JDK 7:使用了分段锁(Segment Lock),将数据分成一段一段存储,给每一段数据配一把锁。
- JDK 8 及以后 :摒弃了 Segment,采用了与
HashMap类似的数组 + 链表 + 红黑树结构。锁的粒度进一步降低到**桶(Bucket)**级别。
JDK 8 的实现细节:
- 读操作无锁 :
get方法完全不需要加锁,依靠volatile关键字保证数据的可见性,性能极高。 - 写操作锁粒度细化 :只有当发生哈希冲突(即链表或红黑树操作)时,才会使用
synchronized锁住当前的头节点。这意味着不同的线程可以同时写入 Map 中不同的桶,互不干扰。 - CAS 优化:在插入新节点时,如果该位置为空,会使用 CAS(Compare-And-Swap)操作进行无锁插入,进一步提升性能。
数据结构与扩容机制
除了锁机制,两者的底层数据结构也有显著不同。
- Hashtable:结构非常简单,仅由数组和链表组成。当链表过长时,查询效率会退化为 O(n)O(n) 。
- ConcurrentHashMap (JDK 8):引入了红黑树。当链表长度超过 8 且数组长度超过 64 时,链表会转换为红黑树,将查询时间复杂度降低到 O(logn)O(logn) 。
扩容时的并发能力:
- Hashtable:扩容时需要重新哈希所有元素,且由于全局锁的存在,扩容期间所有线程都被阻塞。
- ConcurrentHashMap:支持多线程协同扩容。当一个线程正在迁移数据时,其他线程可以协助迁移(transfer),或者继续执行读操作(通过旧表读取),极大地减少了停顿时间。
迭代器行为:Fail-Fast vs 弱一致性
在遍历集合时,两者的表现截然不同:
-
Hashtable (Fail-Fast) :
如果在遍历过程中,有其他线程修改了 Map 的结构(添加或删除元素),
Hashtable的迭代器会立即抛出ConcurrentModificationException异常。这是一种"快速失败"机制,旨在尽早发现并发错误,但它不适合高并发场景下的遍历。 -
ConcurrentHashMap (弱一致性) :
它的迭代器不会 抛出
ConcurrentModificationException。在遍历过程中,它可能会反映部分修改后的数据,也可能不反映,这取决于遍历时的具体时机。这种设计保证了在遍历高并发数据时不会因为异常而中断,适合统计、监控等对实时一致性要求不苛刻的场景。
Null 键值的处理
- Hashtable :严格禁止
null键和null值。这是因为Hashtable早期设计时,contains方法的行为存在歧义(无法区分是键不存在还是值为 null),为了线程安全,直接禁止了 null。 - ConcurrentHashMap :同样禁止
null键和null值。官方文档明确指出,在并发环境下,null值会导致歧义(例如map.get(key)返回null,你无法判断是键不存在,还是该键对应的值就是null)。
注意 :如果你确实需要存储
null值,可以考虑使用Collections.synchronizedMap(new HashMap<>()),但这会牺牲性能。
实战建议:如何选择?
结论非常明确:在现代 Java 开发中,彻底抛弃 Hashtable。
-
高并发场景(Web 服务器、分布式缓存、计数器) :
必须使用
ConcurrentHashMap。它能提供极高的吞吐量,且支持原子操作(如putIfAbsent、compute、merge),非常适合处理复杂的并发逻辑。 -
遗留系统维护 :
只有在维护非常古老的代码(Java 1.0 时代的代码)时,你才可能遇到
Hashtable。在新项目中,没有任何理由使用它。 -
单线程环境 :
如果不需要线程安全,直接使用
HashMap,因为它没有同步开销,性能最好。
总结
ConcurrentHashMap 是 Java 并发编程集大成的产物。它通过 CAS、细粒度锁(synchronized 锁节点)和红黑树优化,完美解决了 Hashtable 性能低下和 HashMap 线程不安全的问题。
记住这个核心公式:
ConcurrentHashMap = 线程安全 + 高并发性能 + 现代数据结构