HashMap
是 Java 中最常用的数据结构之一,它实现了 Map
接口,用于存储键值对。HashMap
允许 null
值和 null
键,并且通过哈希表的方式实现,能够提供常数时间复杂度的 O(1)
查找和插入操作(在理想情况下)。它在处理大量数据时性能非常高。
1. HashMap 的基本结构
(a) 底层数据结构:数组 + 链表(链地址法)
HashMap
底层通过一个 数组 来存储键值对,每个数组元素称为 桶(bucket)。
每个桶中存储的是 链表 或 红黑树(Java 8 之后引入红黑树来优化性能),当发生哈希冲突(多个键的哈希值映射到同一个位置)时,这些键值对会以链表或红黑树的形式存储在同一个桶中。
(b) 哈希表原理
每个键通过其哈希值(hashCode()
)计算出在数组中的索引。HashMap
通过公式来计算数组下标:
index = (n - 1) & hash
其中,n
是数组的长度,hash
是键的哈希值,&
是按位与操作符。这种计算方式可以确保键的哈希值分布在数组的不同位置上。
(c) 负载因子与容量
HashMap
有两个重要的参数:初始容量 和 负载因子。
数组初始容量的默认大小是 16
。负载因子的默认值是 0.75
。当 HashMap
中元素数量超过数组长度 ×负载因子
时,HashMap
会进行扩容,将数组长度加倍并重新分配所有键值对到新的位置。
(d) 链表转红黑树
在 Java 8 之前,当多个键的哈希值相同并存储在同一个桶中时,HashMap
使用链表来存储这些键值对。
Java 8 引入了红黑树来优化查找性能。当链表长度超过一定阈值(默认是 8),链表会转换为红黑树,以提高查找速度从 O(n)
下降到 O(log n)
。
2. 插入、查找和删除过程
(a) 插入过程
首先调用键的 hashCode()
方法计算哈希值。根据哈希值计算出键值对在数组中的索引位置。
在是检查是否有冲突,查看对应位置是否已经有数据(哈希冲突)。如果没有冲突,则将键值对直接插入该位置。如果有冲突(多个键的哈希值映射到同一个桶),则根据情况:如果链表长度超过阈值,则链表转换为红黑树,键值对插入红黑树中。如果是链表,则将键值对添加到链表末尾。
最后检查负载因子,插入完成后,检查 HashMap
的大小是否超过了负载因子阈值,如果超过,则进行 扩容。
(b) 查找过程
计算哈希值:根据键的 hashCode()
计算出哈希值。使用哈希值计算键在数组中的索引位置。遍历链表或红黑树,找到该位置后:如果是链表,则遍历链表,找到对应的键。如果是红黑树,则通过树的结构查找对应的键。
(c)删除过程
首先,HashMap
使用哈希函数对目标键(Key)进行哈希值计算。哈希值决定了键可能所在的桶(数组索引)。计算公式如下:
int hash = hash(key);
int index = (n - 1) & hash;
其中n
是桶数组的长度,hash(key)
是对键应用哈希函数的结果。通过该计算,定位目标键的桶。
在遍历桶中的链表或树,找到对应的桶后,HashMap
需要遍历这个桶内的所有元素(在存在哈希冲突的情况下)。桶中的元素可以是:
链表节点:如果桶中是链表,遍历链表查找目标键。
红黑树节点:如果桶中是红黑树,则使用红黑树的搜索算法查找目标键。
最后是删除操作,根据找到的节点类型,remove
有不同的处理方式:
链表删除:如果目标键位于链表中,HashMap
会修改链表指针,将目标节点从链表中移除。通常的删除方式是将当前节点的前一个节点指向它的下一个节点,以此跳过要删除的节点。如果是链表的头节点,直接将头节点替换为下一个节点。
红黑树删除:如果目标键位于红黑树中,删除操作会涉及红黑树的删除平衡调整。红黑树删除较为复杂,需要根据树的特性进行重新平衡。
调整大小和清理:
桶数组更新:一旦删除成功,HashMap
会更新桶数组中的引用,确保指针的正确性。
树化和去树化:如果在一个桶中删除某个节点后,红黑树的节点数量减少到阈值以下,HashMap
会将红黑树重新转化为链表。
返回删除的值:
remove
方法会返回与被删除键对应的值(如果存在)。如果目标键不在HashMap
中,则返回null
。
在上面的插入过程中,如果不同键 Key1
和 Key9
的哈希值计算后得到的数组下标相同,它们会形成一个链表。随着数据量增大,链表长度过长时,链表可能转为红黑树。
3. 扩容机制
当 HashMap
中存储的元素数量超过 数组容量 × 负载因子
时,HashMap
会自动进行 扩容。扩容的过程如下:
创建一个新的数组,容量是原来的两倍。
将旧数组中的所有键值对重新计算哈希并放入新数组中。这是因为扩容后数组大小发生变化,原有的哈希值对应的数组下标可能会改变。
重新分配后,旧数组中的链表或红黑树会被拆分并重新放入新数组中。
扩容虽然能避免数组过满导致哈希冲突增多,但会带来较大的性能消耗,尤其是当数据量很大时,频繁的扩容会导致性能下降。因此,合理设置初始容量是优化 HashMap
性能的一个重要手段。
4. HashMap 的线程安全问题和应用场景
HashMap
本身并不是线程安全的。在多线程环境下,多个线程同时访问和修改 HashMap
可能会导致数据不一致或死循环。因此,在多线程场景下建议使用 ConcurrentHashMap
,它是线程安全的 HashMap
实现。
HashMap
常用于需要通过键值对快速查找数据的场景。典型应用场景包括:
缓存:将常用数据缓存到内存中以加快查找速度。
配置存储:存储配置文件中的键值对,例如应用配置、用户设置等。
计数器:例如,统计单词出现的次数,每个单词作为键,出现次数作为值。
优缺点:
优点:插入、查找、删除操作非常快,适合大规模数据的处理。灵活支持 null
值和 null
键。
缺点:不是线程安全的,需在多线程场景中小心使用。扩容时可能会带来性能损耗。