qinghuan
← 回到知识探索

后端现场 / 动手实验

只做了一次 get,为什么 LinkedHashMap 遍历就失败了?

同一个线程、条目数没变,旧迭代器仍可能失效。关键是这张表按插入顺序走,还是按访问顺序走。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 五次独立实验,只改一个条件
  4. 构造器最后一个 true,改变了“顺序”的含义
  5. 旧迭代器对顺序的假设已经过期
  6. 缓存遍历:先说清楚是否要刷新热度
  7. 保存完整程序,核对真实输出
  8. 实测边界:失效原因和诊断方式分开
  9. 记住这些要点
  10. 别把例子用过头
  11. 自己试一小步
  12. 查原始资料
开始前,会这些就够了
  • 知道 Map 按键取值,以及 entrySet 可以遍历键值对。
  • 能区分“修改值”和“改变遍历顺序”。
  • 先读 List、Set、Map:先看业务,再选集合:先掌握键值映射与集合遍历,再比较两种顺序约定。
遇到不熟悉的词,先看这 2 个解释
访问顺序
按近期访问情况排列条目,较新访问的条目在后面。
尽早失败检查
迭代器发现相关结构变化时,用异常尽早暴露错误用法。
打开完整术语小抄 ↗

程序先放入 A、B、C,迭代器取出 A,然后调用 map.get("A")。下一次 iterator.next() 居然抛出 ConcurrentModificationException。你检查了代码:没有第二个线程,也没有 put 或 remove。

异常名字容易让人只盯着并发。这个反例里,真正发生的变化是遍历顺序。对于按访问顺序维护的 LinkedHashMap,读一个键也可能调整内部次序。

把例子带到自己的电脑上

下载完整练习包

需要 完整 JDK 17 或更新版本;实测 Windows / JDK 17.0.20.1 / 21.0.12。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • Main.java:完整可运行源码
  • README.md:运行步骤与练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和运行验证记录
  • SHA256SUMS.txt:文件摘要

在这个文件夹打开 PowerShell,逐行运行

java -version
javac -version
javac -encoding UTF-8 --release 17 Main.java
java -cp . Main

解压后,在含 Main.java 的目录打开终端。命令分别回车执行。固定输入的 Java 17 单文件实验;两套真实 JDK 输出可复核,互动页只回放这些记录。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

单线程一步步回放:数量不变,顺序可能已经改变

正在准备实验控件…

第 0 步:建立 A、B、C 三条映射

条目数
3
当前键顺序
A → B → C
旧迭代器首次读取
尚未创建
旧迭代器第二次读取
尚未执行

选定操作:尚未执行

先预测下一步会不会改变顺序,再点击执行。所有步骤都在同一个线程中。

数据来自本页 Java 程序在 JDK 17/21 的实际输出,浏览器不实现 LinkedHashMap。异常只是本次观察;fail-fast 尽力检测错误,不能依赖它保证线程安全。

五次独立实验,只改一个条件

每个场景都重新建立 A=10、B=20、C=30,并让迭代器先取出 A。默认插入顺序模式下,get(A) 返回 10,次序仍为 A,B,C,旧迭代器下一项为 B。

切换为访问顺序后,同样的 get(A) 让次序变成 B,C,A,旧迭代器下一次取值抛异常。条目数始终为 3,值也没有被修改。决定这次失效的变化不是 size。

还有三个对照:containsKey(A)、get(C)、对已取得的 entry 调用 getValue()。本次这三组都保留 A,B,C,旧迭代器还能取到 B。它们为什么不同,需要分别解释,不能都归为“读操作”。

构造器最后一个 true,改变了“顺序”的含义

new LinkedHashMap<>(16, 0.75f, true) 开启访问顺序。先插入 A、B、C 后,可以把左边理解为较早访问,右边理解为最近访问。再 get(A),就把 A 移到右端,得到 B,C,A。

普通 new LinkedHashMap<>() 使用插入顺序。该模式下,get(A) 不会把 A 移到最后。选集合时只记“LinkedHashMap 有顺序”还不够;还要说清顺序由哪一种事件维护。

containsKey(A) 回答键是否存在,但在该 API 约定下不构成这种访问。对 entrySet 提供的条目视图进行访问也不会刷新访问顺序。因此,遍历时若已经拿到 entry,需要值就直接读 entry.getValue(),通常不必再用同一个键回头调用 map.get。

旧迭代器对顺序的假设已经过期

在阅读的固定 OpenJDK 源码中,afterNodeAccess 只有在访问顺序模式且目标不是尾节点时,才移动节点并增加修改计数。迭代器创建时保存一个预期修改计数,下一次 nextNode 检查到二者不同,就抛出异常。

这也解释了 get(C) 的对照:C 本来就在尾部,本次参考实现没有移动它,没有增加这项修改计数。这个细节帮助理解源码,但不应写成“访问最后一个键就可以安全修改正在遍历的表”的业务策略。

ConcurrentModificationException 是尽早暴露错误用法的诊断机制,不是加锁,也不是完整的并发检测。文档明确说这种检查尽力而为;程序正确性不能建立在“一定会抛异常”或“没有抛就安全”上。

缓存遍历:先说清楚是否要刷新热度

假设你在做一个小缓存,最近访问的键放在末端。管理页面只是要展示缓存条目,却在 entrySet 循环内又调用 get(entry.getKey()),可能无意改变缓存次序,甚至使当前遍历失败。读取已经得到的 entry 值,更符合“只展示”的意图。

如果业务动作确实要求访问并重新排序,就把它设计成与当前遍历分开的操作。复制一份键列表后再访问,是一种可讨论的单线程方案,但会使用额外空间,也需要考虑复制后原表发生变化。多线程情形还必须另行设计同步,本文没有验证任何并发方案。

访问顺序可以作为实现 LRU 缓存的一部分,但单独打开 true 不会自动设置容量、删除最久未访问条目或提供线程安全。不要把一次顺序演示扩张成完整缓存方案。

保存完整程序,核对真实输出

程序只使用一个线程,每种模式都从新表开始。before 和 after 通过新的 keySet 遍历记录当前次序;next 列观察的是更早创建、已经取出 A 的那个迭代器。异常被捕获仅用于显示实验结果。

2026-10-26 实际使用 Microsoft OpenJDK 17.0.20.1 和 21.0.12,两个版本分别以 --release 17 编译并运行三次,输出一致。页面互动区回放这些固定记录,浏览器没有启动 JVM。固定源码阅读使用 OpenJDK jdk17u 的 jdk-17.0.17+10 标签,它是解释机制的参考版本,不冒充本机发行版的精确源码。

展开收起代码 · 小实验java · 54 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · java
import java.util.ConcurrentModificationException;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

public class Main {
    static void run(String mode) {
        boolean accessOrder = !mode.equals("insertion-get-a");
        LinkedHashMap<String, Integer> map =
                new LinkedHashMap<>(16, 0.75f, accessOrder);
        map.put("A", 10);
        map.put("B", 20);
        map.put("C", 30);
        String before = String.join(",", map.keySet());
        Iterator<Map.Entry<String, Integer>> iterator = map.entrySet().iterator();
        Map.Entry<String, Integer> first = iterator.next();
        String operation;
        if (mode.equals("access-contains-a")) {
            operation = "containsKey(A)=" + map.containsKey("A");
        } else if (mode.equals("access-get-c")) {
            operation = "get(C)=" + map.get("C");
        } else if (mode.equals("access-entry-value")) {
            operation = "entry.getValue()=" + first.getValue();
        } else {
            operation = "get(A)=" + map.get("A");
        }
        String after = String.join(",", map.keySet());
        String next;
        try {
            next = iterator.next().getKey();
        } catch (ConcurrentModificationException e) {
            next = e.getClass().getSimpleName();
        }
        System.out.printf(
                "{\"mode\":\"%s\",\"accessOrder\":%s,\"before\":\"%s\","
                + "\"first\":\"%s\",\"operation\":\"%s\",\"after\":\"%s\","
                + "\"next\":\"%s\",\"size\":%d}%n",
                mode, accessOrder, before, first.getKey(), operation, after, next, map.size());
        List<String> expected = mode.equals("access-get-a")
                ? List.of("B", "C", "A") : List.of("A", "B", "C");
        if (!List.copyOf(map.keySet()).equals(expected)) {
            throw new AssertionError("wrong encounter order");
        }
    }

    public static void main(String[] args) {
        run("insertion-get-a");
        run("access-get-a");
        run("access-contains-a");
        run("access-get-c");
        run("access-entry-value");
    }
}

怎样运行:在独立练习文件夹中,依次执行下载区的命令。2026-10-26 使用 完整 JDK 17 或更新版本;实测 Windows / JDK 17.0.20.1 / 21.0.12 复跑这份源码并核对完整输出。固定输入的 Java 17 单文件实验;两套真实 JDK 输出可复核,互动页只回放这些记录。

运行后对照结果
{"mode":"insertion-get-a","accessOrder":false,"before":"A,B,C","first":"A","operation":"get(A)=10","after":"A,B,C","next":"B","size":3}
{"mode":"access-get-a","accessOrder":true,"before":"A,B,C","first":"A","operation":"get(A)=10","after":"B,C,A","next":"ConcurrentModificationException","size":3}
{"mode":"access-contains-a","accessOrder":true,"before":"A,B,C","first":"A","operation":"containsKey(A)=true","after":"A,B,C","next":"B","size":3}
{"mode":"access-get-c","accessOrder":true,"before":"A,B,C","first":"A","operation":"get(C)=30","after":"A,B,C","next":"B","size":3}
{"mode":"access-entry-value","accessOrder":true,"before":"A,B,C","first":"A","operation":"entry.getValue()=10","after":"A,B,C","next":"B","size":3}

实测边界:失效原因和诊断方式分开

稳定的理解是:访问顺序模式的成功查询可能重新排列条目,所以“没有增删键”不等于“遍历结构未变化”。本次异常和 get(C) 不抛异常,是这两套实际 JDK 对固定步骤的观察。

我们没有覆盖 compute、merge、putAll、驱逐策略或多个线程。遇到这些操作,应回到对应方法的访问规则,不要只按方法名中有没有 get 来猜。

记住这些要点

  • LinkedHashMap 的插入顺序与访问顺序,是两种不同约定。
  • 访问顺序模式中的 get 可能移动节点,条目数不变也可能影响迭代器。
  • 遍历已有 entry 时读 entry.getValue(),可以避免为取同一个值再次执行 map.get。
  • 异常是诊断信号,不能当作线程安全保障。

别把例子用过头

  • 认为 ConcurrentModificationException 只能由两个线程触发。
  • 只检查 size 是否变化,就判断旧迭代器仍然有效。
  • 把 get(C) 的本次实现细节用作避开失效的通用技巧。
  • 以为 accessOrder=true 就已经得到完整、线程安全的 LRU 缓存。

自己试一小步

管理页面展示一份访问顺序缓存时,怎样证明自己没有刷新缓存的访问次序?

  1. 分别写出 entry.getValue() 和 map.get(entry.getKey()) 两个遍历版本,先预测顺序和异常位置。
  2. 加入一个不存在的键查询,观察它是否改变次序;把结果与已存在键查询分开记录。
  3. 将要触发真实访问的业务动作移出展示循环,解释两种动作为什么应有不同意图。

做到这些,就算完成

  • 每组从新建的 A,B,C 表开始,避免上一组改变后续初始状态。
  • 同时记录键次序、条目数和旧迭代器结果。
  • 把异常检测写成观察项,不把捕获异常后继续遍历当作修复方案。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • Java 17 LinkedHashMap:访问顺序与迭代器 (新标签页)↗Oracle / OpenJDK · 英文

    哪些方法构成访问、访问顺序下结构修改的含义,以及 fail-fast 只尽力检查。

    实际阅读类说明中的 access-order、结构修改、集合视图和 fail-fast 段;没有逐个阅读所有继承方法。

  • OpenJDK 17u 固定源码:LinkedHashMap (新标签页)↗Oracle / OpenJDK · 英文

    afterNodeAccess 在目标不是尾节点时移动条目并增加 modCount;nextNode 检查迭代器预期值。

    实际阅读 jdk-17.0.17+10 标签的 afterNodeAccess、LinkedHashIterator 构造器及 nextNode;参考源码版本与本机发行版版本分别注明。