HashMap、HashTable、ConcurrentHashMap详解
JHashMap、HashTable、ConcurrentHashMap详解
一、概述:三者核心差异
特性维度 | HashMap | HashTable | ConcurrentHashMap(JDK 1.8+) |
---|---|---|---|
线程安全性 | 非线程安全 | 线程安全(全表 synchronized 锁) | 线程安全(CAS + 局部 synchronized 锁) |
键 / 值允许 null | Key 允许 1 个 null,Value 允许多个 null | Key/Value 均不允许 null | Key/Value 均不允许 null |
底层实现(JDK1.8) | 数组 + 链表(阈值后转红黑树) | 数组 + 链表(无红黑树优化) | 数组 + 链表(阈值后转红黑树) |
性能 | 高(无锁竞争) | 低(全表锁,并发冲突严重) | 高(细粒度锁,支持高并发) |
适用场景 | 单线程环境、非并发场景 | 遗留代码、低并发场景(不推荐新用) | 高并发场景(如分布式系统、缓存) |
二、HashMap 详解
1. 核心定义与定位
HashMap 是 Java 集合框架中 最常用的非线程安全哈希表,实现了 Map
接口,基于 “数组 + 链表 / 红黑树” 的混合结构(JDK 1.8 优化),核心目标是高效查询、插入、删除(理想时间复杂度 O (1))。
// 核心继承关系
public class HashMap<K,V> extends AbstractMap<K,V>implements Map<K,V>, Cloneable, Serializable
2. 底层数据结构(JDK 1.8)
(1)结构组成
-
哈希数组(table):存储键值对的核心容器,数组元素是
Node<K,V>
对象(链表节点)或TreeNode<K,V>
对象(红黑树节点)。 -
链表:当多个 Key 哈希冲突(哈希值对应数组索引相同)时,用链表串联节点,避免数组下标冲突。
-
红黑树:当链表长度超过阈值(默认 8)且数组长度 ≥ 64 时,链表转为红黑树,将查询时间复杂度从 O (n) 优化为 O (logn)(避免链表过长导致性能退化)。
(2)关键参数
参数名 | 默认值 | 作用说明 |
---|---|---|
initialCapacity | 16 | 初始数组容量(必须是 2 的幂,便于通过位运算计算索引) |
loadFactor | 0.75 | 负载因子(衡量数组满的程度,0.75 是时间 / 空间平衡的最优值) |
threshold | 12 | 扩容阈值(= 容量 × 负载因子,当元素数量超过阈值时,数组扩容为原来的 2 倍) |
TREEIFY_THRESHOLD | 8 | 链表转红黑树的阈值 |
UNTREEIFY_THRESHOLD | 6 | 红黑树转链表的阈值(当树节点数量减少到 6 时,转回链表节省空间) |
3. 核心原理:哈希计算与索引定位
HashMap 的高效性依赖于 “通过 Key 的哈希值快速定位数组索引”,核心步骤如下:
- 计算 Key 的哈希值:调用
key.hashCode()
获取原始哈希值,再通过 “扰动函数” 优化哈希分布(减少冲突):
static final int hash(Object key) {int h;// 扰动函数:将哈希值的高位与低位混合,增强哈希分布均匀性return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
- 计算数组索引:通过位运算
(n - 1) & hash
计算索引(n
是数组容量,必须是 2 的幂,确保结果在 [0, n-1] 范围内):
- 示例:容量 n=16(二进制
10000
),n-1=15(二进制01111
),与哈希值进行 & 运算,本质是 “取哈希值的低 4 位”,避免数组越界。
4. 线程安全问题
HashMap 是非线程安全的,在多线程并发操作(如同时 put、扩容)时可能出现以下问题:
-
数据覆盖:两个线程同时计算出相同索引,后插入的元素覆盖前一个。
-
死循环(JDK 1.7):JDK 1.7 中扩容时采用 “头插法” 移动链表节点,多线程下可能导致链表成环,查询时陷入死循环(JDK 1.8 改为 “尾插法”,已修复此问题,但仍非线程安全)。
解决方案:
-
单线程场景:直接使用 HashMap。
-
多线程场景:使用
ConcurrentHashMap
(推荐),或通过Collections.synchronizedMap(new HashMap<>())
包装(性能差,不推荐)。
5. 关键注意点
-
Key 的重写要求:若 Key 是自定义对象,必须同时重写
hashCode()
和equals()
方法,否则会导致 “相同对象判断为不同 Key” 或 “不同对象哈希冲突无法区分”。- 规则:
equals()
返回 true 的两个对象,hashCode()
必须相等;hashCode()
相等的两个对象,equals()
不一定返回 true(哈希冲突)。
- 规则:
-
null 键处理:Key 允许 1 个 null(哈希值固定为 0,索引为 0),Value 允许多个 null。
三、HashTable 详解
1. 核心定义与定位
HashTable 是 Java 早期提供的线程安全哈希表,实现了 Map
接口,底层结构与 JDK 1.7 的 HashMap 类似(数组 + 链表,无红黑树优化),但因性能低下,目前已被 ConcurrentHashMap
替代,仅在遗留系统中可能见到。
// 核心继承关系
public class Hashtable<K,V> extends Dictionary<K,V>implements Map<K,V>, Cloneable, Serializable
2. 核心特性与 HashMap 的差异
(1)线程安全实现:全表 synchronized 锁
HashTable 的线程安全通过在所有核心方法(put、get、remove 等)上添加 synchronized 关键字实现,本质是 “对整个 HashTable 对象加锁”:
public synchronized V put(K key, V value) {// 禁止 key/value 为 nullif (value == null) {throw new NullPointerException();}// ... 其余逻辑
}
- 问题:多线程并发时,无论操作哪个 Key,都会竞争同一把锁,导致锁冲突严重,并发性能极低(相当于单线程执行)。
(2)禁止 null 键 / 值
HashTable 中 Key 和 Value 均不允许为 null,否则会抛出 NullPointerException
(HashMap 允许 Key 为 null)。
(3)初始容量与扩容
-
初始容量:默认 11(HashMap 默认 16,且必须是 2 的幂)。
-
扩容机制:当元素数量超过
容量 × 负载因子(默认 0.75)
时,扩容为原容量 × 2 + 1
(保证容量为奇数,减少哈希冲突,但计算索引时用取模%
,效率低于 HashMap 的位运算)。
3. 适用场景
-
仅推荐用于遗留代码维护,新代码中需线程安全哈希表时,优先使用
ConcurrentHashMap
。 -
低并发场景(如单线程偶尔多线程访问)也可使用,但性能不如 HashMap + 手动锁。
四、ConcurrentHashMap 详解(JDK 1.8+)
1. 核心定义与定位
ConcurrentHashMap(简称 CHM)是 Java 并发包(java.util.concurrent
)提供的高性能线程安全哈希表,解决了 HashTable 全表锁的性能问题,同时保证线程安全,是高并发场景(如缓存、分布式系统)的首选。
// 核心继承关系
public class ConcurrentHashMap<K,V> extends AbstractMap<K,V>implements ConcurrentMap<K,V>, Serializable
2. 底层结构(JDK 1.8 优化)
JDK 1.8 对 CHM 进行了彻底重构,放弃了 JDK 1.7 的 “分段锁(Segment)” 机制,改为 **“数组 + 链表 / 红黑树 + CAS + 局部 synchronized 锁”** 的实现,进一步提升并发性能。
- 核心改进:将锁粒度从 “分段” 缩小到 “数组单个节点(Node)”,即仅对哈希冲突的链表 / 红黑树的首节点加锁,不同索引的节点操作完全并行,并发度大幅提升。
3. 线程安全实现:CAS + 局部锁
CHM 的线程安全依赖两种核心机制:CAS(无锁原子操作) 和 局部 synchronized 锁,具体逻辑如下:
(1)CAS 机制(无锁操作)
对于无哈希冲突的节点插入(即数组对应索引为 null),通过 CAS 直接原子性插入节点,无需加锁:
-
CAS 原理:Compare And Swap(比较并交换),通过硬件指令保证原子性,核心是 “先比较当前值是否符合预期,符合则修改,否则重试”。
-
示例:插入节点时,先通过 CAS 判断数组索引位置是否为 null,若是则将节点写入,避免锁开销。
(2)局部 synchronized 锁(有冲突时加锁)
当存在哈希冲突(多个 Key 对应同一索引,需操作链表 / 红黑树)时,对链表的首节点(或红黑树的根节点)加 synchronized
锁,确保同一链表 / 红黑树的操作串行执行,不同索引的操作并行执行:
// 简化逻辑:对冲突节点的首节点加锁
synchronized (f) {if (tabAt(tab, i) == f) { // 再次确认首节点未被修改(防止并发修改)// 遍历链表或红黑树,执行插入/删除操作}
}
(3)volatile 保证可见性
数组 table
被声明为 volatile
,确保一个线程修改数组后,其他线程能立即看到最新值,避免 “脏读”:
private transient volatile Node<K,V>[] table;
4. 核心特性与优势
(1)高效并发
-
锁粒度极小:仅锁定冲突的链表 / 红黑树,不同索引的操作完全并行,并发性能接近 HashMap。
-
无锁操作:无冲突时通过 CAS 插入,避免锁开销。
(2)禁止 null 键 / 值
与 HashTable 一致,Key 和 Value 均不允许为 null,否则抛出 NullPointerException
(保证并发场景下的一致性,避免 null 引发的逻辑歧义)。
(3)红黑树优化
与 HashMap 一致,当链表长度超过 8 且数组容量 ≥ 64 时,链表转为红黑树,优化查询性能(O (logn))。
(4)原子操作支持
实现 ConcurrentMap
接口,提供原子性的复合操作(无需手动加锁):
-
putIfAbsent(K key, V value)
:仅当 Key 不存在时插入,避免覆盖。 -
remove(Object key, Object value)
:仅当 Key 对应 Value 匹配时删除。 -
replace(K key, V oldValue, V newValue)
:仅当 Key 对应 Value 为 oldValue 时替换。
5. 与 HashTable 的性能对比
场景 | HashTable(全表锁) | ConcurrentHashMap(局部锁) |
---|---|---|
单线程操作 | 慢(锁开销) | 快(接近 HashMap) |
多线程无冲突操作 | 慢(锁竞争) | 快(并行执行) |
多线程有冲突操作 | 极慢(串行执行) | 较快(仅冲突节点串行) |
高并发(100+ 线程) | 性能崩溃 | 性能稳定,吞吐量高 |
五、三者核心区别总结与使用场景推荐
1. 核心区别对比表
对比项 | HashMap | HashTable | ConcurrentHashMap(JDK1.8+) |
---|---|---|---|
线程安全 | 非线程安全 | 线程安全(全表锁) | 线程安全(CAS + 局部锁) |
null 允许 | Key:1 个 null,Value: 多个 null | Key/Value: 均不允许 | Key/Value: 均不允许 |
底层结构(JDK1.8) | 数组 + 链表 / 红黑树 | 数组 + 链表(无红黑树) | 数组 + 链表 / 红黑树 |
锁粒度 | 无锁 | 全表锁 | 节点锁(链表首节点 / 红树根节点) |
并发性能 | 高(单线程),多线程不安全 | 低 | 高(支持高并发) |
扩容机制 | 2 倍,初始 16 | 2 倍 + 1,初始 11 | 2 倍,初始 16 |
2. 使用场景推荐
- 单线程 / 非并发场景:优先使用 HashMap(性能最优,支持 null 键)。
- 示例:普通业务逻辑中的本地缓存、临时数据存储。
- 高并发场景:必须使用 ConcurrentHashMap(性能安全兼顾,支持原子操作)。
- 示例:分布式系统中的共享缓存、线程池任务状态存储、秒杀系统的库存计数。
- 遗留系统维护:仅在维护旧代码时使用 HashTable,新代码绝对避免(性能差,功能可被 ConcurrentHashMap 完全替代)。
六、常见面试题
- HashMap 为什么线程不安全?JDK 1.7 和 1.8 有什么区别?
- 答:JDK 1.7 中多线程扩容可能导致链表成环(死循环),JDK 1.8 改为尾插法修复,但仍会出现数据覆盖;两者均无锁机制,多线程 put 会冲突。
- ConcurrentHashMap JDK 1.7 和 1.8 的实现区别是什么?
- 答:JDK 1.7 用 “分段锁(Segment)”,将数组分为 16 个段,每段一把锁;JDK 1.8 放弃分段锁,改用 “CAS + 局部节点锁”,锁粒度更小,并发性能更高。
- 为什么 HashMap 的容量必须是 2 的幂?
- 答:为了通过
(n-1) & hash
位运算计算索引(效率高于取模%
),且确保索引均匀分布在 [0, n-1] 范围内,减少哈希冲突。
- HashMap 的负载因子为什么默认是 0.75?
- 答:0.75 是 “时间复杂度” 与 “空间复杂度” 的平衡:负载因子过高(如 1.0)会导致哈希冲突加剧,查询变慢;过低(如 0.5)会导致数组利用率低,浪费空间。