LFU的基本原理與實現
前言:之前有寫過一篇關於LRU的文章連結https://www.cnblogs.com/wyq178/p/9976815.html LRU全稱:Least Recently Used:最近最少使用策略,判斷最近被使用的時間,距離目前最遠的資料優先被淘汰,作為一種根據訪問時間來更改連結串列順序從而實現快取淘汰的演算法,它是redis採用的淘汰演算法之一。redis還有一個快取策略叫做LFU, 那麼LFU是什麼呢?我們本期部落格來分下一下LFU:
本篇部落格的目錄:
一: LRU是什麼
二:LRU的實現
三:測試
四:LRU和LFU的區別以及缺陷
五:總結
正文
一:LRU是什麼
LFU,全稱是:Least Frequently Used,最不經常使用策略,在一段時間內,資料被使用頻次最少的,優先被淘汰。最少使用(LFU)是一種用於管理計算機記憶體的快取演算法。主要是記錄和追蹤記憶體塊的使用次數,當快取已滿並且需要更多空間時,系統將以最低記憶體塊使用頻率清除記憶體.採用LFU演算法的最簡單方法是為每個載入到快取的塊分配一個計數器。每次引用該塊時,計數器將增加一。當快取達到容量並有一個新的記憶體塊等待插入時,系統將搜尋計數器最低的塊並將其從快取中刪除(本段摘自維基百科)
解釋:上面這個圖就是一個LRU的簡單實現思路,在連結串列的開始插入元素,然後每插入一次計數一次,接著按照次數重新排序連結串列,如果次數相同的話,按照插入時間排序,然後從連結串列尾部選擇淘汰的資料~
二:LRU實現
2.1 定義Node節點
Node主要包含了key和value,因為LFU的主要實現思想是比較訪問的次數,如果在次數相同的情況下需要比較節點的時間,越早放入的越快 被淘汰,因此我們需要在Node節點上加入time和count的屬性,分別用來記錄節點的訪問的時間和訪問次數。其他的版本實現方式裡有新加個內部類來記錄 key的count和time,但是我覺得不夠方便,還得單獨維護一個map,成本有點大。還有一點注意的是這裡實現了comparable介面,覆寫了compare方法,這裡 的主要作用就是在排序的時候需要用到,在compare方法裡面我們首先比較節點的訪問次數,在訪問次數相同的情況下比較節點的訪問時間,這裡是為了 在排序方法裡面通過比較key來選擇淘汰的key
/** * 節點 */ public static class Node implements Comparable<Node>{ //鍵 Object key; //值 Object value; /** * 訪問時間 */ long time; /** * 訪問次數 */ int count; public Node(Object key, Object value, long time, int count) { this.key = key; this.value = value; this.time = time; this.count = count; } public Object getKey() { return key; } public void setKey(Object key) { this.key = key; } public Object getValue() { return value; } public void setValue(Object value) { this.value = value; } public long getTime() { return time; } public void setTime(long time) { this.time = time; } public int getCount() { return count; } public void setCount(int count) { this.count = count; } @Override public int compareTo(Node o) { int compare = Integer.compare(this.count, o.count); //在數目相同的情況下 比較時間 if (compare==0){ return Long.compare(this.time,o.time); } return compare; } }
2.2:定義LFU類
定義LFU類,這裡採用了泛型,聲明瞭K和V,還有總容量和一個Map(caches)用來維護所有的節點。在構造方法裡將size傳遞進去,並且建立了一個LinkedHashMap,採用linkedHashMap的主要原因是維護key的順序
public class LFU<K,V> { /** * 總容量 */ private int capacity; /** * 所有的node節點 */ private Map<K, Node> caches; /** * 構造方法 * @param size */ public LFU(int size) { this.capacity = size; caches = new LinkedHashMap<K,Node>(size); } }
2.3: 新增元素
新增元素的邏輯主要是先從快取中根據key獲取節點,如果獲取不到,證明是新新增的元素,然後和容量比較,大於預定容量的話,需要找出count計數最小(計數相同的情況下,選擇時間最久)的節點,然後移除掉那個。如果在預定的大小之內,就新建立節點,注意這裡不能使用 System.currentTimeMillis()方法,因為毫秒級別的粒度無法對插入的時間進行區分,在執行比較快的情況下,只有System.nanoTime()才可以將key的插入時間區分,預設設定count計數為1.如果能獲取到,表示是舊的元素,那麼就用新值覆蓋舊值,計數+1,設定key的time為當前納秒時間。最後還需要進行排序,這裡可以看出插入元素的邏輯主要是新增進入快取,更新元素的時間和計數~
/** * 新增元素 * @param key * @param value */ public void put(K key, V value) { Node node = caches.get(key); //如果新元素 if (node == null) { //如果超過元素容納量 if (caches.size() >= capacity) { //移除count計數最小的那個key K leastKey = removeLeastCount(); caches.remove(leastKey); } //建立新節點 node = new Node(key,value,System.nanoTime(),1); caches.put(key, node); }else { //已經存在的元素覆蓋舊值 node.value = value; node.setCount(node.getCount()+1); node.setTime(System.nanoTime()); } sort(); }
每次put或者get元素都需要進行排序,排序的主要意義在於按照key的cout和time進行一個key順序的重組,這裡的邏輯是首先將快取map建立成一個list,然後按照Node的value進行,重組整個map。然後將原來的快取清空,遍歷這個map, 把key和value的值放進去原來的快取中的順序就進行了重組~這裡區分於LRU的不同點在於使用了java的集合API,LRU的排序是進行節點移動。而在LFU中實現比較複雜,因為put的時候不光得比較基數還有時間。如果不借助java的API的話,可以新維護一個節點頻率連結串列,每次將key儲存在這個節點頻率連結串列中移動指標,從而也間接可以實現排序~
/** * 排序 */ private void sort() { List<Map.Entry<K, Node>> list = new ArrayList<>(caches.entrySet()); Collections.sort(list, new Comparator<Map.Entry<K, Node>>() { @Override public int compare(Map.Entry<K, Node> o1, Map.Entry<K, Node> o2) { return o2.getValue().compareTo(o1.getValue()); } }); caches.clear(); for (Map.Entry<K, Node> kNodeEntry : list) { caches.put(kNodeEntry.getKey(),kNodeEntry.getValue()); } }
移除最小的元素
淘汰最小的元素這裡呼叫了Collections.min方法,然後通過比較key的compare方法,找到計數最小和時間最長的元素,直接從快取中移除~
/** * 移除統計數或者時間比較最小的那個 * @return */ private K removeLeastCount() { Collection<Node> values = caches.values(); Node min = Collections.min(values); return (K)min.getKey(); }
2.4:獲取元素
獲取元素首先是從快取map中獲取,否則返回null,在獲取到元素之後需要進行節點的更新,計數+1和重新整理節點的時間,根據LFU的原則,在當前時間獲取到這個節點以後,這個節點就暫時變成了熱點節點,但是它的cout計數也有可能是小於某個節點的count的,所以
此時不能將它直接移動到連結串列頂,還需要進行一次排序,重組它的位置~
/** * 獲取元素 * @param key * @return */ public V get(K key){ Node node = caches.get(key); if (node!=null){ node.setCount(node.getCount()+1); node.setTime(System.nanoTime()); sort(); return (V)node.value; } return null; }
三:測試
首先宣告一個LRU,然後預設的最大的大小為5,依次put進入A、B、C、D、E、F6個元素,此時將會找到計數最小和時間最短的元素,那麼將會淘汰A(因為count值都是1)。記著get兩次B元素,那麼B元素的count=3,時間更新為最新。此時B將會移動到頂,接著在getC元素,C元素的count=2,時間會最新,那麼此時因為它的count值依然小於B,所以它依然在B後面,再getF元素,F元素的count=2,又因為它的時間會最新,所以在與C相同的計數下,F元素更新(時間距離現在最近),所以連結串列將會移動,F會在C的前面,再次put一次C,此時C的count=3,同時時間為最新,那麼此刻C的count和B保持一致,則他們比較時間,C明顯更新,所以C將會排在B的前面,最終的順序應該是:C->B->F->E->D
public static void main(String[] args) { LFU<Integer, String> lruCache = new LFU<>(5); lruCache.put(1, "A"); lruCache.put(2, "B"); lruCache.put(3, "C"); lruCache.put(4, "D"); lruCache.put(5, "E"); lruCache.put(6, "F"); lruCache.get(2); lruCache.get(2); lruCache.get(3); lruCache.get(6);
//重新put節點3
lruCache.put(3,"C");
final Map<Integer, Node> caches = (Map<Integer, Node>) lruCache.caches; for (Map.Entry<Integer, Node> nodeEntry : caches.entrySet()) { System.out.println(nodeEntry.getValue().value); } }
執行結果:
四:LRU和LFU的區別以及LFU的缺點
LRU和LFU側重點不同,LRU主要體現在對元素的使用時間上,而LFU主要體現在對元素的使用頻次上。LFU的缺陷是:在短期的時間內,對某些快取的訪問頻次很高,這些快取會立刻晉升為熱點資料,而保證不會淘汰,這樣會駐留在系統記憶體裡面。而實際上,這部分資料只是短暫的高頻率訪問,之後將會長期不訪問,瞬時的高頻訪問將會造成這部分資料的引用頻率加快,而一些新加入的快取很容易被快速刪除,因為它們的引用頻率很低。
五:總結
本篇部落格針對LFU做了一個簡單的介紹,並詳細介紹瞭如何用java來實現LFU,並且和LRU做了一個簡單的比較。針對一種快取策略。LFU有自己的獨特使用場景,如何實現LFU和利用LFU的特性來實現開發過程部分功能是我們需要思考的。實際在使用中LRU的使用頻率要高於LFU,不過了解這種演算法也算是程式設計師的必備技能。
最後: 如果對學習java有興趣可以加入群:618626589,本群旨在打造無培訓廣告、無閒聊扯皮、無注水鬥圖的純技術交流群,群裡每天會分享有價值的問題和學習資料,歡迎各位隨時加入~ &n