写Java有年头的人,几乎都逃不开一个日常动作:new HashMap<>()。Map 作为整个 Java 集合体系里用途最广的容器之一,既承担业务里的参数传递、数据分组、缓存存储,又是面试从初级到高级都会被连环追问的常客。最近我在帮新人梳理知识结构,发现大多数人停留在“会用 put 和 get”的层面,对底层结构、不同实现怎么选、并发场景有哪些坑,理解得都比较零散。这篇就把 Map 家族做一次系统整理,覆盖底层原理、核心参数、常用实现、并发方案、实战套路和典型面试题,适合想把这块彻底吃透的开发者,无论你是在校准备面试,还是日常做技术沉淀,都能直接参考。
1. Map 家族全景:选对实现比会写代码更重要
1.1 为什么 Map 是 Java 集合里最活跃的选手
Java 集合体系里,List 和 Set 经常被拿来当“入门第一课”,但真正落到业务开发,Map 的使用频率往往是最高的。一个典型的后端接口里,查询条件、请求头、响应结构、缓存数据,几乎处处都有 Map 的影子。原因很简单:Map 解决的是“通过某个 key 快速找到对应 value”的问题,这种键值映射模型天然贴近现实业务,比如把用户 ID 映射到用户对象、把订单号映射到订单状态、把配置名映射到配置值。
而且 Map 在底层数据结构上也相当有代表性。从经典的“数组加链表”,到 JDK 8 引入红黑树做冲突优化,再到并发场景下的分段锁、CAS 加 synchronized,这些知识点不仅面试常考,也是理解其他分布式组件、缓存中间件的基础。我个人带新人的时候,通常会把 Map 当作“集合知识的枢纽”:搞懂了它,再去理解 HashSet(底层就是 HashMap)、理解线程安全的集合包装、理解各种缓存框架的淘汰策略,都会顺畅很多。
还有一个特点是 Map 系列的实现类非常多,不同实现的差异直接决定使用场景。很多开发者的通病是一律HashMap,偶尔要排序就用TreeMap,并发就无脑上ConcurrentHashMap,但对其中的设计取舍说不清楚。实际上每个实现类的出现都有明确的历史背景和适用场景,理清这一层,面试时讲故事才有深度。
1.2 主流实现类速览:一张表看明白差异
为了先建立整体认知,我把开发中最常接触的 Map 实现列成一个对照表。这张表我在内部培训时也经常用,帮大家快速定位“这个场景该用谁”。
| 实现类 | 底层结构 | 是否有序 | 线程安全 | 典型适用场景 |
|---|---|---|---|---|
| HashMap | 数组+链表+红黑树 | 无序 | 否 | 绝大多数业务映射、缓存载体 |
| LinkedHashMap | 哈希表+双向链表 | 按插入序或访问序 | 否 | 需要保序的映射、LRU 缓存的底层结构 |
| TreeMap | 红黑树 | 按键自然序或自定义序 | 否 | 范围查询、按 key 排序、需要 floor/ceiling 等导航方法 |
| Hashtable | 数组+链表 | 无序 | 是(方法级synchronized) | 基本已被废弃,仅在遗留项目中出现 |
| ConcurrentHashMap | 数组+链表+红黑树 | 无序 | 是(CAS+synchronized) | 高并发读写场景,多线程环境的首选 |
| EnumMap | 数组 | 按枚举定义顺序 | 否 | key 是枚举类型时,性能远高于 HashMap |
| IdentityHashMap | 数组+链表 | 无序 | 否 | 需要按引用相等性(==)比较 key 时 |
这里要特别提醒一句:上面说的“有序”需要区分两种含义。LinkedHashMap 的顺序是“插入顺序”或“访问顺序”,TreeMap 的顺序是“键的排序顺序”,两者完全不同。有些面试题喜欢在这个细节上挖坑,比如问“LinkedHashMap 和 TreeMap 都能保证顺序,区别是什么”,本质上考的就是你对这两类“有序”的理解。
2. HashMap 原理拆解:高频面试与底层实现的交汇点
2.1 从结构演变看设计思路:数组、链表与红黑树
HashMap 在 JDK 8 之前的结构是“数组加链表”,JDK 8 之后变成了“数组加链表加红黑树”。理解这个演变,要回到哈希冲突这个核心问题。
假设我们有一个长度 16 的数组,元素放进去时通过哈希函数计算一个下标,理想状态下每个元素都落在不同下标,存取都是 O(1)。但哈希函数不可能保证绝对均匀,两个不同 key 算出同一个下标,就叫冲突。解决冲突最经典的办法是“链地址法”:数组每个下标上挂一个链表,冲突的元素依次追加到链表里。冲突少时链表很短,查找效率接近 O(1);但一旦哈希函数设计不合理,或者大量 key 撞到同一个桶,链表越来越长,查找就退化成了 O(n),性能会急剧恶化。
JDK 8 引入红黑树的时机就很有意思。规范里写的是:当链表长度超过阈值 8,并且数组容量达到 64 时,链表会转换成红黑树。为什么是 8 而不是其他数字?这是基于泊松分布做的统计估算:在负载因子 0.75、哈希函数随机性良好的前提下,链表长度达到 8 的概率大约是千万分之六,属于极小概率事件。换句话说,正常情况下你不该遇到树化,一旦链表真的长到 8,说明要么数据分布极度不均匀,要么哈希函数出了严重问题,这时用红黑树把 O(n) 的查找降为 O(logn),算是一种兜底保护。
红黑树本身也不是没有代价:每个节点要额外存储颜色标记,插入和删除的旋转操作比链表复杂,所以树化只在链表足够长时才有净收益。这也是为什么当树的节点数降到 6 时会退化为链表,而不是在 8 就立刻退化——留一点“缓冲带”,避免元素在 7、8 之间反复横跳造成无谓的结构切换。
2.2 两个关键参数:初始容量与负载因子
HashMap 有两个参数让无数面试者栽过跟头:初始容量(initialCapacity)和负载因子(loadFactor)。
初始容量默认是 16,负载因子默认是 0.75。负载因子的含义是“扩容阈值 = 容量 × 负载因子”。比如默认情况下,当 HashMap 里的元素个数超过 16 × 0.75 = 12 时,就会触发扩容,容量翻倍到 32,然后重新计算所有已有元素的新下标,也就是我们常说的 rehash。
为什么负载因子取 0.75 而不是 1 或者 0.5?这里有一个空间与时间的平衡。负载因子太高,比如 1.0,意味着数组要装到完全填满才扩容,空间利用率高了,但哈希冲突的概率也高了,链表变长,查询效率下降。负载因子太低,比如 0.5,冲突少、查询快,但数组一半的空间是空着的,内存浪费严重。0.75 是工程实践中测试出来的一个比较均衡的值,既不会让冲突过于频繁,也不会浪费太多空间。
实际开发中一个典型的误区是:预估有 1000 个元素要放进去,就直接new HashMap<>(1000)。这是错的。因为 HashMap 会在元素数量达到 capacity × 0.75 时扩容,容量 1000 时,实际能稳妥容纳约 750 个元素,放 1000 个必然触发扩容。正确做法是new HashMap<>(1000 / 0.75 + 1),或者干脆用 Guava 的Maps.newHashMapWithExpectedSize(1000)。我一般图省事直接按“预估数量 ÷ 0.75 + 1”来算,既避免了扩容造成的性能损耗,又不会因为容量设得过大而浪费内存。
另外注意,初始容量必须是 2 的幂次方。如果你传一个不是 2 的幂的数,比如 17,HashMap 会通过tableSizeFor方法把它向上修正为 32。这个“必须是 2 的幂”的设计并非强迫症,而是为了让取模运算可以用位运算替代:index = hash & (capacity - 1),前提就是 capacity 是 2 的幂,这样 capacity - 1 的二进制低位全是 1,& 运算才能正确得到哈希值的低位作为下标。
2.3 put 和 get 的完整链路:hash 计算、寻址与冲突处理
很多人背了“数组加链表加红黑树”,但问他一个 key 从 put 进去到 get 出来,中间到底发生了哪些步骤,就含糊了。我把这条链路完整讲一遍。
先看 hash 的计算。key 的hashCode()返回的是一个 int,HashMap 并不直接拿这个 int 去计算下标,而是先做一次扰动:h = key.hashCode() ^ (h >>> 16)。也就是把高 16 位和低 16 位做异或。为什么这么做?因为数组长度默认是 16,下标计算用的是hash & (capacity - 1),也就是只取 hash 的低 4 位。如果两个 key 的 hashCode 在高位不同、低位相同,不扰动的话它们会落进同一个桶,冲突就变多了。把高位的特征“混”到低位去,可以让分布更均匀。这个细节面试常问,答案就在“扰动函数”这四个字里。
put 的流程可以概括为:先算 hash,再定位到数组下标;如果这个位置是空的,直接放入节点;如果非空,就遍历链表(或树),用equals()比较 key。如果找到相同的 key,就把旧 value 替换掉并返回旧值;如果没找到,就在链表末尾(JDK 8 是尾插法)追加新节点,然后判断是否需要树化、是否需要扩容。
这里有一个非常值得提的细节:HashMap 判断 key 是否相同时,先比较 hash,再用equals()。也就是说,自定义对象作为 key 时,hashCode()和equals()必须遵循同一个逻辑。比如你只重写了equals()但没重写hashCode(),两个逻辑上相同的对象可能 hashCode 不同,导致它们落到不同桶里,你用第二个对象去 get 第一个对象对应 value 时,根本匹配不上。反过来只重写hashCode()不重写equals(),也会出现哈希值相同但 equals 返回 false 的情况。这是业务中非常容易踩的坑,后面我会再展开。
get 的流程与 put 对称:计算 hash,定位下标,比较第一个节点;如果第一个不是目标,继续查链表或树,比对 hash 和 equals,找到就返回 value,找不到返回 null。要注意的是,get 返回 null 有两种可能:一是 key 不存在,二是 key 存在但 value 本身为 null。所以判断“Map 里有没有这个 key”不能用map.get(key) == null来一刀切,而应该用containsKey()。这个细节在业务上也引发过不少小 bug。
3. 特定场景下的其他常用 Map 实现类
3.1 LinkedHashMap:当有序性成为硬需求
HashMap 不保证顺序,遍历顺序和 put 顺序没有任何关系。但有些业务场景确实需要“按照插入顺序读取”,比如接口文档要求的参数顺序、批量操作时保持前端传入的顺序。这时 LinkedHashMap 就派上用场了。
LinkedHashMap 的底层还是 HashMap,只不过额外维护了一条双向链表,把所有节点穿起来。默认是“插入顺序”模式,也就是先 put 的节点在链表前面,遍历时就按这条链表的顺序走。它还支持另一种模式:“访问顺序”,即每次执行 get 或 put 操作时,被访问的节点会被移动到链表末尾。这种模式用来实现 LRU(最近最少使用)缓存非常顺手。
网上流传很多手写 LRU 缓存的方案,最简单且靠谱的其实就是继承 LinkedHashMap,重写removeEldestEntry方法。核心代码如下:
public class LruCache<K, V> extends LinkedHashMap<K, V> { private final int maxSize; public LruCache(int maxSize) { // accessOrder 传 true,开启访问顺序模式 super(maxSize, 0.75f, true); this.maxSize = maxSize; } @Override protected boolean removeEldestEntry(Map.Entry<K, V> eldest) { // 链表头部的节点就是“最久没被访问”的节点 return size() > maxSize; } }removeEldestEntry在每次插入新节点后都会被调用,返回 true 表示移除链表头部的“最老”节点。只用十几行代码,一个线程不安全的简单 LRU 缓存就完成了。我曾在某个内部管理系统里用它缓存数据字典,效果不错,唯一提醒是它本身不是线程安全的,多线程环境要加锁或改用其他方案。
3.2 TreeMap:基于红黑树的天然排序容器
TreeMap 的底层是一棵红黑树,和 HashMap 完全不同。它的核心价值是“有序”和“范围操作”。插入 TreeMap 的 key 会自动排序,既可以按 key 的自然顺序(要求 key 实现 Comparable),也可以在构造时传入 Comparator 自定义排序规则。
TreeMap 比 HashMap 多出来的那一批导航方法,在实际业务中非常实用。比如firstKey()拿到最小的 key,lastKey()拿到最大的,floorKey(k)返回小于等于 k 的最大 key,ceilingKey(k)返回大于等于 k 的最小 key,subMap(from, to)直接截取一段子映射。举一个我实际处理过的例子:某业务需要按时间段聚合数据,时间戳是 key,在 TreeMap 里直接调用subMap(startTime, endTime)就能拿到区间内的所有数据,不用遍历全量再过滤,代码也干净很多。
选型时要注意,TreeMap 的插入、删除、查找都是 O(logn),比 HashMap 的 O(1) 慢,但如果业务本身就需要排序输出的结果,用 TreeMap 避免“存完再排序”反而更高效。还有一点,TreeMap 不允许 key 为 null,因为 null 无法参与比较;HashMap 则允许一个 null key(且 null key 固定放在下标 0 的桶)。这些细节在面试和代码 review 中都是常见的出题点。
3.3 EnumMap 与 IdentityHashMap:冷门但好用
EnumMap 是那种“没人提但用起来真香”的实现。它的 key 被限定为枚举类型,底层是一个和枚举常量一一对应的数组,直接按下标取值,因此性能比 HashMap 高,内存也更紧凑。比如状态机的状态流转、类型与处理器的映射,都很适合用 EnumMap。我在做某次策略重构时,把一个用 HashMap 存“设备类型 -> 处理器”的代码换成 EnumMap,不仅去掉了一大堆类型转换和强制判空,还因为枚举本身就自带了齐全的常量定义,整个逻辑清晰了一大截。
IdentityHashMap 则比较特殊,它判断 key 相等用的是引用相等(==),而不是equals()。这意味着即使两个对象的 equals 相同,只要不是同一个对象,就会被当成不同的 key。这个特性在极少数场景有用,比如做对象级缓存、跟踪某个实例的状态,但日常开发中很少用到。面试如果提到它,通常是为了考察“equals 与 == 的区别在 Map 中的体现”,能主动说出这个差异,说明你对 Map 的认知不局限于 API 层面。
4. 并发条件下的线程安全 Map
4.1 Hashtable 的锁粒度问题与历史包袱
Hashtable 是 JDK 1.0 就存在的早期集合类,线程安全是靠给每个公开方法加 synchronized 来实现的,也就是说任何线程执行 put、get、remove 时,整个 Hashtable 都会被锁住。在并发量低的老系统里这没什么问题,但一旦多个线程同时读,即使根本没有写操作,读操作之间也会互相阻塞,吞吐量直接被打折扣。
这其实是“锁粒度太粗”的典型问题。后来 Java 的集合框架引入了 Collections 工具类,提供Collections.synchronizedMap(new HashMap<>())这种方式,本质上也是包一层全局锁,问题没有根本改善。所以在现在的新代码里,Hashtable 和 synchronizedMap 都不推荐使用。面试时如果被问到它们,一句话总结就是:能用 ConcurrentHashMap 就别用它们,除非你的并发量低到可以忽略锁竞争。
4.2 ConcurrentHashMap 的演进:分段锁到 CAS 加 synchronized
ConcurrentHashMap 是并发场景下的正解,它的设计演进本身就值得仔细讲一讲。
JDK 7 时代的 ConcurrentHashMap 采用“分段锁”设计:内部把数据分成一段一段的 Segment,每个 Segment 持有一把锁,线程操作哪个段就锁哪个段,不同段的操作互不干扰。这样并发能力是随着段数量线性增加的,但问题是段一旦确定,扩容时还是需要把整个段锁住,结构上也略显笨重。
JDK 8 对 ConcurrentHashMap 做了一次大改,放弃了 Segment,直接使用数组加链表加红黑树的结构,搭配 CAS 和 synchronized 实现线程安全。这里的设计思路很巧妙:在插入元素时,如果目标桶为空,就用 CAS 尝试直接放入节点,不用加锁;如果目标桶非空,才用 synchronized 锁住这个桶的头节点,锁的粒度从“一段”缩小到了“一个桶”。同时,扩容、计数等机制也都走了更精细化的方案。结果就是:读操作几乎不加锁,写操作只在发生哈希冲突时才锁桶,并发性能比 Hashtable 高了一个量级。
使用 ConcurrentHashMap 时有一个很重要的点:不要再用老的写法new ConcurrentHashMap<>()然后手动判断containsKey再 put,而应该优先用computeIfAbsent、putIfAbsent、merge这类原子方法。比如多个线程要并发往 Map 里放一个按 key 初始化的对象,用computeIfAbsent(key, k -> new Value())就能保证只初始化一次,不会出现两个线程各自创建对象互相覆盖的问题。我见过不少线上数据不一致的 case,追根溯源就是“先判断再 put”这种非原子操作在并发下失效了。
4.3 并发缓存设计的一个实例
之前做某模拟项目时,我需要在服务端维护一份设备状态缓存,允许多个线程同时读,偶尔有线程更新状态。第一版我图省事用了 HashMap 加外层 synchronized,结果压测一上来,读并发就把锁竞争拉满,接口耗时直线上升。后来改成 ConcurrentHashMap,配合compute方法做状态的更新合并,性能立刻好看了很多。
一个简化版的示意代码:
public class DeviceStateCache { private final ConcurrentHashMap<String, DeviceState> cache = new ConcurrentHashMap<>(); public DeviceState get(String deviceId) { return cache.get(deviceId); } public void updateHeartbeat(String deviceId, long timestamp) { cache.compute(deviceId, (k, old) -> { DeviceState state = old; if (state == null) { state = new DeviceState(deviceId); } state.setLastHeartbeat(timestamp); return state; }); } }这里用compute而不是“get 后 set 再 put”,就是为了保证同一个 key 的更新操作具备原子性,多个线程不会读到中间状态。实际经验是,compute、computeIfAbsent、merge这三个方法,是 ConcurrentHashMap 在并发场景下最值钱的 API,建议重点掌握。
5. 业务开发中的 Map 实战套路与代码范式
5.1 分组、计数、去重:三个最常用范式
业务开发里,把一堆对象按某个维度分组是出现频率最高的操作之一。传统写法是好几个循环加判断,代码又长又容易错。Java 8 之后,借助 Stream 的Collectors.groupingBy,一行就能解决:
List<User> users = getUsers(); Map<String, List<User>> byCity = users.stream() .collect(Collectors.groupingBy(User::getCity));如果要分组后只保留某个字段,还可以配合Collectors.mapping。比如按城市分组并只取用户名列表:
Map<String, List<String>> namesByCity = users.stream() .collect(Collectors.groupingBy(User::getCity, Collectors.mapping(User::getName, Collectors.toList())));计数是另一个高频需求。统计一段文本里每个单词出现的次数,用Map.merge非常优雅:
Map<String, Integer> wordCount = new HashMap<>(); for (String word : words) { wordCount.merge(word, 1, Integer::sum); }merge的语义是:如果 key 不存在,就放入默认值 1;如果存在,就按第三个参数把旧值和增量合并,也就是旧值加 1。整个过程不用手动判断是否包含 key,代码干净且不容易出错。
去重则更简单,HashSet 本身就是基于 HashMap 实现的,直接把元素往 Set 里塞就行。但有一种去重要小心:对自定义对象去重,必须确保它的equals和hashCode是按业务逻辑重写过的,否则默认的引用相等会让“内容相同”的对象无法被正确去重,结果比预期多了很多。
5.2 多层嵌套 Map 的数据建模技巧与简化方案
在报表、配置中心这类业务里,经常出现“外层按日期分组,内层按渠道分组,再内层存指标值”的层级结构。大多数人第一时间会写Map<String, Map<String, Map<String, Long>>>,这种代码光看类型声明就让人头皮发麻,而且每一层取值要逐层判空,写着写着就会漏掉某个中间 map 的 null 判断,线上 NPE 就是这么来的。
我的建议是,嵌套超过两层就不太适合用裸 Map 了。第一种做法是定义专门的领域对象,比如StatisticsKey包含日期、渠道、指标名三个字段,然后外层 Map 的 key 直接用一个组合对象。这样取值时只要get一次,配合computeIfAbsent做缺失初始化,代码可读性高出一大截:
Map<StatisticsKey, Long> stats = new HashMap<>(); stats.computeIfAbsent(new StatisticsKey(date, channel, metric), k -> 0L);第二种做法是引入中间层的默认方法。比如Map<String, Map<String, List<String>>>这种结构,可以用computeIfAbsent在初始化内层 Map 时避免大量判空:
Map<String, Map<String, List<String>>> index = new HashMap<>(); index.computeIfAbsent(category, k -> new HashMap<>()) .computeIfAbsent(subCategory, k -> new ArrayList<>()) .add(value);这个写法相当于“没有就建,有就直接用”,比一层层if (map.get(key) == null) { ... }清爽得多。我自己早期写代码非常依赖嵌套 Map,踩过几次无数层判空的坑之后,现在基本默认“超过两层,要么封装对象,要么封装方法”,宁可多写几个类,也不想让后续接手的人面对一坨类型地狱。
5.3 不可变 Map 与序列化的注意事项
业务中偶尔需要把配置类数据做成不可变 Map,防止被意外修改。Java 9 之后可以直接用Map.of()创建不可变 Map,但要注意它最多支持 10 组键值对,超过就得用Map.ofEntries()。在 JDK 8 环境里,可以依赖 Guava 的ImmutableMap,或者用Collections.unmodifiableMap(map)包一层。注意Collections.unmodifiableMap只是包装,底层的 map 如果还在被其他引用修改,包装后的“不可变”也形同虚设。
序列化方面,不同 Map 实现的可序列化能力差异不大,但有几个常见坑。一是 key 或 value 如果包含不可序列化的对象,直接序列化就会报NotSerializableException,很多团队在做 Redis 缓存时最容易遇到。二是反序列化回来的 HashMap,底层结构和序列化时的结构可能不同,capacity和链表形态都会变,如果依赖了这些内部结构做判断,那是非常脆弱的写法。
还有一点我踩过:把 HashMap 放进 Session 或 HTTP 请求传参时,某些框架为了安全会要求 Map 的泛型明确,否则会有类型擦除隐患。虽然平时不一定会触发,但建议所有对外传输的 Map 都明确写出泛型类型,不要用什么“裸 Map”。
6. 面试连环炮与线上排查经验实录
6.1 一组有代表性的面试题与参考思路
整理面试题时,我习惯把 Map 相关的问题按“基础-进阶-底层-实战”四个层次分开。基础层比如 HashMap 和 Hashtable 的区别、HashMap 允许 null key 而 Hashtable 不允许;进阶层比如负载因子为什么是 0.75、初始容量为什么是 2 的幂;底层就是 put 流程、hash 扰动、树化阈值;实战层则喜欢问“如何用 Map 实现一个固定容量的缓存”或者“多线程下如何安全地统计 key 出现次数”。
有一个连环炮式的组合题很有代表性,面试官会从HashMap 的 hash 方法为什么这么设计开始,一路问到为什么链表转红黑树的阈值是 8,再到如果让你自己设计一个高性能并发 Map,你会怎么做。这类题目考察的不是你是不是背了答案,而是你理解不了解数据结构、哈希函数、锁竞争这几件事之间的联动关系。
应对这类问题,最关键的一点是“说原理,不要说结论”。比如回答负载因子时,不要只说“默认 0.75”,而是把空间与时间的权衡逻辑讲清楚;回答为什么 8 时,把泊松分布概率、链表与红黑树的成本对比讲出来。面试官听到这种深度的回答,通常会有明显的好感。
6.2 生产中踩过的 Map 相关坑
最后分享几个我在实际开发中遇到的真实事故,每一个都包含了非常具体的教训。
第一个坑是“可变对象做 key”。某团队在缓存用户查询条件时,直接拿一个自定义对象做 key,而这个对象里有个 List 字段,后续业务修改了这个 List 里的内容。结果再 get 的时候,hashCode变了,在 HashMap 里找不到了旧值,整个缓存逻辑失效。排查了半天才发现问题出在 key 被外部修改上。教训是:用作 key 的对象必须不可变,或者在设计上保证它的 hashCode 相关字段不可被外部修改。
第二个坑是“遍历时删除元素导致 ConcurrentModificationException”。在 HashMap 上执行for (Map.Entry e : map.entrySet())遍历时,如果在循环体内直接调用map.remove(key),大概率抛出异常。正确的做法是使用迭代器的remove()方法,或者直接改成map.entrySet().removeIf(entry -> 条件)。这个异常在单线程下也会出现,不是并发专属的。
第三个坑是“自定义 equals 但没重写 hashCode”。这个我之前提过,这里补充一个具体的线上案例。某系统用对象做 key,只重写了equals,没重写hashCode,导致两个内容完全相同的对象被放进 HashMap 的不同桶里。前端传进来一个新对象尝试 get 老对象的 value,永远返回 null。因为hashCode不一致,HashMap 压根不会把它们分配到同一个桶,equals就算返回 true 也没机会触发。这个问题隐蔽性很高,代码 review 时不容易发现。
第三个坑想展开的是“扩容导致的多线程死循环”。这是 JDK 7 之前 HashMap 在并发扩容时可能出现的问题,因为当时的头插法在扩容重排时可能形成环形链表,之后 get 操作就会陷入死循环。JDK 8 改成尾插法后这个问题基本消失,但并发下 HashMap 的数据覆盖、丢失问题依然存在。所以别以为 JDK 8 之后就万事大吉,多线程环境一律用 ConcurrentHashMap。
还有一个容易被忽视的坑是“大 Map 的初始容量设置不当导致频繁扩容”。我用过一次默认容量的 HashMap 承载几十万条数据,过程中发生了十几次扩容,每次扩容都要重新计算所有 key 的 hash 并搬移元素,接口耗时从 200ms 飙到 1.5s。后来改成预估容量后,一次扩容都不发生,性能立刻回到正常范围。对于大数据量的场景,容量预估不是优化项,而是必需项。
我个人现在的习惯是,写代码前先快速想一遍这个 Map 的使用场景:要不要保序、要不要并发、大概放多少数据、key 是什么类型。花十秒钟想清楚,能省掉后面好几个小时的排查时间。Map 这块的技术点看起来零散,但串起来之后会发现,从 HashMap 到 ConcurrentHashMap,从普通业务映射到 LRU 缓存,背后都是同一套数据结构、哈希、锁的思想在不同约束下的组合。把这些想通了,不管是写业务代码还是面对面试提问,都会从容很多。