阶段十:总结专题(第六章:缓存篇)
- Day-第六章:缓存篇
 - ==1. Redis 数据类型==
 - **String**
 - **List**
 - **Hash**
 - **Sorted Set**
 
- ==2. keys 命令问题==
 - ==3. 过期 key 的删除策略==
 - ==4. Redis 持久化==
 - **AOF 持久化**
 - **AOF 重写**
 - **RDB 持久化**
 - **混合持久化**
 
- ==5. 缓存问题==
 - **缓存击穿**
 - **缓存雪崩**
 - **缓存穿透**
 - **缓存一致性问题——旁路缓存**
 - ==旁路缓存==
 - **==用锁解决一致性==**
 
- ==6. 缓存原子性==
 - **乐观锁保证原子性**
 - **lua 脚本保证原子性**
 
- ==7. LRU Cache(淘汰策略)实现==
 - **LRU Cache 链表实现(考代码)**
 - **参考代码一**:(回答这个代码,体现了对链表的了解)
 - 参考代码二
 
- **Redis LRU Cache 实现**
 
Day-第六章:缓存篇
1. Redis 数据类型
要求
- 掌握常见数据类型的底层结构
 
概述
数据类型实际描述的是 value 的类型,key 都是 string,常见数据类型(value)有
- (字符串)string(embstr、raw、int)
 - (列表)list(quicklist,由多个 ziplist 双向链表组成)
 - (哈希表)hash(ziplist、hashtable)
 - (集合中的元素不能重复)set(intset、hashtable)
 - (在set基础上有序)sorted set(ziplist、skiplist)
 - (用的不多,做统计)bitmap
 - (用的不多,做统计)hyperloglog
 
每一种类型都用 redisObject 结构体来表示,每种类型根据情况不同,有不同的编码 encoding(即底层数据结构)
String
-  
如果字符串保存的是整数值,则底层编码为
int(4字节),实际使用 long(8字节) 来存储好处:①数字占用的空间小;②做数据运算方便,不用把字符装换成数字在做运算啦;
 -  
如果字符串保存的是非整数值(浮点数字或其它字符)又分两种情况
- 长度 <= 39 字节,使用 
embstr编码来保存,即将 redisObject 和 sdshdr 结构体保存在一起,分配内存只需一次 - 长度 > 39 字节,使用 
raw编码来保存,即 redisObject 结构体分配一次内存,sdshdr 结构体分配一次内存,用指针相连 
 - 长度 <= 39 字节,使用 
 -  
sdshdr 称为简单动态字符串,实现上有点类似于 java 中的 StringBuilder,有如下特性
- 单独存储字符长度,相比 char* 获取长度效率高(char* 是 C 语言原生字符串表示)
 - 支持动态扩容,方便字符串拼接操作
 - 预留空间,减少内存分配、释放次数(< 1M 时容量是字符串实际长度 2 倍,>= 1M 时容量是原有容量 + 1M)
 - 二进制安全,例如传统 char* 以 \0 作为结束字符,这样就不能保存视频、图片等二进制数据,而 sds 以长度来进行读取
 
 
List
面试题:quicklist与ziplist的区别
-  
3.2 开始,Redis 采用 quicklist(大链表)作为其编码方式,它是一个双向链表,节点元素是 ziplist(小链表,其中存储数据)
- 由于是链表,内存上不连续
 - 操作头尾效率高,时间复杂度 O(1),操作其他部位平均时间复杂度是O(n)
 - 链表中 ziplist 的大小和元素个数都可以设置,其中大小默认 8kb
 
 -  
ziplist 用一块连续的内存存储数据,设计目标是让数据存储更紧凑,减少碎片开销,节约内存,它的结构如下
- zlbytes – 记录整个 ziplist 占用字节数
 - zltail-offset – 记录尾节点偏移量
 - zllength – 记录节点数量
 - entry – 节点,1 ~ N 个,每个 entry 记录了前一 entry 长度(为了倒序遍历),本 entry 的编码、长度、实际数据,为了节省内存,根据实际数据长度不同,用于记录长度的字节数也不同,例如前一 entry 长度是 253 时,需要用 1 个字节,但超过了 253,需要用 5 个字节
 - zlend – 结束标记
 
 -  
ziplist 适合存储少量元素,否则查询效率不高,并且长度可变的设计会带来连锁更新问题
 
Hash
-  
在数据量较小时,采用 ziplist 作为其编码(将键值作为两个连续entry),当键或值长度过大(64)或个数过多(512)时,转为 hashtable 编码
 -  
hashtable 编码【下面都是要点,就不做标注了】
-  
hash 函数,Redis 5.0 采用了 SipHash 算法
 -  
采用拉链法解决 key 冲突(解决哈希冲突)
 -  
rehash 时机
① 当元素数 < 1 * 桶个数时,不扩容
② 当元素数 > 5 * 桶个数时,一定扩容
③ 当 1 * 桶个数 <= 元素数 <= 5 * 桶个数时,如果此时没有进行 AOF 或 RDB 操作时,就进行rehash
④ 当元素数 < 桶个数 / 10 时,缩容
 -  
rehash 要点
① 每个字典有两个哈希表,桶个数为 2 n 2^n 2n,平时使用 ht[0],ht[1] 开始为 null,扩容时新数组大小为元素个数 * 2
② 渐进式 rehash(防止一次时间过长),即不是一次将所有桶都迁移过去,每次对这张表 CRUD 仅迁移一个桶
③ active(主动) rehash,server 的主循环中,每 100 ms 里留出 1s 进行主动迁移
④ rehash 过程中,新增操作 ht[1] ,其它操作先操作 ht[0],若没有,再操作 ht[1]
⑤ redis 所有 CRUD 都是单线程,因此 rehash 一定是线程安全的
 
 -  
 
Sorted Set
-  
在数据量较小时,采用 ziplist 作为其编码,按 score 有序,当键或值长度过大(64)或个数过多(128)时,转为 skiplist(跳表) + hashtable 编码,同时采用的理由是
- 只用 hashtable,CRUD 是 O(1),但要执行有序操作,需要排序,带来额外时间空间复杂度
 - 只用 skiplist,虽然范围操作优点保留,但时间复杂度上升
 - 虽然同时采用了两种结构,但由于采用了指针,元素并不会占用双份内存
 
 -  
skiplist 要点:多层链表、排序规则、 backward、level(span,forward)

 
- score 存储分数、member 存储数据、按 score 排序,如果 score 相同再按 member 排序
 - backward 存储上一个节点指针(方便反向遍历)
 - 每个节点中会存储层级信息(level),同一个节点可能会有多层,每个 level 有属性: 
  
- foward 同层中下一个节点指针(前向指针,方便正序遍历)
 - span 跨度,用于计算排名,不是所有跳表都实现了跨度,Redis 实现特有
 
 
-  
多层链表可以加速查询,减少查询次数,规则为,从顶层开始
-  
大于同层右边的,继续在同层向右找
 -  
相等找到了
 -  
小于同层右边的或右边为 NULL,下一层,重复 1、2 步骤

 
 -  
 
- 以查找【崔八】为例 
  
- 从顶层(4)层向右找到【王五】节点,22 > 7 继续向右找,但右侧是 NULL,下一层
 - 在【王五】节点的第 3 层向右找到【孙二】节点,22 < 37,下一层
 - 在【王五】节点的第 2 层向右找到【赵六】节点,22 > 19,继续向右找到【孙二】节点,22 < 37,下一层
 - 在【赵六】节点的第 1 层向右找到【崔八】节点,22 = 22,返回
 
 
注意
- 数据量较小时,不能体现跳表的性能提升,跳表查询的时间复杂度是 l o g 2 ( N ) log_2(N) log2(N),与二叉树性能相当
 
2. keys 命令问题
面试题:redis有一亿个 key,使用 keys 命令是否会影响线上服务?
要求
- 理解低效命令对单线程的 Redis 影响
 
问题描述
- redis有一亿个 key,使用 keys 命令是否会影响线上服务?
 
解答
- keys 命令时间复杂度是 O ( n ) O(n) O(n),n 即总的 key 数量,n 如果很大,性能非常低
 - redis 执行命令是单线程执行,一个命令执行太慢会阻塞其它命令,阻塞时间长甚至会让 redis 发生故障切换
 
改进方案【重要】
- 可以使用 
scan命令替换keys命令,语法scan 起始游标 match 匹配规则 count 提示数目,返回值代表下次的起点- 虽然 scan 命令的时间复杂度仍是 O ( n ) O(n) O(n),但它是通过游标分步执行,不会导致长时间阻塞
 - 可以用 count 参数提示返回 key 的个数(默认是10)
 - 返回值代表下次的起点(桶下标)
 - 弱状态,客户端仅需维护游标
 - scan 能保证在 rehash 也正常工作
 - 缺点是可能会重复遍历 key(缩容时)、应用程序应自己处理重复 key

 
 
3. 过期 key 的删除策略
要求
- 了解 Redis 如何记录 key 的过期时间
 - 掌握 Redis 对过期 key 的删除策略
 
记录 key 过期时间
- 每个库中都包含了 expires 过期字典 
  
- hashtable结构,键为指针,指向真正 key,值为 long 类型的时间戳,毫秒精度
 
 - 当设置某个 key 有过期时间时,就会向过期字典中添加此 key 的指针和时间戳
 
过期 key 删除策略
-  
惰性删除
- 在执行读写数据库的命令时,执行命令前会检查 key 是否过期,如果已过期,则删除 key
 
 -  
定期删除
-  
redis 有一个定时任务处理器 serverCron,负责周期性任务处理,默认 100 ms 执行一次(hz 参数控制)包括:① 处理过期 key、② hash 表 rehash、③ 更新统计结果、④ 持久化、⑤ 清理过期客户端
 -  
对于处理过期 key 会:依次遍历库,在规定时间(默认2.5ms)内运行如下操作(每次删一点)
① 从每个库的 expires 过期字典中随机选择 20 个 key 检查,如果过期则删除
② 如果删除达到 5 个,重复 ① 步骤,没有达到,遍历至下一个库
③ 规定时间没有做完,等待下一轮 serverCron 运行
 
 -  
 
4. Redis 持久化
要求
- 掌握 AOF 持久化和 AOF 重写
 - 掌握 RDB 持久化
 - 了解混合持久化
 
AOF 持久化
- AOF - 将每条写命令追加至 aof 文件,当重启时会执行 aof 文件中每条命令来重建内存数据
 - AOF 日志是写后日志,即先执行命令,再记录日志 
  
- Redis 为了性能,向 aof 记录日志时没有对命令进行语法检查,如果要先记录日志,那么日志里就会记录语法错误的命令
 
 - 记录 AOF 日志时,有三种同步策略 
  
Always同步写【安全性高,性能不高】,日志写入磁盘再返回,可以做到基本不丢数据,性能不高- 为什么说基本不丢呢,因为 aof 是在 serverCron 事件循环中执行 aof 写入的,并且这次写入的是上一次循环暂存在 aof 缓冲中的数据,因此最多还是可能丢失一个循环的数据
 
Everysec每秒写,日志写入 AOF 文件的内存缓冲区,每隔一秒将内存缓冲区数据刷入磁盘,最多丢一秒的数据No操作系统写【性能高,安全性不高】,日志写入AOF 文件的内存缓冲区,由操作系统决定何时将数据刷入磁盘
 
AOF 重写
- 针对AOF 文件太大引起的问题 
  
- 文件大小受操作系统限制
 - 文件太大,写入效率变低
 - 文件太大,恢复时非常慢
 
 - 重写就是对同一个 key 的多次操作进行瘦身 
  
- 例如一个 key 我改了 100 遍,aof 里记录了100 条修改日志,但实际上只有最后一次有效
 - 重写无需操作现有 aof 日志,只需要根据当前内存数据的状态,生成相应的命令,记入一个新的日志文件即可
 - 重写过程是由另一个后台子进程完成的,不会阻塞主进程
 
 - AOF 重写过程 
  
- 创建子进程时会根据主进程生成内存快照(记录当前的状态),只需要对子进程的内存进行遍历,把每个 key 对应的命令写入新的日志文件(即重写日志)
 - 此时如果有新的命令执行,修改的是主进程内存,不会影响子进程内存,并且新命令会记录到 
重写缓冲区 - 等子进程所有的 key 处理完毕,再将 
重写缓冲区记录的增量指令写入重写日志 - 在此期间旧的 AOF 日志仍然在工作,待到重写完毕,用重写日志替换掉旧的 AOF 日志
 
 
RDB 持久化
- RDB - 是把整个内存数据以二进制方式写入磁盘 
  
- 对应数据文件为 
dump.rdb - 好处是恢复速度快
 
 - 对应数据文件为 
 - 相关命令有两个 
  
- save - 在主进程执行,会阻塞其它命令
 bgsave- 创建子进程执行,避免阻塞,是默认方式- 子进程不会阻塞主进程,但创建子进程的期间,仍会阻塞,内存越大,阻塞时间越长
 - bgsave 也是利用了快照机制,执行 RDB 持久化期间如果有新数据写入,新的数据修改发生在主进程,子进程向 RDB 文件中写入还是旧的数据,这样新的修改不会影响到 RDB 操作
 - 但这些新数据不会补充至 RDB 文件 【备份时期间如果有更改,若下一次备份时宕机就会丢失信息】
 
 - 缺点: 可以通过调整 redis.conf 中的 save 参数来控制 rdb 的执行周期,但这个周期不好把握
 
  - 频繁执行的话,会影响性能
 - 偶尔执行的话,如果宕机又容易丢失较多数据
 
 
混合持久化
- 从 4.0 开始,Redis 支持混合持久化,即使用 RDB 作为全量备份,两次 RDB 之间使用 AOF 作为增量备份 
  
- 配置项 
aof-use-rdb-preamble用来控制是否启用混合持久化,默认值 no - 持久化时将数据都存入 AOF 日志,日志前半部分为二进制的 RDB 格式,后半部分是 AOF 命令日志
 - 下一次 RDB 时,会覆盖之前的日志文件
 
 - 配置项 
 - 优缺点 
  
- 结合了 RDB 与 AOF 的优点,恢复速度快,增量用 AOF 表示,数据更完整(取决于同步策略)、也无需 AOF 重写
 - 与旧版本的 redis 文件格式不兼容
 
 
5. 缓存问题
要求
- 掌握缓存击穿
 - 掌握缓存雪崩
 - 掌握缓存穿透
 - 掌握旁路缓存与缓存一致性
 
缓存击穿
-  
缓存击穿是指:某一热点 key 在缓存和数据库中都存在,它过期时,这时由于并发用户特别多,同时读缓存没读到,又同时去数据库去读,压垮数据库;
 -  
解决方法
- 热点数据不过期【推荐】
 - 对【查询缓存没有,查询数据库,结果放入缓存】这三步进行加锁,这时只有一个客户端能获得锁,其它客户端会被阻塞,等锁释放开,缓存已有了数据,其它客户端就不必访问数据库了。但会影响吞吐量(有损方案)

 
 
缓存雪崩
-  
情况1:由于大量 key 设置了相同的过期时间(数据在缓存和数据库都存在),一旦到达过期时间点,这些 key 集体失效,造成访问这些 key 的请求全部进入数据库。
给某个 key 加锁能解决雪崩吗?:不行 -  
解决方法:
- 错开过期时间:在过期时间上加上随机值(比如 1~5 分钟)
 - 服务降级:暂停非核心数据查询缓存,返回预定义信息(错误页面,空值等)(有损方案)
 
 -  
情况2:Redis 实例宕机,大量请求进入数据库
 -  
解决方法:
- 事前预防:搭建高可用集群
 - 多级缓存(搭建一个本地缓存):缺点是实现复杂度高
 - 熔断:通过监控一旦雪崩出现,暂停缓存访问待实例恢复,返回预定义信息(有损方案)
 - 限流:通过监控一旦发现数据库访问量超过阈值,限制访问数据库的请求数(有损方案)
 
 
缓存穿透
-  
缓存穿透是指:如果一个 key 在缓存和数据库都不存在,那么访问这个 key 每次都会进入数据库
- 很可能被恶意请求利用
 - 缓存雪崩与缓存击穿都是数据库中有,但缓存暂时缺失
 - 缓存雪崩与缓存击穿都能自然恢复,但缓存穿透则不能
 
 -  
解决方法
-  
如果数据库没有,也将此不存在的 key 关联 null 值放入缓存,缺点是这样的 key 没有任何业务作用,白占空间
 -  
布隆过滤器(一个插件)
加在缓存和数据库之前

 
① 过滤器可以用来判定 key 不存在,发现这些不存在的 key,把它们过滤掉就好
② 需要将所有的 key 都预先加载至布隆过滤器
③ 布隆过滤器不能删除,因此查询删除的数据一定会发生穿透(布谷鸟过滤器可以解决) -  
 
缓存一致性问题——旁路缓存
旁路缓存
-  
旁路缓存(Cache Aside),是一种常见的使用缓存的策略
 -  
查询规则
- 先读缓存
 - 如果命中,直接返回
 - 如果缺失,查 DB(数据库) 并将结果放入缓存,再返回
 
 -  
增、删、改规则
- 新增数据,直接存 DB(数据库)
 - 修改、删除数据,先更新DB(数据库),再删缓存【最终一致,数据一致性要求不高时,会有短暂的不一致,但最终一致】
 
为什么要先操作库,再操作缓存?
- 假设操作库和缓存均能成功,如果先操作缓存,会大几率出现数据库与缓存不一致的情况
 
一致性分析 - 先清缓存,再更新库

一致性分析 - 先更新库,再清缓存

-  
会有短暂不一致,但最终会一致

 -  
假设查询线程 A 查询数据时恰好缓存数据由于时间到期失效,或是第一次查询,会如上图所示出现不一致

但这种几率出现机会很小 
 
用锁解决一致性

- 缺点:影响吞吐量、分布式锁设计较为复杂
 
6. 缓存原子性
要求
- 掌握 Redis 事务的局限性
 - 理解用乐观锁保证原子性
 - 理解用 lua 脚本保证原子性
 
Redis 事务局限性:不支持回滚
- 单条命令是原子性,这是由 redis 单线程保障的
 - 多条命令能否用 
multi + exec来保证其原子性呢? 
Redis 中 multi + exec 并不支持回滚,例如有初始数据如下
set a 1000
set b 1000
set c a
 
执行
multi
decr a   /*a减1*/
incr b   /*b加1*/
incr c
exec
 
执行 incr c 时,由于字符串不支持自增导致此条命令失败,但之前的两条命令并不会回滚【上面的结果是前两条命令成功,后一条命令失败;】
更为重要的是,multi + exec 中的读操作没有意义,因为读的结果并不能赋值给临时变量,用于后续的写操作,既然 multi + exec 中读没有意义,就无法保证读 + 写的原子性(一个事务无法同时控制读和写),例如有初始数据如下
set a 1000
set b 1000
 
假设 a 和 b 代表的是两个账户余额,现在获取旧值,执行转账 500 的操作:
get a /* 存入客户端临时变量 */
get b /* 存入客户端临时变量 */
/* 客户端计算出 a 和 b 更新后的值 */
multi
set a 500
set b 1500
exec
 
但如果在 get 与 multi 之间其它客户端修改了 a 或 b,会造成丢失更新
乐观锁保证原子性
watch 命令,用来盯住 key(一到多个),如果这些 key 在事务期间:
- 没有被别的客户端修改,则 
exec才会成功 - 被别的客户端改了,则 
exec返回nil,进而保证了原子性 
还是上一个例子
get a /* 存入客户端临时变量 */
get b /* 存入客户端临时变量 */
/* 客户端计算出 a 和 b 更新后的值 */
watch a b /* 盯住 a 和 b */
multi
set a 500
set b 1500
exec
 
此时,如果其他客户端修改了 a 和 b 的值,那么 exec 就会返回 nil,并不会执行两条 set 命令,此时客户端可以进行重试
lua 脚本保证原子性
Redis 支持 lua 脚本,能保证 lua 脚本执行的原子性,可以取代 multi + exec
例如要解决上面的问题,可以执行如下命令
eval "local a = tonumber(redis.call('GET',KEYS[1]));local b = tonumber(redis.call('GET',KEYS[2]));local c = tonumber(ARGV[1]); if(a >= c) then redis.call('SET', KEYS[1], a-c); redis.call('SET', KEYS[2], b+c); return 1;else return 0; end" 2 a b 500
 
- eval 用来执行 lua 脚本
 - 2 表示后面用空格分隔的参数中,前两个是 key,剩下的是普通参数
 - 脚本中可以用 
keys[n]来引用第 n 个 key,用argv[n]来引用第 n 个普通参数 - 其中双引号内部的即为 lua 脚本,格式化如下
 
local a = tonumber(redis.call('GET',KEYS[1]));//tonumber把字符串转换成数字
local b = tonumber(redis.call('GET',KEYS[2]));
local c = tonumber(ARGV[1]); 
if(a >= c) then 
    redis.call('SET', KEYS[1], a-c); 
    redis.call('SET', KEYS[2], b+c); 
    return 1;
else 
    return 0; 
end
 
7. LRU Cache(淘汰策略)实现
要求
- 掌握基于链表的 LRU Cache 实现
 - 了解 Redis 在 LRU Cache 实现上的变化
 
LRU Cache 淘汰规则
Least Recently Used,将最近最少使用的 key 从缓存中淘汰掉。
- 时间上,新的留下,老的淘汰
 - 如果访问了某个 key,则它就变成最新的
 
实现策略:
- 链表法,最近访问的 key 移动到链表头,不常访问的自然靠近链表尾,如果超过容量、个数限制,移除尾部的
 - 随机取样法(Redis采用的),链表法占用内存较多,redis 使用的是随机取样法,每次只抽 5 个 key,每个 key 记录了它们的最近访问时间,在这 5 个里挑出最老的移除
 
以链表法为例,最近访问的 key 移动到链表头,不常访问的自然靠近链表尾,如果超过容量、个数限制,移除尾部的
-  
例如有原始数据如下,容量规定为 3

 -  
时间上,新的留下,老的淘汰,比如 put d,那么最老的 a 被淘汰

 -  
如果访问了某个 key,则它就变成最新的,例如 get b,则 b 被移动到链表头

 
LRU Cache 链表实现(考代码)
- 如何断开节点链接
 

- 如何链入头节点

 
参考代码一:(回答这个代码,体现了对链表的了解)
package day06;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class LruCache1 {
    static class Node {
       //节点的参数;
        Node prev;
        Node next;
        String key;
        Object value;
        public Node(String key, Object value) {
            this.key = key;
            this.value = value;
        }
        // 打印节点的信息:node的toString(prev <- node -> next)
        public String toString() {
            StringBuilder sb = new StringBuilder(128);
            sb.append("(");
            sb.append(this.prev == null ? null : this.prev.key);
            sb.append("<-");
            sb.append(this.key);
            sb.append("->");
            sb.append(this.next == null ? null : this.next.key);
            sb.append(")");
            return sb.toString();
        }
    }
    //断开节点链接的方法(删除一个节点)
    public void unlink(Node node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }
    //把新节点加入到head头结点之后
    public void toHead(Node node) {
        node.prev = this.head;
        node.next = this.head.next;
        this.head.next.prev = node;
        this.head.next = node;
    }
    int limit;       //元素上限,超出则删除较老的
    Node head;      //头结点
    Node tail;      //尾节点
    Map<String, Node> map;      //Map集合存放真正的键值,键存放key,值存放每个节点对象
    //初始化上面的这些成员
    public LruCache1(int limit) {
        this.limit = Math.max(limit, 2); //通过参数传进来,最小值设置成2;
        this.head = new Node("Head", null);
        this.tail = new Node("Tail", null);
        head.next = tail;//头结点尾结点相连;
        tail.prev = head;//头结点尾结点相连;
        this.map = new HashMap<>();//空Map
    }
    //删除逻辑
    public void remove(String key) {
        Node old = this.map.remove(key);//调用底层map的remove将key删掉;
        unlink(old);//断开节点的连接
    }
    //查询逻辑
    public Object get(String key) {
        Node node = this.map.get(key);
        if (node == null) {//如果key在链表中没有
            return null;
        }
        //如果key在链表中有,在链表中断开这个节点,并把它插入到头部;
        unlink(node);
        toHead(node);
        return node.value;//返回值;
    }
    //新增逻辑;
    public void put(String key, Object value) {
        Node node = this.map.get(key); //先查一下这个key在链表中有没有
        if (node == null) {      //没有
            node = new Node(key, value);     //创建一个新的节点;
            this.map.put(key, node);//存入map;
        } else {      //有
            node.value = value;//更新一下值
            unlink(node); //断开连接
        }
        toHead(node); //将节点移入头部
        if(map.size() > limit) {      //看看当前map的大小是否超出了上限,如果超出了
            Node last = this.tail.prev;//找到最后一个节点;
            this.map.remove(last.key);//将最后一个节点删掉;
            unlink(last); //链表中断开最后一个节点的链接;
        }
    }
    @Override
    //cache的toString
    public String toString() {   
        StringBuilder sb = new StringBuilder();
        sb.append(this.head);//拼接头结点
        Node node = this.head;
        while ((node = node.next) != null) {//遍历链表;
            sb.append(node);
        }
        return sb.toString();
    }
    public static void main(String[] args) {
        LruCache1 cache = new LruCache1(5); //创建 LruCache的实例,规定上限是5;
        System.out.println(cache);
        cache.put("1", 1);//添加元素
        System.out.println(cache);
        cache.put("2", 1);
        System.out.println(cache);
        cache.put("3", 1);
        System.out.println(cache);
        cache.put("4", 1);
        System.out.println(cache);
        cache.put("5", 1);
        System.out.println(cache);
        cache.put("6", 1);//此时将删除1结点
        System.out.println(cache);
        cache.get("2");//cache的get方法,此时2到达了头部
        System.out.println(cache);
        cache.put("7", 1);//cache的put方法,此时又将删除一个节点;
        System.out.println(cache);
    }
}
 

参考代码二
使用继承父类LinkedHashMap
package day06;
import java.util.LinkedHashMap;
import java.util.Map;
public class LruCache2 extends LinkedHashMap<String, Object> {
    private int limit; //作为元素个数的限制;
    public LruCache2(int limit) {
        // 1 2 3 4 false
        // 1 3 4 2 true ,此时调用2,就把2调到了右侧头部;按访问顺序调整;
        super(limit * 4 /3, 0.75f, true);//调用有参构造,参数:长度limit * 4 /3防止扩容,扩容因子,true
        this.limit = limit;
    }
    @Override
    //此方法把最老的键值对移除掉;返回true时则移除最老的
    protected boolean removeEldestEntry(Map.Entry<String, Object> eldest) {
        if (this.size() > this.limit) {
            return true;
        }
        return false;
    }
    public static void main(String[] args) {
        LruCache2 cache = new LruCache2(5);
        System.out.println(cache);
        cache.put("1", 1);
        System.out.println(cache);
        cache.put("2", 1);
        System.out.println(cache);
        cache.put("3", 1);
        System.out.println(cache);
        cache.put("4", 1);
        System.out.println(cache);
        cache.put("5", 1);
        System.out.println(cache);
        cache.put("6", 1);//此时由于新加了一个元素,超出了上限,会将最老的元素移除;
        System.out.println(cache);
        cache.get("2");
        System.out.println(cache);
        cache.put("7", 1);
        System.out.println(cache);
    }
}
 
Redis LRU Cache 实现
Redis 采用了随机取样法,较之链表法占用内存更少,每次只抽 5 个 key,每个 key 记录了它们的最近访问时间,在这 5 个里挑出最老的移除
- 例如有原始数据如下,容量规定为 160,put 新 key a
 

-  
每个 key 记录了放入 LRU 时的时间,随机挑到的 5 个 key(16,78,90,133,156),会挑时间最老的移除(16)
 -  
再 put b 时,会使用上轮剩下的 4 个(78,90,133,156),外加一个随机的 key(125),这里面挑最老的(78)
 

- 如果 get 了某个 key,它的访问时间会被更新(下图中 90)这样就避免了它下一轮被移除

 


















