面试现场
面试官:你好,谢飞机是吧?我们今天聊聊Java基础。先说说,HashMap 的底层数据结构是什么?
谢飞机(自信满满):哎呀,这个我熟!就是一个...一个大篮子!里面放了好多好多小篮子!对,就是数组加链表!哦不对,好像新版本还有红黑树!反正就是存键值对的嘛,贼快!
面试官(点点头):嗯,回答得还算到位,知道有数组、链表和红黑树。那你知道什么时候会从链表转成红黑树吗?
谢飞机(挠头,开始胡诌):呃...这个嘛...应该是...当链表里的东西太多,挤不下了,它自己就“进化”了!就像我打游戏,小兵攒够经验就变英雄一样!具体多少个...8个?9个?反正就是很多个的时候!
面试官(扶额):...行吧。那如果让你设计一个简单的缓存,你会用 HashMap 吗?为什么?
谢飞机(眼睛一亮):当然用啊!HashMap 这么快,不用它用谁?我上次写代码,直接 new HashMap() 就搞定了,跑得飞快!
面试官(叹了口气):...好的,谢飞机同学,你的思路很...清奇。今天的面试就到这里,你回去等通知吧。
谢飞机:好的好的,谢谢面试官!我回去就等您电话!
技术点解析:HashMap 底层原理详解
上面的故事虽然搞笑,但其中涉及的 HashMap 核心知识点非常重要。下面为各位小白同学详细拆解:
1. 底层数据结构
HashMap 在 JDK 1.8 及以后版本中,底层是由 数组 + 链表 + 红黑树 组成的。
- 数组 (Node<K,V>[] table): 这是主体,也叫哈希桶(bucket)。每个元素都是一个链表或红黑树的头节点。
- 链表: 当发生哈希冲突(即不同的 key 计算出相同的数组下标)时,新的元素会以链表的形式挂在该数组下标位置上。
- 红黑树: 为了优化在极端情况下(大量哈希冲突导致链表过长)的查询性能,当链表长度达到阈值(默认为8)并且数组长度大于等于64时,链表会转换为红黑树,将查询时间复杂度从 O(n) 降低到 O(log n)。
2. 核心工作流程
- Put 操作 (存储):
- 计算哈希值: 对 key 调用
hashCode()方法,然后通过一个扰动函数(hash function)进行二次哈希,目的是让高位也参与到寻址中,减少哈希冲突。 - 确定数组下标: 用计算出的哈希值与
(数组长度 - 1)进行按位与 (&) 操作,得到最终的数组下标。这也是为什么HashMap的数组长度总是2的幂次方,因为(length-1)的二进制全是1,能保证均匀分布。 - 处理冲突:
- 如果该下标位置为空,直接放入。
- 如果不为空,则遍历链表/红黑树,检查是否有相同的 key(通过
equals()方法)。如果有,则更新 value;如果没有,则将新节点插入到链表尾部(JDK 1.8 是尾插法)或红黑树中。
- 计算哈希值: 对 key 调用
- Get 操作 (查询):
- 同样先计算 key 的哈希值,找到对应的数组下标。
- 在该下标位置的链表或红黑树中,通过
equals()方法逐个比较 key,找到匹配的节点并返回其 value。
3. 关于链表转红黑树
- 阈值: 链表长度达到 8 时,会考虑转换。
- 前提条件: 数组的长度必须 大于等于 64。这是为了避免在数组很小、哈希冲突本就频繁的情况下,过早地进行树化操作,因为树化本身也有开销。如果数组长度小于64,
HashMap会选择先进行扩容(resize),而不是树化。
4. 为什么不直接用 HashMap 做缓存?
故事里谢飞机的回答是典型错误。HashMap 本身 不是线程安全的,在多线程并发环境下使用,可能会导致数据丢失、死循环等问题。此外,它没有提供任何缓存策略,比如 过期淘汰机制(TTL)、最大容量限制、淘汰算法(LRU, LFU等)。
因此,在实际项目中,做缓存通常会选择专业的缓存框架,如 Guava Cache、Caffeine 或者分布式缓存 Redis,它们都内置了完善的线程安全和缓存策略。