实现一个线程安全并且可以设置过期时间的LRU(LinkedHashMap原理)

目录

1、HashMap原理

2、LinkedHashMap实现LRU原理(accessOrder = true)

2.1 数据结构

2.2 put方法

2.3 get方法

2.4 remove方法

3、普通LRU代码实现

4、实现一个线程安全并且可以设置过期时间的LRU缓存

4.1 解决安全问题

4.2 实现定期删除


FIFO的思想是实现一个先进先出的队列,LRU最近最久未使用。可以用双向链表linkedList来实现,同时为了兼顾查询节点时的效率,结合HashMap来实现。双向链表linkedList+HashMap的数据结构可以联想到LinkedHashMap,就不需要我们自己来实现了。LinkedHashMap存储数据是有序的,可以分为插入顺序(accessOrder = false)和访问顺序(accessOrder = true),默认为插入顺序,而且LinkedHashMap提供了删除最后一个节点的方法removeEldestEntry(Map.Entry eldest),正好可以用来实现FIFO(LinkedHashMap按插入顺序存储数据)和LRU算法(LinkedHashMap按访问顺序存储数据)。

1、HashMap原理

底层是Entry数组+链表(Entry节点的next指针)+红黑树。JDK8中,链表长度不小于8时,将链表转化为红黑树。默认无参构造函数会初始化大小为16,向集合中添加元素至集合大小的0.75倍时,会生成一个大小为原来2倍的新集合,然后重新计算元素的地址,将集合中元素插入到新集合,届时效率很低。线程不安全。(例如:put的时候导致的数据覆盖、集合扩展时(resize方法)会出现死循环)。

//HashMap的Entry结构:
static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next;
        int hash;
}

2、LinkedHashMap实现LRU原理(accessOrder = true

  • 2.1 数据结构

HashMap的原理是内部维护了一个Entry数组,而LinkedHashMap在HashMap的基础上,增加了链表头节点和尾节点两个指针,增加了排序方式的标志,Entry节点增加了前后两个指针。因此LinkedHashMap的Entry节点有三个指针,一个是双向链表的前指针、一个是双向链表的后指针、一个是HashMap的hash地址重复时拉链法解决冲突的next的指针。

/*LinkedHashMap的Entry结构:*/
//双向链表头结点
transient LinkedHashMap.Entry<K,V> head;
//双向链表尾节点
transient LinkedHashMap.Entry<K,V> tail;
//是否基于访问顺序排序(默认为false即插入顺序排序)
final boolean accessOrder;
//Entry继承了HashMap的Entry,又增加了before, after两个指针
private static class Entry<K,V> extends HashMap.Entry<K,V> {
        Entry<K,V> before, after;
        Entry(int hash, K key, V value, HashMap.Entry<K,V> next) {
            super(hash, key, value, next);
        }
}
  • 2.2 put方法

如果put的是新key,则将Entry节点添加到Map中,并添加到双向链表的尾部,若initialCapacity数量已满,删除最近最久未使用的Entry节点即双向链表的头结点;若put的是已有的key,更新节点的value,并将节点删除并添加到尾部。

HashMap的put方法会生成一个节点,调用了newNode方法,而LinkedHashMap重写了此方法
 /**
     * 创建一个节点
     * @param hash  hash值
     * @param key   键
     * @param value 值
     * @param e     下一个节点,这个是HashMap节点的属性
     * @return
     */
    Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
        //调用构造方法
        LinkedHashMap.Entry<K,V> p =
            new LinkedHashMap.Entry<K,V>(hash, key, value, e);
        //维护链表
        linkNodeLast(p);
        return p;
    }

/**
     * 添加一个节点到末尾
     * @param p 节点
     */
    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;
        }
    }

HashMap当put一个已经存在的key时,会触发是否更新的操作,之后会调用afterNodeAccess方法,LinkedHashMap重写了此方法
/**
     * accessOrder为true时,将操作的节点移到链表尾部
     * @param e 节点
     */
    void afterNodeAccess(Node<K,V> e) {
        LinkedHashMap.Entry<K,V> last;
        //accessOrder 这个参数是指在进行操作的时候,是否将操作的节点移动到链表的最后,默认false
        //也就是说accessOrder为false的时候链表就是按照插入顺序维护的
        //true的时候,会将最近使用的节点移动到链表最后
        if (accessOrder && (last = tail) != e) {
            //保存当前节点和其前置节点和后置节点
            LinkedHashMap.Entry<K,V> p =
                (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
            //清空后置节点,因为当前节点要被移动到最后了
            p.after = null;
            //判断前置节点是否为空节点
            if (b == null)
                //前置节点为空,说明当前节点是头节点,将它的后置节点也就是第二个节点设置为头节点
                head = a;
            else
                //存在前置节点,将前置节点的后置节点连接到当前节点的下一个节点
                b.after = a;
            //判断后置节点是否为空
            if (a != null)
                //后置节点不为空,更新后置节点的前置节点
                a.before = b;
            else
                //说明该节点就是尾部节点,设置前置节点为后节点
                //a == null 说明p就是尾部节点? 有点不清楚
                last = b;
            //统一更新尾部节点
            if (last == null)
                //说明只有这么一个节点
                head = p;
            else {
                //将当前节点挂到链表末尾
                p.before = last;
                last.after = p;
            }
            //设置尾部节点
            tail = p;
            ++modCount;
        }
    }

LinkedHashMap也重写了afterNodeInsertion方法
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);
        }
    }


 需要注意:

removeEldestEntry方法是是否删除链表的头结点,默认为不删除,实现LRU需要覆盖此方法
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
        return false;
    }
  • 2.3 get方法

移动当前操作的节点到链表最后

    public V get(Object key) {
        // 调用genEntry得到Entry
        Entry<K,V> e = (Entry<K,V>)getEntry(key);
        if (e == null)
            return null;
        // 如果LinkedHashMap是访问顺序的,则get时,也需要重新排序
        e.recordAccess(this);
        return e.value;
    }
  • 2.4 remove方法

从Map和链表中删除

LinkedHashMap调用了HashMap的remove方法,重写了afterNodeRemoval方法

LinkedHashMap调用了HashMap的remove方法
重写了afterNodeRemoval方法
 /**
     * 删除链表中的节点
     * @param e
     */
    void afterNodeRemoval(Node<K,V> e) {
        //获取当前节点的前置后置节点
        LinkedHashMap.Entry<K,V> p =
            (LinkedHashMap.Entry<K,V>)e, b = p.before, 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;
    }

3、普通LRU代码实现

1、removeEldestEntry方法(是否删除元素)默认返回false,需要重写

2、通过LinkedHashMap构造函数中的参数accessOrder来指定数据存储的顺序(false为插入顺序,true为访问顺序)

//LinkedHashMap构造函数
public LinkedHashMap(int initialCapacity,
                         float loadFactor,
                         boolean accessOrder) {
        super(initialCapacity, loadFactor);
        this.accessOrder = accessOrder;
    }
//LRU算法 (FIFO算法只需要将LinkedHashMap的第三个参数true改为false)
public class LRUCache {
    
    private int capacity;
    
    private LinkedHashMap<Integer, Integer> cache = new LinkedHashMap<Integer, Integer>(capacity,0.75f,true) {
        @Override
        protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
            return size() > capacity;
        }
    };
    
    public LRUCache(int capacity) {
        this.capacity = capacity;
    }
    
    public int get(int key) {
        Integer res = cache.get(key);
        return res;
    }
    
    public void put(int key, int value) {
        cache.put(key, value);
    }
}

4、实现一个线程安全并且可以设置过期时间的LRU缓存

  • 4.1 解决安全问题

线程不安全主要是因为HashMap和LinkedHashMap都是线程不安全的,而且同时修改map和双向链表之间也会产生并发问题,所以仅仅使用线程安全的ConcurrentHashMap、ConcurrentLinkedQueue并不能解决问题,还要解决map和链表间的同步问题,最简单的方法就是在put或get时直接使用ReenTrantLock进行同步,如com.google.gson包提供的LruCache类,直接在方法上使用synchronized进行同步

package com.google.gson;

import java.util.LinkedHashMap;
import java.util.Map.Entry;

final class LruCache<K, V> extends LinkedHashMap<K, V> implements Cache<K, V> {
    private static final long serialVersionUID = 1L;
    private final int maxCapacity;

    public LruCache(int maxCapacity) {
        super(maxCapacity, 0.7F, true);
        this.maxCapacity = maxCapacity;
    }

    public synchronized void addElement(K key, V value) {
        this.put(key, value);
    }

    public synchronized V getElement(K key) {
        return this.get(key);
    }

    public synchronized V removeElement(K key) {
        return this.remove(key);
    }

    protected boolean removeEldestEntry(Entry<K, V> entry) {
        return this.size() > this.maxCapacity;
    }
}
  • 4.2 实现定期删除

使用ScheduledThreadPoolExecutor这种定时任务线程池来实现,ScheduledThreadPoolExecutor 使用的任务队列 DelayQueue 封装了一个 PriorityQueue,PriorityQueue 会对队列中的任务进行排序(堆排序),延迟时间最短的放在前面先被执行,如果执行所需时间相同则先提交的任务将被先执行。

具体实现就是增加一个方法,在增加缓存时(put方法中)提交延时任务。具体实现参考(https://zhuanlan.zhihu.com/p/135936339

//过期后清除键值对
private void removeAfterExpireTime(K key, long expireTime) {
        scheduledExecutorService.schedule(() -> {
            //1、从map中删除
            //2、从链表删除
        }, expireTime, TimeUnit.MILLISECONDS);
    }

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章