在 Java 开发中,HashMap 和 Hashtable 是最常用的两种键值对存储结构,但很多时候我们可能只是默认使用HashMap,没有去深究两者的差异,对它们的区别并不完全清楚。今天我们就来深入剖析这两个类的差异,帮助我们在实际开发中做出正确选择。
一、历史渊源:从"古老"到"现代"的进化
1.1 诞生背景
- Hashtable 是 Java 1.0 就存在的"元老级"类,属于早期集合框架的一部分,设计上带有上个世纪的编程风格。
- HashMap 则是 Java 1.2 引入的新成员,属于现代 Java Collections Framework(JCF),吸收了多年的编程实践经验。
1.2 线程安全设计
这是两者最直观的区别:
- Hashtable 的所有核心方法(如
put
、get
)都被synchronized
修饰,天生线程安全。 - HashMap 则完全不考虑线程安全,这反而让它在单线程环境下性能更优。
// Hashtable 的线程安全实现(简化版)
public synchronized V put(K key, V value) {
// 加锁逻辑...
}
// HashMap 的非线程安全实现(简化版)
public V put(K key, V value) {
// 无锁逻辑...
}
为什么 Hashtable 的线程安全不被推荐?
因为它使用的是全局锁,所有线程都要竞争同一把锁,在高并发场景下会导致严重的性能瓶颈。
二、数据结构:从"简单"到"智能"的升级
2.1 底层实现对比
JDK 8 之后的 HashMap 采用了更先进的数据结构:
-
HashMap:数组 + 链表 + 红黑树
当链表长度超过 8 且数组容量 ≥ 64 时,自动将链表转换为红黑树,把查询时间复杂度从 O(n) 优化到 O(log n)。 -
Hashtable:仅使用数组 + 链表
无论链表多长都不会进化,极端情况下查询效率会大幅下降。
2.2 容量与扩容策略
-
HashMap:
- 初始容量默认 16(必须是 2 的幂)
- 扩容时新容量 = 原容量 × 2(如 16 → 32 → 64)
- 扩容因子 0.75(当元素数量超过容量×0.75时触发扩容)
-
Hashtable:
- 初始容量默认 11(不是 2 的幂)
- 扩容时新容量 = 原容量 × 2 + 1(如 11 → 23 → 47)
- 扩容因子同样是 0.75
为什么 HashMap 要用 2 的幂作为容量?
因为可以用位运算 (n-1) & hash
代替取模运算,大幅提升效率。而 Hashtable 由于容量不是 2 的幂,只能使用较慢的取模运算。
三、null 值处理:灵活与严格的选择
3.1 HashMap 的"宽容"设计
HashMap 对 null 值非常友好:
- 允许一个 null 键(因为键必须唯一,多个 null 键会覆盖)
- 允许任意数量的 null 值
- 常用于需要处理缺失值的场景
HashMap<String, String> map = new HashMap<>();
map.put(null, "nullKeyValue"); // 合法
map.put("normalKey", null); // 合法
3.2 Hashtable 的"严格"限制
Hashtable 对 null 值非常严格:
- 不允许 null 键
- 不允许 null 值
- 任何尝试存储 null 的操作都会抛出
NullPointerException
Hashtable<String, String> table = new Hashtable<>();
table.put(null, "error"); // 抛出 NullPointerException
table.put("key", null); // 抛出 NullPointerException
设计理念差异:
HashMap 认为 null 是一种合法的"不存在"状态,而 Hashtable 认为所有数据都必须是明确存在的。
四、性能对比:速度与安全的权衡
4.1 单线程场景
在单线程环境下,HashMap 的性能明显优于 Hashtable:
- 无锁开销,所有操作更轻量
- 更优的哈希算法和数据结构
- 测试数据:HashMap 的
put
操作比 Hashtable 快约 30%
4.2 多线程场景
Hashtable 虽然线程安全,但性能极差:
- 全局锁导致所有操作串行化
- 高并发下吞吐量可能下降 10 倍以上
- 正确做法:使用
ConcurrentHashMap
替代
五、迭代器与遍历方式
5.1 HashMap 的迭代器
- 使用标准的
Iterator
- 支持 fail-fast 机制:当集合被并发修改时,会抛出
ConcurrentModificationException
- 推荐遍历方式:
for (Map.Entry<String, Integer> entry : hashMap.entrySet()) {
System.out.println(entry.getKey() + ": " + entry.getValue());
}
5.2 Hashtable 的双重遍历方式
- 同时支持
Iterator
和古老的Enumeration
Iterator
支持 fail-fast,Enumeration
不支持- 古老的遍历方式:
Enumeration<String> keys = hashtable.keys();
while (keys.hasMoreElements()) {
String key = keys.nextElement();
System.out.println(key + ": " + hashtable.get(key));
}
六、实际应用场景建议
6.1 优先选择 HashMap
- 场景:单线程环境、需要处理 null 值、追求高性能
- 示例:缓存系统、本地数据结构、单例服务中的数据存储
6.2 谨慎使用 Hashtable
- 场景:仅在以下情况考虑
- 需要兼容 JDK 1.0 时代的遗留代码
- 简单场景下的线程安全(但更推荐
Collections.synchronizedMap(hashMap)
)
6.3 多线程场景的正确选择
- 高并发场景一律使用
ConcurrentHashMap
- 它采用分段锁(JDK 7)或 CAS + 红黑树(JDK 8)实现,性能远超 Hashtable
七、总结:一张表看懂所有区别
特性 | HashMap | Hashtable |
---|---|---|
线程安全 | 非线程安全 | 线程安全(全局锁) |
null 支持 | 允许 null 键和 null 值 | 不允许 null 键/值 |
数据结构 | 数组+链表+红黑树(JDK 8+) | 数组+链表 |
初始容量 | 16 | 11 |
扩容机制 | 容量 × 2 | 容量 × 2 + 1 |
迭代器 | Iterator(支持 fail-fast) | Iterator 和 Enumeration |
推荐场景 | 单线程环境、高性能需求 | 遗留代码兼容、简单线程安全 |
核心思想:HashMap 代表了"现代 Java"的设计哲学——牺牲默认的线程安全,换取更高的性能和灵活性;而 Hashtable 则是"古老 Java"的产物,虽然线程安全但设计不够精巧。在实际开发中,除了极少数兼容场景,HashMap 都是更优的选择。
如果你需要线程安全的哈希表,记住:最好不要使用 Hashtable,而是使用 ConcurrentHashMap。