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() 返回不同值
  • 计算出的索引不同,导致 putget 操作在不同桶进行
  • 结果:extractednull,逻辑错误!

✅ 正确做法:

@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() 不同,减少冲突

✅ 好消息:StringInteger 等常用类已正确重写这两个方法,可直接作为键使用。

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


原始标题:An Introduction to java.util.Hashtable Class