Java 面试:HashMap 底层原理怎么答才不乱?
摘要
HashMap 是 Java 面试中非常高频的集合类问题。很多人知道它是 key-value 结构,也知道 JDK 1.8 后引入了红黑树,但面试时容易答散。本文用面试视角,简单梳理 HashMap 的底层结构、put 流程、扩容机制、哈希冲突、线程安全问题和常见追问。
前言
前面两篇文章分别梳理了 Java 集合分类,以及 ArrayList 和 LinkedList 的区别。
这一篇继续讲集合里最常见、也最容易被问深的一个类:
HashMap
HashMap 平时开发用得非常多,比如:
- 根据用户编号查用户信息;
- 根据编码映射名称;
- 做临时缓存;
- 做数据分组;
- 做批量查询后的内存匹配。
但面试时如果只是回答:
HashMap 是 key-value 结构。
那肯定不够。
这篇就用比较短的方式,把面试常问点梳理一下。
一、面试官一般怎么问?
HashMap 常见问法有这些:
HashMap底层数据结构是什么?HashMap的 put 流程是什么?HashMap怎么解决哈希冲突?- 为什么 JDK 1.8 要引入红黑树?
HashMap默认容量是多少?HashMap什么时候扩容?HashMap是线程安全的吗?HashMap和Hashtable有什么区别?HashMap和ConcurrentHashMap有什么区别?
这篇先讲 HashMap 本身。
二、先给结论
HashMap 是一种基于哈希表实现的 key-value 集合。
JDK 1.8 之后,它的底层结构可以简单理解为:
数组 + 链表 + 红黑树
其中:
- 数组用来定位桶的位置;
- 链表用来解决哈希冲突;
- 红黑树用来优化链表过长时的查询效率。
一句话回答:
HashMap 底层是数组 + 链表 + 红黑树。put 元素时,会先根据 key 的 hash 值计算数组下标,如果当前位置没有元素就直接放入;如果发生哈希冲突,就通过 equals 判断 key 是否相同,相同则覆盖,不同则挂到链表或红黑树上。
三、HashMap 底层结构是什么?
HashMap 底层核心是一个数组。
数组中的每个位置,可以理解成一个桶。
每个桶里可能存放:
- 一个节点;
- 一条链表;
- 一棵红黑树。
简单理解:
HashMap
↓
数组 table
↓
每个数组位置是一个桶
↓
桶里可能是链表或红黑树
结构可以简单画成这样:
table[0] -> null
table[1] -> Node
table[2] -> Node -> Node -> Node
table[3] -> TreeNode
也就是说:
HashMap 不是单纯的数组,也不是单纯的链表,而是数组、链表、红黑树结合使用。
四、HashMap 为什么查询快?
因为它不是从头遍历所有元素。
它会先根据 key 的 hash 值计算出数组下标。
比如:
map.get("user001");
大致过程是:
计算 key 的 hash
↓
根据 hash 定位数组下标
↓
到对应桶里查找
如果桶里只有一个元素,直接判断 key 是否相等即可。
如果桶里是链表,就沿着链表查找。
如果桶里是红黑树,就按树结构查找。
所以 HashMap 的平均查询效率比较高。
五、put 方法大概流程
面试时,put 流程是高频点。
可以按下面这个顺序答。
1. 计算 key 的 hash 值
先根据 key 计算 hash。
hash = hash(key)
2. 根据 hash 计算数组下标
通过 hash 和数组长度计算桶位置。
index = hash & (length - 1)
3. 判断当前位置是否为空
如果这个桶是空的,直接放入新节点。
table[index] == null
直接新增。
4. 如果当前位置不为空,说明发生了哈希冲突
这时要继续判断:
- key 是否相同;
- 如果相同,覆盖旧值;
- 如果不同,挂到链表或红黑树上。
5. 插入后判断是否需要扩容
如果元素数量超过阈值,就触发扩容。
六、什么是哈希冲突?
不同的 key,计算出来的数组下标可能一样。
这就是哈希冲突。
比如:
key1 -> index = 3
key2 -> index = 3
两个 key 都要放到同一个桶里,就冲突了。
HashMap 解决冲突的方式是:
链表 + 红黑树
JDK 1.8 之前主要是数组 + 链表。
JDK 1.8 之后,如果链表过长,会转成红黑树。
七、什么时候链表会转红黑树?
JDK 1.8 中,链表转红黑树有两个重要条件:
链表长度大于等于 8
数组长度大于等于 64
也就是说,不是链表长度到了 8 就一定树化。
还要看数组容量是否达到 64。
如果数组还比较小,通常会优先扩容,而不是直接树化。
简单记:
链表长度 >= 8,并且数组长度 >= 64,才会转红黑树。
为什么要这样设计?
因为数组还小的时候,冲突可能是容量太小导致的。
这时扩容比树化更合适。
八、为什么要引入红黑树?
链表查询是从头往后找。
如果链表很长,查询效率会下降。
链表查询时间复杂度是:
O(n)
红黑树查询效率更稳定:
O(log n)
所以当某个桶里的链表太长时,转成红黑树可以提升查询效率。
一句话:
红黑树是为了解决哈希冲突严重时,链表过长导致查询性能下降的问题。
九、HashMap 默认容量和负载因子
HashMap 默认初始容量是:
16
默认负载因子是:
0.75
扩容阈值是:
容量 * 负载因子
所以默认情况下:
16 * 0.75 = 12
也就是说,当 HashMap 中的元素数量超过 12 时,就会触发扩容。
十、HashMap 怎么扩容?
HashMap 扩容时,容量一般会变成原来的 2 倍。
比如:
16 -> 32 -> 64 -> 128
扩容不是简单改一个数字。
它需要把原来的数据重新分布到新数组中。
所以扩容是有成本的。
如果一开始就知道大概会放多少数据,可以指定初始容量。
比如:
Map<String, String> map = new HashMap<>(128);
这样可以减少扩容次数。
十一、为什么容量要是 2 的幂?
HashMap 的数组长度一般是 2 的幂。
比如:
16、32、64、128
这样做主要是为了让下标计算更高效。
HashMap 计算下标时常见方式是:
index = hash & (length - 1)
当 length 是 2 的幂时,这种位运算可以比较均匀地分布数据,也比取模运算更高效。
面试时可以这样说:
HashMap 容量使用 2 的幂,是为了配合 hash & (length - 1) 计算数组下标,提高计算效率,同时让元素分布相对均匀。
十二、HashMap 是线程安全的吗?
不是。
HashMap 不是线程安全的。
如果多个线程同时修改同一个 HashMap,可能出现:
- 数据覆盖;
- 数据丢失;
- 读取到脏数据;
- 扩容时出现异常问题。
所以多线程共享修改场景,不建议直接使用 HashMap。
可以考虑:
ConcurrentHashMap- 加锁
- 使用局部变量,避免多线程共享
常见回答:
HashMap 不是线程安全的,适合单线程或局部变量场景。如果多个线程同时读写同一个 Map,应该考虑 ConcurrentHashMap 或额外加锁。
十三、HashMap 可以存 null 吗?
可以。
HashMap 允许:
- 一个 null key;
- 多个 null value。
示例:
Map<String, String> map = new HashMap<>();
map.put(null, "default");
map.put("name", null);
但需要注意:
key 只能有一个 null。
因为 key 不能重复。
十四、HashMap 和 Hashtable 有什么区别?
这个也是经典面试题。
简单对比:
|
对比项 |
HashMap |
Hashtable |
|---|---|---|
|
线程安全 |
不安全 |
安全 |
|
性能 |
较好 |
较差 |
|
null key/value |
允许 |
不允许 |
|
出现时间 |
较新 |
较老 |
|
常用程度 |
常用 |
很少用 |
一句话回答:
HashMap 不是线程安全的,允许 null key 和 null value,性能更好,实际开发中更常用。Hashtable 是早期集合类,方法使用 synchronized 保证线程安全,但性能较差,而且不允许 null key 和 null value,现在用得比较少。
十五、HashMap 和 ConcurrentHashMap 有什么区别?
简单说:
HashMap 不是线程安全的。
ConcurrentHashMap 是线程安全的,适合并发场景。
在多线程环境下,如果多个线程会同时修改 Map,一般使用 ConcurrentHashMap。
示例:
Map<String, String> map = new ConcurrentHashMap<>();
面试时可以这样答:
HashMap 适合单线程或局部变量场景;ConcurrentHashMap 适合多线程并发读写场景。它通过更细粒度的并发控制来保证线程安全,同时尽量减少锁竞争。
十六、项目里常见怎么用 HashMap?
实际开发中,HashMap 最常见的用法不是炫底层,而是提升查询效率。
比如先批量查数据,再转成 Map,避免循环查库。
示例:
List<User> users = userService.queryByIds(userIds);
Map<Long, User> userMap = users.stream()
.collect(Collectors.toMap(User::getId, item -> item));
后面使用时:
User user = userMap.get(userId);
这种写法很常见。
尤其是在批处理、数据组装、报表统计里,经常会用 Map 做内存匹配。
但要注意,如果 key 可能重复,要处理合并逻辑。
比如:
Map<Long, User> userMap = users.stream()
.collect(Collectors.toMap(
User::getId,
item -> item,
(oldValue, newValue) -> oldValue
));
否则 key 重复时会报错。
十七、面试回答模板
如果面试官问:
HashMap 底层原理了解吗?
可以这样回答:
HashMap 是基于哈希表实现的 key-value 集合。
JDK 1.8 之后,底层主要是数组 + 链表 + 红黑树。put 元素时,会先根据 key 计算 hash 值,再通过 hash 和数组长度计算出桶下标。
如果当前位置没有元素,就直接放入;如果当前位置已经有元素,说明发生了哈希冲突,就会比较 key 是否相同,相同则覆盖 value,不同则挂到链表或红黑树上。
当链表长度达到一定条件,并且数组容量足够大时,链表会转成红黑树,主要是为了避免链表过长导致查询效率下降。
HashMap 默认容量是 16,负载因子是 0.75,超过阈值后会扩容,扩容一般是原来的 2 倍。
另外,HashMap 不是线程安全的,多线程并发修改时应该使用 ConcurrentHashMap 或额外加锁。
这段基本就够用了。
十八、一句话总结
HashMap 底层是:
数组 + 链表 + 红黑树
它通过 hash 定位数组下标,通过链表或红黑树解决哈希冲突。
面试时不要只说:
HashMap 是 key-value 结构
更好的回答是:
HashMap 的核心是哈希定位、冲突处理、扩容机制和线程安全问题。
把这几个点讲清楚,基本就不会乱。
大家加油 :)
更多推荐
所有评论(0)