在 Java 开发的旅程中,当我们深入探讨集合框架时,你会发现 HashMap 无疑是最常被提及的类之一,它像是我们手中的瑞士军刀,灵活且强大。然而,一旦我们踏入并发编程的领域,HashMap 的局限性就会暴露无遗。这时,ConcurrentHashMap 便作为更专业的并发工具进入了我们的视野。除了“一个是非线程安全的,一个是线程安全的”这一根本区别之外,它们之间在底层实现、性能表现以及适用场景上还存在许多微妙的差异。在这篇文章中,我们将一起详细拆解这两者背后的技术细节,通过丰富的代码示例和实战分析,让你能够自信地在不同场景下做出正确的选择。
核心差异概览
在我们深入代码之前,让我们先建立一个宏观的认知。HashMap 是 JDK 1.2 引入的传统集合类,旨在为单线程环境提供高效的数据存储和检索。而 ConcurrentHashMap 则是在 JDK 1.5 中由 SUN Microsystems 引入的,它属于 java.util.concurrent 包,专门为高并发场景设计。
1. 线程安全性与同步机制
HashMap 本质上是非同步的。 这意味着,如果多个线程同时修改 HashMap,可能会导致数据不一致,甚至在某些情况下导致死循环(在 JDK 1.7 中尤为常见,虽然 JDK 1.8 修复了死循环问题,但仍存在数据覆盖风险)。因此,我们可以断言:HashMap 不是线程安全的。
相比之下,ConcurrentHashMap 本质上是线程安全的。 它采用了非常精妙的设计来保证并发安全。在 JDK 1.7 中,它使用了“分段锁”,也就是将数据分成一段一段存储,给每一段数据配一把锁,并发情况下不同段的数据可以并发访问。而在 JDK 1.8 中,进一步优化为使用 CAS(Compare And Swap)+ synchronized 来保证并发安全,锁粒度更细,直接锁定链表或红黑树的头节点。
#### 代码示例 1:HashMap 在多线程下的不安全问题
让我们来看一个场景:我们创建一个 HashMap,让多个线程同时向其中存放元素。
import java.util.HashMap;
public class HashMapUnsafeExample {
public static void main(String[] args) throws InterruptedException {
// 创建一个 HashMap
HashMap map = new HashMap();
// 定义任务:向 map 中添加数据
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
map.put(Thread.currentThread().getName() + "-" + i, i);
}
};
// 启动 10 个线程同时执行
for (int i = 0; i < 10; i++) {
new Thread(task).start();
}
// 主线程稍作等待,确保子线程执行完毕
Thread.sleep(2000);
// 我们期望有 10000 个元素
// 但实际输出可能会少于 10000,或者抛出异常
System.out.println("Map 的大小: " + map.size());
// 你可能会看到输出大小不稳定,或者在某些极端情况下报错
}
}
分析: 在这个例子中,虽然我们执行了 10,000 次 put 操作,但由于 HashMap 的 put 方法没有任何同步措施,多个线程同时执行时,它们对内部数组的修改可能会相互覆盖,导致最终 size() 的结果往往小于 10,000。这就是“非同步”带来的直接后果。
2. 性能对比:为何 ConcurrentHashMap 更优?
由于 HashMap 是非同步的,它不需要处理锁竞争,因此在单线程环境下,HashMap 的性能确实非常高,没有任何额外的锁开销。
但是,ConcurrentHashMap 的性能有时在单线程下可能略低于 HashMap,但在多线程环境下却远超 HashMap。 为什么?如果我们在多线程环境中强行使用 HashMap,为了安全,我们通常需要使用 Collections.synchronizedMap(new HashMap()) 来包装它。这种方式会给整个 Map 对象加一把重量级锁,同一时刻只允许一个线程操作 Map,效率极低。
而 ConcurrentHashMap 通过分段锁(JDK 1.7)或 CAS+synchronized(JDK 1.8),允许多个线程并发地读写 Map 的不同部分,极大地提高了吞吐量。线程只有在操作同一个哈希槽(Bucket)时才需要等待,操作不同槽位的线程可以完全并行。
#### 代码示例 2:ConcurrentHashMap 的高效并发
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class ConcurrentHashMapSafeExample {
public static void main(String[] args) throws InterruptedException {
// 创建一个 ConcurrentHashMap
ConcurrentHashMap map = new ConcurrentHashMap();
// 使用线程池管理 10 个线程
ExecutorService executor = Executors.newFixedThreadPool(10);
// 提交任务
for (int i = 0; i {
for (int j = 0; j < 1000; j++) {
// put 方法是线程安全的,内部使用了 CAS 或 synchronized
map.put("Thread-" + threadId + "-Key-" + j, j);
}
});
}
// 关闭线程池并等待所有任务结束
executor.shutdown();
executor.awaitTermination(1, TimeUnit.MINUTES);
// 结果永远是准确的 10000
System.out.println("ConcurrentHashMap 的大小: " + map.size());
}
}
分析: 在这段代码中,ConcurrentHashMap 能够轻松应对 10 个线程的并发写入。即使在高并发下,它也能保证数据的准确性,同时保持优秀的性能。你不必担心数据丢失,也不必担心死锁(前提是你在业务逻辑中没有死锁)。
3. 迭代器行为与 Fail-Fast 机制
这是两者之间一个非常关键但常被忽视的区别。
HashMap 的迭代器是 Fail-Fast(快速失败)的。 当一个线程正在遍历 HashMap 对象时,如果其他线程(甚至是同一个线程)尝试添加、修改或删除该对象的内容,我们将立即遇到运行时异常,即 ConcurrentModificationException。这是 HashMap 为了防止数据在遍历过程中被破坏而设计的保护机制。
而在 ConcurrentHashMap 中,我们在遍历的同时进行任何修改操作都不会抛出异常。 ConcurrentHashMap 的迭代器是 Weakly Consistent(弱一致性)的。它可能反映出迭代开始时 Map 的状态,也可能反映迭代过程中某些修改的状态,但它绝不会抛出 ConcurrentModificationException,并且尽最大努力来保证数据的准确性。
#### 代码示例 3:HashMap 的 ConcurrentModificationException
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
public class HashMapIterationFail {
public static void main(String[] args) {
Map map = new HashMap();
map.put("Key1", "Value1");
map.put("Key2", "Value2");
// 获取迭代器
Iterator iterator = map.keySet().iterator();
// 第一次遍历
while (iterator.hasNext()) {
String key = iterator.next();
System.out.println("遍历 Key: " + key);
// 在遍历过程中,我们尝试修改 Map 结构(添加新元素)
if ("Key1".equals(key)) {
// 这一行代码会触发 ConcurrentModificationException
// 因为我们正在通过迭代器之外的途径修改 Map
map.put("Key3", "Value3");
}
}
}
}
输出示例:
遍历 Key: Key1
Exception in thread "main" java.util.ConcurrentModificationException
at java.util.HashMap$HashIterator.nextNode(HashMap.java:1437)
at java.util.HashMap$KeyIterator.next(HashMap.java:1461)
at HashMapIterationFail.main(HashMapIterationFail.java:18)
解决方案: 如果在单线程遍历中需要修改,请使用迭代器的 INLINECODE45a71d34 方法。如果是多线程环境,必须使用 ConcurrentHashMap 或使用 INLINECODEb680d4c2 块包裹整个遍历过程。
#### 代码示例 4:ConcurrentHashMap 的安全迭代
import java.util.concurrent.ConcurrentHashMap;
public class ConHashMapIterationSafe {
public static void main(String[] args) {
ConcurrentHashMap map = new ConcurrentHashMap();
map.put("Key1", "Value1");
map.put("Key2", "Value2");
// 启动一个线程负责遍历
new Thread(() -> {
try {
// 稍微暂停,确保写入线程先跑一会儿
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("
开始遍历...");
for (String key : map.keySet()) {
System.out.println("遍历 Key: " + key + ", Value: " + map.get(key));
// 遍历时也可以安全地修改,不会抛出异常
// 注意:这里虽然不报错,但可能无法遍历到刚刚新增的元素,这是弱一致性的体现
}
System.out.println("遍历结束。");
}).start();
// 主线程负责不断添加数据
for (int i = 3; i < 10; i++) {
map.put("Key" + i, "Value" + i);
try { Thread.sleep(50); } catch (Exception e) {}
}
}
}
在这个例子中,程序会顺利完成,不会抛出任何异常。这就是 ConcurrentHashMap 在处理并发迭代时的优雅之处。
4. Null 值的处理限制
这是一个非常经典的面试题,也是开发中容易遇到的坑。
- HashMap: 键和值都允许为 null。你可以有一个 key 为 null 的键值对,也可以有多个 value 为 null 的条目。
- ConcurrentHashMap: 键和值都不允许为 null,否则我们将遇到运行时异常,即 NullPointerException。
为什么 ConcurrentHashMap 不允许 Null 键?
这主要是在多线程环境下为了避免“二义性”。如果在单线程的 HashMap 中,INLINECODE890f0bc9 返回 INLINECODE41509aa0,你可以确定有两种可能:要么这个 key 不存在,要么这个 key 对应的值就是 null。你可以通过 containsKey(key) 来区分。
但是在 ConcurrentHashMap 中,情况变复杂了。假设 INLINECODEdf192bc6 返回了 INLINECODEc95e7332。在你调用 INLINECODE2b31d471 来检查的一瞬间,可能另一个线程已经修改了 Map,导致状态发生了变化。也就是说,你根本无法区分刚才返回的 INLINECODEed6e1bc7 到底是“没有这个键”还是“键对应的值是 null”。为了消除这种歧义,Doug Lea(并发包作者)直接在源码中禁止了 Null 键和 Null 值,直接抛出 NPE,强制你在代码中显式处理逻辑,而不是依赖 null 来表示状态。
#### 代码示例 5:ConcurrentHashMap 的 NPE 问题
import java.util.concurrent.ConcurrentHashMap;
public class NullPointerDemo {
public static void main(String[] args) {
ConcurrentHashMap map = new ConcurrentHashMap();
// 尝试插入 null 键
try {
map.put(null, "Value");
} catch (NullPointerException e) {
System.out.println("捕获异常:Key 不能为 null");
}
// 尝试插入 null 值
try {
map.put("Key", null);
} catch (NullPointerException e) {
System.out.println("捕获异常:Value 不能为 null");
}
}
}
输出示例:
捕获异常:Key 不能为 null
捕获异常:Value 不能为 null
实际应用场景与最佳实践
了解了这些差异,我们在实际编码中该如何选择呢?
- 本地缓存与状态管理: 如果你只是在单线程的方法内部构建一个临时的查找表,或者仅仅在单线程环境下维护状态,请毫不犹豫地使用 HashMap。它的零锁开销是最快的。
- 高并发服务端缓存: 在 Web 应用中,我们经常需要做一个全局的缓存(例如缓存用户配置、字典表)。这种情况下,多个请求线程会同时读写缓存,ConcurrentHashMap 是标准选择。它避免了使用
synchronized造成的性能瓶颈。
- 构建工具: 即使你不是在做 Web 开发,在编写一些并发工具类(如线程池的配置管理、任务队列的状态跟踪)时,为了防止未来引入多线程时出现难以排查的 Bug,使用 ConcurrentHashMap 也是未雨绸缪的好习惯。
常见错误与解决方案
- 错误: 在 HashMap 遍历中直接删除元素导致
ConcurrentModificationException。
解决: 使用 Iterator 的 INLINECODE945da4be 方法,或者在 JDK 8+ 中使用 INLINECODE3df8e15c 方法。
- 错误: 在多线程共享的变量中使用了 HashMap,导致生产环境偶发性数据丢失或死循环(JDK 1.7)。
解决: 检查代码,将共享的 HashMap 替换为 ConcurrentHashMap。这是一个性能与安全兼顾的改动,通常只需要替换类名即可。
总结
让我们回顾一下这场 HashMap 与 ConcurrentHashMap 的深度对比:
- 安全: HashMap 非同步,适合单线程;ConcurrentHashMap 线程安全,专为并发设计,使用 CAS/synchronized 实现高效锁定。
- 性能: 在单线程下 HashMap 略占优势,但在多线程环境下,ConcurrentHashMap 的通过分段锁机制实现了远高于同步包装类的性能,完全碾压非线程安全的 HashMap(后者在多线程下甚至可能出现错误)。
- 异常处理: HashMap 迭代器快速失败,修改即报错;ConcurrentHashMap 迭代器弱一致性,允许遍历中修改,安全且灵活。
- Null 值: HashMap 允许 Null 键值;ConcurrentHashMap 禁止 Null 键值,彻底消除了多线程下的二义性。
正如我们所见,选择哪种 Map 并不是一个随意决定,而是基于应用场景的架构决策。掌握这些细节,不仅能帮助我们编写出健壮的代码,还能在系统出现性能瓶颈时提供优化思路。希望这篇深入的分析能让你对这两个核心类有全新的认识。下次当你声明一个 Map 变量时,你会更加胸有成竹。