在 Java 的集合框架中,HashMap 和 HashTable 都是用于存储键值对的数据结构,但在实际开发中,我们该如何在它们之间做出选择呢?为什么现代 Java 开发中更倾向于使用 HashMap,甚至在某些场景下会选择 ConcurrentHashMap?在这篇文章中,我们将深入探讨这两者之间的核心区别,从底层实现原理到线程安全机制,再到性能优化和最佳实践。让我们一起来揭开这些常见数据结构的神秘面纱。
目录
哈希表的基础原理:它们是如何工作的?
在深入对比之前,让我们先快速回顾一下哈希表的核心工作原理,这是我们理解两者差异的基础。
HashMap 和 HashTable 在底层数据结构上都利用了哈希表技术。当我们想要存储一个键值对时,我们需要提供一个对象作为键,以及一个想要关联到该键的值。随后,键会被哈希化处理,计算出的哈希码将作为索引,用于在内部数组中确定该值的存储位置。
简单来说,这个过程就像是在图书馆找书:
- 哈希化:根据书名(Key)计算出一个分类号。
- 索引:根据分类号直接找到书架(内存桶)。
- 存储:把书放进去,如果位置已经被占了(哈希冲突),就按规则处理(比如链表或红黑树)。
核心差异一:线程安全与同步机制
这是两者之间最显著、最常被面试官问到的区别。
HashMap:非同步,高性能
HashMap 是非同步的。这意味着它不是线程安全的。如果没有额外的同步代码,我们不能在多个线程之间共享一个 HashMap 实例。
让我们看看源码层面的含义:在 HashMap 中,像 INLINECODEed8db70c、INLINECODE8eb9e218、INLINECODE8dfd7cfd 这样的操作方法都没有使用 INLINECODE13edf600 关键字修饰。这在单线程环境下是非常高效的,因为线程不需要获取锁,也没有锁等待的开销。
实际场景:如果你正在编写一个单线程的应用程序,或者方法内部定义的局部变量,请务必使用 HashMap。它的性能远超 HashTable。
HashTable:同步, Legacy 类
相反,HashTable 是同步的。它是线程安全的,可以安全地与多个线程共享。它是如何做到的?通过在几乎所有公共方法上加上 synchronized 关键字。
深入源码逻辑:
// HashTable 的源码片段(简化示意)
public synchronized V put(K key, V value) {
// 确保该 value 不为 null
if (value == null) {
throw new NullPointerException();
}
// ... 插入逻辑 ...
}
这种全表锁的策略虽然保证了安全,却付出了巨大的代价。因为同一时间只允许一个线程操作 HashTable 的对象,其他线程必须排队等待。这在高并发场景下会成为性能瓶颈。
> 💡 实战见解:如果你需要线程安全的哈希表,现代 Java 开发通常不推荐直接使用 HashTable。我们更倾向于使用 ConcurrentHashMap,它使用了分段锁或 CAS 机制,并发性能比 HashTable 高出几个数量级。
核心差异二:对 Null 的处理
我们在编码时经常会遇到 null 值,两者的处理方式截然不同。
HashMap:宽容的 Null 策略
HashMap 允许一个 null 键和多个 null 值。
内部实现原理:当我们将 null 作为键存入 HashMap 时,它不会调用 hashCode() 方法(因为会报空指针),而是直接将其哈希值固定为 0,并存入数组的第一个位置(桶位置 0)。
HashTable:严禁 Null
而 HashTable 不允许任何 null 键或 null 值。如果你尝试这样做,它会毫不留情地抛出 NullPointerException。
示例演示:NullPointerException
import java.util.Hashtable;
public class NullTest {
public static void main(String[] args) {
Hashtable ht = new Hashtable();
// 尝试存储 null 值
try {
ht.put("Name", null);
} catch (NullPointerException e) {
System.out.println("HashTable 拒绝存储 null 值!");
}
// 尝试存储 null 键
try {
ht.put(null, "Unknown");
} catch (NullPointerException e) {
System.out.println("HashTable 拒绝存储 null 键!");
}
}
}
为什么 HashTable 不允许 Null?
你可能会问:为什么设计 HashTable 时要禁止 null? 答案其实很务实。
为了成功地从 HashTable 中存储和检索对象,用作键的对象必须实现 INLINECODE1a1535c9 方法和 INLINECODE43a6aa0e 方法。由于 INLINECODE52d4559e 不是一个对象,它无法调用这些方法。如果在 INLINECODE519f7f53 返回 null 时,我们无法区分是“键不存在”还是“键对应的值就是 null”。为了避免这种二义性,HashTable 选择了一刀切地禁止 null。而 HashMap 作为后来者,改进了这一逻辑,显式地支持了 null 键。
核心差异三:历史渊源与继承体系
了解历史有助于我们理解为什么现在 HashTable 被标记为“过时”。
- 发布时间:HashTable 是在 JDK 1.0 版本中引入的“元老”。而 HashMap 是在 JDK 1.2 版本中引入的,属于 Java 集合框架的重新设计的一部分。
- 类继承:
* HashMap 继承自 INLINECODE4e352e7b,实现了 INLINECODEb01c6ce9 接口。
* HashTable 继承自 INLINECODEbc89dca4(这是一个已经过时的抽象类),同时也实现了 INLINECODE03cd255d 接口。
正因为如此,HashTable 被称为遗留类。除了兼容旧代码,我们在新项目中几乎没有理由使用它。
详细对比表
为了让你一目了然,我们将刚才讨论的所有关键点汇总如下:
HashMap
:—
没有任何方法是同步的(非线程安全)。
多个线程可以同时操作,性能较高。
允许一个 null 键和多个 null 值。
JDK 1.2 (新式集合)。
INLINECODE017da071。
Fail-fast(快速失败)迭代器。
代码实战:不仅仅是存取数据
让我们通过更详细的代码示例来看看它们在实际运行中的表现,以及一些容易踩坑的地方。
示例 1:基本操作对比
我们将创建两个类,分别演示 HashMap 和 HashTable 的基本用法。注意观察代码风格的统一性,但在使用限制上的不同。
import java.util.*;
import java.lang.*;
import java.io.*;
class HashMapVsHashTableDemo {
public static void main(String args[]) {
// -------------------- HashTable 演示 --------------------
System.out.println("------------- HashTable 演示 --------------");
// 创建 HashTable 并指定泛型类型
Hashtable ht = new Hashtable();
// 添加元素:注意,这里不能加 null
ht.put(101, "Ajay");
ht.put(101, "Vijay"); // 键重复,会覆盖旧的值
ht.put(102, "Ravi");
ht.put(103, "Rahul");
// 遍历 HashTable
for (Map.Entry m : ht.entrySet()) {
System.out.println("Key: " + m.getKey() + " & Value: " + m.getValue());
}
// -------------------- HashMap 演示 ---------------------
System.out.println("
----------- HashMap 演示 -----------");
// 创建 HashMap
HashMap hm = new HashMap();
// 添加元素:我们可以尝试添加 null
hm.put(100, "Amit");
hm.put(104, null); // HashMap 允许 null 值
hm.put(null, "Unknown"); // HashMap 允许 null 键
hm.put(101, "Vijay");
hm.put(102, "Rahul");
// 遍历 HashMap
for (Map.Entry m : hm.entrySet()) {
// 这里演示了 null 键和 null 值的输出
System.out.println("Key: " + m.getKey() + " & Value: " + m.getValue());
}
}
}
运行结果分析:
请注意,HashTable 中的键 101 对应的值变成了 "Vijay",因为键必须是唯一的,后 put 的值会覆盖先前的值。而在 HashMap 中,我们成功存储了键为 null 和值为 null 的数据,这在处理缓存或可选参数时非常有用。
示例 2:线程安全性的直观体现
让我们模拟一个多线程环境,看看 HashMap 在非同步情况下会发生什么。
import java.util.*;
class ThreadSafetyDemo {
public static void main(String[] args) throws InterruptedException {
// 1. 测试 HashMap (非线程安全)
Map hashMap = new HashMap();
// 创建两个线程,同时向 HashMap 写入数据
Thread t1 = new Thread(() -> {
for (int i = 0; i {
for (int i = 0; i < 1000; i++) {
hashMap.put(i, "Thread-2-" + i);
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("HashMap 预期大小: 1000, 实际大小: " + hashMap.size());
// 你可能会发现实际大小小于 1000,这是因为多线程写入导致数据覆盖或丢失,
// 甚至在极高并发下可能抛出 ConcurrentModificationException 或进入死循环(JDK 1.7 特性)。
// 2. 测试 HashTable (线程安全)
Map hashTable = new Hashtable();
Thread t3 = new Thread(() -> {
for (int i = 0; i {
for (int i = 0; i < 1000; i++) {
hashTable.put(i, "Thread-4-" + i);
}
});
t3.start();
t4.start();
t3.join();
t4.join();
System.out.println("HashTable 预期大小: 1000, 实际大小: " + hashTable.size());
// 结果始终是 1000,保证了原子性,但运行速度会比 HashMap 慢很多。
}
}
实战见解:这个示例展示了为什么我们在多线程环境下需要小心。虽然 HashTable 没有丢失数据,但它的速度是你无法忍受的。在真实的生产环境中,我们应该使用 INLINECODE86f0bf4d 或者更优秀的 INLINECODEbf432058。
常见错误与解决方案
在开发过程中,我们可能会遇到以下问题,这里提供了一些调试思路和解决方案。
错误 1:ConcurrentModificationException (快速失败)
你肯定遇到过这个异常:当我们在用 Iterator 遍历 Map 的过程中,直接使用 INLINECODE2df24d13 删除元素时,就会抛出这个异常。这是因为迭代器会检查 Map 的 INLINECODE590c647b(修改次数),发现被意外修改了就会“快速失败”。
解决方案:
Map map = new HashMap();
map.put(1, "One");
map.put(2, "Two");
// 错误的做法
// for (Map.Entry entry : map.entrySet()) {
// if (entry.getKey() == 1) {
// map.remove(1); // 抛出 ConcurrentModificationException
// }
// }
// 正确的做法 1:使用 Iterator 的 remove 方法
Iterator<Map.Entry> it = map.entrySet().iterator();
while (it.hasNext()) {
Map.Entry entry = it.next();
if (entry.getKey() == 1) {
it.remove(); // 安全删除
}
}
// 正确的做法 2:使用 Java 8+ removeIf
map.entrySet().removeIf(entry -> entry.getKey() == 1);
错误 2:错误的 equals 和 hashCode 实现
如果我们在 HashMap 中使用自定义对象作为键,必须正确重写 INLINECODEf3e1c7b1 和 INLINECODEa64d790e。如果两个对象 equals 相等,但 hashCode 不等,HashMap 会认为它们是不同的对象,导致数据冗余或无法查找到数据。
最佳实践与性能优化建议
- 指定初始容量:如果你知道大概要存多少数据,请在构造 HashMap 时指定初始容量
new HashMap(expectedSize * 4 / 3)。这可以避免频繁的扩容(Resize)操作,扩容是一个非常消耗性能的过程(涉及哈希重排)。
- 避免使用 HashTable:除非你在维护遗留系统(JDK 1.0 时代的代码),否则不要使用 HashTable。即使需要线程安全,也请优先考虑
ConcurrentHashMap。
- 使用枚举作为键:如果你在 HashMap 中使用枚举类型作为键,性能会非常好,因为 Java 保证枚举的hashCode 是固定的、唯一的,且由 JVM 处理。
总结
通过这篇文章,我们从源码、原理、实战和性能等多个维度对比了 HashMap 和 HashTable。让我们回顾一下最重要的几点:
- HashMap 是现代的、高性能的、非线程安全的 Map 实现。它允许 null 键和值。它应该是你默认的选择。
- HashTable 是一个遗留类。它是线程安全的,但通过全表锁实现,性能低下,且不支持 null。
- 对于并发场景,请抛弃 HashTable,转投
ConcurrentHashMap的怀抱。
希望这些深入的分析能帮助你在下一次面试或编码中更加自信地做出选择。编程不仅仅是写出能运行的代码,更是写出优雅、高效且易于维护的代码。