LRU 算法学习与动手实现

459 阅读5分钟

「这是我参与11月更文挑战的第2天,活动详情查看:2021最后一次更文挑战」。

1.什么是LRU 算法

LRU (Least Recently Used) 算法是据数据的历史访问记录来进行淘汰数据,其核心思想是 “如果数据最近被访问过,那么将来被访问的几率也更高 "。当工作区内存满了之后,如果要添加新的数据,要对老的数据进行删除,最近没有被使用过的数据将会优先被淘汰。


2. java 中已经实现的LRU 数据结构

在java 中已经帮我们实现了这样一个数据结构,那就是LinkedHashMap 。LinkedHashMap 可以实现按照插入顺序排列Key Value ,也可以按照访问顺序排序K V 。 今天就来探索其LRU 的实现方式。

3. 源码阅读LinkedHashMap

image.png LinkedHashMap继承自HashMap,它的多种操作都是建立在HashMap操作的基础上的。同HashMap不同的是,LinkedHashMap维护了一个Entry的双向链表,保证了插入的Entry中的顺序。详细代码如下:

//定义双向链表
static class Entry<K,V> extends HashMap.Node<K,V> {
    Entry<K,V> before, after;
    Entry(int hash, K key, V value, Node<K,V> next) {
        super(hash, key, value, next);
    }
}

private static final long serialVersionUID = 3801124242820219131L;

transient LinkedHashMap.Entry<K,V> head;

/**
 * The tail (youngest) of the doubly linked list.
 */
transient LinkedHashMap.Entry<K,V> tail;

//决定是否使用LRU
final boolean accessOrder;
//有参构造。当传入初始大小,影响因子,true 时,那么开启LRU。
public LinkedHashMap(int initialCapacity,
                     float loadFactor,
                     boolean accessOrder) {
    super(initialCapacity, loadFactor);
    this.accessOrder = accessOrder;
}

LinkedHashMap 继承了HashMap,既然是map 那么必然离不开put 和get。LinkedHashMap 没有重写put方法,所以插入方法依然调用了HashMap 的实现,但是与之不同的是,LinkedHashMap 重写了HashMap 中的以下几个方法。

Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {}
void afterNodeAccess(Node<K,V> p) {}
void afterNodeInsertion(boolean evict) { }
void afterNodeRemoval(Node<K,V> p) { }

3.1 put方法逻辑分析

LinkedHashMap put 方法的伪代码逻辑如下:

    put(ket ,val){
    //哈希运算之后,如果目标位置没有元素,那么需要新建node 
        if((table[i]&hash)==null){
       
            newNode(){
                
                doSomething() { //新建node }
                
                linkNodeLast(){ //新节点默认是tail,并且和原tail 链接 }
            }
        }
    }else{
             
    }
      //修改完成之后,启动淘汰策略
    afterNodeInsertion(){  
        //判断是否开启了LRU
        if (evict && (first = head) != null && removeEldestEntry(first)) {
        
            removeNode(){ //开启了,将被访问的节点移动到尾部 }
        }

    }
        

当插入新建元素时,如果tail 为空,那么说明才初始化,将新节点赋予头尾节点。

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;
    }
}

接下来是afterNodeAccess ,核心代码如下 ,功能是当插入一个元素之后,会将插入的元素移动到双端队列尾部节点

void afterNodeAccess(Node<K,V> e) { // move node to last
    LinkedHashMap.Entry<K,V> last;
    //如果开启了LRU ,且当前元素不是tail,那么当前节点移动到双端队列尾部
    if (accessOrder && (last = tail) != e) {
    //定义三个节点p ,b,a 分别保存 e ,e的前一个元素和后一个元素
        LinkedHashMap.Entry<K,V> p =
            (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
        //移除节点后续,方便挂在尾部节点之后
        p.after = null; 
     /** 这段代码含义是 如果原有的是双端队列顺序 为 1 <=> 2 <=>3 <=>4 <=>5,其中head 为1 ,tail 为5
     /*  当我们get(2) 时,执行完毕之后,会将2节点移动到队伍尾部,变为1 <=> 3 <=>4 <=>5 <=>2 ,
     /*  
        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;
    }
}

接着调用 afterNodeInsertion , afterNodeInsertion 中因为调用了removeEldestEntry , 所以当我们决定启用LRU之后,需要重写removeEldestEntry 方法,来执行淘汰策略,否则原方法默认 返回false 。

//evict put 调用时候默认传入true ,removeEldestEntry 这个方法默认返回false.

void afterNodeInsertion(boolean evict) { // possibly remove eldest
    LinkedHashMap.Entry<K,V> first;
   
    if (evict && (first = head) != null && removeEldestEntry(first)) {
        K key = first.key;
        //这里的remov 方法仍然为HashMap 中方法。执行remove 方法,删除双链表头节点元素。
        removeNode(hash(key), key, null, false, true);
    }
}
//重写淘汰策略
 protected boolean removeEldestEntry(Map.Entry eldest) {
      return size() > INITIAL_CAPACITY;
 }


3.2 get 方法分析

get 方法依然是HashMap 方法,由于我们重写了afterNodeAccess ,当我们查询过之后,会调用afterNodeAccess 将查询过的元素移动到队列尾部。

public V get(Object key) {
    Node<K,V> e;
    //这里的getNode 是继承自hashmap ,不做多描述
    if ((e = getNode(hash(key), key)) == null)
        return null;
    //关键在这里,是否实现了LRU ,我们设置为TRUE
    if (accessOrder)
        afterNodeAccess(e);
    return e.value;
}

通过以上一遍流程,明白了LRU 算法的工作逻辑,接下来就来自己动手实践.

4. 动手实现LRU 算法。

public class LRUCache<K,V> {

    static class Node<K,V>{
        K key ;
        V value ;

        public Node(K key, V value) {
            this.key = key;
            this.value = value;
        }

        Node<K,V> before ;
        Node<K,V> after ;

    }

    private int capacity = 2<<4 ;
    public int count = 0 ;

    private HashMap<K , Node<K,V>>  map ;

    private Node<K,V> head ;
    private Node<K,V> tail;

    public LRUCache(int capacity) {
        this() ;
        this.capacity = capacity;
    }

    public LRUCache() {
        map = new HashMap<>() ;
    }

      //get 获取
    public V get(K key){
        Node<K,V> node = map.get(key) ;
        if(node == null)
            return null ;
        moveNodeToLast(node);
        return node.value ;
    }

    
    public  void put(K key ,V value){

        Node<K ,V> node = map.get(key) ;
        if(node == null){
            //新建node,查看长度是否超过capacity
            node = new Node<K, V>(key ,value) ;
            //超过了,remove head
            if(map.size() >= capacity){
                removeHeadNode() ;
                count -- ;
            }
            //新建之后将节点移动到尾部
            setTailNode(node);
            count ++ ;
        }else {
            node.value = value ;
            map.put(key ,node) ;
            //访问之后,移动到尾节点
            moveNodeToLast(node);
        }
        map.put(key ,node) ;

    }

    //删除头节点
    private void removeHeadNode() {
        Node<K,V> e = map.get(head.key) ;
        map.remove(head.key) ;
        //保存p节点
        Node<K,V> p = e ,a = p.after ,b = p.before ;
        //断开前后链接
        p.before = p.after  = null ;
        if(b == null )
            head = a  ;
        else
            b.after = a ;
        if(a == null )
            tail = b ;
        else
            a.before = b ;
    }

    //设置末尾指针
    public void setTailNode(Node<K,V> node){
        Node<K,V> last = tail ;
        tail  = node ;
        //tai 为空的时候,初始化
        if(last == null ){
            head = node ;
        }
        else{
            last.after = tail;
            tail.before = last ;
        }
    }

    //put 调用时候移动到tail
    public void moveNodeToLast(Node<K,V> node){
        Node<K,V> e = node ,a = node.after ,b = node.before ;
        Node<K,V> last = tail ;
        //当它不为末尾节点时候
        if(last != node ){
            if(b == null ){
                head = a ;
            }else{
                b.after = a ;
            }

            if(a != null ){
                a.before = b ;
            }else{
                last = b ;
            }

            //链接尾节点和新节点
            if(last == null ){
                head = e ;
            }else{
                last.after = e ;
                e.before = last ;
            }
            tail = e ;
        }
    }

}

5.测试代码

image.png

不涉及线程安全的情况下,已经符合LRU 算法的基本要求。打完收工!

image.png