JavaEE——多线程(6)

在 Java 集合框架中,HashTable、HashMap、ConcurrentHashMap 这三个"哈希家族"的成员,是日常开发和面试中的高频考点。很多小伙伴刚开始接触时,很容易被它们的用法和特性绕晕------比如"哪个线程安全?""哪个支持 null 键值?""并发场景下该选谁?"。

今天这篇文章,就用通俗的语言 + 清晰的对比,把这三者的核心区别讲透,帮你彻底理清它们的适用场景。

一、先明确核心定位:三者的本质差异

这三者本质上都是基于"哈希表"实现的键值对存储容器,核心作用是通过 key 快速查找 value,但设计目标和适用场景完全不同:

  • HashMap:最基础的哈希表实现,追求极致性能,不保证线程安全,适合单线程或无并发竞争的场景;

  • HashTable:古老的线程安全哈希表,通过全表锁保证安全性,性能较差,现在基本被淘汰;

  • ConcurrentHashMap:并发安全的哈希表,兼顾安全性和高性能,专门为多线程并发场景设计,是 HashTable 的替代方案。

二、核心区别对比表(一目了然)

为了方便大家快速查阅,先整理一张核心维度对比表,后续再逐一展开说明:

对比维度 HashMap HashTable ConcurrentHashMap
线程安全性 不安全(非线程安全) 安全(全表 synchronized 锁) 安全(分段锁/CSM + CAS 优化)
key/value 是否允许 null 允许 1 个 null key,多个 null value 不允许 null key 和 null value 不允许 null key 和 null value
底层数据结构(JDK 1.8 后) 数组 + 链表(红黑树优化,链表长度≥8 转树) 数组 + 链表(无红黑树优化) 数组 + 链表(红黑树优化,同 HashMap)
性能(单线程) 最高(无锁 overhead) 最低(全表锁,竞争激烈时卡顿) 中等(锁粒度细,有少量锁 overhead)
性能(多线程并发) 不可用(可能出现死循环、数据错乱) 差(全表锁,同一时间只能一个线程操作) 最高(分段锁/CSM 机制,支持多线程并行操作)
迭代器类型 快速失败(fail-fast),迭代中修改会抛 ConcurrentModificationException 安全失败(fail-safe),迭代中修改不会抛异常(基于快照) 安全失败(fail-safe),同 HashTable
初始容量 & 扩容机制 初始容量 16,扩容为 2 倍,负载因子 0.75 初始容量 11,扩容为 2 倍 + 1,负载因子 0.75 初始容量 16,扩容为 2 倍,负载因子 0.75(同 HashMap)
适用场景 单线程环境、无并发竞争的缓存存储 基本淘汰,仅兼容旧代码 多线程并发场景(如分布式锁、接口限流、并发缓存)

三、关键区别展开说明(重点必看)

1. 线程安全性:从"全表锁"到"精细化锁"的进化

这是三者最核心的差异,直接决定了适用的并发场景:

  • HashMap:完全不安全

    HashMap 没有任何锁机制,多线程环境下如果同时进行"put""remove"等修改操作,可能会导致链表成环(JDK 1.7 及之前)、数据丢失或错乱。即使是单纯的"get"操作,如果同时有线程在修改,也可能拿到脏数据。所以绝对不能在多线程并发修改的场景下使用 HashMap

  • HashTable:粗暴的线程安全

    HashTable 的线程安全是通过"全表锁"实现的------它的所有 public 方法都加了 synchronized 关键字,比如 put、get、remove 等。这意味着,无论多个线程操作的是 HashTable 中的哪个 key,都会竞争同一把锁。一旦锁被某个线程拿到,其他线程只能阻塞等待,效率极低。比如 100 个线程同时 put 数据,只能串行执行,性能开销极大。

  • ConcurrentHashMap:高效的线程安全

    ConcurrentHashMap 摒弃了 HashTable 的全表锁,采用了更精细化的锁机制,而且在 JDK 1.7 和 1.8 中还有优化:

  • JDK 1.7:分段锁(Segment),把 HashTable 分成多个 Segment,每个 Segment 对应一把锁。多个线程操作不同 Segment 中的数据时,不会竞争锁,可以并行执行;

  • JDK 1.8:去掉了 Segment,改用"数组 + 链表/红黑树"的结构,通过"CSM(乐观锁)+ CAS(原子操作)"优化,只有在修改同一节点时才会使用 synchronized 锁(只锁当前节点,不锁全表),并发性能进一步提升。

2. null 键值的支持:为什么有的能存,有的不能?

这一点很容易踩坑,记住核心结论:只有 HashMap 支持 null key 和 null value,另外两个都不支持。

原因其实和线程安全有关:

  • HashMap 是单线程使用,当用 get(null) 时,能明确判断是"不存在该 key"还是"key 存在但 value 为 null"(通过 containsKey(null) 辅助判断);

  • HashTable 和 ConcurrentHashMap 是线程安全的,支持多线程并发操作。如果允许 null 键值,当多个线程操作时,无法区分"key 不存在"和"key 存在但 value 为 null"(比如 get(null) 返回 null,到底是没这个 key,还是 value 本身就是 null?),会导致逻辑混乱。所以设计时直接禁止了 null 键值。

3. 迭代器的"快速失败"与"安全失败"

当你在迭代容器的过程中,如果有其他线程修改了容器(比如 add、remove),不同容器的迭代器会有不同的表现:

  • HashMap 的迭代器是"快速失败(fail-fast)"的:迭代器创建时会记录容器的修改次数,迭代过程中如果发现修改次数变化,会立即抛出 ConcurrentModificationException 异常,防止拿到脏数据;

  • HashTable 和 ConcurrentHashMap 的迭代器是"安全失败(fail-safe)"的:它们的迭代器是基于容器的"快照"创建的,迭代过程中修改容器不会影响快照,所以不会抛出异常。但要注意,迭代器拿到的是修改前的数据,不是实时数据(这是 trade-off,为了安全牺牲了数据实时性)。

四、总结:如何快速选择合适的容器?

记住这 3 条原则,再也不用纠结:

  1. 如果是单线程环境(比如普通的单线程业务逻辑、本地缓存):选 HashMap,性能最好;

  2. 如果是多线程并发环境(比如分布式系统中的并发缓存、多线程处理数据):选 ConcurrentHashMap,兼顾安全和性能;

  3. 无论什么场景,都不要选 HashTable:它的性能差,而且功能完全可以被 ConcurrentHashMap 替代,只在维护非常古老的代码时才可能遇到。

最后补充一个面试小考点

面试官可能会问:"为什么不建议用 Collections.synchronizedMap(HashMap) 替代 ConcurrentHashMap?"

答案很简单:Collections.synchronizedMap 本质是给 HashMap 加了一把"全表锁"(和 HashTable 类似),所有操作都要竞争同一把锁,并发性能远不如 ConcurrentHashMap 的精细化锁机制。所以并发场景下,ConcurrentHashMap 才是最优解。

相关推荐
CRUD酱2 天前
微服务分模块后怎么跨模块访问资源
java·分布式·微服务·中间件·java-ee
while(1){yan}2 天前
图书管理系统(超详细版)
spring boot·spring·java-ee·tomcat·log4j·maven·mybatis
苏小瀚2 天前
[JavaEE] SpringBoot 配置文件
数据库·spring boot·java-ee
我命由我123453 天前
Android Jetpack Compose - Compose 重组、AlertDialog、LazyColumn、Column 与 Row
android·java·java-ee·kotlin·android studio·android jetpack·android-studio
手握风云-3 天前
JavaEE 进阶第八期:Spring MVC - Web开发的“交通枢纽”(二)
前端·spring·java-ee
鸽鸽程序猿3 天前
【JavaEE】【SpringCloud】负载均衡_LoadBalancer
spring cloud·java-ee·负载均衡
大爱编程♡3 天前
JAVAEE-Spring Web MVC
前端·spring·java-ee
qualifying3 天前
JavaEE——多线程(5)
java·jvm·java-ee
Predestination王瀞潞3 天前
Java EE数据访问框架技术(第三章:Mybatis多表关系映射-下)
java·java-ee·mybatis