LinkedHashMap
LinkedHashMap 源码分析
HashMap 的遍历顺序不可预测,LinkedHashMap 用最小代价补上这一点:每个节点在哈希桶之外再挂进一条贯穿全表的双向链表,查找仍是哈希的 O(1),遍历严格按插入顺序(accessOrder=true 时按访问顺序)。
它自己几乎没有”算法”,全部秘密在于 HashMap 预留的三个回调钩子;accessOrder 加上重写 removeEldestEntry,就是一个现成的 LRU。
// 基于 JDK 25 (本地 D:/1ForCode/JAVA_Source), java.util.LinkedHashMap —— 骨架与链表原语
public class LinkedHashMap<K,V>
extends HashMap<K,V>
implements SequencedMap<K,V> // JDK 21 起实现 SequencedMap:"有顺序"从此有了官方接口(reversed/putFirst/putLast)
{
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after; // 双向链表指针,独立于哈希桶的 next——同一个节点同时挂在两套结构上
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
transient LinkedHashMap.Entry<K,V> head; // 最老的节点(eldest),LRU 淘汰从这里拿
transient LinkedHashMap.Entry<K,V> tail; // 最新的节点。早期版本用环形哨兵 header,后改为裸头尾双指针
final boolean accessOrder; // false=插入顺序(默认);true=访问顺序,get 也会挪节点 -> LRU 的总开关。final:中途不能改
static final int PUT_NORM = 0;
static final int PUT_FIRST = 1; // JDK 21 的 putFirst/putLast 靠这个临时标志复用 put 全链路,而不是复制一份 putVal
static final int PUT_LAST = 2;
transient int putMode = PUT_NORM;
// link at the end of list
private void linkNodeAtEnd(LinkedHashMap.Entry<K,V> p) { // JDK 17 里叫 linkNodeLast;21 起为 putFirst 加了头插分支
if (putMode == PUT_FIRST) {
LinkedHashMap.Entry<K,V> first = head;
head = p;
if (first == null)
tail = p;
else {
p.after = first;
first.before = p;
}
} else {
LinkedHashMap.Entry<K,V> last = tail;
tail = p;
if (last == null)
head = p; // 空链表:头尾同指
else {
p.before = last;
last.after = p;
}
}
}
// apply src's links to dst
private void transferLinks(LinkedHashMap.Entry<K,V> src,
LinkedHashMap.Entry<K,V> dst) { // 树化/退树时 HashMap 会换掉节点对象,链表指针必须跟着搬家
LinkedHashMap.Entry<K,V> b = dst.before = src.before;
LinkedHashMap.Entry<K,V> a = dst.after = src.after;
if (b == null)
head = dst;
else
b.after = dst;
if (a == null)
tail = dst;
else
a.before = dst;
}
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) { // HashMap.putVal 造节点的工厂钩子:造完顺手挂上链表
LinkedHashMap.Entry<K,V> p =
new LinkedHashMap.Entry<>(hash, key, value, e);
linkNodeAtEnd(p);
return p;
}
TreeNode<K,V> newTreeNode(int hash, K key, V value, Node<K,V> next) { // 桶树化后新插入走这里,链表照挂:遍历顺序不受树化影响
TreeNode<K,V> p = new TreeNode<>(hash, key, value, next);
linkNodeAtEnd(p);
return p;
}
// ... replacementNode / replacementTreeNode 略:链表节点与树节点互转时新建对象 + transferLinks 搬指针
public LinkedHashMap(int initialCapacity,
float loadFactor,
boolean accessOrder) { // 唯一能打开 accessOrder 的构造器,其余构造器一律 false
super(initialCapacity, loadFactor);
this.accessOrder = accessOrder;
}
}// 基于 JDK 25 (本地 D:/1ForCode/JAVA_Source), java.util.LinkedHashMap —— 三个回调钩子与 get
void afterNodeRemoval(Node<K,V> e) { // unlink HashMap.removeNode 删完桶后回调:把节点从链表上摘下
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,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;
}
void afterNodeInsertion(boolean evict) { // possibly remove eldest
LinkedHashMap.Entry<K,V> first;
if (evict && (first = head) != null && removeEldestEntry(first)) {
K key = first.key;
removeNode(hash(key), key, null, false, true); // 淘汰链表头=最老节点。evict 在构造/反序列化灌数据时是 false,半成品阶段不触发淘汰
}
}
// Called after update, but not after insertion
void afterNodeAccess(Node<K,V> e) {
LinkedHashMap.Entry<K,V> last;
LinkedHashMap.Entry<K,V> first;
if ((putMode == PUT_LAST || (putMode == PUT_NORM && accessOrder)) && (last = tail) != e) { // JDK 17 的条件只有 accessOrder;已在尾部就什么都不做
// move node to last
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
p.after = null; // 先摘:下面四个分支处理 p 是头/是中间两种位置
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; // 挪链表不改 size 但改遍历顺序,也算结构修改——accessOrder=true 时迭代中 get 会 fail-fast,经典坑
} else if (putMode == PUT_FIRST && (first = head) != e) {
// move node to first
// ... 与上一分支镜像对称:把 p 摘下挂到 head,putFirst 覆盖已有 key 时走这里,略
}
}
public V get(Object key) {
Node<K,V> e;
if ((e = getNode(key)) == null) // 查找完全复用 HashMap.getNode,重写 get 只为了下面两行
return null;
if (accessOrder)
afterNodeAccess(e); // 读也"提鲜":LRU 里的 R 就是这一行
return e.value;
}
// ... getOrDefault 同样补了 afterNodeAccess;containsKey 没重写——探测不算"使用",不影响 LRU 顺序
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return false; // 默认永不淘汰。做 LRU 只需重写这一个方法,链表代码一行不用碰
}// 基于 JDK 25 (本地 D:/1ForCode/JAVA_Source), java.util.LinkedHashMap —— 顺序遍历与迭代器
public boolean containsValue(Object value) {
for (LinkedHashMap.Entry<K,V> e = head; e != null; e = e.after) { // 顺链表走 O(size);HashMap 得扫整个桶数组 O(capacity)
V v = e.value;
if (v == value || (value != null && value.equals(v)))
return true;
}
return false;
}
abstract class LinkedHashIterator {
LinkedHashMap.Entry<K,V> next;
LinkedHashMap.Entry<K,V> current;
int expectedModCount;
boolean reversed; // JDK 21 的 reversed() 视图复用同一个迭代器,只是反着走
LinkedHashIterator(boolean reversed) {
this.reversed = reversed;
next = reversed ? tail : head;
expectedModCount = modCount;
current = null;
}
public final boolean hasNext() {
return next != null;
}
final LinkedHashMap.Entry<K,V> nextNode() {
LinkedHashMap.Entry<K,V> e = next;
if (modCount != expectedModCount) // accessOrder=true 时迭代中 get 也会改 modCount,在这里炸 CME
throw new ConcurrentModificationException();
if (e == null)
throw new NoSuchElementException();
current = e;
next = reversed ? e.before : e.after; // 顺链表走,顺序与桶分布完全无关
return e;
}
public final void remove() {
Node<K,V> p = current;
if (p == null)
throw new IllegalStateException();
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
current = null;
removeNode(p.hash, p.key, null, false, false); // 走 HashMap.removeNode,回调 afterNodeRemoval 同步断链
expectedModCount = modCount;
}
}一个开箱即用的 LRU 缓存,全部逻辑就靠上面的钩子:
代码块收起展开
class LRUCache<K,V> extends LinkedHashMap<K,V> {
private final int capacity;
LRUCache(int capacity) {
super(16, 0.75f, true); // accessOrder=true:get/put 都算"使用",用过的沉到尾部
this.capacity = capacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return size() > capacity; // 超容量就淘汰链表头(最久未访问的那个)
}
}原理串讲
以 accessOrder=true 的 LRU 场景走一遍完整链路。
put(k, v) 没有被 LinkedHashMap 重写,走的仍是 HashMap.putVal:定位桶之后若是新 key,putVal 调用工厂方法 newNode——LinkedHashMap 重写了它,new Entry 之后顺手 linkNodeAtEnd(p) 把节点接到 tail;
putVal 收尾处回调 afterNodeInsertion(true),它取出 head(最老节点)去问 removeEldestEntry(first),你重写的这个方法返回 true 时当场 removeNode 淘汰,而 removeNode 删完桶又回调 afterNodeRemoval 把节点从链表摘下——一次 put 的”进一个、出一个”就此闭环。
若 key 已存在,putVal 不造新节点,覆盖 value 后回调 afterNodeAccess(e),把 e 摘下重挂到 tail。
读路径同理:get 先用父类 getNode 按哈希定位(O(1) 不变),命中且 accessOrder 为 true 时调 afterNodeAccess 提鲜。
head 于是永远沉淀着最久未用的节点,淘汰只需 O(1) 取头。
为什么用回调钩子而不重写 put/remove?HashMap 的 putVal/removeNode 里预埋了 afterNodeAccess/afterNodeInsertion/afterNodeRemoval 三个空方法(模板方法模式),LinkedHashMap 只填钩子,哈希定位、扩容、树化那一大坨逻辑全部原样复用;HashMap 也因此完全不需要知道子类在维护什么结构。
代价是钩子永远躺在 HashMap 的热路径上,好在对 HashMap 实例这些是空方法,JIT 内联后近乎免费。
为什么 afterNodeAccess 要 ++modCount?挪链表不改 size,但迭代器是顺着 after 链走的:如果 get 把一个尚未遍历到的节点挪去尾部,这个节点会被遍历两次;把已遍历过的挪走则顺序错乱。
所以顺序变更必须视为结构修改,宁可 fail-fast 也不给出错误结果——反过来记:accessOrder=true 的 map 在 for-each 里调 get 必炸 ConcurrentModificationException。
为什么 afterNodeInsertion 要带 evict 参数?LinkedHashMap(Map) 构造和反序列化都靠 putMapEntries 灌数据,此时传 evict=false:对象还是半成品,不该触发用户重写的淘汰逻辑(此时子类字段可能还没初始化)。只有正常的 put 才传 true。
树化为什么不破坏顺序?HashMap.TreeNode 在继承链上是 TreeNode -> LinkedHashMap.Entry -> HashMap.Node,树节点天生带 before/after;桶内节点在链表形态与树形态之间转换时新建对象,由 transferLinks 把两根指针原位接管。
桶里怎么折腾是哈希的事,全局遍历顺序只认这条双向链表。
设计取舍
- 每个节点多背 before/after 两根指针,换来确定性遍历顺序,且遍历成本从 HashMap 的 O(capacity) 降到 O(size)——大容量稀疏表里反而更快。
- HashMap.TreeNode 继承 LinkedHashMap.Entry:普通 HashMap 的树节点也白背两根指针。JDK 注释明说这是刻意为之——树桶本就罕见,用一点空间换掉两套节点类的复杂度。
- accessOrder=true 之后 get 是写操作:并发读也会写坏链表,包一层 Collections.synchronizedMap 时 get 同样要进锁;这也是它只配当单机小缓存的原因。
- removeEldestEntry 只在插入后被调用、一次最多淘汰一个,是”容量恒定”钩子,做不了按时间过期;要过期、要并发、要命中率统计,生产上直接上 Caffeine。
- putFirst/putLast 用临时字段 putMode 改写 linkNodeAtEnd/afterNodeAccess 的行为来复用 put 全链路,代价是这个字段线程不安全——本类本来就不是线程安全的,索性不设防。