本文将以面试官提问的形式,对 Java 中 HashMap 的核心知识点进行系统性检验。通过问题自测与答案详解,帮助你巩固对 HashMap 原理、使用场景、性能优化及线程安全等问题的理解,直面技术面试。
HashMap常见面试题自测
以下是关于 HashMap 的 11 个高频面试问题,建议你先尝试自己回答,再看后面的参考答案,检验自己的掌握程度。
- 什么是 HashMap?你在平时开发有用到过吗?
- HashMap 的底层数据结构是什么?
- 为什么这里要用红黑树呢?用平衡二叉树不可以吗?
- 谈一谈 HashMap 的 put 的过程。可以说一下在 put 过程中,JDK 做了哪些优化吗?
- HashMap 是怎么解决哈希冲突的?还有什么其它方法可以解决哈希冲突?
- 链表转换为红黑树的阈值为什么设置成 8 呢?转为红黑树之后,还会继续转为链表吗?
- 再来聊一聊 HashMap 的扩容吧?什么时候扩容,扩容因子是多少,为什么要选择这个值?
- 既然频繁扩容会影响性能,那么在开发中应该怎么优化避免频繁扩容呢?
- HashMap 的 put 操作是线程安全的吗?可以举个例子,哪里不安全吗?
- 既然头插法会导致死循环,那为什么在之前的版本中还要用头插法呢?
- 如果想让 HashMap 变成线程安全的,你觉得可以怎么做?
面试题精解与剖析
下面将对上述问题逐一进行解析,并提供参考回答。
- 什么是 HashMap?你在平时开发有用到过吗?(考虑结合项目)
HashMap 是 Java 集合框架中最常用的键值对容器,基于哈希表实现,其特点是查询、插入在理想情况下时间复杂度都是 O(1),效率很高。在实际开发中,HashMap 的应用场景非常广泛。例如,我曾用它来缓存某些复杂的计算结果,避免重复计算,或者作为中间数据结构来快速聚合、统计某些业务数据。
- HashMap 的底层数据结构是什么?
在 JDK 1.8 中,HashMap 的底层数据结构是“数组 + 链表 + 红黑树”。它使用数组作为哈希桶(Bucket),当通过哈希计算定位到数组的某个槽位后,如果发生哈希冲突(即不同的 key 计算出相同的数组索引),就会在该槽位上形成链表。当链表长度过长,达到某个阈值(JDK 1.8 设定为 8),链表会转换为红黑树,以提高极端情况下的查询效率;而当红黑树的节点数减少到另一阈值(默认为 6)时,则会转换回链表。
- 为什么这里要用红黑树呢?用平衡二叉树不可以吗?
红黑树是一种近似平衡的二叉搜索树,它通过特定的着色规则来保持树的“黑平衡”。相比于严格的平衡二叉树(如 AVL 树),红黑树在插入和删除节点时需要的旋转操作更少。对于 HashMap 这种写操作(尤其是扩容时的节点迁移)也相对频繁的场景,使用红黑树在整体性能上更有优势,它用牺牲一点点的平衡性换取了更高效的动态调整成本。
- 谈一谈 HashMap 的 put 的过程。可以说一下在 put 过程中,JDK 做了哪些优化吗?
put 操作的核心步骤如下:
- 计算 key 的哈希值(经过扰动函数处理)。
- 根据哈希值和当前数组长度,计算出该 key 应存放的数组下标。
- 定位到该下标处的桶:
- 若桶为空,直接新建节点放入。
- 若桶不为空(哈希冲突),则遍历该桶上的链表或红黑树。
- 若找到 key 相等的节点,则更新其 value 并返回旧值。
- 若未找到,则将新节点插入链表尾部或红黑树中。
- 插入后,判断当前元素总数是否超过阈值(容量 * 负载因子),若超过,则进行扩容。
在 put 过程中,JDK 有两个显著的优化:
一、哈希值的优化:通过扰动函数 (h = key.hashCode()) ^ (h >>> 16),将 hashCode 的高 16 位与低 16 位进行异或混合。目的是增加哈希值的随机性,让 key 的每一位变化都能对最终的下标计算产生影响,从而降低哈希冲突的概率。
二、高效的下标计算:使用位运算 (n - 1) & hash 替代取模运算 hash % n 来计算下标(n 为数组长度)。位运算效率远高于取模运算,但此优化的前提是数组长度必须是 2 的幂次,这保证了 (n-1) 的二进制形式是全 1,从而能将哈希值均匀映射到数组索引上。
- HashMap 是怎么解决哈希冲突的?还有什么其它方法可以解决哈希冲突?
HashMap 采用的是链地址法(也称拉链法),即在发生冲突的数组槽位上建立一个链表(或红黑树),将所有哈希值相同的元素都放在这个桶内。
解决哈希冲突的常见方法还有:
- 开放定址法:当发生冲突时,按照某种探测序列(如线性探测、二次探测)去寻找数组中的下一个空闲槽位。
- 再哈希法:准备多个哈希函数,当第一个哈希函数发生冲突时,立即换用第二个、第三个哈希函数重新计算,直到找到空位。
- 公共溢出区法:将所有冲突的元素都放入一个独立的存储区域(溢出表)。
相比之下,链地址法实现简单,且能有效处理冲突,对哈希函数的要求不高,在删除操作和扩容时也更灵活,因此成为 HashMap 的选择。如果你想深入学习更多数据结构与算法,可以关注相关专题。
- 链表转换为红黑树的阈值为什么设置成 8 呢?转为红黑树之后,还会继续转为链表吗?
根据 JDK 源码注释中的解释,这个阈值 8 是基于泊松分布的概率统计得出的。在理想的随机哈希情况下,链表长度达到 8 的概率极低(小于千万分之一)。因此,将树化阈值设为 8,可以确保在绝大多数情况下,HashMap 都运行在高效的链表模式下。只有当哈希函数设计不佳或存在恶意数据导致严重的哈希碰撞时,链表才会转为红黑树来保证性能下限。
会转为链表。当红黑树中的节点数量减少到 6 时,红黑树会退化为链表。设置不同的“树化”和“链化”阈值(8 和 6),是为了在阈值附近(比如 7 个元素时)避免因为频繁的插入和删除操作,导致数据结构在链表和红黑树之间来回反复转换,造成不必要的性能开销。
- 再来聊一聊 HashMap 的扩容吧?什么时候扩容,扩容因子是多少,为什么要选择这个值?
触发扩容的场景主要有:
- 初始化后首次 put:使用默认构造器时,首次 put 会初始化数组为默认容量 16。
- 指定初始容量的首次 put:会根据给定容量计算出一个不小于它的 2 的幂次作为初始容量。
- 非首次 put 后容量超过阈值:这是最常见的扩容场景。此时,数组容量和扩容阈值均会变为原来的 2 倍。
默认的扩容因子(负载因子)是 0.75。选择 0.75 是空间利用率和时间成本的一个经验性折中。
- 如果负载因子过高(如 0.9),虽然空间利用率高,但哈希冲突的概率会显著增加,导致链表变长,查询性能下降。
- 如果负载因子过低(如 0.5),虽然哈希冲突减少,查询快,但会频繁触发扩容,消耗更多内存,并且扩容时的数据迁移(rehash)会带来额外性能开销。
0.75 这个值在多数场景下能够较好地平衡这两者。
- 既然频繁扩容会影响性能,那么在开发中应该怎么优化避免频繁扩容呢?
最有效的优化方法是:在创建 HashMap 时,根据业务数据量合理预估并指定初始容量。
例如,如果你预计最终会存入 1000 个元素,可以这样创建:new HashMap<>(1024)。这样,HashMap 在初始化时就会将容量设置为不小于 1024 的 2 的幂次(实际是 1024),从而避免了在 put 前 768 个元素(1024*0.75)的过程中发生扩容。你不需要自己计算精确的 2 的幂次,HashMap 的 tableSizeFor 方法会帮你完成这个工作。
- HashMap 的 put 操作是线程安全的吗?可以举个例子,哪里不安全吗?
HashMap 的 put 操作不是线程安全的,主要存在两类问题:
- 数据覆盖:当两个线程同时判断某个桶为空,并执行插入时,后一个线程写入的值可能会覆盖前一个线程写入的值,导致数据丢失。
- 环形链表(JDK 1.7 特有):在 JDK 1.7 及之前,HashMap 在扩容迁移链表节点时采用头插法。在多线程并发扩容时,可能会修改节点的
next 引用,形成一个环形链表。后续对该桶进行查询时,就可能陷入死循环,CPU 飙升至 100%。这是 Java 并发编程中一个经典的 HashMap 问题。
注意:JDK 1.8 将扩容时的头插法改为了尾插法,解决了成环问题,但数据覆盖的问题依然存在,因此 HashMap 在并发环境下仍然不安全。
- 既然头插法会导致死循环,那为什么在之前的版本中还要用头插法呢?
在单线程环境下,头插法有两个潜在优点:
- 利用局部性原理:最近插入的数据更有可能被马上访问,头插法使其位于链表头部,能在 O(1) 时间内被找到。
- 插入速度快:头插法只需要修改新节点的
next 指向和桶的头指针,无需遍历链表找到尾部。
早期 HashMap 的设计目标主要是单线程高性能,并没有将并发安全作为首要考虑。因此,在当时的认知和技术背景下,头插法是一个合理的选择。线程安全问题暴露后,在 JDK 1.8 中才被调整为尾插法。
- 如果想让 HashMap 变成线程安全的,你觉得可以怎么做?
主要有两种思路:
- 外部同步:使用
Collections.synchronizedMap(new HashMap<>()) 对 HashMap 进行包装。它通过在所有公开方法上加 synchronized 锁来保证线程安全,但锁粒度是整个 Map,并发性能较差。
- 使用并发容器:这是更推荐的方案。
- Hashtable:一个古老的线程安全类,同样是全局锁,性能不佳,已不推荐使用。
- ConcurrentHashMap:这是最主流的解决方案。在 JDK 1.7 中采用分段锁降低锁粒度;在 JDK 1.8 及以后,则大量使用 CAS 操作和针对单个桶(Node)的
synchronized 锁,在保证线程安全的同时提供了更高的并发性能。
熟练掌握 HashMap 的原理是 Java 开发的必备基础,也是技术面试中的常客。希望这 11 个问题的剖析能帮助你更好地理解这个核心容器。如果想与更多开发者交流技术,欢迎访问云栈社区。