⭐️ 本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 和 [BaguTree Pro] 知识星球提问。
学习数据结构与算法的关键在于掌握问题背后的算法思维框架,你的思考越抽象,它能覆盖的问题域就越广,理解难度也更复杂。在实际的业务开发中,往往不需要我们手写数据结构,而是直接使用标准库的数据结构 / 容器类。
本文是 Java & Android 集合框架系列的第 7 篇文章,完整文章目录请移步到文章末尾~
前言
大家好,我是小彭。
在上一篇文章里,我们聊到了 HashMap 的实现原理和源码分析,在源码分析的过程中,我们发现一些 LinkedHashMap 相关的源码,当时没有展开,现在它来了。
那么,LinkedHashMap 与 HashMap 有什么区别呢?其实,LinkedHashMap 的使用场景非常明确 —— LRU 缓存。今天,我们就来讨论 LinkedHashMap 是如何实现 LRU 缓存的。
本文源码基于 Java 8 LinkedHashMap。
思维导图:
- 认识 LRU 缓存淘汰算法
1.1 什么是缓存淘汰算法?
缓存是提高数据读取性能的通用技术,在硬件和软件设计中被广泛使用,例如 CPU 缓存、Glide 内存缓存,数据库缓存等。由于缓存空间不可能无限大,当缓存容量占满时,就需要利用某种策略将部分数据换出缓存,这就是缓存的替换策略 / 淘汰问题。常见缓存淘汰策略有:
- 1、随机策略: 使用一个随机数生成器随机地选择要被淘汰的数据块;
- 2、FIFO 先进先出策略: 记录各个数据块的访问时间,最早访问的数据最先被淘汰;
- 3、LRU (Least Recently Used)最近最少策略: 记录各个数据块的访问 “时间戳” ,最近最久未使用的数据最先被淘汰。与前 2 种策略相比,LRU 策略平均缓存命中率更高,这是因为 LRU 策略利用了 “局部性原理”:最近被访问过的数据,将来被访问的几率较大,最近很久未访问的数据,将来访问的几率也较小;
- 4、LFU (Least Frequently Used)最不经常使用策略: 与 LRU 相比,LFU 更加注重使用的 “频率” 。LFU 会记录每个数据块的访问次数,最少访问次数的数据最先被淘汰。但是有些数据在开始时使用次数很高,以后不再使用,这些数据就会长时间污染缓存。可以定期将计数器右移一位,形成指数衰减。
FIFO 与 LRU 策略
1.2 向外看:LRU 的变型
其实,在标准的 LRU 算法上还有一些变型实现,这是因为 LRU 算法本身也存在一些不足。例如,当数据中热点数据较多时,LRU 能够保证较高的命中率。但是当有偶发的批量的非热点数据产生时,就会将热点数据寄出缓存,使得缓存被污染。因此,LRU 也有一些变型:
- LRU-K: 提供两个 LRU 队列,一个是访问计数队列,一个是标准的 LRU 队列,两个队列都按照 LRU 规则淘汰数据。当访问一个数据时,数据先进入访问计数队列,当数据访问次数超过 K 次后,才会进入标准 LRU 队列。标准的 LRU 算法相当于 LRU-1;
- Two Queue: 相当于 LRU-2 的变型,将访问计数队列替换为 FIFO 队列淘汰数据数据。当访问一个数据时,数据先进入 FIFO 队列,当第 2 次访问数据时,才会进入标准 LRU 队列;
- Multi Queue: 在 LRU-K 的基础上增加更多队列,提供多个级别的缓冲。
小彭在 Redis 和 Vue 中有看到这些 LRU 变型的应用,在 Android 领域的框架中还没有看到具体应用,你知道的话可以提醒我。
1.3 如何实现 LRU 缓存淘汰算法?
这一小节,我们尝试找到 LRU 缓存淘汰算法的实现方案。经过总结,我们可以定义一个缓存系统的基本操作:
- 操作 1 - 添加数据: 先查询数据是否存在,不存在则添加数据,存在则更新数据,并尝试淘汰数据;
- 操作 2 - 删除数据: 先查询数据是否存在,存在则删除数据;
- 操作 3 - 查询数据: 如果数据不存在则返回 null;
- 操作 4 - 淘汰数据: 添加数据时如果容量已满,则根据缓存淘汰策略一个数据。
我们发现,前 3 个操作都有 “查询” 操作, 所以缓存系统的性能主要取决于查找数据和淘汰数据是否高效。 下面,我们用递推的思路推导 LRU 缓存的实现方案,主要分为 3 种方案:
- 方案 1 - 基于时间戳的数组: 在每个数据块中记录最近访问的时间戳,当数据被访问(添加、更新或查询)时,将数据的时间戳更新到当前时间。当数组空间已满时,则扫描数组淘汰时间戳最小的数据。
+ 查找数据: 需要遍历整个数组找到目标数据,时间复杂度为 O(n);
+ 淘汰数据: 需要遍历整个数组找到时间戳最小的数据,且在移除数组元素时需要搬运数据,整体时间复杂度为 O(n)。
- 方案 2 - 基于双向链表: 不再直接维护时间戳,而是利用链表的顺序隐式维护时间戳的先后顺序。当数据被访问(添加、更新或查询)时,将数据插入到链表头部。当空间已满时,直接淘汰链表的尾节点。
+ 查询数据:需要遍历整个链表找到目标数据,时间复杂度为 O(n);
+ 淘汰数据:直接淘汰链表尾节点,时间复杂度为 O(1)。
- 方案 3 - 基于双向链表 + 散列表: 使用双向链表可以将淘汰数据的时间复杂度降低为 O(1),但是查询数据的时间复杂度还是 O(n),我们可以在双向链表的基础上增加散列表,将查询操作的时间复杂度降低为 O(1)。
+ 查询数据:通过散列表定位数据,时间复杂度为 O(1);
+ 淘汰数据:直接淘汰链表尾节点,时间复杂度为 O(1)。
方案 3 这种数据结构就叫 “哈希链表或链式哈希表”,我更倾向于称为哈希链表,因为当这两个数据结构相结合时,我们更看重的是它作为链表的排序能力。
我们今天要讨论的 Java LinkedHashMap 就是基于哈希链表的数据结构。
- 认识 LinkedHashMap 哈希链表
2.1 说一下 LinkedHashMap 的特点
需要注意:LinkedHashMap 中的 “Linked” 实际上是指双向链表,并不是指解决散列冲突中的分离链表法。
- 1、LinkedHashMap 是继承于 HashMap 实现的哈希链表,它同时具备双向链表和散列表的特点。事实上,LinkedHashMap 继承了 HashMap 的主要功能,并通过 HashMap 预留的 Hook 点维护双向链表的逻辑。
+ 1.1 当 LinkedHashMap 作为散列表时,主要体现出 O(1) 时间复杂度的查询效率;
+ 1.2 当 LinkedHashMap 作为双向链表时,主要体现出有序的特性。
- 2、LinkedHashMap 支持 2 种排序模式,这是通过构造器参数
accessOrder
标记位控制的,表示是否按照访问顺序排序,默认为 false 按照插入顺序。
+ **2.1 插入顺序(默认):** 按照数据添加到 LinkedHashMap 的顺序排序,即 FIFO 策略;
+ **2.2 访问顺序:** 按照数据被访问(包括插入、更新、查询)的顺序排序,即 LRU 策略。
- 3、在有序性的基础上,LinkedHashMap 提供了维护了淘汰数据能力,并开放了淘汰判断的接口
removeEldestEntry()
。在每次添加数据时,会回调removeEldestEntry()
接口,开发者可以重写这个接口决定是否移除最早的节点(在 FIFO 策略中是最早添加的节点,在 LRU 策略中是最早未访问的节点); - 4、与 HashMap 相同,LinkedHashMap 也不考虑线程同步,也会存在线程安全问题。可以使用 Collections.synchronizedMap 包装类,其原理也是在所有方法上增加 synchronized 关键字。
2.2 说一下 HashMap 和 LinkedHashMap 的区别?
事实上,HashMap 和 LinkedHashMap 并不是平行的关系,而是继承的关系,LinkedHashMap 是继承于 HashMap 实现的哈希链表。
两者主要的区别在于有序性: LinkedHashMap 会维护数据的插入顺序或访问顺序,而且封装了淘汰数据的能力。在迭代器遍历时,HashMap 会按照数组顺序遍历桶节点,从开发者的视角看是无序的。而是按照双向链表的顺序从 head 节点开始遍历,从开发者的视角是可以感知到的插入顺序或访问顺序。
LinkedHashMap 示意图
- HashMap 预留的 Hook 点
LinkedHashMap 继承于 HashMap,在后者的基础上通过双向链表维护节点的插入顺序或访问顺序。因此,我们先回顾下 HashMap 为 LinkedHashMap 预留的 Hook 点:
- afterNodeAccess: 在节点被访问时回调;
- afterNodeInsertion: 在节点被插入时回调,其中有参数
evict
标记是否淘汰最早的节点。在初始化、反序列化或克隆等构造过程中,evict
默认为 false,表示在构造过程中不淘汰。 - afterNodeRemoval: 在节点被移除时回调。
HashMap.java
1 | java复制代码// 节点访问回调 |
除此了这 3 个空方法外,LinkedHashMap 也重写了部分 HashMap 的方法,在其中插入双链表的维护逻辑,也相当于 Hook 点。在 HashMap 的添加、获取、移除方法中,与 LinkedHashMap 有关的 Hook 点如下:
3.1 HashMap 的添加方法中的 Hook 点
LinkedHashMap 直接复用 HashMap 的添加方法,也支持批量添加:
- HashMap#put: 逐个添加或更新键值对;
- HashMap#putAll: 批量添加或更新键值对。
不管是逐个添加还是批量添加,最终都会先通过 hash 函数计算键(Key)的散列值,再通过 HashMap#putVal
添加或更新键值对,这些都是 HashMap 的行为。关键的地方在于:LinkedHashMap 在 HashMap#putVal
的 Hook 点中加入了双线链表的逻辑。区分 2 种情况:
- 添加数据: 如果数据不存在散列表中,则调用
newNode()
或newTreeNode()
创建节点,并回调afterNodeInsertion()
; - 更新数据: 如果数据存在散列表中,则更新 Value,并回调
afterNodeAccess()
。
HashMap.java
1 | java复制代码// 添加或更新键值对 |
HashMap#put 示意图
3.2 HashMap 的获取方法中的 Hook 点
LinkedHashMap 重写了 HashMap#get
方法,在 HashMap 版本的基础上,增加了 afterNodeAccess()
回调。
HashMap.java
1 | java复制代码public V get(Object key) { |
LinkedHashMap.java
1 | java复制代码public V get(Object key) { |
HashMap#get 示意图
3.3 HashMap 的移除方法中的 Hook 点
LinkedHashMap 直接复用 HashMap 的移除方法,在移除节点后,增加 afterNodeRemoval()
回调。
HashMap.java
1 | java复制代码// 移除节点 |
HashMap#remove 示意图
- LinkedHashMap 源码分析
这一节,我们来分析 LinkedHashMap 中主要流程的源码。
4.1 LinkedHashMap 的属性
- LinkedHashMap 继承于 HashMap,并且新增
head
和tail
指针指向链表的头尾节点(与 LinkedList 类似的头尾节点); - LinkedHashMap 的双链表节点 Entry 继承于 HashMap 的单链表节点 Node,而 HashMap 的红黑树节点 TreeNode 继承于 LinkedHashMap 的双链表节点 Entry。
节点继承关系
LinkedHashMap.java
1 | java复制代码public class LinkedHashMap<K,V> extends HashMap<K,V> implements Map<K,V> { |
LinkedList.java
1 | java复制代码public class LinkedList<E> extends AbstractSequentialList<E> implements List<E>, Deque<E>, Cloneable, java.io.Serializable { |
LinkedHashMap 的属性很好理解的,不出意外的话又有小朋友出来举手提问了:
- 🙋🏻♀️疑问 1:HashMap.TreeNode 和 LinkedHashMap.Entry 的继承顺序是不是反了?
我的理解是作者希望简化节点类型,所以采用了非常规的做法(不愧是标准库)。由于 Java 是单继承的,如果按照常规的做法让 HashMap.TreeNode 直接继承 HashMap.Node,那么在 LinkedHashMap 中就需要区分 LinkedHashMap.Entry 和 LinkedHashMap.TreeEntry,再使用接口统一两种类型。
常规实现
4.2 LinkedHashMap 的构造方法
LinkedHashMap 有 5 个构造方法,作用与 HashMap 的构造方法基本一致,区别只在于对 accessOrder
字段的初始化。
1 | java复制代码// 带初始容量和装载因子的构造方法 |
4.3 LinkedHashMap 如何维护双链表
现在,我们看下 LinkedHashMap 是如何维护双链表的。其实,我们将上一节所有的 Hook 点汇总,会发现这些 Hook 点正好组成了 LinkedHashMap 双向链表的行为:
- 添加数据: 将数据链接到双向链表的尾节点,时间复杂度为 O(1);
- 访问数据(包括添加、查询、更新): 将数据移动到双向链表的尾节点,亦相当于先移除再添加到尾节点,时间复杂度为 O(1);
- 删除数据: 将数据从双向链表中移除,时间复杂度为 O(1);
- 淘汰数据: 直接淘汰双向链表的头节点,时间复杂度为 O(1)。
LinkedHashMap.java
1 | java复制代码// -> 1.1 如果节点不存在,则新增节点 |
4.4 LinkedHashMap 的迭代器
与 HashMap 类似,LinkedHashMap 也提供了 3 个迭代器:
- LinkedEntryIterator: 键值对迭代器
- LinkedKeyIterator: 键迭代器
- LinkedValueIterator: 值迭代器
区别在于 LinkedHashMap 自己实现了 LinkedHashIterator
。在迭代器遍历时,HashMap 会按照数组顺序遍历桶节点,从开发者的视角看是无序的。而 LinkedHashMap 是按照双向链表的顺序从 head 节点开始遍历,从开发者的视角是可以感知到的插入顺序或访问顺序。
LinkedHashMap.java
1 | java复制代码abstract class LinkedHashIterator { |
4.5 LinkedHashMap 的序列化过程
与 HashMap 相同,LinkedHashMap 也重写了 JDK 序列化的逻辑,并保留了 HashMap 中序列化的主体结构。LinkedHashMap 只是重写了 internalWriteEntries()
,按照双向链表的顺序进行序列化,这样在反序列化时就能够恢复双向链表顺序。
HashMap.java
1 | java复制代码// 序列化过程 |
LinkedHashMap.java
1 | java复制代码// 重写:按照双向链表顺序写入 |
- 基于 LinkedHashMap 实现 LRU 缓存
这一节,我们来实现一个简单的 LRU 缓存。理解了 LinkedHashMap 维护插入顺序和访问顺序的原理后,相信你已经知道如何实现 LRU 缓存了。
- 首先,我们已经知道,LinkedHashMap 支持 2 种排序模式,这是通过构造器参数
accessOrder
标记位控制的。所以,这里我们需要将accessOrder
设置为 true 表示使用 LRU 模式的访问顺序排序。 - 其次,我们不需要实现淘汰数据的逻辑,只需要重写淘汰判断接口
removeEldestEntry()
,当缓存数量大于缓存容量时返回 true,表示移除最早的节点。
MaxSizeLruCacheDemo.java
1 | java复制代码public class MaxSizeLruCacheDemo extends LinkedHashMap { |
- 总结
- 1、LRU 是一种缓存淘汰算法,与其他淘汰算法相比,LRU 算法利用了 “局部性原理”,缓存的平均命中率更高;
- 2、使用双向链表 + 散列表实现的 LRU,在添加、查询、移除和淘汰数据的时间复杂度都是 O(1),这种数据结构也叫哈希链表;
+ **查询数据:** 通过散列表定位数据,时间复杂度为 O(1);
+ **淘汰数据:** 直接淘汰链表尾节点,时间复杂度为 O(1)。
- 3、使用 LinkedHashMap 时,主要关注 2 个 API:
+ **`accessOrder` 标记位:** LinkedHashMap 同时实现了 FIFO 和 LRU 两种淘汰策略,默认为 FIFO 排序,可以使用 accessOrder 标记位修改排序模式。
+ **`removeEldestEntry()` 接口:** 每次添加数据时,LinkedHashMap 会回调 removeEldestEntry() 接口。开发者可以重写 removeEldestEntry() 接口决定是否移除最早的节点(在 FIFO 策略中是最早添加的节点,在 LRU 策略中是最久未访问的节点)。
- 4、Android 的 LruCache 内存缓存和 DiskLruCache 磁盘缓存中,都直接复用了 LinkedHashMap 的 LRU 能力。
今天,我们分析了 LinkedHashMap 的实现原理。在下篇文章里,我们来分析 LRU 的具体实现应用,例如 Android 标准库中的 LruCache 内存缓存。
可以思考一个问题,LinkedHashMap 是非线程安全的,Android 的 LruCache 是如何解决线程安全问题的?请关注 小彭说 · Android 开源组件 专栏。
版权声明
本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!
参考资料
- 数据结构与算法分析 · Java 语言描述(第 5 章 · 散列)—— [美] Mark Allen Weiss 著
- 算法导论(第 11 章 · 散列表)—— [美] Thomas H. Cormen 等 著
- 数据结构与算法之美(第 6、18~22 讲) —— 王争 著,极客时间 出品
- LinkedHashMap 源码详细分析(JDK1.8)—— 田小波 著
- LRU 算法及其优化策略——算法篇 —— 豆豉辣椒炒腊肉 著
- 缓冲池(buffer pool),这次彻底懂了! —— 58 沈剑 著
- LeetCode 146. LRU 缓存 —— LeetCode
- Cache replacement policies —— Wikipedia
推荐阅读
Java & Android 集合框架系列文章目录(2023/07/08 更新):
- #1 ArrayList 可以完全替代数组吗?
- #2 说一下 ArrayList 和 LinkedList 的区别?
- #3 CopyOnWriteArrayList 是如何保证线程安全的?
- #4 ArrayDeque:如何用数组实现栈和队列?
- #5 万字 HashMap 详解,基础(优雅)永不过时 —— 原理篇
- #6 万字 HashMap 详解,基础(优雅)永不过时 —— 源码篇
- #7 如何使用 LinkedHashMap 实现 LRU 缓存?
- #8 说一下 WeakHashMap 如何清理无效数据的?
- #9 全网最全的 ThreadLocal 原理详细解析 —— 原理篇
- #10 全网最全的 ThreadLocal 原理详细解析 —— 源码篇
数据结构与算法系列文章:跳转阅读
⭐️ 永远相信美好的事情即将发生,欢迎加入小彭的 Android 交流社群~
本文转载自: 掘金