在 Java 集合框架中,HashTable、HashMap、ConcurrentHashMap 这三个"哈希家族"的成员,是日常开发和面试中的高频考点。很多小伙伴刚开始接触时,很容易被它们的用法和特性绕晕------比如"哪个线程安全?""哪个支持 null 键值?""并发场景下该选谁?"。
今天这篇文章,就用通俗的语言 + 清晰的对比,把这三者的核心区别讲透,帮你彻底理清它们的适用场景。
一、先明确核心定位:三者的本质差异
这三者本质上都是基于"哈希表"实现的键值对存储容器,核心作用是通过 key 快速查找 value,但设计目标和适用场景完全不同:
-
HashMap:最基础的哈希表实现,追求极致性能,不保证线程安全,适合单线程或无并发竞争的场景;
-
HashTable:古老的线程安全哈希表,通过全表锁保证安全性,性能较差,现在基本被淘汰;
-
ConcurrentHashMap:并发安全的哈希表,兼顾安全性和高性能,专门为多线程并发场景设计,是 HashTable 的替代方案。
二、核心区别对比表(一目了然)
为了方便大家快速查阅,先整理一张核心维度对比表,后续再逐一展开说明:
| 对比维度 | HashMap | HashTable | ConcurrentHashMap |
|---|---|---|---|
| 线程安全性 | 不安全(非线程安全) | 安全(全表 synchronized 锁) | 安全(分段锁/CSM + CAS 优化) |
| key/value 是否允许 null | 允许 1 个 null key,多个 null value | 不允许 null key 和 null value | 不允许 null key 和 null value |
| 底层数据结构(JDK 1.8 后) | 数组 + 链表(红黑树优化,链表长度≥8 转树) | 数组 + 链表(无红黑树优化) | 数组 + 链表(红黑树优化,同 HashMap) |
| 性能(单线程) | 最高(无锁 overhead) | 最低(全表锁,竞争激烈时卡顿) | 中等(锁粒度细,有少量锁 overhead) |
| 性能(多线程并发) | 不可用(可能出现死循环、数据错乱) | 差(全表锁,同一时间只能一个线程操作) | 最高(分段锁/CSM 机制,支持多线程并行操作) |
| 迭代器类型 | 快速失败(fail-fast),迭代中修改会抛 ConcurrentModificationException | 安全失败(fail-safe),迭代中修改不会抛异常(基于快照) | 安全失败(fail-safe),同 HashTable |
| 初始容量 & 扩容机制 | 初始容量 16,扩容为 2 倍,负载因子 0.75 | 初始容量 11,扩容为 2 倍 + 1,负载因子 0.75 | 初始容量 16,扩容为 2 倍,负载因子 0.75(同 HashMap) |
| 适用场景 | 单线程环境、无并发竞争的缓存存储 | 基本淘汰,仅兼容旧代码 | 多线程并发场景(如分布式锁、接口限流、并发缓存) |
三、关键区别展开说明(重点必看)
1. 线程安全性:从"全表锁"到"精细化锁"的进化
这是三者最核心的差异,直接决定了适用的并发场景:
-
HashMap:完全不安全
HashMap 没有任何锁机制,多线程环境下如果同时进行"put""remove"等修改操作,可能会导致链表成环(JDK 1.7 及之前)、数据丢失或错乱。即使是单纯的"get"操作,如果同时有线程在修改,也可能拿到脏数据。所以绝对不能在多线程并发修改的场景下使用 HashMap。
-
HashTable:粗暴的线程安全
HashTable 的线程安全是通过"全表锁"实现的------它的所有 public 方法都加了 synchronized 关键字,比如 put、get、remove 等。这意味着,无论多个线程操作的是 HashTable 中的哪个 key,都会竞争同一把锁。一旦锁被某个线程拿到,其他线程只能阻塞等待,效率极低。比如 100 个线程同时 put 数据,只能串行执行,性能开销极大。
-
ConcurrentHashMap:高效的线程安全
ConcurrentHashMap 摒弃了 HashTable 的全表锁,采用了更精细化的锁机制,而且在 JDK 1.7 和 1.8 中还有优化:
-
JDK 1.7:分段锁(Segment),把 HashTable 分成多个 Segment,每个 Segment 对应一把锁。多个线程操作不同 Segment 中的数据时,不会竞争锁,可以并行执行;
-
JDK 1.8:去掉了 Segment,改用"数组 + 链表/红黑树"的结构,通过"CSM(乐观锁)+ CAS(原子操作)"优化,只有在修改同一节点时才会使用 synchronized 锁(只锁当前节点,不锁全表),并发性能进一步提升。
2. null 键值的支持:为什么有的能存,有的不能?
这一点很容易踩坑,记住核心结论:只有 HashMap 支持 null key 和 null value,另外两个都不支持。
原因其实和线程安全有关:
-
HashMap 是单线程使用,当用 get(null) 时,能明确判断是"不存在该 key"还是"key 存在但 value 为 null"(通过 containsKey(null) 辅助判断);
-
HashTable 和 ConcurrentHashMap 是线程安全的,支持多线程并发操作。如果允许 null 键值,当多个线程操作时,无法区分"key 不存在"和"key 存在但 value 为 null"(比如 get(null) 返回 null,到底是没这个 key,还是 value 本身就是 null?),会导致逻辑混乱。所以设计时直接禁止了 null 键值。
3. 迭代器的"快速失败"与"安全失败"
当你在迭代容器的过程中,如果有其他线程修改了容器(比如 add、remove),不同容器的迭代器会有不同的表现:
-
HashMap 的迭代器是"快速失败(fail-fast)"的:迭代器创建时会记录容器的修改次数,迭代过程中如果发现修改次数变化,会立即抛出 ConcurrentModificationException 异常,防止拿到脏数据;
-
HashTable 和 ConcurrentHashMap 的迭代器是"安全失败(fail-safe)"的:它们的迭代器是基于容器的"快照"创建的,迭代过程中修改容器不会影响快照,所以不会抛出异常。但要注意,迭代器拿到的是修改前的数据,不是实时数据(这是 trade-off,为了安全牺牲了数据实时性)。
四、总结:如何快速选择合适的容器?
记住这 3 条原则,再也不用纠结:
-
如果是单线程环境(比如普通的单线程业务逻辑、本地缓存):选 HashMap,性能最好;
-
如果是多线程并发环境(比如分布式系统中的并发缓存、多线程处理数据):选 ConcurrentHashMap,兼顾安全和性能;
-
无论什么场景,都不要选 HashTable:它的性能差,而且功能完全可以被 ConcurrentHashMap 替代,只在维护非常古老的代码时才可能遇到。
最后补充一个面试小考点
面试官可能会问:"为什么不建议用 Collections.synchronizedMap(HashMap) 替代 ConcurrentHashMap?"
答案很简单:Collections.synchronizedMap 本质是给 HashMap 加了一把"全表锁"(和 HashTable 类似),所有操作都要竞争同一把锁,并发性能远不如 ConcurrentHashMap 的精细化锁机制。所以并发场景下,ConcurrentHashMap 才是最优解。