目錄
2、LinkedHashMap實現LRU原理(accessOrder = true)
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);
}