美团技术读后感系列——LruCache原理及实现过程

LruCache介绍

LruCache采用的缓存算法为LRU(Least Recently Used),即最近最少使用算法。这一算法的核心思想是当缓存数据达到预设上限后,会优先淘汰近期最少使用的缓存对象。

LruCache内部维护一个双向链表和一个映射表。链表按照使用顺序存储缓存数据,越早使用的数据越靠近链表尾部,越晚使用的数据越靠近链表头部;映射表通过Key-Value结构,提供高效的查找操作,通过键值可以判断某一数据是否缓存,如果缓存直接获取缓存数据所属的链表节点,进一步获取缓存数据。

LruCache结构图如下所示,上半部分是双向链表,下半部分是映射表(不一定有序)。双向链表中
value_1所处位置为链表头部,value_N所处位置为链表尾部。
在这里插入图片描述

LruCache读操作,通过键值在映射表中查找缓存数据是否存在。如果数据存在,则将缓存数据所处节点从链表中当前位置取出,移动到链表头部;如果不存在,则返回查找失败,等待新数据写入。下图为通过LruCache查找key_2后LruCache结构的变化。
在这里插入图片描述
LruCache没有达到预设上限情况下的写操作,直接将缓存数据加入到链表头部,同时将缓存数据键值与缓存数据所处的双链表节点作为键值对插入到映射表中。下图是LruCache预设上限大于N时,将数据M写入后的数据结构。
在这里插入图片描述
LruCache达到预设上限情况下的写操作,首先将链表尾部的缓存数据在映射表中的键值对删除,并删除链表尾部数据,再将新的数据正常写入到缓存中。下图是LruCache预设上限为N时,将数据M写入后的数据结构。
在这里插入图片描述
线程安全的LruCache在读写操作中,全部使用锁做临界区保护,确保缓存使用是线程安全的。

手写LruCache

手写LruCache,主要是要实现以下几个功能点:

  1. 读写并发控制,确保线程安全
  2. 读操作中,要将节点从链表中当前位置取出,移动到链表头部。这个操作要尽可能减少耗时,不影响读操作数据获取。
  3. LruCache保证线程安全时,要尽可能缩小临界区和锁粒度,内部可以采用分片方式,不同的key路由到不同的分片,一定程度上能减小锁的粒度。
  4. LruCache分片数、每个分片的最大缓存数和过期时间支持定制。
  5. LruCache尽可能操作简单,使用者初始化之后,仅需关注 get、put 操作。

1.LruCache底层数据结构

LruCache 内部维护了一个用于存储数据的 Map,每个 key 代表一个分片,在 LruCache 初始化的时候,可以通过 maxNumsOfSlices 参数定制分片数,每个分片对应一个 LRUHashMap。

// 最大分片数量,默认为16
private final int maxNumsOfSlices;
// 每个分片的缓存大小,默认为1000
private final int maxSliceCacheSize;
// 缓存过期时间,单位毫秒,默认60秒
private final int expiredTime;

private HashMap<Integer, LRUHashMap<K, LRUNode<K, V>>> map;

LRUHashMap 继承于 HashMap,Key 支持自定义类型,Value 值是双向链表节点 LRUNode,并且记录双向链表的头节点 head 和尾节点 tail。

static class LRUHashMap<K, LRUNode> extends HashMap<K, LRUNode> {
	private LRUNode head;
	private LRUNode tail;
	
	public LRUHashMap(int initialCapacity) {
		super(initialCapacity);
	}
}

双向链表节点,除了记录 key、value 和前后节点外,需要记录节点的创建时间,缓存过期清退机制就是靠这个时间进行判断。如果当前时间 - 创建时间 > 过期时间阈值,则该 key 已过期可清退。

static class LRUNode<K, V> {
	private LRUNode<K, V> pre;
	private LRUNode<K, V> next;
	private K key;
	private V value;
	private Date createTime;
}

2.缓存写操作

写操作首先需要定位到缓存 key 需要写入的分片,这里使用对 key 的 hashcode 取模的方法进行分片。

private LRUHashMap<K, LRUNode<K, V>> switchToSlice(K key) {
		int slice = (key.hashCode() & Integer.MAX_VALUE) % maxNumsOfSlices;
		return map.get(slice);
	}

定位到分片之后,使用 synchronized 对分片加锁,确保线程安全。

如果该 key 已在缓存中存在,更新 value 值;
如果该 key 不存在,则判断缓存是否达到最大容量:

  • 如果已达到缓存最大容量,删除最久未使用的 key,也就是双向链表尾节点,并把当前 key 添加到链表头节点;
  • 如果未达到缓存最大容量,直接将当前 key 添加到链表头节点
public void put(K key, V value) {
	// 找到该key对应的分片
	LRUHashMap<K, LRUNode<K, V>> sliceMap = switchToSlice(key);

	LRUNode<K, V> node = new LRUNode<>();
	node.setKey(key);
	node.setValue(value);
	node.setCreateTime(new Date());

	// 对该分片加锁
	synchronized (sliceMap) {
		// 如果已存在该key, 更新value值
		LRUNode<K, V> existNode = sliceMap.get(key);
		if (null != existNode) {
			existNode.setValue(value);
			return;
		}

		// 缓存达到预设上限,删除尾节点,添加新元素到头节点;没达到上限,直接添加为头节点
		if (sliceMap.size() >= maxSliceCacheSize) {
			removeTailNode(sliceMap);
		}
		addToHead(sliceMap, node);
	}
}

3.缓存读操作

读操作第一步也是定位当前 key 所在分片,如果该 key不存在返回 null;如果存在,将该 key 对应节点调整为链表头节点(该操作异步进行,减少主方法 get(key) 耗时)。

众所周知,获取缓存的操作一般都比较频繁,所以这里使用线程池来支持异步操作,避免频繁的创建/销毁线程,影响系统性能。

线程池:
这里阻塞队列如果超过最大容量,采用的拒绝策略是直接丢弃,不抛异常。因为某些节点即使没有移动到头节点,不会造成任何数据错误,也不影响使用,只会影响某些 key 被使用了但依旧处于链表尾端,被移除的概率大一点。

private static final ExecutorService executorService = new ThreadPoolExecutor(2, 6, 1, TimeUnit.MINUTES,
		new ArrayBlockingQueue<>(1000, true), Executors.defaultThreadFactory(),
		new ThreadPoolExecutor.DiscardPolicy());

缓存读操作代码:

public V get(K key) {
	// 找到该 key 对应的分片
	LRUHashMap<K, LRUNode<K, V>> sliceMap = switchToSlice(key);

	// 1.获取节点
	LRUNode<K, V> node = sliceMap.get(key);
	if (null != node) {
		// 异步操作,节省get操作时间
		executorService.execute(() -> {
			// 2.将该节点移动为头结点
			synchronized (sliceMap) {
				// 安全操作,已被其他线程删除,直接返回
				if (sliceMap.get(key) == null)
					return;
				// 如果已过期,删除该节点
				if (isExpired(sliceMap.get(key).getCreateTime())) {
					removeNode(sliceMap, sliceMap.get(key));
					return;
				}
				// 如果链表只有一个节点,直接返回
				if (sliceMap.getHead().equals(sliceMap.getTail())) {
					return;
				}
				// 如果该节点是头节点,直接返回
				if (sliceMap.getHead() != null && sliceMap.getHead().equals(node)) {
					return;
				}
				// 如果是尾节点或者中间节点,移动到头节点位置
				if (sliceMap.getTail() != null && sliceMap.getTail().equals(node)) {
					// 如果是尾节点
					LRUNode<K, V> preTail = node.getPre();
					preTail.setNext(null);
					sliceMap.setTail(preTail);
					node.setPre(null);
					sliceMap.getHead().setPre(node);
					node.setNext(sliceMap.getHead());
					sliceMap.setHead(node);
				} else {
					// 如果是中间节点
					LRUNode<K, V> preNode = node.getPre();
					LRUNode<K, V> nextNode = node.getNext();
					nextNode.setPre(preNode);
					preNode.setNext(nextNode);

					sliceMap.getHead().setPre(node);
					node.setNext(sliceMap.getHead());
					node.setPre(null);
					sliceMap.setHead(node);
				}
			}
		});

		return node.getValue();
	}
	return null;
}

源代码

github源代码地址:LruCache
对源码或者实现原理有疑问,欢迎私信咨询。

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

星空是梦想

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值