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操作命中某个节点这个节点就会被移动到链表尾部表示“刚刚被使用过”。这样一来链表头部永远是“最长时间没有被访问”的节点尾部永远是“最近刚被访问”的节点。LRULeast 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 LinkedHashMapK,V extends HashMapK,V implements MapK,V它直接继承HashMap所以HashMap里那套数组table、负载因子loadFactor、扩容阈值threshold全部原样复用。LinkedHashMap本身没有新增任何存储桶相关的结构只是多维护了一条双向链表。内部的节点类长这样static class EntryK,V extends HashMap.NodeK,V { EntryK,V before, after; Entry(int hash, K key, V value, NodeK,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.EntryK,V head; transient LinkedHashMap.EntryK,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(NodeK,V e) { LinkedHashMap.EntryK,V last; if (accessOrder (last tail) ! e) { LinkedHashMap.EntryK,V p (LinkedHashMap.EntryK,V) e; LinkedHashMap.EntryK,V b p.before; LinkedHashMap.EntryK,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.EntryK,V first; if (evict (first head) ! null removeEldestEntry(first)) { K key first.key; removeNode(hash(key), key, null, false, true); } }这是LRU缓存的核心入口。每次put完成后HashMap的putVal会带着evicttrue调用这个方法。方法逻辑是如果允许驱逐并且链表头节点不为空并且removeEldestEntry返回true就把头节点移除。再看removeEldestEntry的默认实现protected boolean removeEldestEntry(Map.EntryK,V eldest) { return false; }默认永远返回false所以默认情况下afterNodeInsertion什么也不做。但这是一个protected方法专门留给子类覆写的。实现LRU时你通常会写Override protected boolean removeEldestEntry(Map.EntryK,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(NodeK,V e) { LinkedHashMap.EntryK,V p (LinkedHashMap.EntryK,V) e; LinkedHashMap.EntryK,V b p.before; LinkedHashMap.EntryK,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方法NodeK,V newNode(int hash, K key, V value, NodeK,V e) { LinkedHashMap.EntryK,V p new LinkedHashMap.Entry(hash, key, value, e); linkNodeLast(p); return p; }new Entry时你看LinkedHashMap.Entry的构造函数Entry(int hash, K key, V value, NodeK,V next) { super(hash, key, value, next); linkNodeLast(this); }也就是说每新建一个节点立刻就把它挂到双向链表尾部。linkNodeLast的实现如下private void linkNodeLast(LinkedHashMap.EntryK,V p) { LinkedHashMap.EntryK,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) { NodeK,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 LRUCacheK, V extends LinkedHashMapK, V { private final int capacity; public LRUCache(int capacity) { super(capacity, 0.75f, true); this.capacity capacity; } Override protected boolean removeEldestEntry(Map.EntryK, 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.7575时就会触发扩容变成容量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包裹MapK, 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.EntryK,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都设为nullpublic 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没区别。有的同学会直觉地认为“容量传了16Map最多存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集合框架里少有的“小类大用”的典范。它的源码总量不大但每一处设计都值得把玩从模板方法到双向链表从访问钩子到驱逐策略环环相扣。把这些思路吃透再去理解外部缓存框架的设计会发现很多思想都是相通的。最后再分享一个小技巧解析这种“在父类上扩展”的类永远先列一张“父类有哪些空方法、子类覆写了哪些”的对照表把差异点形成画面感整个类的结构就一目了然了。