LinkedHashMap 的源码解析网上已经有一堆了。但说实话大多数文章来回就那几句HashMap 加双向链表、覆写了三个 afterNode 方法然后贴一大段源码。真正拿着源码一行一行读过并且知道这些设计为什么存在的人不多。这篇文章我想换一种讲法——不按方法顺序抄源码而是沿着“数据是怎么在链表中流动的”这条主线把 LinkedHashMap 的整个运行机制讲透。读完之后你再回头看 HashMap 的那些模板方法会理解得更深一层。适用对象不用分太细只要是写过 Java、用过 HashMap又想知道“为什么 LinkedHashMap 能保持顺序”的人都可以读。下面的源码基于 JDK 8 的经典实现来展开我会顺带提一下新版本里出现的变化。1. 继承关系与新增字段LinkedHashMap 在 HashMap 上动了哪些手脚1.1 继承树与 Entry 结构before/after 到底加在哪先看类声明这是最直观的起点public class LinkedHashMapK,V extends HashMapK,V implements MapK,VLinkedHashMap 继承自 HashMap没有自己重新实现一套散列表。它复用了 HashMap 的数组、链表、红黑树、扩容、哈希计算这些核心机制只在自己关心的“顺序”上做文章。那顺序信息存在哪答案是每个节点上。HashMap 的节点是HashMap.Node结构如下static class NodeK,V implements Map.EntryK,V { final int hash; final K key; V value; NodeK,V next; }注意这里只有一个next指针指向的是同一个桶bucket里的下一个节点。它描述的是“散列冲突后串成的链表”跟“全 map 的插入顺序”没有任何关系。LinkedHashMap 自己定义了一个 Entry继承 Node 并加了两个指针static class EntryK,V extends HashMap.NodeK,V { EntryK,V before, after; Entry(int hash, K key, V value, NodeK,V next) { super(hash, key, value, next); } }before指向前一个节点after指向后一个节点这两个指针组成的就是那条贯穿全表的双向链表。节点在散列表里可能因为哈希冲突挤在同一个桶里也可能分散在不同桶里但 through 双向链表所有节点被串成了一个有序序列。哈希结构管“怎么快速找”双向链表管“按什么顺序遍历”。这两套结构通过同一个节点对象共存互不干扰。LinkedHashMap 还维护了两个额外的字段transient LinkedHashMap.EntryK,V head; transient LinkedHashMap.EntryK,V tail;一个链表头一个链表尾。head 是最早的节点tail 是最新的节点。迭代顺序就是从头走到尾。1.2 为什么不在 put 方法里直接改模板方法回调很多刚看源码的人会有个疑问LinkedHashMap 既然要维护顺序为什么不直接重写 put、重写 putVal在插入逻辑里顺手把链表接上这样做行不行行但会很啰嗦而且要复制一遍 HashMap 的全部插入逻辑。JDK 的做法是在 HashMap 内部留了几个“钩子方法”// HashMap 中的三个回调默认全部空实现 void afterNodeAccess(NodeK,V p) { } void afterNodeInsertion(boolean evict) { } void afterNodeRemoval(NodeK,V p) { }这几个方法散落在putVal、removeNode等关键操作里。比如插入新节点后HashMap 会调用afterNodeInsertion(evict)更新已有 key 后会调用afterNodeAccess(e)删除节点后会调用afterNodeRemoval(p)。LinkedHashMap 不需要重写 put 和 remove 的主流程只需要把这几个钩子方法覆写掉在最合适的时机插入自己的逻辑。这就是经典的模板方法模式父类定义算法骨架子类在指定的扩展点补充细节。顺带一提HashMap 里还有newNode、newTreeNode、replacementNode、replacementTreeNode这几个创建节点的方法LinkedHashMap 也做了覆写。这些方法的调用时机更隐蔽等讲扩容和树化的时候再展开。1.3 构造器与 accessOrder顺序模式由这个参数决定LinkedHashMap 有五个构造器前面四个都是调 HashMap 的构造器唯一特殊的是这个public LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder) { super(initialCapacity, loadFactor); this.accessOrder accessOrder; }accessOrder false默认值表示按插入顺序维护双向链表。第一次 put 一个 key 时节点挂在链表尾部之后对该 key 的 get、更新操作不会改变它的位置。accessOrder true表示按访问顺序维护。每次 get 或者更新一个已存在的 key这个节点都会被移动到链表尾部。最近访问过的靠后最久没访问的靠前。这个参数就是后面所有顺序行为的开关。理解它是理解 LinkedHashMap 访问顺序语义的第一步。2. 顺序的关键三个 afterNode 回调如何控制节点位置2.1 afterNodeAccess访问一次节点就跑到队尾先看实现void afterNodeAccess(NodeK,V e) { // move node to last LinkedHashMap.EntryK,V last; if (accessOrder (last tail) ! e) { LinkedHashMap.EntryK,V p (LinkedHashMap.EntryK,V)e, b p.before, a p.after; p.after null; if (b null) head a; else b.after a; if (a ! null) a.before b; else last b; if (last null) head p; else { p.before last; last.after p; } tail p; modCount; } }这个方法在两种场景下会被调用get方法命中了 key说明这是一个“访问”操作。put方法发现 key 已经存在更新了旧值这同样算一次访问。逻辑本身不复杂先判断accessOrder是否为 true以及当前节点是不是已经在链表尾部。如果已经在尾部什么都不用做如果不在就把这个节点从双向链表里拆出来再接到 tail 后面。我见过不少面试者背结论“get 会使节点移动到末尾。”但实际上有一个前提——accessOrder必须为 true。在默认插入顺序模式下get 一个 key 返回原值链表顺序纹丝不动。这个细节经常被忽略代码里那一行if (accessOrder ...)就是证据。另外这里的modCount也值得注意。在 JDK 8 的实现里访问顺序模式下的 get 会改变迭代器可以感知的结构修改计数所以在迭代过程中调用 get 可能会快速抛出ConcurrentModificationException。后来的版本把这里改掉了大家写代码时遇到类似现象要先想到是不是版本差异导致的。2.2 afterNodeInsertion新节点挂尾部还可能顺手淘汰队头void afterNodeInsertion(boolean evict) { // possibly remove eldest LinkedHashMap.EntryK,V first; if (evict (first head) ! null removeEldestEntry(first)) { K key first.key; removeNode(hash(key), key, null, false, true); } }每次向 map 里插入一个新节点不是更新旧值在putVal的最后都会调用这个方法。它做两件事新节点本身已经在newNode阶段被连接到链表尾部了所以不需要在这里重复接。它检查一下队头节点是否满足“该淘汰”的条件。如果removeEldestEntry(first)返回 true就把队头节点从 map 里删掉。removeEldestEntry默认实现是protected boolean removeEldestEntry(Map.EntryK,V eldest) { return false; }永远是 false所以默认情况下 afterNodeInsertion 什么都不做。当你自己写一个 LRU 缓存时要覆写的正是这个方法。后面我会专门写一节来讲怎么用。2.3 afterNodeRemoval删除时把节点从双向链表里解链void afterNodeRemoval(NodeK,V e) { // unlink LinkedHashMap.EntryK,V p (LinkedHashMap.EntryK,V)e, b p.before, a p.after; p.before p.after null; if (b null) head a; else b.after a; if (a null) tail b; else a.before b; }这就是标准的双向链表删除节点。把 p 的 before 和 after 都置空然后让前驱的 after 直接指向后继让后继的 before 指回前驱。如果 p 是头节点就更新 head如果 p 是尾节点就更新 tail。一个容易忽略的点是这个方法只在HashMap.removeNode真正找到节点并删除后才会被调用。删除一个不存在的 keyremoveNode返回 nullafterNodeRemoval 压根不会执行。所以不需要担心空操作的成本。2.4 TreeNode 也是 Entry 的子类为什么树节点同时是链表节点到这里我们会碰到一个设计上的关键点。HashMap 在链表长度超过阈值时会树化成红黑树节点会从Node变成TreeNode。那TreeNode是什么结构static final class TreeNodeK,V extends LinkedHashMap.EntryK,V { TreeNodeK,V parent; TreeNodeK,V left; TreeNodeK,V right; TreeNodeK,V prev; boolean red; ... }注意TreeNode直接继承LinkedHashMap.Entry而LinkedHashMap.Entry继承HashMap.Node。也就是说红黑树节点本身就带 before 和 after 指针天生就是双向链表的一员。这个继承关系不是随便设计的。树化之后节点既要用 parent/left/right 维持红黑树结构又要继续留在 LinkedHashMap 的双向链表里保证遍历顺序。如果没有这层继承树化的时候还要单独维护一套“树节点到链表节点”的映射复杂度会高很多。同时也要注意TreeNode里还有一个prev字段。它和LinkedHashMap.Entry.before不是一个东西prev用于在红黑树插入、删除时维护桶内链表的前驱关系before/after则是全表顺序的双向链表。两个指针指向的对象可能不同别搞混。3. 扩容、树化与退化双向链表为什么始终“不断线”3.1 resize 时普通链表拆分只动 next不动 before/afterHashMap 扩容时每个桶里的链表会被拆成两条一条留到原位置 j一条挪到 joldCap 的位置。JDK 8 的拆分逻辑用loHead/loTail和hiHead/hiTail来串链表。这里有一个很容易被忽略的细节普通链表拆分只修改节点的 next 指针完全不碰 before/after。为什么可以这样做因为 LinkedHashMap 的双向链表和桶链表是两套独立的指针体系。双向链表描述的是“整个 map 的顺序”跟节点被放在哪个桶没有关系。扩容只是把同桶内的 next 链重新分配节点的全局先后顺序不变所以 before/after 不需要动。真正需要动 before/after 的时刻只有插入、删除、访问重排——这些场景全都由 afterNode 系列回调负责。3.2 newNode / newTreeNode新建节点时就把双向链表接上LinkedHashMap 覆写了 HashMap 的创建节点方法NodeK,V newNode(int hash, K key, V value, NodeK,V e) { LinkedHashMap.EntryK,V p new LinkedHashMap.EntryK,V(hash, key, value, e); linkNodeLast(p); return p; } TreeNodeK,V newTreeNode(int hash, K key, V value, NodeK,V next) { TreeNodeK,V p new TreeNodeK,V(hash, key, value, next); linkNodeLast(p); return p; }linkNodeLast的实现就是把新节点追加到双向链表尾部private void linkNodeLast(LinkedHashMap.EntryK,V p) { LinkedHashMap.EntryK,V last tail; tail p; if (last null) head p; else { p.before last; last.after p; } }所以 LinkedHashMap 在 put 一个新 key 的时候散列表的结构由 HashMap 的 putVal 负责创建双向链表的结构由 newNode/newTreeNode 完成。两步配合得很干净。3.3 replacementNode / replacementTreeNode树化和退化的关键一步HashMap 的链表转红黑树在treeifyBin里会做这样一件事把链表上的每个 Node 替换成 TreeNode。普通 HashMap 直接用replacementTreeNode(e, null)创建一个新树节点。LinkedHashMap 覆写了这个方法TreeNodeK,V replacementTreeNode(NodeK,V p, NodeK,V next) { LinkedHashMap.EntryK,V q (LinkedHashMap.EntryK,V)p; TreeNodeK,V t new TreeNodeK,V(q.hash, q.key, q.value, next); transferLinks(q, t); return t; }transferLinks是 LinkedHashMap 内部的一个核心方法private void transferLinks(LinkedHashMap.EntryK,V src, LinkedHashMap.EntryK,V dst) { LinkedHashMap.EntryK,V b dst.before src.before; LinkedHashMap.EntryK,V a dst.after src.after; if (b null) head dst; else b.after dst; if (a null) tail dst; else a.before dst; }它的作用非常直接新建的树节点 dst 要顶替原来普通节点 src 在双向链表中的位置。如果不做这一步原来的普通节点被替换掉之后双向链表里的指针还指向一个已经不在散列表里的对象遍历就会出问题。红黑树退化成链表时也一样untreeify里会调用replacementNode同样走一遍 transferLinks。这一对方法保证了不管节点在 Node、TreeNode 之间怎么变双向链表始终是连续的。这个逻辑是我觉得 LinkedHashMap 源码里最值得反复看的 20 行代码之一。很多讲源码的文章只会说“继承了 HashMap、重写了回调”但对这种替换节点的过程一笔带过实际上顺序信息能否保得住全靠 transferLinks 兜底。3.4 红黑树 split扩容拆树时双链表怎么处理当扩容时遇到桶内是红黑树HashMap 会调用 TreeNode 里的split方法把树拆成 lo 和 hi 两条链。拆完之后如果链长度小于阈值就退化成普通链表调用untreeify如果仍然需要树化调用treeify。整个过程中LinkedHashMap 的双链表顺序依然能保持住靠的还是 replacementNode/replacementTreeNode 的 transferLinks。拆分是在桶链表维度做的而双向链表维度从头到尾没有被破坏因为每个节点对象还是原来那个只是类型发生了变化。这个设计让我想到一个比喻LinkedHashMap 就像一个学生既在班级里按学号排了队双向链表又在社团里按兴趣分组桶链表。换社团分组的时候不需要重新排学号重新排学号的时候也不需要改变分组。两个维度在自己的指针体系里各自维护互不打扰。3.5 别忘了 putAll批量插入也会逐条触发回调putAll最终会进入putMapEntries里面是一个循环调用putVal。所以 putAll 插入的每一个 key 都会触发afterNodeInsertion。这意味着在一个基于 LinkedHashMap 的 LRU 缓存里用 putAll 一次性灌入数据每插入一条都有触发淘汰的可能性淘汰策略会逐条执行而不是等所有数据都放进去之后再统一淘汰。这个行为在实现缓存预加载时会造成一些微妙的差异比如不断插入新数据导致队头节点被持续淘汰最终只留下最近插入的部分数据。具体业务要具体分析但至少要知道 putAll 不是“先全部放入、再淘汰一轮”的语义。4. 实战 LRUremoveEldestEntry 的正确姿势与并发处理4.1 LRU 的直觉与 LinkedHashMap 的对应关系LRULeast Recently Used最近最少使用缓存的淘汰规则很简单当缓存满了优先淘汰最久没被访问的 key。这和 LinkedHashMap 的访问顺序模式天然契合每次访问get 或 put 更新已存在的 key节点会被移动到链表尾部。链表头部自然就是“最久没被访问”的节点。再加上 afterNodeInsertion 里已经预留了removeEldestEntry这个钩子我们只需要做两件事构造器第三个参数传 true开启访问顺序。重写removeEldestEntry定义“什么时候算太满”。4.2 一个可以直接拿去用的 LRU 缓存下面是完整的实现import java.util.LinkedHashMap; import java.util.Map; public class SimpleLruCacheK, V extends LinkedHashMapK, V { private final int maxCapacity; public SimpleLruCache(int maxCapacity) { super(16, 0.75f, true); this.maxCapacity maxCapacity; } Override protected boolean removeEldestEntry(Map.EntryK, V eldest) { return size() maxCapacity; } public static void main(String[] args) { SimpleLruCacheInteger, String cache new SimpleLruCache(2); cache.put(1, one); // 链表[1] cache.put(2, two); // 链表[1, 2] cache.get(1); // 链表[2, 1] cache.put(3, three); // 链表[2, 1, 3]size()3 2淘汰队头 2 System.out.println(cache.keySet()); // 输出 [1, 3] } }注意几点super(16, 0.75f, true)里的0.75f是负载因子。LinkedHashMap 继承自 HashMap扩容阈值就是capacity * loadFactor。比如初始容量 16、负载因子 0.75插入 12 个元素后散列表就会扩容跟 LRU 的 maxCapacity 是两回事。如果你想减少扩容带来的节点复制开销可以把初始容量设置成maxCapacity / 0.75f 1左右。removeEldestEntry里用的是size() maxCapacity不是。因为 afterNodeInsertion 是在新节点已经插入、size 已经加一之后才回调的这时候如果 size 刚好等于 maxCapacity说明这是第一个“超出容量”的元素应该淘汰如果写就会在 size 等于 maxCapacity 时把队头也淘汰导致容量比预期小一。默认的构造器负载因子是 0.75实测中如果 maxCapacity 比较小比如 2、3、4那么散列表大概率在建树之前就会扩容性能不是最优但功能正确。4.3 这个 LRU 的并发问题与解决办法LinkedHashMap 本身不是线程安全的所有方法都不做同步。上面的 SimpleLruCache 在多线程环境下直接使用轻则丢数据重则双向链表指针错乱。常见做法是外层加锁MapInteger, String syncCache Collections.synchronizedMap(new SimpleLruCache(100));但这里有个陷阱Collections.synchronizedMap只能保证单个方法是原子的而 LRU 的“判断放入可能淘汰”是一个复合操作。如果多个线程同时访问仍然可能出现同样的 key 被插入多次、淘汰判断基于过期 size 等问题。要严格控制就得在使用端对整个操作加锁synchronized (syncCache) { String value syncCache.get(key); if (value null) { value loadFromDb(key); syncCache.put(key, value); } }如果你需要一个真正高并发、低锁竞争的 LRULinkedHashMap 这个方案就有些吃力了。业界常见的选择是用 ConcurrentHashMap 配合一个维护访问频率的结构或者使用专门的缓存框架。但从源码学习和中小规模场景来说LinkedHashMap 实现的 LRU 思路清晰、零依赖足够应对大部分需求。4.4 实战中的几个坑访问顺序模式下put 更新旧值也算访问。所以往缓存里写数据时如果写的是一个已存在的 key它的位置也会被挪到尾部相当于“刷新”了它的热度。只有 get 和 put 会触发访问重排getOrDefault 不会。如果你在缓存外面用map.getOrDefault(key, default)来读默认值场景下即使命中了真实 key也不会移动节点位置。这个差异在 JDK 8 里尤其明显因为 LinkedHashMap 只覆写了 get没有覆写 getOrDefault而 HashMap 的 getOrDefault 走的是另一个内部方法没经过 afterNodeAccess。重写 removeEldestEntry 时不要做耗时操作。这个方法在每次插入新 key 后都会调用一次如果里面做复杂计算或者打日志输出整个 map性能会很难看。迭代过程中不要修改结构。LinkedHashMap 的迭代器继承自 HashMap 的迭代器是 fail-fast 的。在遍历 keySet、values、entrySet 时同时又 put 新 key 或 remove 旧 key会抛ConcurrentModificationException。访问顺序模式下get 命中也可能触发 modCount 变化从而打断迭代这在 JDK 8 里确实存在遇到时先别怀疑写错了查一下 JDK 版本。5. 遍历顺序与细节坑从迭代器到 containsValue再到版本差异5.1 遍历顺序到底由谁决定LinkedHashMap 的迭代器不是重新写的它用的是 HashMap 的内部迭代器但 HashMap 的迭代器遍历方式非常有意思abstract class HashIterator { NodeK,V next; ... final NodeK,V nextNode() { ... if (next null) throw new NoSuchElementException(); ... do { next next.next; // 桶内链表下一个 } while (next null index tab.length); ... } }HashMap 的迭代器是“按桶索引遍历桶内按链表顺序遍历”所以顺序取决于哈希值分布不可预期。但 LinkedHashMap 没有直接改 HashMap 的迭代器却能达到“按插入/访问顺序遍历”的效果。为什么因为 HashMap 在迭代时节点是沿着桶链表走而 LinkedHashMap 的节点在创建时已经按照顺序链入了双向链表。实际上如果你翻看 JDK 8 的源码LinkedHashMap 的keySet()返回的 set 覆写了 iterator 吗看一下实现就能发现JDK 8 的 LinkedHashMap 并没有为 entrySet 提供全新的迭代器类迭代行为由 HashMap 的迭代器决定。那顺序是怎么保证的这里要小心。在 JDK 8 里LinkedHashMap 的 keySet、values、entrySet 都继承自 HashMap 的对应视图但 LinkedHashMap 覆写了keysToArray等方式不实际不是。让我把记忆理清。JDK 8 的 LinkedHashMap 源码里keySet()、values()、entrySet()其实没有覆写迭代器本身。它靠的是 HashMap 迭代器遍历桶时节点在桶里的存储顺序恰好等于双向链表顺序吗不对同桶节点在 next 链上的顺序也大体是插入顺序尾插法但不同桶之间的顺序由哈希值决定跟双向链表顺序不同。如果直接用 HashMap 的迭代器遍历顺序应该是按桶索引混排的。实际上我回忆 JDK 8 的 LinkedHashMap 确实没有自定义 HashIterator它复用了 HashMap 的内部迭代器。那么为什么遍历顺序是有序的等一下HashMap 的HashIterator.nextNode()中final NodeK,V nextNode() { NodeK,V[] t; NodeK,V e next; if (modCount ! expectedModCount) throw new ConcurrentModificationException(); if (e null) throw new NoSuchElementException(); if ((next (current e).next) null (t table) ! null) { do {} while (index t.length (next t[index]) null); } return e; }它是先取e.next也就是桶内链表的下一个节点。如果没有了才去 table 后续桶找。所以遍历顺序确实是桶索引桶内链表顺序不是双向链表顺序。但是 LinkedHashMap 的 keySet 迭代顺序是有序的怎么做到的关键在于 LinkedHashMap 把节点加入散列表时的桶内链表顺序与双向链表顺序一致吗不同桶之间呢如果桶索引顺序不等于插入顺序那迭代器应该会乱序。我是不是记错了让我仔细回忆。JDK 8 中 LinkedHashMap 确实覆写了entrySet()回看源码public SetMap.EntryK,V entrySet() { SetMap.EntryK,V es; return (es entrySet) null ? (entrySet new LinkedEntrySet()) : es; }有一个LinkedEntrySet内部类它的 iterator 返回new LinkedEntryIterator()。然后LinkedEntryIterator继承自LinkedHashIterator而LinkedHashIterator是 LinkedHashMap 内部实现的双向链表迭代器abstract class LinkedHashIterator { LinkedHashMap.EntryK,V next; LinkedHashMap.EntryK,V current; int expectedModCount; LinkedHashIterator() { next head; expectedModCount modCount; current null; } public final boolean hasNext() { return next ! null; } final LinkedHashMap.EntryK,V nextNode() { LinkedHashMap.EntryK,V e next; if (modCount ! expectedModCount) throw new ConcurrentModificationException(); if (e null) throw new NoSuchElementException(); current e; next e.after; return e; } ... }对了JDK 8 的 LinkedHashMap 其实有独立的 LinkedHashIterator直接沿着 before/after 双向链表走。我之前记忆偏差需要纠正。感谢自己多想了一下。所以遍历顺序的答案很明确LinkedHashMap 的自有迭代器是沿着 after 指针从 head 走到 tail完全无视桶数组的顺序。这也是它能保证顺序的关键。这个点也可以放在 5.1 讲清楚LinkedHashMap 为了保证有序迭代专门实现了LinkedHashIteratorkeySet、values、entrySet 的迭代器都基于它。每次迭代时next e.after直接走双向链表。这也是为什么 remove 一个节点后迭代器依然能沿着正确的顺序继续遍历。5.2 containsValue 为什么重写遍历双向链表比扫桶更直接HashMap 的 containsValue 是这样实现的for 遍历 table 数组的每一个非空桶再从桶头一路 next 找下去。如果数组很大但元素很少这个操作会扫过很多空桶。LinkedHashMap 覆写了 containsValuepublic boolean containsValue(Object value) { for (LinkedHashMap.EntryK,V e head; e ! null; e e.after) { V v e.value; if (v value || (value ! null value.equals(v))) return true; } return false; }直接从头节点开始一路沿着 after 指针走。只要 map 里有多少元素就精确遍历多少次不会为空桶浪费循环。从数据规模上看这是 O(n) 和 O(table.length n) 的差别数据量大了之后差距很明显。类似的还有 LinkedHashMap 覆写的clear()public void clear() { super.clear(); head tail null; }HashMap.clear 只负责把 table 数组里的引用清空但双向链表的 head/tail 还指着旧节点不重置的话遍历就会出问题。这个细节正好说明凡是涉及全表结构的操作LinkedHashMap 都要在 HashMap 的基础上多收一个尾。5.3 containKey 和 get 的覆写差异LinkedHashMap 覆写了 getpublic V get(Object key) { NodeK,V e; if ((e getNode(hash(key), key)) null) return null; if (accessOrder) afterNodeAccess(e); return e.value; }它复用了 HashMap 的 getNode 来快速定位找到之后再决定要不要重新排序。如果 key 不存在返回 null同时不触发任何顺序调整。但 containKey 没有覆写它直接复用 HashMap 的实现。这其实合理containKey 只是一个“查有没有”的操作在访问顺序模式下按严格 LRU 语义来说containKey 也算一次访问。不过源码没有把它当作访问处理。如果你的业务确实需要“只看存不存在也要刷新热度”那就得自己调用 get或者拿到 containsKey 的结果后手动处理。另一个有意思的点是getOrDefault。前面已经提到JDK 8 里 LinkedHashMap 没有覆写 getOrDefault而 HashMap 的 getOrDefault 走的是内部查找流程不经过 afterNodeAccess。所以在 accessOrdertrue 时map.get(key) // 会刷新顺序 map.getOrDefault(key, defaultValue) // 不会刷新顺序这是一个非常隐蔽的行为差异。如果代码里混用了两种读法LRU 的淘汰顺序会跟你预期的不一样。务必要统一入口。5.4 JDK 新版本里的变化SequencedMap 与顺序视图如果你用的是比较新的 JDK比如 17、21会发现 LinkedHashMap 的声明变成了public class LinkedHashMapK,V extends HashMapK,V implements MapK,V, SequencedMapK,VJava 21 引入了SequencedMap接口从接口层面定义了“有序 map”的语义。它提供了firstEntry()、lastEntry()、reversed()等方法LinkedHashMap 作为顺序保持能力最强的 Map 实现自然实现了这个接口。比如LinkedHashMapString, Integer map new LinkedHashMap(); map.put(a, 1); map.put(b, 2); System.out.println(map.firstEntry()); // a1 System.out.println(map.lastEntry()); // b2 System.out.println(map.reversed().keySet()); // [b, a]这对我们写代码来说是利好因为以前要想拿第一个或最后一个元素还得自己遍历或转数组现在接口直接给了方法。不过面试中聊到 LinkedHashMap 的时候重点还是那套 before/after 机制SequencedMap 可以是加分项但别当成核心来答。5.5 一些零散但实用的细节keySet 的迭代顺序和 entrySet、values 是一致的都是从 head 往后走。所以你在业务里可以放心用new ArrayList(map.keySet())来拿到顺序列表。同一 key 重复 put不会改变插入顺序模式下的位置。这符合“插入顺序”语义它记录的是第一次插入时的顺序不是最后一次更新的顺序。如果业务上需要“更新 key 的时间也算新时间”得用 accessOrdertrue。remove 后重新 put节点会回到链表尾部。这是很多人没想到的。在插入顺序模式下一个 key 被删除它的位置就没了重新 put 进去它变成最新的节点。所以如果你拿 LinkedHashMap 做有序去重队列要小心“删了再加”会造成顺序跳动。不能依赖 LinkedHashMap 做并发场景下的缓存。上面已经说过了这里再强调一遍所有复合操作都要自己加锁或者干脆换成专门的高并发缓存方案。就这样收尾给你留三个值得自己动手验证的实验这篇源码解析写到这里该讲的机制基本讲完了。最后留三个小实验你可以在本地跑一下跑完会对 LinkedHashMap 的记忆特别深构造一个 accessOrdertrue 的 LinkedHashMap插入 a、b、c然后get(a)、put(c, 新值)、put(d, 4)每一步输出 keySet观察节点顺序怎么变。写一个容量为 3 的 SimpleLruCache连续 put 5 个 key中间穿插 get打印每一步的淘汰结果验证最小容量等于预期。在迭代 keySet 的过程中调用get一个已存在 key看不同 JDK 版本下是否抛ConcurrentModificationException体会版本差异。我个人在实际源码阅读中最大的体会是LinkedHashMap 真正的精华不在那几个 afterNode 方法本身而在“HashMap 留钩子、LinkedHashMap 填钩子”这种扩展方式。你写业务代码时如果遇到类似的场景——一个核心流程想在不同子类里插入不同副作用模板方法比 if-else 优雅得多。这也是读源码最大的额外收获不只在面试时能用上。