LinkedHashMap 在 Java 集合框架里是个挺特殊的存在:它明明是 HashMap 的子类,却在哈希表之上多维护了一条双向链表。很多人用过它,但对它的理解停留在“输出顺序和插入顺序一致”这句话上,直到某天被问到“那访问顺序怎么解释”,或者“用 LinkedHashMap 怎么实现 LRU”,才突然发现自己对它的了解其实很浅。
这篇文章不打算按 API 文档的套路来写,而是从一个开发者的角度拆开它:底层链表到底做了什么,两个顺序模式里藏着哪些行为差异,为什么一个 LRU 缓存用它可以写得很短,以及我在实际项目里因为忽略这些细节踩过的坑。如果你之前只把它当成“有序版 HashMap”,那看完这篇,应该会对它有一层更完整的认识。
1. 为什么说 LinkedHashMap 是“更好看的 HashMap”
1.1 哈希表加双向链表,解决的是顺序稳定性问题
HashMap 的性能毋庸置疑,但它有一个长期让人头疼的特点:遍历顺序不稳定。同一个 HashMap,在扩容前后、在哈希值重新分布之后,输出的键值对位置可能完全不一样。很多场景其实并不需要排序,只需要一个“顺序稳定”的表——比如配置项的加载顺序、接口返回字段的固定展示顺序。HashMap 给不了这个保证。
LinkedHashMap 的做法很直接:继承 HashMap,然后在每个键值对节点上额外维护前驱和后继两个指针,让所有节点串成一条贯穿始终的双向链表。哈希表仍然负责快速定位数据,而这条链表只负责记录全局顺序。这两者在结构上分层,逻辑上互不干扰:插入一个 key 时,HashMap 的 put 逻辑正常执行,同时新节点会被挂到链表尾部;扩容时哈希桶里的节点会重新分布,但链表的前后关系不会因为桶位置的变化而打乱。
这里有个很多人没意识到的点:LinkedHashMap 底层用的哈希表,和 HashMap 完全一致,包括哈希冲突时链表转红黑树的优化。也就是说,它继承了 HashMap 的全部查找和存储能力,只是在“每个节点的生命周期回调”里多了些动作。这也是它能把顺序维护得如此透明的原因。
1.2 插入顺序模式,不只是“按顺序遍历”
默认情况下,new LinkedHashMap<>()走的是插入顺序模式。这里有几个容易被忽略的细节:
- 只有真正新增的 key 才会放到链表尾部。
- 如果某个 key 已经存在,再次 put 只是更新 value,它在链表中的位置丝毫不动。
- 遍历时如果用迭代器做纯读操作,顺序是绝对稳定的。
这些细节在实际使用中很有用。比如我维护过一套后台配置模块,配置项按加载顺序逐一注册,后续某个配置被修改时,我不希望因为这次修改打乱整个配置在页面上的展示顺序。用插入顺序模式的 LinkedHashMap,更新 value 不会引起顺序跳动,维护成本很低。
插入顺序模式还有一个隐藏优点:它天然具有“稳定视图”的能力。你在单线程循环里反复遍历,每一次拿到的顺序都是可预期的。这种可预期性在生成固定格式报文、导出发送顺序记录、渲染步骤条等场景里,是很值钱的。
2. accessOrder 这个构造参数,才是它真正的灵魂
2.1 访问顺序模式到底改了什么
LinkedHashMap 有一个构造函数:
public LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder)前两个参数和 HashMap 一样,代表初始桶容量和负载因子;第三个参数 accessOrder,默认 false,表示按键值插入顺序维护链表。一旦把它设成 true,行为会发生质的变化:任何一次 get 命中,或者 put 命中已有 key,都会把该节点从链表里摘下来,重新接到链表尾部。
用一句话概括:头部永远是“最久没被访问”的节点,尾部永远是“最近刚被用”的节点。于是,这张 map 在“有序表”之外,又多了一层“按最近使用排序”的含义。
我一开始用这个参数时,心里有个误区:以为访问顺序模式只是“遍历顺序按照最后访问时间刷新”。其实它不是“刷新”这么浅,而是每一次访问都在做一次链表摘除和尾部插入。这个动作是 O(1) 的,因为每个节点都能直接知道前后节点是谁。但 O(1) 不代表零成本,高频访问模式下,它每次都会产生几步指针赋值。
2.2 从“有序表”到“LRU 表”,只差一个开关
LRU(Least Recently Used)是缓存淘汰的经典策略:当容器满员时,优先淘汰最长时间没被使用的数据。手工实现 LRU 通常要写一个 HashMap 加双向链表,维护起来很繁琐。而 LinkedHashMap 在 accessOrder=true 时,已经把这张“最近使用链表”给你建好了,开发者要做的事只剩两件:打开开关,再定义“满了之后怎么办”。
第二件事其实也已经被设计好了,就是 removeEldestEntry 这个钩子。两者配合,LRU 缓存的实现可以非常短,这部分放到下一节集中讲。
这里想先强调一句:accessOrder 不能随便开。它会让每次 get 都产生类似写操作的开销,对高频只读场景不太友好。设计上,这个参数通常用于构建缓存或需要按热点排序的模块,不太适合那种“Map 很大、get 很频繁、但根本不在乎顺序”的业务代码。
3. 手写一个 LRU 缓存:核心代码不超过三行
3.1 removeEldestEntry 到底怎么触发
removeEldestEntry 是 LinkedHashMap 的 protected 方法,源码默认返回 false。它本身不负责删除任何东西,只负责回答一个问题:“在刚才那次插入之后,你希不希望把链表头部最老的那个节点淘汰掉?”
回答完之后,删除动作由 LinkedHashMap 内部逻辑执行。这个方法在每次插入新键值对之后都会被调用。如果你用继承或匿名内部类的形式重写,逻辑通常只有一行:size() > 最大容量。超过容量就允许淘汰,否则保持现有数据。
有个看似不起眼的细节值得一说:在 accessOrder=true 时,put 一个已有 key 也会触发 removeEldestEntry 判断。此时节点已经被移动到尾部,如果 map 超容量,淘汰的是另一个真正“最久没被访问”的节点,而不是你刚刚更新的这个 key。这个行为其实就是 LRU 想要的效果:更新一个 key 代表它刚被使用过,不该立刻被淘汰。
3.2 一个可直接运行的缓存类
基于上面机制,LRU 缓存的代码很短:
import java.util.LinkedHashMap; import java.util.Map; public class LRUCache<K, V> extends LinkedHashMap<K, V> { private final int maxCapacity; public LRUCache(int maxCapacity) { super(maxCapacity, 0.75f, true); this.maxCapacity = maxCapacity; } @Override protected boolean removeEldestEntry(Map.Entry<K, V> eldest) { return size() > maxCapacity; } }用的时候和普通 Map 没什么两样:
LRUCache<String, String> cache = new LRUCache<>(3); cache.put("k1", "v1"); cache.put("k2", "v2"); cache.put("k3", "v3"); System.out.println(cache.keySet()); // [k1, k2, k3] cache.get("k1"); // 命中 k1,k1 被移到尾部 cache.put("k4", "v4"); // 容量超限,k2 被淘汰 System.out.println(cache.keySet()); // [k3, k1, k4]这里有个很容易被新人误解的地方:构造函数的第一个参数 initialCapacity,是哈希桶数组的初始大小,不是缓存容量上限。maxCapacity 由我们自己通过 removeEldestEntry 来控制。如果你把super(maxCapacity, 0.75f, true)当成“容量最多存 maxCapacity 个”,那就错了,它只是设置了桶的初始大小,真正的限制器是那一行size() > maxCapacity。哈希表会在元素达到阈值时自动扩容,桶初始大小不会限制键值对数量。
3.3 线程安全和边界情况,别等线上出问题再补
LinkedHashMap 和 HashMap 一样,不是线程安全的。LRU 缓存在并发访问时,链表指针可能被多个线程同时修改,轻则顺序错乱,重则丢数据甚至链表成环。最直接的做法,是继承这个类,重写公共方法加锁,或者对所有 get/put/remove 操作包一层 synchronized。如果图省事用Collections.synchronizedMap包装,只能保证单个方法原子性;像“先判断、再插入”这类复合操作,依旧需要额外加锁,否则判断和插入之间被其他线程插进来,淘汰结果就会失真。
边界情况也要考虑清楚:
- 容量为 1 时,每 put 一个 key,前一个就会被淘汰,缓存里始终只有最后写入的键。
- 容量为 0 且 removeEldestEntry 写的是
size() > 0,任何插入都会被立刻淘汰,这个缓存永远为空。 - 如果把 removeEldestEntry 改成
return size() > maxCapacity * 2,你会得到一个容量超标两倍才开始淘汰的宽松缓存,这在批量写入场景里反而能减少频繁淘汰带来的抖动。
这些边界情况的决策完全取决于业务语义,LinkedHashMap 把决策权开放给子类,这正是 removeEldestEntry 设计的精髓。
4. 源码里的三次回调钩子,藏着它的完整生命周期
4.1 afterNodeInsertion / afterNodeAccess / afterNodeRemoval
在 HashMap 源码里,埋了三个空实现的回调:
void afterNodeAccess(Node<K,V> p) { } void afterNodeInsertion(boolean evict) { } void afterNodeRemoval(Node<K,V> p) { }这三个方法本身不做事,LinkedHashMap 把它们重写后,分别在三个时机派上用场:
- afterNodeInsertion:新节点插入完成后被调用。它会检查 removeEldestEntry,返回 true 时把头节点(最老的节点)删除。
- afterNodeAccess:节点被 get 命中,或 put 命中已有 key 时被调用。它把该节点从链表中摘下,接到尾部。
- afterNodeRemoval:节点被正常删除(remove、clear、淘汰)后调用,它负责把节点从双向链表中正确摘除,再让前后节点连到一起。
看明白这套机制后,你会对设计模式有更直观的感受:LinkedHashMap 没有重写 HashMap 的 put 或 get 算法,它只是把差异用“钩子”挂在节点事件上。无论查字典、扩容、转红黑树这些底层逻辑怎么演进,LinkedHashMap 只需要在正确时机做链表的拼接和摘除。这种“复用核心算法 + 事件回调自定义”的思路,比直接复制一份 HashMap 源码来改要优雅得多。
4.2 为什么链表节点必须是双向的
如果只给 LinkedHashMap 配单向链表,很多操作会很难受。以移除一个节点为例:你需要知道它的前一个节点是谁才能完成链表拼接,但单向链表只有 next 指针,想知道前驱只能从头遍历,O(n) 的成本对于缓存场景完全不能接受。双向链表让每个节点同时持有 before 和 after,删除当前节点时,两行指针操作就能完成拼接,复杂度 O(1)。
同样的道理,在访问顺序模式下把节点“移到末尾”这个动作,本质是摘除 + 尾插两步,两步都是 O(1),所以 LRU 缓存才能长期维持高性能。反过来,如果用单向链表,每次访问都要重新找前驱,热点越高的数据反而越拖累性能。LinkedHashMap 的“链”选成双向,是有真实性能考量的。
4.3 很容易记错的源码细节
说几个我最初也理解错的点。
第一个:在访问顺序模式下,get 不只是查询,它还会改变内部链表结构。这带来一个连锁后果:如果你用 for-each 遍历的同时调用 map.get(key),迭代器的 modCount 检测会认为发生了结构性修改,直接抛 ConcurrentModificationException。JDK 里 afterNodeAccess 在节点确实移动时会做 modCount++,这不是偶发问题,只要访问顺序模式下边遍历边 get,就会触发。想要在访问顺序模式下做统计,建议先把 key 复制到数组里,再统一处理。
第二个:在插入顺序模式下,删除之后再重新 put,会让这个 key 重新出现在链表尾部。换句话说,delete + put 相当于把元素“挪到末尾”,而单纯 put 更新已有 key 不会。这个细节在写流程引擎时很有用,调整节点顺序可以通过删除再写入完成。
第三个:构造参数 initialCapacity 和 loadFactor 依然生效。桶太小会频繁扩容,扩容虽然不影响链表顺序,但会带来内存搬迁和新数组复制,不必要的开销还是能省则省。
5. 性能与内存账本,以及什么时候不该用它
5.1 多出来的指针和遍历效率到底怎么样
引入双向链表不是免费的。和 HashMap 相比,LinkedHashMap 每个节点多出 before、after 两个引用,如果数据量大,额外内存可能达到几十上百兆(具体看引用大小和对象头)。对内存极度敏感、又不关心顺序的场景,用 HashMap 更划算。
但迭代速度上,LinkedHashMap 反而可能占优。HashMap 遍历时要沿着底层哈希桶数组一个个槽位扫过去,哪怕某个槽是空的,也要跳过;哈希表越稀疏,空槽越多,遍历效率越低。LinkedHashMap 遍历时直接从头到尾走双向链表,沿途每个节点都是真实存在的键值对,不需要访问空桶。数据量大且哈希表装载率低时,你可能会明显感觉到 LinkedHashMap 的遍历更快。
至于 get/put 的复杂度,LinkedHashMap 和 HashMap 一样是 O(1),只是在访问顺序模式下,get 命中会额外产生一次链表结构调整,代价是常数级别的操作,但毕竟多了几步指针赋值。高并发、高频读、不需要顺序时,这个额外开销会累积,要谨慎开 accessOrder。
5.2 选型:什么时候不要硬上 LinkedHashMap
我见过一些团队把集合类当“全家桶”,一律用 LinkedHashMap,理由很含糊:万一哪天要顺序输出呢?这种思路在数据量小的时候无所谓,数据量上去之后,内存会被多出来的引用占掉一大块。
按照我自己平时写代码的标准,选型建议可以整理成几条:
- 只在键值对数量不大、顺序输出是刚需要求时,使用插入顺序模式。
- 需要 LRU 缓存语义、想淘汰最久未使用的数据时,使用访问顺序模式 + removeEldestEntry。
- 不需要顺序但需要并发安全时,优先 ConcurrentHashMap,别指望给 LinkedHashMap 包一层同步就解决所有问题。
- 需要线程安全的顺序表,又希望并发读时,要仔细设计锁粒度;如果并发不高,直接在 LinkedHashMap 外面加锁更简单。
顺序是一个“锦上添花”的能力,可一旦用了它,就得承担内存和复杂度成本。核心思路很简单:业务真的需要稳定顺序或 LRU 吗?需要,LinkedHashMap 是很好用的答案;不需要,就别为可能性买单。
6. 实际项目里踩过的几个坑,以及我最终的实践习惯
6.1 更新已有 key,插入顺序居然没变
以前在做一个展示页面时,我默认用 LinkedHashMap 保存菜单项。后台改某个菜单配置后重新 put 同一个 key,我以为它会顺理成章地被挪到序列末尾,结果页面上的菜单顺序始终没变。排查了很久才明白:插入顺序模式下,更新 value 不会让 key 在链表中移动一格。想让它移动,只能 delete 后重新 put,或者干脆开 accessOrder。那次之后,我对“插入顺序”这四个字的理解才真正精准起来。
6.2 访问顺序模式下,边遍历边 get 会炸
还有一次做缓存命中率统计,为了给每个 key 累计访问次数,我在循环里直接调用了 map.get(key),心想这也不算修改数据啊,结果线上日志开始刷 ConcurrentModificationException。查源码才看到 afterNodeAccess 里会有 modCount++,访问顺序模式下 get 根本不是“只读操作”。从那以后,凡是 accessOrder=true 的 map,我绝不会在遍历时碰 get,要么先用 keySet 复制一份,要么把统计逻辑抽到循环外面。
6.3 我最终的实践习惯
总结一下我自己用 LinkedHashMap 的习惯,也许对你有参考价值。
开 accessOrder 之前,先问一句:真的需要 LRU 吗?如果只是需要“最近使用的东西靠前展示”,那插入顺序模式配合手动调整,可能比开启访问顺序更省心,也更少遇到遍历时的并发修改问题。
容量参数不要照抄。new LinkedHashMap<>(16, 0.75f, true)里的 16 是桶大小,不是数据量上限。做 LRU 时,我用多少容量是根据业务数据量和可接受的内存开销算出来的,不会随手填一个数字。
并发要求高时,别硬用 LinkedHashMap。哪怕只是做一个 LRU 缓存,同步锁一旦争抢激烈,性能会明显下降,这种场景更适合专门为并发设计的缓存方案。
最后一点,写 removeEldestEntry 时,我习惯在刚开始调试时打印一条淘汰日志,看看被淘汰的节点是否真的是业务预期里的冷数据。这招在调 LRU 参数时特别管用,能直观看到淘汰策略跟业务直觉是否一致。多试几次之后,你也会对 LinkedHashMap 的内部行为有一个更踏实的认知。