☰
LinkedHashMap源码解析:有序Map与LRU缓存实现原理
2026/10/17 10:04:46 网站建设 项目流程

1. 它到底解决了什么问题

1.1 从HashMap的“无序”说起

只要是写过Java的开发者,多少都跟HashMap打过交道。拿它存数据、取数据,O(1)的时间复杂度确实舒服。但有个老生常谈的坑:HashMap不保证遍历顺序,甚至扩容之后同一个key可能跑到完全不同的桶里,迭代顺序完全乱掉。早期版本里,相同的插入顺序在不同容量下遍历结果都不一样,排查问题的时候很容易被带偏。

LinkedHashMap的出现,简单说就是给HashMap补上了“顺序”这块短板。它继承了HashMap的全部功能,还是那套数组加链表加红黑树的结构,却在内部额外维护了一条双向链表,把所有插入的节点串起来。这条链表记的是“谁先进来的、谁后进来的”,遍历的时候就按这条链表的顺序走,跟哈希桶的物理位置彻底解耦。

第一次看到这个设计的时候,我最大的感受是:居然可以用这么轻量的方式,把一个无序容器变成有序容器,而且几乎不损失查询性能。增删改查的复杂度依然是O(1),只是每个节点多存了两个引用而已。这种“组合优于继承”的思路,在后面的源码里体现得淋漓尽致。

1.2 插入顺序与访问顺序两个核心能力

LinkedHashMap提供了两种顺序模式,由构造器里的accessOrder字段控制。

第一种是插入顺序,这是默认行为。每次往map里放一个新key,对应的节点就挂到双向链表的尾部。遍历的时候,从链表头部往后走,出来的顺序就是你put进去的顺序,先来的在前,后来的在后。如果你put了一个已经存在的key,只是覆盖value,那么节点在链表中的位置不会变。这个特性非常适合做“保持插入顺序的映射表”,比如配置文件解析、参数顺序展示、批量任务的先后依赖。

第二种是访问顺序,需要显式开启。在这种模式下,每次通过get或者put操作命中某个节点,这个节点就会被移动到链表尾部,表示“刚刚被使用过”。这样一来,链表头部永远是“最长时间没有被访问”的节点,尾部永远是“最近刚被访问”的节点。LRU(Least Recently Used)缓存的核心思想,就是看谁最久没被碰过,就优先淘汰谁。LinkedHashMap的这个设计,等于是把LRU的数据结构底子直接给你铺好了。

这两种模式切换起来就一个布尔值的事,但背后对应的是完全不同的业务语义。前者保的是“公平的先后”,后者保的是“冷热分离”。

1.3 适合谁来看、用来做什么

如果你是刚学完HashMap源码,想进一步了解Java集合框架的扩展思路,这篇内容会帮你把继承、覆写、钩子方法这些概念串起来。如果你在项目里需要一个“带顺序的Map”或者轻量级LRU缓存,又不想引入外部缓存框架,那LinkedHashMap就是最顺手的一个工具。

我见过有人在业务代码里用HashMap存参数,打印日志的时候顺序总是乱的,排查半天才发现是遍历顺序问题。换用LinkedHashMap之后,日志一目了然。也见过有人用LinkedList加HashMap手写LRU,代码写了一百多行,还要自己处理并发和删除逻辑,后来换成LinkedHashMap重写,十几行就搞定了。很多问题不是你写不出来,而是JDK早就给了更好的现成方案,只是没注意而已。

下面就从数据结构开始,一步步把LinkedHashMap的源码拆开看。

2. 整体设计:一张哈希表加一条双向链表

2.1 继承关系与Entry节点结构

先看类声明:

public class LinkedHashMap<K,V> extends HashMap<K,V> implements Map<K,V>

它直接继承HashMap,所以HashMap里那套数组table、负载因子loadFactor、扩容阈值threshold,全部原样复用。LinkedHashMap本身没有新增任何存储桶相关的结构,只是多维护了一条双向链表。

内部的节点类长这样:

static class Entry<K,V> extends HashMap.Node<K,V> { Entry<K,V> before, after; Entry(int hash, K key, V value, Node<K,V> next) { super(hash, key, value, next); } }

继承自HashMap.Node,所以hash、key、value、next(链表指针)这些字段都在,红黑树节点TreeNode也是Entry的子类。在此基础上,它多了两个指针:before和after,分别指向前一个节点和后一个节点。正是这两个指针,构成了贯穿所有节点的双向链表。

这里有一个值得琢磨的细节:Entry实际上同时存在于两个结构里。一个是HashMap的哈希桶链表或红黑树,负责快速查找;另一个是LinkedHashMap的双向链表,负责维护顺序。两个结构通过同一个节点对象共享,不额外复制数据,代价只是每个节点多了两个引用。这种“一个对象挂两条链”的思路,在操作系统页表、缓存系统里都很常见,但JDK源码里露得这么直白的,LinkedHashMap算是一个。

2.2 三个决定命运的关键字段

LinkedHashMap只新增了三个成员变量:

transient LinkedHashMap.Entry<K,V> head; transient LinkedHashMap.Entry<K,V> tail; final boolean accessOrder;

head是双向链表的头节点,tail是尾节点。每次新节点插入,默认挂到tail后面;每次访问命中的节点,如果accessOrder为true,就把它摘下来重新挂到tail。

accessOrder是最后一个决定性变量,它一旦在构造器里确定,整个map的行为模式就固定了。false表示“保持插入顺序”,true表示“保持访问顺序”。这个字段被声明为final,意思是构造完之后不允许再改,避免运行时行为突然变化导致难以排查的bug。

我第一次看这里的时候想:为什么不直接提供一个set方法,让开发者在运行期随时切换?后来想明白了,顺序语义是容器的核心契约,如果允许切换,迭代中可能出现前后不一致的情况,还容易引发并发问题。final就是告诉你:选定了就锁死,别折腾。

2.3 构造函数逐个拆解

LinkedHashMap一共提供了五个构造器:

// 默认,容量16,负载因子0.75,插入顺序 public LinkedHashMap() // 指定初始容量 public LinkedHashMap(int initialCapacity) // 指定初始容量和负载因子 public LinkedHashMap(int initialCapacity, float loadFactor) // 从已有Map构造 public LinkedHashMap(Map<? extends K, ? extends V> m) // 核心构造器:第三个参数决定顺序模式 public LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder)

前四个内部都是调HashMap对应构造器,然后把accessOrder设为默认的false。真正特殊的是最后一个三参构造器:

public LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder) { super(initialCapacity, loadFactor); this.accessOrder = accessOrder; }

注意super(initialCapacity, loadFactor)走的是HashMap的构造逻辑,会校验参数合法性并算好扩容阈值,LinkedHashMap只是额外记录顺序模式。这里有个常见误用:只传两个参数想开启访问顺序,是不行的,必须用三参构造器。我在不少面试题里见过这个坑,也有开发者在代码里new LinkedHashMap<>(16, 0.75f)然后疑惑为什么顺序不变,其实人家默认就是插入顺序。

2.4 模板方法模式的精妙

LinkedHashMap能保持HashMap的代码不被大改,靠的是HashMap里预留的“钩子方法”。在HashMap源码里,能看到几段这样看似无意义的调用:

afterNodeAccess(e); afterNodeInsertion(evict); afterNodeRemoval(node);

这几个方法在HashMap里实现是空的,啥也不干。如果直接看HashMap源码,可能根本注意不到它们的存在。但正是这几个空方法,给LinkedHashMap留出了“插一脚”的机会。

LinkedHashMap覆写了这些方法,在HashMap执行完核心逻辑后,顺势把双向链表的维护动作补上。这就是典型的模板方法模式:父类规定框架流程,子类实现细节差异。你不需要改动HashMap的put、get、remove核心文章,只要把“额外要做的动作”挂到钩子上,整个类的行为就变了。

这种设计在阅读源码时最有启发意义:好的框架不是靠大量if-else区分场景,而是靠预留扩展点让子类自行决定。以后自己设计容易被继承扩展的类时,不妨也留几个空方法来当钩子。

3. 核心机制逐行拆解

3.1 afterNodeAccess:访问顺序的维护

这个方法是LinkedHashMap里最关键的一块,直接看实现:

void afterNodeAccess(Node<K,V> e) { LinkedHashMap.Entry<K,V> last; if (accessOrder && (last = tail) != e) { LinkedHashMap.Entry<K,V> p = (LinkedHashMap.Entry<K,V>) e; LinkedHashMap.Entry<K,V> b = p.before; LinkedHashMap.Entry<K,V> a = p.after; p.after = null; 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; } }

这段代码做的事情,翻译成人话就是:如果开启了访问顺序,并且命中的节点还不是尾节点,就把它从链表中“摘下来”,再“接到尾部”。

为什么要判断(last = tail) != e?如果访问的本来就是最后一个节点,那就没必要动了,省去不必要的指针操作。别小看这个判断,在频繁访问热数据的场景下,很多被访问的key恰恰就是最近插入的,直接命中尾部的情况很常见,能省则省。

++modCount这行值得注意。modCount是AbstractList里定义的“结构修改计数器”,迭代器靠它来检测并发修改。在HashMap中,插入、删除都会自增modCount。在访问顺序模式下,移动节点位置改变了链表结构,虽然map里的逻辑键值对没变,但迭代顺序变了,也算一种结构性修改,所以同样要自增。如果不加这一行,迭代过程中访问节点就会让迭代器检测不到结构变化,导致遍历结果出现前后矛盾。

这个方法的调用点主要集中在三个地方:get命中、getOrDefault命中、put覆盖已有key。逻辑上很好理解:只要某个操作让节点的“最后访问时间”变了,就要把它挪到尾部。

3.2 afterNodeInsertion与removeEldestEntry的驱逐逻辑

再看插入完成后的钩子方法:

void afterNodeInsertion(boolean evict) { LinkedHashMap.Entry<K,V> first; if (evict && (first = head) != null && removeEldestEntry(first)) { K key = first.key; removeNode(hash(key), key, null, false, true); } }

这是LRU缓存的核心入口。每次put完成后,HashMap的putVal会带着evict=true调用这个方法。方法逻辑是:如果允许驱逐,并且链表头节点不为空,并且removeEldestEntry返回true,就把头节点移除。

再看removeEldestEntry的默认实现:

protected boolean removeEldestEntry(Map.Entry<K,V> eldest) { return false; }

默认永远返回false,所以默认情况下afterNodeInsertion什么也不做。但这是一个protected方法,专门留给子类覆写的。实现LRU时,你通常会写:

@Override protected boolean removeEldestEntry(Map.Entry<K,V> eldest) { return size() > capacity; }

这里一定注意:removeEldestEntry是在插入完成之后才调用的,所以size()已经是插入后的值。当size超过容量上限时返回true,触发头节点移除。头节点在整个双向链表里就是“最老”的节点,在访问顺序模式下也就是“最久没被访问”的节点,拿它开刀完全符合LRU策略。

还有个细节:这里直接调用removeNode方法,而不是remove方法。removeNode是HashMap里更底层的删除逻辑,按key删除节点并返回该节点。LinkedHashMap的remove(key)最终也走这条路,只是在后面又挂了一个afterNodeRemoval钩子来维护链表。在afterNodeInsertion内部手动调用removeNode时,同样会触发afterNodeRemoval,形成完整的“从哈希表和双向链表同时摘除”的连锁反应。

3.3 afterNodeRemoval:删除时的链表维护

void afterNodeRemoval(Node<K,V> e) { LinkedHashMap.Entry<K,V> p = (LinkedHashMap.Entry<K,V>) e; LinkedHashMap.Entry<K,V> b = p.before; LinkedHashMap.Entry<K,V> 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; }

逻辑很直白:把节点p从双向链表中摘除。如果p是头节点,则新头节点变为p.after;如果p是尾节点,则新尾节点变为p.before;否则将前后节点互连。

注意末尾把p.before和p.after都设为null,一方面是为了帮助垃圾回收,另一方面也避免这个已经脱离链表的节点还持有对相邻节点的引用,造成意外泄漏。很多人写自己的链表时会忽略这一步,其实清理干净引用是好习惯。

这个方法会被HashMap.removeNode调用,而removeNode是remove、clear、afterNodeInsertion会共同依赖的底层方法。也就是说,无论你是主动remove还是被LRU自动驱逐,都会走到这里完成双向链表的同步摘除。

3.4 newNode、linkNodeLast与插入顺序的保证

插入顺序是怎么保证的?关键在于LinkedHashMap覆写了newNode和newTreeNode方法:

Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) { LinkedHashMap.Entry<K,V> p = new LinkedHashMap.Entry<>(hash, key, value, e); linkNodeLast(p); return p; }

new Entry时,你看LinkedHashMap.Entry的构造函数:

Entry(int hash, K key, V value, Node<K,V> next) { super(hash, key, value, next); linkNodeLast(this); }

也就是说,每新建一个节点,立刻就把它挂到双向链表尾部。linkNodeLast的实现如下:

private void linkNodeLast(LinkedHashMap.Entry<K,V> p) { LinkedHashMap.Entry<K,V> last = tail; tail = p; if (last == null) head = p; else { p.before = last; last.after = p; } }

这一步和afterNodeInsertion是配合关系:newNode负责“把新节点串进链表尾部”,afterNodeInsertion负责“插入后检查要不要把最老的踢出去”。两个动作一个在前一个在后,共同完成了LRU缓存的“加新、删旧”闭环。

红黑树节点的创建是newTreeNode,底层同样调用linkNodeLast,所以树化之后,顺序链表依然完好无损。这也是LinkedHashMap能同时兼容红黑树和双向链表的原因。

3.5 get、put、remove完整流程追踪

通过以上机制,可以完整追踪一次get操作在访问顺序模式下发生了什么:

public V get(Object key) { Node<K,V> e; if ((e = getNode(hash(key), key)) == null) return null; if (accessOrder) afterNodeAccess(e); return e.value; }

getNode是HashMap的底层查找方法,找到节点后,如果accessOrder为true,就调用afterNodeAccess把它挪到尾部。

put操作走HashMap.putVal。大致流程:计算hash定位桶,如果在桶里找到相同key的节点,则覆盖value并调用afterNodeAccess移动该节点;如果没找到,则调用newNode创建节点并插入哈希桶,然后调用afterNodeInsertion检查是否驱逐最老节点。整个过程在HashMap的框架里完成,LinkedHashMap只负责在钩子位置补充链表维护。

remove操作走HashMap.removeNode,删除节点后调用afterNodeRemoval完成双向链表的摘除。

再强调一下,这三个钩子方法在HashMap里都是空方法,只在LinkedHashMap的子类继承时才被覆写。这也是为什么HashMap本身完全不受影响,性能没有任何损失——空方法调用在JIT编译后基本就是几条指令的事。

4. 实战:基于LinkedHashMap实现LRU缓存

4.1 最少代码实现

实现一个LRU缓存,最经典的写法如下:

class LRUCache<K, V> extends LinkedHashMap<K, V> { private final int capacity; public LRUCache(int capacity) { super(capacity, 0.75f, true); this.capacity = capacity; } @Override protected boolean removeEldestEntry(Map.Entry<K, V> eldest) { return size() > capacity; } }

只用四行核心代码,就完成了一个基本的LRU缓存。关键点有三处:

第一,super(capacity, 0.75f, true)必须传true,否则accessOrder为false,整个缓存退化成一个按插入顺序排列的普通map,淘汰策略就全错了。第二,capacity要单独存一份,因为LinkedHashMap没有暴露容量上限的字段。第三,removeEldestEntry的比较条件是size() > capacity,不是>=,因为在插入完成后调用时,新元素已经写进去了。

如果你有cleanup回调需求,比如缓存项被淘汰时要释放外部资源,也可以覆写remove方法:

@Override public V remove(Object key) { return super.remove(key); }

然后在调用处对返回值做资源清理。不过要注意LRU自动驱逐时走的是afterNodeInsertion——removeNode,返回值拿不到,所以要么在Entry的value上做文章,要么再用一个监听器模式处理。实际项目中,直接扩展removeEldestEntry并在外层包装一个淘汰监听器也不难。

4.2 参数选型与容量设计

构造器里的initialCapacity值得单独说说。如果你把缓存容量定为100,直接写super(100, 0.75f, true),HashMap在元素数量超过100*0.75=75时就会触发扩容,变成容量200。对LRU缓存来说,最多只会存100个元素,扩容到200完全没必要,白白浪费内存。

正确的做法是给哈希表预留足够的初始容量。HashMap扩容阈值计算公式是capacity * loadFactor >= size * 1。要让容量100的缓存不扩容,需要initialCapacity * 0.75 >= 100,也就是initialCapacity至少为134。往大了取,可以直接写:

super((int) Math.ceil(capacity / 0.75f) + 1, 0.75f, true);

这行代码保证缓存最多存capacity个元素时,哈希表不会触发扩容。省掉的是一次O(n)的重哈希开销,对高并发、高吞吐场景来说,这笔买卖很划算。

还有一个小tips:如果你能预估元素的平均大小,或者业务key有特殊分布,别怕打破“0.75”这个默认值。比如你知道所有key的hash分布非常好,可以适当提高负载因子到0.8甚至0.9来减少内存占用;反之如果hash碰撞多,就降低一点。一切以实测和监控为准,默认值只是均值最优,不一定是你的业务最优。

4.3 需要注意的边界条件

LRU缓存有几个边界条件经常出问题。

第一个是容量为1甚至为0的情况。容量为1时,每次插入都会立刻触发淘汰,表现是缓存里永远只有一个元素。容量为0时,每次put会插入再马上删除,如果业务逻辑对删除有依赖,可能导致意想不到的循环。建议在构造时对capacity做下限校验。

第二个是removeEldestEntry的入参,严格说是“当前最老的节点”,但如果accessOrder为true,这个最老节点就是最久没被访问的节点,符合LRU语义。如果误用insertionOrder模式实现缓存,淘汰的永远是“最先插入的”,这其实更接近FIFO(先进先出),很多业务场景需要的就是FIFO,所以这也不全是坑,要注意区分缓存淘汰策略:LRU是按访问时间淘汰,FIFO是按插入时间淘汰。如果你的业务本质是FIFO,那在LinkedHashMap里用默认的插入顺序,然后重写removeEldestEntry,效果就是FIFO缓存。这是不少开发者容易混淆的地方。

第三个是线程安全。LinkedHashMap本身非线程安全,如果LRU缓存会被多线程并发读写,必须在外层加锁或者用Collections.synchronizedMap包裹:

Map<K, V> cache = Collections.synchronizedMap(new LRUCache<>(100));

但注意synchronizedMap只会同步map方法,对你自定义的removeEldestEntry覆写没有影响,因为它是在内部被调用的,整个put流程在synchronized同步块中完成,安全。真正的坑在于,如果你用迭代器遍历缓存,还需要手动加锁,否则并发修改异常照样会抛。

5. 与HashMap的几处关键差异

5.1 containsValue的效率差异

这是一个经常被忽略的点。HashMap的containsValue实现是遍历整个table,从每个桶的头节点出发,沿着链表和红黑树逐个比对value。在最坏情况下,如果所有key都被哈希到同一个桶里,一次containsValue的复杂度是O(n),还要算上table里空桶的遍历开销,非常浪费。

LinkedHashMap覆写了containsValue,直接遍历双向链表:

public boolean containsValue(Object value) { for (LinkedHashMap.Entry<K,V> e = head; e != null; e = e.after) { V v = e.value; if (v == value || (value != null && value.equals(v))) return true; } return false; }

双向链表的长度等于map里的实际元素个数n,不管哈希桶有多稀疏,遍历成本就是O(n),而且不会碰空桶。从实现上看,这属于“用一条额外链表换遍历效率”的典型收益。如果你的业务里经常要判断某个value是否存在,用LinkedHashMap比HashMap更合适,顺便还能拿到稳定的遍历顺序。

5.2 clear与迭代器的顺序感知

HashMap的clear就是遍历table把每个桶置null,简单粗暴。LinkedHashMap额外覆写了clear,除了清空table,还把head和tail都设为null:

public void clear() { super.clear(); head = tail = null; }

这个覆写不是可有可无的。如果不把head、tail置空,双向链表里还残留着旧节点引用,下次put新节点时,新节点会错误地挂到旧链表上,导致整个顺序乱掉。这是典型的“父类逻辑完成,子类状态也要同步清理”的场景。

迭代器方面,LinkedHashMap的iterator直接基于双向链表的遍历,而HashMap的iterator基于哈希桶的遍历。所以LinkedHashMap的迭代总是稳定的、可预期的;HashMap的迭代顺序完全取决于哈希表结构和当前容量。如果代码里对“遍历顺序”有依赖,比如按序输出配置、按序同步数据,一定记得用LinkedHashMap。

5.3 序列化中的链表重建

LinkedHashMap覆写了writeObject和readObject。writeObject先写出容量、size、accessOrder等元信息,再按双向链表的顺序写出所有key-value。readObject反向操作,一个个读取键值对并调用putVal,最终重新构建双向链表。

这里有个很有意思的点:LinkedHashMap的readObject重建后,链表顺序是完全按照序列化时的顺序恢复的,因为序列化时就是按链表顺序写入的,读取时又按相同顺序插入,顺序天然一致。这让人能放心地把LinkedHashMap持久化到磁盘,再恢复出来,顺序不会丢。

如果不用LinkedHashMap而用HashMap做序列化,恢复出来的遍历顺序几乎铁定是乱的,因为HashMap的哈希桶结构与初始容量强相关,反序列化后的容量已经和当时序列化前不一样了。所以涉及配置类、模板类数据的持久化,LinkedHashMap是更靠谱的选择。

5.4 性能与内存开销的权衡

任何方案都有代价。LinkedHashMap在提供顺序能力的同时,确实比HashMap多付出了一些资源。

内存方面,每个Entry多了before和after两个引用,64位JVM下开启指针压缩时大概是16字节的额外占用。十万条数据也就多1.6MB左右,对大多数应用来说是完全可以接受的。但如果存的是海量小对象,比如千万级key-value,这个开销就不得不纳入预算了。

性能方面,put和get在开启accessOrder时,多了一次链表节点的摘除和重挂操作,涉及若干次指针读写。这个操作是O(1)的,但常数比HashMap的纯查找大。实测下来,在日常业务流量的压力下,差距通常在个位数百分比以内。如果你对超高性能有极致要求,并且完全不需要顺序,那纯净的HashMap依然是更好的选择。

一句话总结:顺序是付费功能,但没有贵到用不起。

6. 常见问题与踩坑总结

6.1 并发安全问题

LinkedHashMap解决的是顺序问题,不是并发问题。它的所有方法都没有同步控制,多线程并发put、remove、迭代,轻则数据不一致,重则直接抛ConcurrentModificationException。

遇到这个异常,先检查是不是有线程在迭代过程中修改了map。访问顺序模式下,如果另一个线程恰好get了某个key,触发了afterNodeAccess,也会导致modCount变化,进而让正在迭代的线程抛出异常。这个坑比普通HashMap更隐蔽,因为你可能连“看起来在修改”的操作都没有,只是有个线程读了一下就会出事。

解决方案不外乎三种:外部同步、Collections.synchronizedMap包装、换成支持并发顺序的容器。如果的业务既有顺序需求又有并发需求,可以考虑自己加锁,比如用读写锁,读多写少的场景收益明显。

6.2 迭代顺序“意外变化”的排查

有次我在项目里遇到一个很诡异的现象:初始化了一个LinkedHashMap,往里面put了若干键值对,处理完一轮后map的顺序居然变了。排查半天,才发现是某处代码意外调用了get方法,而map是用三参构造器开启的访问顺序模式,get操作直接把节点挪到了尾部。

这类问题的排查思路很简单:先确认accessOrder的值,再检查get、put、getOrDefault这些会触发afterNodeAccess的调用点。如果是插入顺序模式,只要不手动remove再put,顺序就永远不会变。反之,如果你刻意开启了访问顺序,那就要接受“读操作会改变迭代顺序”这个行为,别把它当成bug。

另外,put一个已存在的key时,如果开启了访问顺序,这个节点也会被挪到尾部;插入顺序模式下则原地不动。这个差异在做缓存淘汰时是优势,但如果你误以为“覆盖value不会影响顺序”,就会被绕进去。覆盖在LRU语义下也算一次访问,从“最近使用”的角度看,这个设计是合理的。

6.3 内存控制问题

用LinkedHashMap做缓存时,如果忘记覆写removeEldestEntry,或者覆写了但容量参数传错,最要命的结果就是缓存无限膨胀。比如下面的写法:

super(16, 0.75f, true);

如果不重写removeEldestEntry,这个map就会一直增长,把所有插入的数据全部留在内存里,和普通HashMap没区别。有的同学会直觉地认为“容量传了16,Map最多存16条”——这是完全错误的认知。initialCapacity只是哈希表的初始桶数,不是允许存储的元素个数。

控制内存的正确姿势是明确覆写removeEldestEntry,并且定期监控size()变化。如果发现size一直涨不到上限又掉不下来,说明淘汰策略没有生效,重点检查accessOrder和removeEldestEntry这两个地方。

6.4 阅读源码的几个实用方法

最后聊点方法论。解析LinkedHashMap源码时,我建议按照“对比差异”的方式来读:先把HashMap的putVal、getNode、removeNode读通,心里有了框架,再回来看LinkedHashMap覆写了哪些方法、新增了哪些字段。你会发现改动点非常收敛,基本就是那几个钩子方法。

另外,用IDE的“查找调用层次”功能追一下afterNodeAccess的调用链,能直观看到它在get、put、getOrDefault里分别触发的位置,比死记硬背代码更高效。断点调试一个小型demo,分别用插入顺序和访问顺序跑一遍,观察head和tail的变化,也很容易形成肌肉记忆。

读完一个类的源码后,如果能自己手写一个迷你版实现,或者把LRU缓存应用到一个真实场景里,那理解深度就不是只看一遍源码能比的。源码解析这件事,读十遍不如动手写一遍。

我个人在实际操作中的体会是,LinkedHashMap是Java集合框架里少有的“小类大用”的典范。它的源码总量不大,但每一处设计都值得把玩,从模板方法到双向链表,从访问钩子到驱逐策略,环环相扣。把这些思路吃透,再去理解外部缓存框架的设计,会发现很多思想都是相通的。最后再分享一个小技巧:解析这种“在父类上扩展”的类,永远先列一张“父类有哪些空方法、子类覆写了哪些”的对照表,把差异点形成画面感,整个类的结构就一目了然了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询