LinkedHashMap 的 get 也会改顺序:从迭代失败写到有界 LRU
- 发布时间:2026-10-23 22:12:05
- 本文热度:浏览 3 赞 0 评论 0
- 文章标签: Java LinkedHashMap LRU
- 全文共1字,阅读约需1分钟
遍历Map的键,再用get取值,按理说没有增删,为什么会抛ConcurrentModificationException?先看这个构造参数:new LinkedHashMap<>(16, 0.75f, true)。最后的true把顺序从插入顺序改成访问顺序,某些读取也就会修改结构。
错误不需要第二条线程。只要遍历时get到的键被移到链尾,当前迭代器就可能检测到变化。若把这个Map当成“很多读、少量写”的缓存,还可能误把get放在不需要独占的路径上。
下面先重现失败,再写一个容量受控的小LRU。重点不是发明生产缓存,而是把读取、顺序、迭代和逐出连成一份能验证的协议。
先用失败输出确认这不是普通插入顺序
样本先放a、b、c,开始遍历后读取a。它原先在链头,访问后移动到最近端;继续迭代就能触发异常。若恰好读的是本来就在链尾的键,顺序可能不变,因此偶尔没有异常并不能反证读取会改结构。
在JDK 17执行完整程序,会先看到 iteration=CME,随后才是修正后的LRU结果。异常在此只是诊断信号,不能用来设计并发控制:fail-fast不承诺把所有竞态都抓出来。
小 LRU 的操作规则
容量固定为正数,不接收null键或null值。get命中后成为最近访问项;get未命中返回null。put更新已有键也算一次访问;新增导致超容量时逐出最久未访问项,并把被逐出的键值作为返回值交给调用方。
对外只交付按旧到新排列的键快照,不暴露内部entrySet或iterator。全部公开操作放在同一个synchronized边界,包括get。逐出后的后续动作不在锁内执行,调用方拿到结果以后再记录或关闭相关资源。
保存为 AccessOrderLru.java,使用JDK 17执行 java AccessOrderLru.java。无需依赖和网络环境。
import java.util.*;
public class AccessOrderLru {
static final class Lru<K,V> {
private final int capacity;
private final LinkedHashMap<K,V> values = new LinkedHashMap<>(16, 0.75f, true);
Lru(int capacity) {
if (capacity < 1) throw new IllegalArgumentException("positive capacity required");
this.capacity = capacity;
}
synchronized V get(K key) { return values.get(Objects.requireNonNull(key)); }
synchronized Map.Entry<K,V> put(K key, V value) {
values.put(Objects.requireNonNull(key), Objects.requireNonNull(value));
if (values.size() <= capacity) return null;
Iterator<Map.Entry<K,V>> iterator = values.entrySet().iterator();
Map.Entry<K,V> eldest = iterator.next();
Map.Entry<K,V> result = Map.entry(eldest.getKey(), eldest.getValue());
iterator.remove(); return result;
}
synchronized List<K> keysOldestFirst() { return List.copyOf(values.keySet()); }
}
public static void main(String[] args) {
LinkedHashMap<String,Integer> broken = new LinkedHashMap<>(16, 0.75f, true);
broken.put("a", 1); broken.put("b", 2); broken.put("c", 3);
try {
for (String key : broken.keySet()) broken.get(key);
throw new AssertionError("expected structural modification");
} catch (ConcurrentModificationException expected) { System.out.println("iteration=CME"); }
Lru<String,Integer> cache = new Lru<>(2);
cache.put("a", 1); cache.put("b", 2); cache.get("a");
System.out.println("after_get=" + cache.keysOldestFirst());
Map.Entry<String,Integer> evicted = cache.put("c", 3);
System.out.println("evicted=" + evicted.getKey());
System.out.println("remaining=" + cache.keysOldestFirst());
cache.put("a", 10);
System.out.println("after_update=" + cache.keysOldestFirst());
List<String> before = cache.keysOldestFirst(); cache.get("missing");
System.out.println("miss_unchanged=" + before.equals(cache.keysOldestFirst()));
Lru<String,Integer> one = new Lru<>(1); one.put("x", 1);
if (one.put("x", 2) != null) throw new AssertionError("replacement is not eviction");
System.out.println("replacement_value=" + one.put("y", 3).getValue());
try { cache.keysOldestFirst().add("other"); throw new AssertionError(); }
catch (UnsupportedOperationException expected) { System.out.println("snapshot=IMMUTABLE"); }
try { cache.put("bad", null); throw new AssertionError(); }
catch (NullPointerException expected) { System.out.println("null=REJECTED"); }
}
}
输出:
iteration=CME
after_get=[b, a]
evicted=b
remaining=[a, c]
after_update=[c, a]
miss_unchanged=true
replacement_value=2
snapshot=IMMUTABLE
null=REJECTED
顺序每一步怎样变化
a、b依次进入后,a最旧、b最新。访问a以后,顺序变成b、a;再插入c,超过容量,逐出b,剩下a、c。这个例子能把LRU与简单FIFO区别开:如果不因为访问a更新位置,就会错误逐出a。
随后更新a的值,它也变成最近项,所以键快照变为c、a。实现没有为了更新值先删除再插入,因为access-order的put已经具有相应访问语义。对其他Map方法是否算访问,应查看所用JDK的具体契约,不要从get的行为猜所有方法。
containsKey主要用于判断存在,它不应被随手理解成触摸访问时间。为了满足业务“检查也算使用”的要求,可以明确调用一个touch接口;若检查不应延长寿命,就保持只做存在性判断。不同需求不该藏在工具方法的偶然副作用里。
正确遍历不必先 get 一遍
如果作用域已经独占Map,并且只需要当前键值,直接遍历entrySet读取entry.getValue就能避免通过get改变访问顺序。但别在遍历期间执行会触摸其他键的方法,也别把活的Entry保存到外部长期使用。
若需要在不持锁时做较慢处理,先在锁内复制为独立快照,再释放锁处理。快照提供的是复制时的观察结果,不保证之后缓存没有更新;业务若需要对“仍是同一版本”执行动作,要增加明确版本条件,而不是把快照当成锁的替代品。
本例的键和值协议也有约束。键的equals/hashCode应在存入后保持稳定,值如果是可变对象,快照只保护集合结构,不会深拷贝值。这里使用不可变的字符串键与整数值,避免把可变元素共享问题藏在LRU实现中。
为什么把 get 也放在 synchronized 里
访问顺序链表的调整是结构变化,get不能因为名字叫读就绕过同步。所有操作使用同一把锁,才能把定位、移动与逐出作为单次可解释的动作。给put加锁、get不加锁,或者给两者用不同锁,都没有建立这项一致性。
synchronized不意味着可以把一个业务流程的多次调用自动合并成事务。外部先get未命中,再计算,再put,仍可能和另一个线程重复计算。若需要每个键同时只有一个加载任务,应另行设计加载协调,或选择已提供相应能力的缓存实现。
也不建议在put内部调用用户监听器、写日志远端系统或关闭未知资源。那些动作可能阻塞或重入,把原本很短的缓存锁变成整个系统的等待点。本例返回逐出项,正是把状态变更与外部副作用分开;但调用方若处理失败,如何重试与记录仍要自行决定。
更新旧值,不等于发生容量逐出
容量为1时,先写x=1,再写x=2,条目数仍为1。这次只是替换,没有产生eldest逐出结果;接着写y=3,逐出的x所携带的值应为2。程序单独核对这条路径,避免把更新错误实现成先驱逐再新增,或者把旧值错误留给逐出回调。
这份接口仅返回容量导致的逐出项,覆盖旧值不额外回报。对Integer这类普通值足够明确;若值拥有需要关闭的连接或文件,接口还应把被替换旧值的所有权表达出来,例如返回包含replaced和evicted两项的结果。不能默认容量没有增长,就代表没有资源需要回收。
对外快照的不可修改也通过实际add失败核对,但不可修改只针对这份返回列表。后来put和get仍会改变内部顺序,旧快照保持原来的顺序,它不是一个会自动更新的实时窗口。监控或管理页面据此展示时,应记录采样时点,不要把不同快照中的位置组合成一条访问历史。
容量按项数,不按内存,也不按过期时间
两个键可以各持有几字节,也可以各持有一个巨大的对象图。容量为2只限定条目数量,无法承诺内存上界。若值大小差异很大,应使用权重预算,并决定更新时怎样重新计算;对可变值,权重还可能在存入后变化。
LRU也没有TTL。一个长期不被逐出的旧值会一直存在,不会因为时间过去自动无效。给它再加时间字段时,要决定get是否清理、批量清理如何进行,以及淘汰和过期是否触发不同副作用,不能简单把二者都叫remove。
同样,它没有统计、异步刷新、并发加载去重或持久恢复。容量很小、范围受控的本地映射可以这样写;复杂应用缓存更适合使用成熟组件,并核对其具体保证。展示一份完整小LRU的意义,在于知道它恰好提供了哪些行为,而不是看见二十几行就打算替换所有缓存。
让测试保住访问协议
除了本例命中更新与逐出顺序,还应单独核对未命中不改变顺序、更新已有键不误增容量、容量1、拒绝null,以及快照返回后不能修改内部结构。若以后调整同步方案,要保留并发执行与加载协议测试,不能只验证最终Map.size。
出现CME以后加一层try/catch再继续遍历,不是修复;它只是把不稳定结果变得更难看见。真正需要改的是调用协议:访问是否修改顺序,谁能持有活迭代器,哪些操作必须在同一互斥边界内完成。理解这三项,才能让LRU的每次get不再是一条隐藏写路径。