HashMap底层原理详解及ConcurrentHashMap并发优化
引言
在Java集合框架中,HashMap无疑是使用最广泛的键值对存储结构。它基于哈希表实现,提供了平均O(1)的查找和插入性能。本文将深入剖析HashMap的底层实现原理,包括数据结构、哈希计算、冲突解决、扩容机制等核心内容,并通过图文结合的方式帮助理解。同时,我们还将探讨线程安全的ConcurrentHashMap实现,分析其如何在保证线程安全的同时实现比Hashtable更高的并发效率。
一、HashMap底层数据结构
1.1 JDK 7及之前的实现:数组+链表
早期的HashMap采用数组+链表的复合结构(也称为"拉链法")。数组(称为"哈希桶")是主体,每个数组元素都是一个链表的头节点。当发生哈希冲突时,冲突的元素会被添加到链表中。
1.2 JDK 8及之后的优化:数组+链表+红黑树
JDK 8对HashMap进行了重大优化,引入了红黑树数据结构。当链表长度超过阈值(默认为8)且数组长度不小于64时,链表会转换为红黑树,以提高查询效率(从O(n)优化到O(log n))。
HashMap数据结构示意图,蓝色表示数组,绿色表示链表节点,红色表示红黑树节点
二、哈希函数与索引计算
2.1 哈希值计算
HashMap通过以下步骤计算键的哈希值:
- 调用键的
hashCode()
方法获取初始哈希值 - 对初始哈希值进行扰动处理(JDK 8中简化为一次右移16位并异或):
扰动处理的目的是将高位信息混入低位,减少哈希冲突。static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
2.2 数组索引计算
通过哈希值计算数组索引的公式为:
index = (n - 1) & hash
其中n
是数组长度(必须是2的幂)。这种计算方式等价于hash % n
,但位运算效率更高。
三、哈希冲突解决方法
3.1 链地址法
HashMap采用链地址法(拉链法)解决哈希冲突:当两个不同的键计算出相同的索引时,将它们存储在同一个索引位置的链表(或红黑树)中。
两个不同的键(Key1和Key2)计算出相同索引,通过链表存储在同一位置
3.2 红黑树转换条件
当满足以下两个条件时,链表会转换为红黑树:
- 链表长度超过阈值(TREEIFY_THRESHOLD = 8)
- 数组长度不小于最小树化容量(MIN_TREEIFY_CAPACITY = 64)
如果数组长度小于64,会先进行扩容而非树化,以避免在数组较小时频繁进行树化操作。
四、扩容机制
4.1 扩容触发条件
当HashMap的实际元素数量超过阈值(threshold) 时,会触发扩容。阈值计算公式为:
threshold = capacity * loadFactor
capacity
:数组容量(默认为16,必须是2的幂)loadFactor
:负载因子(默认为0.75)
4.2 扩容过程
扩容(resize()方法)的主要步骤:
- 计算新容量(原容量的2倍)
- 创建新的数组(大小为新容量)
- 将原数组中的元素迁移到新数组中
- 更新阈值和容量
JDK 8对迁移过程进行了优化:无需重新计算哈希值,而是通过高位运算(hash & oldCap)判断元素在新数组中的位置(原位置或原位置+oldCap)。
HashMap扩容过程,数组大小从8扩容到16,元素根据高位信息迁移到新位置
4.3 扩容示例
假设原数组容量为8(n=8),某个元素的哈希值为hash = 0b10101100
:
- 原索引:
(8-1) & hash = 0b00000111 & 0b10101100 = 0b00000100 = 4
- 扩容后容量为16(n=16)
- 新索引:
(16-1) & hash = 0b00001111 & 0b10101100 = 0b00001100 = 12
- 也可通过
hash & oldCap
(0b10101100 & 0b00001000 = 0b00001000 != 0)判断应迁移到原索引+oldCap位置
五、常用方法解析
5.1 put方法流程
- 计算键的哈希值和数组索引
- 如果数组为空,先进行初始化
- 如果目标位置为空,直接插入新节点
- 如果目标位置不为空:
- 如果是红黑树节点,调用红黑树的插入方法
- 如果是链表节点,遍历链表查找是否存在相同键:
- 存在则替换值
- 不存在则添加到链表尾部,并检查是否需要树化
- 检查是否需要扩容
5.2 get方法流程
- 计算键的哈希值和数组索引
- 检查目标位置的第一个节点:
- 如果是红黑树节点,调用红黑树的查找方法
- 如果是链表节点,遍历链表查找匹配的键
- 找到则返回对应值,否则返回null
六、HashMap的线程不安全问题
HashMap不是线程安全的,在多线程环境下可能出现以下问题:
6.1 数据覆盖
当多个线程同时执行put操作,且计算出相同索引时,可能导致后插入的数据覆盖先插入的数据。
6.2 JDK 7中的死循环问题
JDK 7中,多线程扩容时可能导致链表形成环形结构,进而在get操作时陷入死循环。这是因为JDK 7的扩容迁移过程采用头插法,可能导致链表反转。JDK 8改用尾插法解决了此问题,但HashMap仍然不是线程安全的。
七、ConcurrentHashMap介绍
ConcurrentHashMap是HashMap的线程安全版本,专为高并发场景设计。它提供了比Hashtable更高的并发性,同时保证了线程安全。
7.1 与HashMap、Hashtable的对比
特性 | HashMap | Hashtable | ConcurrentHashMap |
---|---|---|---|
线程安全 | 否 | 是 | 是 |
锁机制 | 无 | 全表锁(synchronized) | 分段锁(JDK 7)/CAS+节点锁(JDK 8) |
并发性能 | 无 | 低 | 高 |
支持null键值 | 是 | 否 | 否 |
7.2 JDK 7实现:分段锁(Segment)
JDK 7的ConcurrentHashMap采用分段锁(Segment) 机制,将整个哈希表分为多个Segment(每个Segment是一个独立的哈希表),每个Segment拥有自己的锁。不同Segment上的操作可以并发进行,从而提高并发性。
JDK 7的ConcurrentHashMap结构,多个Segment独立加锁,减少锁竞争
7.3 JDK 8实现:CAS+synchronized+红黑树
JDK 8对ConcurrentHashMap进行了彻底重构,取消了Segment,采用CAS+synchronized+红黑树的实现:
- 底层数据结构与HashMap类似(数组+链表+红黑树)
- 使用CAS操作实现无锁化修改
- 仅对冲突的节点使用synchronized加锁
- 读操作无需加锁,通过volatile保证可见性
图5:JDK 8的ConcurrentHashMap实现,仅对冲突节点加锁,读操作无锁
八、ConcurrentHashMap的线程安全机制
8.1 CAS操作
CAS(Compare-And-Swap)是一种无锁化同步机制,用于原子性地更新值。ConcurrentHashMap在以下场景使用CAS:
- 初始化数组
- 添加第一个节点
- 扩容时迁移数据
- 计数操作(size()方法)
8.2 synchronized节点锁
当需要修改已有节点时,ConcurrentHashMap使用synchronized关键字锁定链表或红黑树的首节点。这种节点级别的细粒度锁大大减少了锁竞争,提高了并发性。
8.3 读操作无锁
读操作无需加锁,通过以下机制保证可见性:
- 节点的val和next字段使用volatile修饰
- 数组引用使用volatile修饰,保证扩容时的可见性
- 红黑树的根节点使用volatile修饰
九、为什么ConcurrentHashMap比Hashtable效率高
9.1 锁粒度的差异
- Hashtable:使用synchronized修饰方法,实现全表锁。任何线程操作Hashtable时都需要获取整个对象的锁,导致多线程环境下严重的锁竞争。
- ConcurrentHashMap:
- JDK 7:分段锁,将锁粒度减小到Segment级别
- JDK 8:节点锁,进一步将锁粒度减小到哈希桶的首节点
锁粒度越小,并发性能越好。
9.2 读写分离
ConcurrentHashMap支持读多写少的并发场景:
- 读操作无需加锁,通过volatile保证可见性
- 写操作仅锁定冲突的节点
- 多个读操作可以完全并发执行
而Hashtable的读操作也需要获取锁,导致读操作之间也存在竞争。
9.3 高效的扩容机制
ConcurrentHashMap的扩容过程支持并发扩容:
- 扩容时,原数组和新数组同时存在
- 多个线程可以同时参与数据迁移
- 迁移期间,读操作可以访问原数组或新数组
- 写操作会协助完成迁移
这种设计大大提高了扩容效率,减少了扩容对性能的影响。
十、总结与应用场景
10.1 HashMap总结
- 底层数据结构:JDK 7为数组+链表,JDK 8为数组+链表+红黑树
- 哈希冲突解决:链地址法,长链表转换为红黑树优化查询
- 扩容机制:2倍扩容,JDK 8优化了数据迁移算法
- 特点:查询和插入效率高(平均O(1)),但线程不安全
- 适用场景:单线程环境下的键值对存储
10.2 ConcurrentHashMap总结
- 线程安全机制:JDK 7为分段锁,JDK 8为CAS+synchronized
- 特点:高并发、线程安全、支持部分原子操作
- 与Hashtable对比:锁粒度更小,读写分离,并发性能更高
- 适用场景:多线程环境下的高并发读写操作,如缓存、共享数据存储
总结
哈希桶数组(Table):初始长度为 16(DEFAULT_INITIAL_CAPACITY),每个元素称为一个 “桶”(Bucket)。
数组长度始终为 2 的幂次方(如 16、32、64…),通过位运算优化哈希冲突。
链表:当多个键的哈希值冲突时,冲突的元素以链表形式存储在同一个桶中。
JDK 8 之前采用头插法(新节点插入链表头部),JDK 8 改为尾插法(避免多线程扩容时的死循环)。
红黑树:当链表长度超过 8(TREEIFY_THRESHOLD)且数组长度 ≥ 64(MIN_TREEIFY_CAPACITY)时,链表转换为红黑树。
红黑树的查询、插入、删除时间复杂度为 O (log n),避免链表过长导致性能下降。