1. 概述
Hashtable
是 Java 中最古老的哈希表数据结构实现,早在 JDK 1.0 时期就已存在。而 HashMap
是在 JDK 1.2 才引入的第二代实现。
两者功能高度相似,但在并发安全、空值支持、迭代方式等方面存在关键差异。本文将深入剖析 Hashtable
的底层机制、使用场景以及与 HashMap
的对比,帮助你在实际开发中避免踩坑。
2. 何时使用 Hashtable
假设你要实现一个词典系统,每个单词对应一个释义,且需要频繁进行增删改查操作。这种“键值映射”且要求高性能访问的场景,天然适合使用哈希表。
✅ 推荐场景:
- 多线程环境下需要线程安全的键值存储
- 键值对数量适中,且对读写性能有一定要求
- 不需要存储
null
键或值
❌ 不推荐场景:
- 单线程环境(性能不如
HashMap
) - 需要支持
null
键或值 - 高并发写入(同步锁粒度大,性能差)
3. 使用示例
继续以词典为例。我们定义 Word
类作为键:
public class Word {
private String name;
public Word(String name) {
this.name = name;
}
// getter, setter 省略
}
创建 Hashtable
实例并进行基本操作:
Hashtable<Word, String> table = new Hashtable<>();
插入数据:
Word word = new Word("cat");
table.put(word, "an animal");
获取数据:
String definition = table.get(word);
删除数据:
definition = table.remove(word);
这些是基础操作,接下来我们深入理解其背后的机制。
4. hashCode() 的重要性
要作为 Hashtable
的键,对象必须遵守 hashCode()
合约:两个相等的对象(equals()
返回 true)必须返回相同的哈希码。否则,get()
可能无法正确命中已插入的键值对。
4.1. 哈希表的组织结构
Hashtable
内部使用数组存储数据,每个数组位置称为“桶(bucket)”,每个桶可以存放一个或多个键值对(通过链表解决冲突)。
为什么不用顺序存储?因为通过索引直接访问是 O(1),而顺序遍历是 O(n)。因此需要一个函数将键映射到数组索引。
最简单的映射是直接寻址表(Direct Address Table),即 index(k) = k
。但这要求:
- 键是整数
- 整数范围不能太大(否则内存浪费严重)
我们的 Word
对象显然不满足,所以需要 hashCode()
来解决。
4.2. hashCode() 方法的作用
所有 Java 对象都继承 hashCode()
方法,它返回一个 int
值。默认实现基于对象内存地址,不同对象返回不同值。
通过 hashCode()
,任何对象都能转为整数,但这个整数可能很大,远超数组长度。
4.3. 哈希值压缩
Hashtable
使用以下公式将哈希值映射到数组索引:
int index = (hash & 0x7FFFFFFF) % tab.length;
其中:
hash
是键的hashCode()
返回值tab.length
是当前数组长度& 0x7FFFFFFF
确保哈希值为非负数
✅ 索引 = 哈希值 % 数组长度,这就是经典的取模散列法。
4.4. 哈希冲突
不同对象的哈希值可能相同(哈希碰撞),或不同哈希值取模后索引相同。Hashtable
使用链地址法(chaining) 解决冲突——每个桶是一个链表,存储多个键值对。
⚠️ 冲突越多,查找性能越差(退化为链表遍历)。
4.5. 负载因子(Load Factor)
负载因子控制哈希表的“拥挤程度”。默认值为 0.75,表示当 75% 的桶被占用时,触发扩容(rehash),数组长度翻倍,重新计算所有键的索引。
扩容是昂贵操作,但能有效降低冲突概率,保持 O(1) 的平均性能。
4.6. 必须重写 equals() 和 hashCode()
看这个例子:
Word word = new Word("cat");
table.put(word, "an animal");
String extracted = table.get(new Word("cat")); // 期望得到 "an animal"
如果 Word
没有重写 equals()
和 hashCode()
:
- 两次
new Word("cat")
是不同对象,hashCode()
返回不同值 - 计算出的索引不同,导致
put
和get
操作在不同桶进行 - 结果:
extracted
为null
,逻辑错误!
✅ 正确做法:
@Override
public boolean equals(Object o) {
if (o == this) return true;
if (!(o instanceof Word)) return false;
Word word = (Word) o;
return Objects.equals(word.name, this.name);
}
@Override
public int hashCode() {
return Objects.hash(name);
}
⚠️ 核心原则:
equals()
相等 ⇒hashCode()
必须相等- 尽量让不等对象的
hashCode()
不同,减少冲突
✅ 好消息:String
、Integer
等常用类已正确重写这两个方法,可直接作为键使用。
5. Hashtable 的遍历方式
5.1. Fail-Fast 迭代器(Iterator)
使用 keySet().iterator()
或 entrySet().iterator()
创建的迭代器是 fail-fast 的。如果在遍历过程中修改了 Hashtable
,会抛出 ConcurrentModificationException
。
Hashtable<Word, String> table = new Hashtable<>();
table.put(new Word("cat"), "an animal");
table.put(new Word("dog"), "another animal");
Iterator<Word> it = table.keySet().iterator();
table.remove(new Word("dog")); // 修改结构
while (it.hasNext()) {
Word key = it.next(); // ❌ 抛出 ConcurrentModificationException
}
✅ 优点:快速发现并发修改错误,避免数据不一致。
5.2. 非 Fail-Fast 枚举(Enumeration)
Hashtable
提供了传统 Enumeration
,它不是 fail-fast 的,遍历中修改不会抛异常。
Enumeration<Word> enumKey = table.keys();
table.remove(new Word("1")); // 修改
while (enumKey.hasMoreElements()) {
Word key = enumKey.nextElement(); // ✅ 正常执行,但行为未定义
}
⚠️ 风险:可能漏遍历、重复遍历或抛异常,行为不可预测,不推荐使用。
5.3. 遍历顺序不可预测
Hashtable
的遍历顺序不保证与插入顺序一致,因为:
- 索引由
hashCode()
决定 - 扩容时会重新哈希,打乱原有顺序
示例输出可能是:
five
four
three
two
one
eight
seven
✅ 如果需要有序,考虑 LinkedHashMap
。
6. Hashtable vs HashMap
特性 | Hashtable | HashMap |
---|---|---|
线程安全 | ✅ 方法同步(synchronized) | ❌ 非线程安全 |
null 键/值 | ❌ 不允许 | ✅ 允许一个 null 键,多个 null 值 |
fail-fast 迭代器 | ✅ 支持 | ✅ 支持 |
Enumeration | ✅ 支持非 fail-fast 枚举 | ❌ 不支持 |
继承 | Dictionary |
AbstractMap |
性能 | 较低(同步开销) | 较高 |
✅ 现代 Java 开发建议:
- 单线程:用
HashMap
- 多线程:用
ConcurrentHashMap
(性能远优于Hashtable
) Hashtable
已基本被废弃,仅用于维护老代码
7. Java 8 新增 API
Java 8 为 Hashtable
(继承自 Map
)引入了更简洁的原子操作方法。
7.1. getOrDefault()
// Java 8 之前
String definition;
if (table.containsKey(key)) {
definition = table.get(key);
} else {
definition = "not found";
}
// Java 8 之后
String definition = table.getOrDefault(key, "not found");
7.2. putIfAbsent()
// 仅当键不存在时插入
table.putIfAbsent(new Word("cat"), definition);
7.3. remove(key, value)
// 仅当键值对匹配时删除
boolean removed = table.remove(new Word("cat"), "an animal");
旧版 remove(key)
返回值,新版返回 boolean
表示是否删除成功。
7.4. replace(key, oldValue, newValue)
// 仅当旧值匹配时替换
table.replace(new Word("cat"), "old def", "new def");
7.5. computeIfAbsent()
// 键不存在时,通过函数计算值并插入
table.computeIfAbsent(new Word("cat"), k -> "an animal");
✅ 优势:值的计算是惰性的,仅在需要时执行,避免无谓开销。
7.6. computeIfPresent()
// 键存在时,通过函数计算新值并替换
table.computeIfPresent(cat, (key, value) -> key.getName() + " - " + value);
7.7. compute()
统计词频的简洁写法:
String[] animals = { "cat", "dog", "dog", "cat", "bird", "mouse", "mouse" };
Hashtable<String, Integer> table = new Hashtable<>();
for (String animal : animals) {
table.compute(animal, (key, value) -> (value == null ? 1 : value + 1));
}
7.8. merge()
另一种统计方式:
for (String animal : animals) {
table.merge(animal, 1, Integer::sum); // 若存在则 oldValue + 1
}
7.9. forEach()
// 遍历并打印
table.forEach((k, v) -> System.out.println(k + " - " + v));
7.10. replaceAll()
// 批量更新所有值
table.replaceAll((k, v) -> k + " - " + v);
8. 总结
本文深入解析了 Hashtable
的核心机制:
- 哈希表原理与冲突解决(链地址法)
hashCode()
和equals()
的契约及其重要性- fail-fast 与非 fail-fast 遍历方式
- 与
HashMap
的关键区别 - Java 8 引入的现代化原子操作 API
✅ 核心结论:
Hashtable
因其粗粒度同步和性能问题,在现代 Java 开发中已不推荐使用。多线程场景应优先选择ConcurrentHashMap
,单线程场景使用HashMap
即可。
完整示例代码见 GitHub:https://github.com/eugenp/tutorials/tree/master/core-java-modules/core-java-collections