开始前,会这些就够了
- 知道
Map按键取值,以及entrySet可以遍历键值对。 - 能区分“修改值”和“改变遍历顺序”。
- 先读
List、Set、Map:先看业务,再选集合:先掌握键值映射与集合遍历,再比较两种顺序约定。
程序先放入 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 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
单线程一步步回放:数量不变,顺序可能已经改变
正在准备实验控件…
数据来自本页 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 标签,它是解释机制的参考版本,不冒充本机发行版的精确源码。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
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 缓存。
自己试一小步
管理页面展示一份访问顺序缓存时,怎样证明自己没有刷新缓存的访问次序?
- 分别写出
entry.getValue()和map.get(entry.getKey()) 两个遍历版本,先预测顺序和异常位置。 - 加入一个不存在的键查询,观察它是否改变次序;把结果与已存在键查询分开记录。
- 将要触发真实访问的业务动作移出展示循环,解释两种动作为什么应有不同意图。
做到这些,就算完成
- 每组从新建的 A,B,C 表开始,避免上一组改变后续初始状态。
- 同时记录键次序、条目数和旧迭代器结果。
- 把异常检测写成观察项,不把捕获异常后继续遍历当作修复方案。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- Java 17 LinkedHashMap:访问顺序与迭代器 (新标签页)↗
哪些方法构成访问、访问顺序下结构修改的含义,以及 fail-fast 只尽力检查。
- OpenJDK 17u 固定源码:LinkedHashMap (新标签页)↗
afterNodeAccess在目标不是尾节点时移动条目并增加modCount;nextNode检查迭代器预期值。