hashMap
# HashMap 知识体系
# 一、HashMap 概述
HashMap 是 Java 中最常用的集合类之一,它实现了 Map 接口,用于存储键值对(key-value pairs)。HashMap 基于哈希表(hash table)实现,提供了快速的插入、删除和查找操作。
关键特性:
- 键值对存储: 存储键值对,key 和 value 都可以是
null(key 只能有一个 null)。 - 无序性: 默认情况下,
HashMap中的元素是无序的(不保证元素的顺序)。 - 允许
null: 允许 key 为null(只能有一个),value 可以为null。 - 非线程安全:
HashMap不是线程安全的,如果需要在多线程环境中使用,可以考虑使用ConcurrentHashMap。 - 哈希表实现: 基于哈希表实现,通过哈希函数将 key 映射到哈希表中的一个位置。
- 平均时间复杂度 O(1): 在理想情况下,
get、put、remove等操作的平均时间复杂度为 O(1)。 - 容量与负载因子: 容量(capacity)是哈希表中桶的数量,负载因子(load factor)是衡量哈希表填充程度的指标。当哈希表中的元素数量超过容量与负载因子的乘积时,会进行扩容(resize)。
- 解决哈希冲突: 使用链表或红黑树来解决哈希冲突(collision)。
# 二、HashMap 核心概念
- 哈希函数 (Hash Function): 用于将 key 转换为哈希码(hash code),哈希码是一个整数,用于确定 key 在哈希表中的位置。好的哈希函数应该尽可能地减少哈希冲突。
- 桶 (Bucket): 哈希表中的一个存储位置,用于存储具有相同哈希码的元素。
- 哈希冲突 (Collision): 当两个或多个 key 具有相同的哈希码时,就会发生哈希冲突。
- 链地址法 (Separate Chaining): 一种解决哈希冲突的方法,将具有相同哈希码的元素存储在同一个桶的链表中。
- 红黑树 (Red-Black Tree): 一种自平衡的二叉查找树,用于在链表长度超过一定阈值时,将链表转换为红黑树,以提高查找效率。
- 容量 (Capacity): HashMap 中桶的数量,初始容量默认为 16。
- 负载因子 (Load Factor): 衡量 HashMap 填充程度的指标,默认值为 0.75。
- 阈值 (Threshold): 容量 * 负载因子,当 HashMap 中元素数量超过阈值时,会进行扩容。
- 扩容 (Resize): 扩展 HashMap 的容量,创建一个新的更大的哈希表,并将所有元素重新哈希到新的哈希表中。 这通常是一个非常耗时的操作。
# 三、HashMap 源码分析 (JDK 8)
数据结构: 数组 + 链表/红黑树
transient Node<K,V>[] table; // 存储键值对的数组static class Node<K,V> implements Map.Entry<K,V> { final int hash; // key 的哈希码 final K key; V value; Node<K,V> next; // 指向下一个节点的指针 (用于解决哈希冲突) ... }构造函数:
public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted } public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); this.loadFactor = loadFactor; this.threshold = tableSizeFor(initialCapacity); // threshold 总是 2 的幂 }put(K key, V value)方法:- 计算 key 的哈希码。
- 根据哈希码确定 key 在哈希表中的位置(桶)。
- 如果桶为空,则创建一个新的节点并放入桶中。
- 如果桶不为空,则遍历桶中的链表或红黑树:
- 如果找到具有相同 key 的节点,则更新该节点的 value。
- 如果没有找到具有相同 key 的节点,则在链表末尾添加一个新的节点(或在红黑树中插入一个新的节点)。
- 如果 HashMap 中的元素数量超过阈值,则进行扩容。
get(Object key)方法:- 计算 key 的哈希码。
- 根据哈希码确定 key 在哈希表中的位置(桶)。
- 如果桶为空,则返回
null。 - 如果桶不为空,则遍历桶中的链表或红黑树:
- 如果找到具有相同 key 的节点,则返回该节点的 value。
- 如果没有找到具有相同 key 的节点,则返回
null。
resize()方法:- 创建一个新的更大的哈希表。 新的容量是旧容量的两倍。
- 将所有元素重新哈希到新的哈希表中。
树化阈值与解除树化阈值:
static final int TREEIFY_THRESHOLD = 8;当链表长度达到 8 时,且数组长度达到 64, 链表会尝试转化为红黑树。static final int UNTREEIFY_THRESHOLD = 6;当红黑树中的节点少于 6 个时,红黑树会转换回链表。
最小树化容量:
static final int MIN_TREEIFY_CAPACITY = 64;当哈希表中的桶的数量小于 64 时,即使链表长度达到 8,也不会进行树化,而是进行扩容。
# 四、HashMap 面试题及详细回答
1. HashMap 的数据结构是什么?
- 回答:
HashMap的数据结构是数组 + 链表/红黑树。数组是HashMap的主体,用于存储键值对。当多个 key 的哈希码冲突时,它们会被存储在同一个数组位置的链表中。当链表长度超过一定阈值(8)时,且数组长度达到 64,链表会转换为红黑树,以提高查找效率。 数组长度小于 64 的时候, 优先进行扩容,而不是转化为红黑树。
2. HashMap 的工作原理是什么?
- 回答:
- 当调用
put(key, value)方法时,HashMap首先会计算 key 的哈希码。 - 然后,根据哈希码确定 key 在哈希表中的位置(桶)。
- 如果桶为空,则创建一个新的节点并放入桶中。
- 如果桶不为空,则遍历桶中的链表或红黑树:
- 如果找到具有相同 key 的节点,则更新该节点的 value。
- 如果没有找到具有相同 key 的节点,则在链表末尾添加一个新的节点(或在红黑树中插入一个新的节点)。
- 如果
HashMap中的元素数量超过阈值,则进行扩容。 - 当调用
get(key)方法时,HashMap 首先会计算 key 的哈希码,并根据哈希码找到对应的桶,然后遍历桶中的链表或红黑树,查找具有相同 key 的节点,并返回其 value。
- 当调用
3. HashMap 如何解决哈希冲突?
- 回答:
HashMap使用链地址法解决哈希冲突。当多个 key 具有相同的哈希码时,它们会被存储在同一个桶的链表中。 在JDK 8 中,如果链表长度超过 8 且数组长度达到 64,链表会转换为红黑树,以提高查找效率。
4. 为什么 HashMap 要使用红黑树?
- 回答: 当哈希冲突严重时,链表会变得很长,导致查找效率降低,时间复杂度会从 O(1) 变为 O(n)。使用红黑树可以提高查找效率,将时间复杂度降低到 O(log n)。红黑树是一种自平衡的二叉查找树,可以保证在最坏情况下,查找、插入和删除操作的时间复杂度都是 O(log n)。
5. HashMap 的容量和负载因子是什么?它们有什么作用?
- 回答:
- 容量(Capacity):
HashMap中桶的数量,初始容量默认为 16。 - 负载因子(Load Factor): 衡量
HashMap填充程度的指标,默认值为 0.75。 - 作用: 容量和负载因子共同决定了
HashMap何时进行扩容。当HashMap中的元素数量超过容量与负载因子的乘积时,就会进行扩容。 扩容是一个非常耗时的操作,因为它需要创建一个新的更大的哈希表,并将所有元素重新哈希到新的哈希表中。 因此,选择合适的容量和负载因子非常重要。
- 容量(Capacity):
6. HashMap 是线程安全的吗?如果不是,如何解决线程安全问题?
- 回答:
HashMap不是线程安全的。如果需要在多线程环境中使用HashMap,可以考虑以下解决方案:- 使用
Collections.synchronizedMap(new HashMap<>()): 将HashMap包装成一个线程安全的Map。但这种方式的效率较低,因为它是通过对整个Map进行加锁来实现线程安全的。 - 使用
ConcurrentHashMap:ConcurrentHashMap是一个线程安全的哈希表,它使用了分段锁(segment locking)技术,将哈希表分成多个段,每个段都有自己的锁。这样可以降低锁的粒度,提高并发性能。 - 使用
Hashtable:Hashtable是一个线程安全的哈希表,但它的效率较低,因为它对整个Hashtable进行加锁。 现在已经很少使用。
- 使用
7. HashMap 的 key 可以为 null 吗?value 可以为 null 吗?
- 回答:
HashMap的 key 可以为null(只能有一个),value 可以为null。
8. HashMap 的扩容机制是什么?
- 回答: 当
HashMap中的元素数量超过阈值(容量 * 负载因子)时,就会进行扩容。扩容的过程如下:- 创建一个新的更大的哈希表,新的容量通常是旧容量的两倍。
- 将所有元素重新哈希到新的哈希表中。 这个过程会遍历旧哈希表中的所有元素,并重新计算它们在新哈希表中的位置。
- 将旧哈希表中的所有元素移动到新的哈希表中。
9. 为什么 HashMap 的容量总是 2 的幂次方?
- 回答:
- 计算索引: 为了将 key 映射到哈希表中的一个位置,
HashMap使用以下公式计算索引:index = (n - 1) & hash,其中n是哈希表的容量,hash是 key 的哈希码。当n为 2 的幂次方时,(n - 1)的二进制表示是一个低位全 1 的数,这样可以保证计算出的索引值更加均匀分布,减少哈希冲突。 - 扩容: 当
HashMap进行扩容时,新的容量是旧容量的两倍。如果容量不是 2 的幂次方,则扩容后需要重新计算所有元素的哈希码,这会降低扩容效率。 - 效率优化: 在计算机中,位运算的效率比取模运算高。将容量设置为 2 的幂次方,可以使用位运算代替取模运算,提高计算索引的效率。
- 计算索引: 为了将 key 映射到哈希表中的一个位置,
10. HashMap 和 Hashtable 的区别?
- 回答:
- 线程安全:
HashMap不是线程安全的,Hashtable是线程安全的。 null值:HashMap允许 key 和 value 为null,Hashtable不允许 key 和 value 为null。- 继承关系:
HashMap继承自AbstractMap,Hashtable继承自Dictionary。 - 初始容量和扩容:
HashMap的初始容量为 16,扩容时容量变为原来的 2 倍。Hashtable的初始容量为 11,扩容时容量变为原来的2n + 1倍。 - 哈希算法:
HashMap使用扰动函数(hash function)来减少哈希冲突,Hashtable直接使用 key 的hashCode()方法。 - 效率: 在单线程环境下,
HashMap的效率通常比Hashtable高。 因为Hashtable是同步的,存在锁的开销。
- 线程安全:
11. ConcurrentHashMap 的实现原理是什么?
- 回答:
- JDK 7:
ConcurrentHashMap使用分段锁(segment locking)技术,将哈希表分成多个段(Segment),每个段都有自己的锁。这样可以降低锁的粒度,提高并发性能。Segment 继承自 ReentrantLock,因此每个 Segment 都是一个可重入锁。 - JDK 8:
ConcurrentHashMap使用 CAS +synchronized+Node+ 红黑树来实现线程安全。 它取消了 Segment 分段锁,采用 CAS 保证原子性,在发生冲突时,使用synchronized锁住链表的头节点,链表长度超过 8 且数组长度达到 64时,链表转换为红黑树。 相比于 JDK 7 的分段锁,JDK 8 的ConcurrentHashMap具有更高的并发性能。
- JDK 7:
12. 简述一下你对哈希碰撞的理解,有哪些解决哈希碰撞的方法?
- 回答:
- 哈希碰撞的理解: 哈希碰撞指的是不同的 key 通过哈希函数计算得到相同的哈希值,导致它们被映射到哈希表的同一个位置(桶)。由于桶只能存储一个元素,因此当发生哈希碰撞时,就需要解决如何存储这些具有相同哈希值的元素的问题。
- 解决哈希碰撞的方法:
- 链地址法 (Separate Chaining): 将具有相同哈希值的元素存储在同一个桶的链表中。这是 HashMap 使用的默认解决方案。
- 开放地址法 (Open Addressing): 当发生哈希碰撞时,按照某种规则在哈希表中寻找下一个可用的空闲位置。常见的开放地址法包括:
- 线性探测 (Linear Probing): 依次检查下一个位置,直到找到空闲位置。
- 二次探测 (Quadratic Probing): 按照二次方序列(1, 4, 9, 16, ...)检查下一个位置。
- 双重哈希 (Double Hashing): 使用另一个哈希函数计算探测步长。
- 再哈希法 (Rehashing): 使用多个不同的哈希函数,当发生哈希碰撞时,使用另一个哈希函数计算哈希值,直到找到空闲位置。
- 建立公共溢出区: 建立另一个单独的存储空间,用于存储发生哈希碰撞的元素。
13. 你在什么情况下会使用 HashMap?
- 回答: 当需要存储键值对,并且需要快速的插入、删除和查找操作时,我会使用
HashMap。HashMap适用于单线程环境,如果需要在多线程环境中使用,我会考虑使用ConcurrentHashMap。 我通常会在以下情况下使用 HashMap:- 缓存: 将数据缓存在 HashMap 中,以提高访问速度。
- 索引: 使用 HashMap 构建索引,以加快查找速度。
- 统计: 使用 HashMap 统计元素的出现次数。
- 配置: 使用 HashMap 存储配置信息。
14. 如何选择 HashMap 的初始容量?
- 回答: 选择 HashMap 的初始容量需要考虑以下因素:
- 预期存储的元素数量: 如果知道 HashMap 大概会存储多少个元素,可以将初始容量设置为略大于预期元素数量的值。
- 负载因子: 负载因子决定了 HashMap 何时进行扩容。 如果负载因子设置的比较小, 那么可以适当降低初始容量。
- 减少扩容次数: 扩容是一个非常耗时的操作,因此应该尽量减少扩容次数。 设置合适的初始容量可以避免频繁扩容。
- 性能测试: 可以通过性能测试来确定最佳的初始容量。
15. HashMap 中的 key 需要实现 hashCode() 和 equals() 方法吗?为什么?
- 回答: 是的,
HashMap中的 key 需要实现hashCode()和equals()方法。 原因如下:- hashCode() 方法:
HashMap使用hashCode()方法计算 key 的哈希码,哈希码用于确定 key 在哈希表中的位置。 如果两个 key 的hashCode()方法返回值不同,则它们一定不会被存储在同一个桶中。 - equals() 方法: 当多个 key 的哈希码冲突时,它们会被存储在同一个桶的链表中或红黑树中。
HashMap使用equals()方法来比较链表或红黑树中的 key 是否相等。 如果两个 key 的hashCode()方法返回值相同,并且equals()方法返回true,则认为它们是相等的。 - 一致性:
hashCode()和equals()方法必须保持一致性。 如果两个 key 的equals()方法返回true,则它们的hashCode()方法返回值必须相同。 如果两个 key 的equals()方法返回false,则它们的hashCode()方法返回值最好不同,以减少哈希冲突。
- hashCode() 方法:
16. HashMap put 方法流程
- 回答:
- 计算哈希值: 首先,对 key 调用
hashCode()方法计算哈希值。 - 检查是否需要初始化: 检查哈希表(
table)是否为空或者长度为 0。如果是,则进行初始化(调用resize()方法进行初始化)。 - 计算索引: 通过哈希值与哈希表长度取模(
(n - 1) & hash)计算出 key 应该被放置在哈希表中的哪个桶(bucket)的索引位置。 - 检查桶是否为空:
- 如果计算得到的桶是空的(即该位置没有元素),则直接将包含 key-value 的新节点放入该桶中。
- 处理哈希冲突: 如果计算得到的桶不是空的(即发生了哈希冲突),则需要进一步处理:
- 检查 key 是否已存在: 遍历桶中的节点(链表或红黑树)。如果找到与要插入的 key 具有相同哈希值且
equals()方法返回 true 的 key,则表示该 key 已经存在于 HashMap 中,此时可以选择:- 更新值: 如果
onlyIfAbsent为 false(允许覆盖旧值),则将该 key 对应的 value 更新为新值,并返回旧值。 - 不更新值: 如果
onlyIfAbsent为 true(不允许覆盖旧值),则不更新值,直接返回旧值。
- 更新值: 如果
- 插入新节点: 如果遍历完桶中的节点后没有找到相同的 key,则将包含 key-value 的新节点插入到桶中。
- 链表插入: 如果桶中的节点是链表结构,则将新节点添加到链表的末尾。
- 红黑树插入: 如果桶中的节点是红黑树结构,则将新节点插入到红黑树中。
- 检查 key 是否已存在: 遍历桶中的节点(链表或红黑树)。如果找到与要插入的 key 具有相同哈希值且
- 检查是否需要树化: 如果在链表插入节点后,链表的长度达到了树化阈值(
TREEIFY_THRESHOLD,默认为 8),则尝试将该链表转换为红黑树。转换的前提是哈希表的总容量达到最小树化容量(MIN_TREEIFY_CAPACITY,默认为 64),否则,如果总容量小于 64,则进行扩容而不是树化。 - 检查是否需要扩容: 在插入新节点后,将 HashMap 的
modCount(修改次数)加 1,并将元素数量加 1。然后,检查 HashMap 的当前元素数量是否超过了扩容阈值(threshold,等于容量乘以负载因子)。如果超过了阈值,则调用resize()方法进行扩容。 - 返回结果: 如果是插入新节点,则返回 null。如果更新了现有节点的值,则返回旧值。
- 计算哈希值: 首先,对 key 调用
# 五、总结
HashMap 是 Java 中非常重要的集合类,掌握 HashMap 的知识对于理解 Java 集合框架和编写高效的 Java 代码至关重要。 在面试中,HashMap 也是一个常见的考点,需要掌握 HashMap 的数据结构、工作原理、哈希冲突解决方案、线程安全问题等。 希望本文能够帮助你更好地理解 HashMap,并在面试中取得好成绩。