LinkedHashMap 访问顺序模式
0. 背景
当 LinkedHashMap
构造参数 accessOrder=true
时,任何一次 get/put/getOrDefault
都会把被访问节点移到双向链表末尾,以保证 LRU 语义。
但在实际业务中,经常出现“只想拿到第一个插入的元素,却又不想破坏原有 LRU 顺序”的需求。本文给出三种零副作用(或低副作用)的解决方案,并附可直接落地的源码。
1. LinkedHashMap 关键行为回顾
构造方法 | 顺序语义 | 是否移动节点 |
---|---|---|
new LinkedHashMap<>() | 插入顺序 | 否 |
new LinkedHashMap<>(16,0.75f,true) | 访问顺序 | get/put 均会移动 |
移动规则
访问(
get
)某节点 ⇒ 把该节点移到链表尾插入已存在 key ⇒ 先删旧节点,再尾插新节点
迭代器顺序 = 链表顺序
2. 问题复现
public static void main(String[] args) {Map<String, String> map = new LinkedHashMap<>(16, 0.75f, true);map.put("A", "1");map.put("B", "2");map.get("A"); // 触发访问,A 被移到最后System.out.println(map.keySet()); // 输出 [B, A]
}
需求:在 不破坏 [B, A] 顺序的前提下,拿到 key="A" 的 value。
3. 解决方案
3.1 方案 A:只读迭代器(零副作用)
适用场景:仅想知道“第一个 key/entry”是什么,而不需要 value。
// 取第一个 key
String firstKey = map.keySet().iterator().next();// 取第一个 entry
Map.Entry<String, String> firstEntry = map.entrySet().iterator().next();
注意:只要不去调用
map.get(...)
,链表就不会被修改。
3.2 方案 B:反射“静默 get”(零副作用,O(1))
适用场景:必须根据 key 拿 value,但又不希望该节点被“访问后移”。
实现原理:直接定位 HashMap 内部 Node[] table
,跳过 afterNodeAccess
钩子。
import java.lang.reflect.Field;
import java.util.LinkedHashMap;public final class LinkedHashMapSilentGet {/*** 读取 value,但不触发 LRU 移动*/@SuppressWarnings("unchecked")public static <K, V> V getQuietly(LinkedHashMap<K, V> map, K key) {try {Field tableField = java.util.HashMap.class.getDeclaredField("table");tableField.setAccessible(true);Object[] tab = (Object[]) tableField.get(map);if (tab == null) return null;int hash = (key == null) ? 0 : (key.hashCode() ^ (key.hashCode() >>> 16));int index = (tab.length - 1) & hash;// 遍历该桶下的链表/树for (Object node = tab[index]; node != null; ) {Class<?> nodeClz = node.getClass();Field keyField = nodeClz.getDeclaredField("key");Field valueField = nodeClz.getDeclaredField("value");Field hashField = nodeClz.getDeclaredField("hash");keyField.setAccessible(true);valueField.setAccessible(true);hashField.setAccessible(true);int h = hashField.getInt(node);K k = (K) keyField.get(node);if (h == hash && (key == k || (key != null && key.equals(k)))) {return (V) valueField.get(node);}// next 字段Field nextField = nodeClz.getDeclaredField("next");nextField.setAccessible(true);node = nextField.get(node);}} catch (Exception ignore) {// 降级:正常 get(会移动节点)return map.get(key);}return null;}/* ---------- 测试 ---------- */public static void main(String[] args) {LinkedHashMap<String, String> map = new LinkedHashMap<>(16, 0.75f, true);map.put("A", "1");map.put("B", "2");System.out.println("Before quiet get: " + map.keySet()); // [A, B]String val = getQuietly(map, "A"); // 不移动System.out.println("value = " + val); // 1System.out.println("After quiet get: " + map.keySet()); // [A, B]}
}
注:JDK 9+ 模块系统需加
--add-opens java.base/java.util=ALL-UNNAMED
3.3 方案 C:快照副本(简单暴力)
适用场景:数据量不大、只读频繁、可接受额外内存。
// 1. 生成当前顺序的快照(插入顺序)
Map<String, String> snap = new LinkedHashMap<>(map);// 2. 在快照里随便 get,不影响原 map
String firstKey = snap.keySet().iterator().next();
String valOfA = snap.get("A"); // 不会动原 map
4. 三种方案对比
维度 | 方案 A 迭代器 | 方案 B 静默 get | 方案 C 快照 |
---|---|---|---|
是否移动节点 | 否 | 否 | 否 |
能否拿 value | 否 | 能 | 能 |
时间复杂度 | O(1) | O(1) | O(n) 复制 + O(1) |
额外内存 | 0 | 0 | O(n) |
代码复杂度 | 最低 | 较高(反射) | 最低 |
JDK 限制 | 无 | 需开放模块 | 无 |
5. 常见坑 & 建议
不要在 foreach 里直接
map.remove
会抛ConcurrentModificationException
,请用迭代器自身删除for (Iterator<Map.Entry<String,String>> it = map.entrySet().iterator(); it.hasNext();) {Map.Entry<String,String> e = it.next();if (needRemove(e)) it.remove(); }
不要把顺序当成
equals
LinkedHashMap.equals
只比较内容与顺序无关,两个顺序不同的 map 仍可能相等。线程安全
所有方案均非同步,并发场景请用Collections.synchronizedMap
包一层,或使用ConcurrentLinkedHashMap
(Guava)。
6. 结论
accessOrder=true
一旦开启,get
必定移动节点,无法通过参数关闭。真正“只读不动序”只有两条路:
① 根本不调用get
,用迭代器;
② 调用但不走get
,用反射或快照。根据数据规模、性能、代码维护成本三选一即可;反射方案在缓存中间件、LRU 统计等高频只读场景下性价比最高。
7.补充方案
public static void main(String[] args) {Map<String, String> map = new LinkedHashMap<>(16, 0.75f, true);map.put("A", "1");map.put("B", "2");ArrayList<String> strings = new ArrayList<>(map.keySet());System.out.println("ceshi" + strings.get(0));System.out.println(map.keySet()); // 输出 [B, A]}