快取淘汰演算法
在高併發、高效能的質量要求不斷提高時,我們首先會想到的就是利用快取予以應對。
第一次請求時把計算好的結果存放在快取中,下次遇到同樣的請求時,把之前儲存在快取中的資料直接拿來使用。
但是,快取的空間一般都是有限,不可能把所有的結果全部儲存下來。那麼,當快取空間全部被佔滿再有新的資料需要被儲存,就要決定刪除原來的哪些資料。如何做這樣決定需要使用快取淘汰演算法。
常用的快取淘汰演算法有:FIFO、LRU、LFU,下面我們就逐一介紹一下。
FIFO
FIFO,First In First Out,先進先出演算法。判斷被儲存的時間,離目前最遠的資料優先被淘汰。簡單地說,先存入快取的資料,先被淘汰。
最早存入快取的資料,其不再被使用的可能性比剛存入快取的可能性大。建立一個FIFO佇列,記錄所有在快取中的資料。當一條資料被存入快取時,就把它插在隊尾上。需要被淘汰的資料一直在佇列頭。這種演算法只是在按線性順序訪問資料時才是理想的,否則效率不高。因為那些常被訪問的資料,往往在快取中也停留得最久,結果它們卻因變“老”而不得不被淘汰出去。
FIFO演算法用佇列實現就可以了,這裡就不做程式碼實現了。
LRU
LRU,Least Recently Used,最近最少使用演算法。判斷最近被使用的時間,目前最遠的資料優先被淘汰。簡單地說,LRU 的淘汰規則是基於訪問時間。
如果一個資料在最近一段時間沒有被使用到,那麼可以認為在將來它被使用的可能性也很小。因此,當快取空間滿時,最久沒有使用的資料最先被淘汰。
在Java中,其實LinkedHashMap已經實現了LRU快取淘汰演算法,需要在建構函式第三個引數傳入true,表示按照時間順序訪問。可以直接繼承LinkedHashMap來實現。
package one.more;
import java.util.LinkedHashMap;
import java.util.Map;
public class LruCache<K, V> extends LinkedHashMap<K, V> {
/**
* 容量限制
*/
private int capacity;
LruCache(int capacity) {
// 初始大小,0.75是裝載因子,true是表示按照訪問時間排序
super(capacity, 0.75f, true);
//快取最大容量
this.capacity = capacity;
}
/**
* 重寫removeEldestEntry方法,如果快取滿了,則把連結串列頭部第一個節點和對應的資料刪除。
*/
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > capacity;
}
}
我寫一個簡單的程式測試一下:
package one.more;
public class TestApp {
public static void main(String[] args) {
LruCache<String, String> cache = new LruCache(3);
cache.put("keyA", "valueA");
System.out.println("put keyA");
System.out.println(cache);
System.out.println("=========================");
cache.put("keyB", "valueB");
System.out.println("put keyB");
System.out.println(cache);
System.out.println("=========================");
cache.put("keyC", "valueC");
System.out.println("put keyC");
System.out.println(cache);
System.out.println("=========================");
cache.get("keyA");
System.out.println("get keyA");
System.out.println(cache);
System.out.println("=========================");
cache.put("keyD", "valueD");
System.out.println("put keyD");
System.out.println(cache);
}
}
執行結果如下:
put keyA
{keyA=valueA}
=========================
put keyB
{keyA=valueA, keyB=valueB}
=========================
put keyC
{keyA=valueA, keyB=valueB, keyC=valueC}
=========================
get keyA
{keyB=valueB, keyC=valueC, keyA=valueA}
=========================
put keyD
{keyC=valueC, keyA=valueA, keyD=valueD}
當然,這個不是面試官想要的,也不是我們想要的。我們可以使用雙向連結串列和雜湊表進行實現,雜湊表用於儲存對應的資料,雙向連結串列用於資料被使用的時間先後順序。
在訪問資料時,如果資料已存在快取中,則把該資料的對應節點移到連結串列尾部。如此操作,在連結串列頭部的節點則是最近最少使用的資料。
當需要新增新的資料到快取時,如果該資料已存在快取中,則把該資料對應的節點移到連結串列尾部;如果不存在,則新建一個對應的節點,放到連結串列尾部;如果快取滿了,則把連結串列頭部第一個節點和對應的資料刪除。
package one.more;
import java.util.HashMap;
import java.util.Map;
public class LruCache<K, V> {
/**
* 頭結點
*/
private Node head;
/**
* 尾結點
*/
private Node tail;
/**
* 容量限制
*/
private int capacity;
/**
* key和資料的對映
*/
private Map<K, Node> map;
LruCache(int capacity) {
this.capacity = capacity;
this.map = new HashMap<>();
}
public V put(K key, V value) {
Node node = map.get(key);
// 資料存在,將節點移動到隊尾
if (node != null) {
V oldValue = node.value;
//更新資料
node.value = value;
moveToTail(node);
return oldValue;
} else {
Node newNode = new Node(key, value);
// 資料不存在,判斷連結串列是否滿
if (map.size() == capacity) {
// 如果滿,則刪除隊首節點,更新雜湊表
map.remove(removeHead().key);
}
// 放入隊尾節點
addToTail(newNode);
map.put(key, newNode);
return null;
}
}
public V get(K key) {
Node node = map.get(key);
if (node != null) {
moveToTail(node);
return node.value;
}
return null;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("LruCache{");
Node curr = this.head;
while (curr != null) {
if(curr != this.head){
sb.append(',').append(' ');
}
sb.append(curr.key);
sb.append('=');
sb.append(curr.value);
curr = curr.next;
}
return sb.append('}').toString();
}
private void addToTail(Node newNode) {
if (newNode == null) {
return;
}
if (head == null) {
head = newNode;
tail = newNode;
} else {
//連線新節點
tail.next = newNode;
newNode.pre = tail;
//更新尾節點指標為新節點
tail = newNode;
}
}
private void moveToTail(Node node) {
if (tail == node) {
return;
}
if (head == node) {
head = node.next;
head.pre = null;
} else {
//調整雙向連結串列指標
node.pre.next = node.next;
node.next.pre = node.pre;
}
node.pre = tail;
node.next = null;
tail.next = node;
tail = node;
}
private Node removeHead() {
if (head == null) {
return null;
}
Node res = head;
if (head == tail) {
head = null;
tail = null;
} else {
head = res.next;
head.pre = null;
res.next = null;
}
return res;
}
class Node {
K key;
V value;
Node pre;
Node next;
Node(K key, V value) {
this.key = key;
this.value = value;
}
}
}
再次執行測試程式,結果如下:
put keyA
LruCache{keyA=valueA}
=========================
put keyB
LruCache{keyA=valueA, keyB=valueB}
=========================
put keyC
LruCache{keyA=valueA, keyB=valueB, keyC=valueC}
=========================
get keyA
LruCache{keyB=valueB, keyC=valueC, keyA=valueA}
=========================
put keyD
LruCache{keyC=valueC, keyA=valueA, keyD=valueD}
LFU
LFU,Least Frequently Used,最不經常使用演算法,在一段時間內,資料被使用次數最少的,優先被淘汰。簡單地說,LFU 的淘汰規則是基於訪問次數。
如果一個資料在最近一段時間很少被使用到,那麼可以認為在將來它被使用的可能性也很小。因此,當空間滿時,最小頻率使用的資料最先被淘汰。
我們可以使用雙雜湊表進行實現,一個雜湊表用於儲存對應的資料,另一個雜湊表用於儲存資料被使用次數和對應的資料。
package one.more;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class LfuCache<K, V> {
/**
* 容量限制
*/
private int capacity;
/**
* 當前最小使用次數
*/
private int minUsedCount;
/**
* key和資料的對映
*/
private Map<K, Node> map;
/**
* 資料頻率和對應資料組成的連結串列
*/
private Map<Integer, List<Node>> usedCountMap;
public LfuCache(int capacity) {
this.capacity = capacity;
this.minUsedCount = 1;
this.map = new HashMap<>();
this.usedCountMap = new HashMap<>();
}
public V get(K key) {
Node node = map.get(key);
if (node == null) {
return null;
}
// 增加資料的訪問頻率
addUsedCount(node);
return node.value;
}
public V put(K key, V value) {
Node node = map.get(key);
if (node != null) {
// 如果存在則增加該資料的訪問頻次
V oldValue = node.value;
node.value = value;
addUsedCount(node);
return oldValue;
} else {
// 資料不存在,判斷連結串列是否滿
if (map.size() == capacity) {
// 如果滿,則刪除隊首節點,更新雜湊表
List<Node> list = usedCountMap.get(minUsedCount);
Node delNode = list.get(0);
list.remove(delNode);
map.remove(delNode.key);
}
// 新增資料並放到資料頻率為1的資料連結串列中
Node newNode = new Node(key, value);
map.put(key, newNode);
List<Node> list = usedCountMap.get(1);
if (list == null) {
list = new LinkedList<>();
usedCountMap.put(1, list);
}
list.add(newNode);
minUsedCount = 1;
return null;
}
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("LfuCache{");
List<Integer> usedCountList = this.usedCountMap.keySet().stream().collect(Collectors.toList());
usedCountList.sort(Comparator.comparingInt(i -> i));
int count = 0;
for (int usedCount : usedCountList) {
List<Node> list = this.usedCountMap.get(usedCount);
if (list == null) {
continue;
}
for (Node node : list) {
if (count > 0) {
sb.append(',').append(' ');
}
sb.append(node.key);
sb.append('=');
sb.append(node.value);
sb.append("(UsedCount:");
sb.append(node.usedCount);
sb.append(')');
count++;
}
}
return sb.append('}').toString();
}
private void addUsedCount(Node node) {
List<Node> oldList = usedCountMap.get(node.usedCount);
oldList.remove(node);
// 更新最小資料頻率
if (minUsedCount == node.usedCount && oldList.isEmpty()) {
minUsedCount++;
}
node.usedCount++;
List<Node> set = usedCountMap.get(node.usedCount);
if (set == null) {
set = new LinkedList<>();
usedCountMap.put(node.usedCount, set);
}
set.add(node);
}
class Node {
K key;
V value;
int usedCount = 1;
Node(K key, V value) {
this.key = key;
this.value = value;
}
}
}
再次執行測試程式,結果如下:
put keyA
LfuCache{keyA=valueA(UsedCount:1)}
=========================
put keyB
LfuCache{keyA=valueA(UsedCount:1), keyB=valueB(UsedCount:1)}
=========================
put keyC
LfuCache{keyA=valueA(UsedCount:1), keyB=valueB(UsedCount:1), keyC=valueC(UsedCount:1)}
=========================
get keyA
LfuCache{keyB=valueB(UsedCount:1), keyC=valueC(UsedCount:1), keyA=valueA(UsedCount:2)}
=========================
put keyD
LfuCache{keyC=valueC(UsedCount:1), keyD=valueD(UsedCount:1), keyA=valueA(UsedCount:2)}
總結
看到這裡,你已經超越了大多數人!
- FIFO,First In First Out,先進先出演算法。判斷被儲存的時間,離目前最遠的資料優先被淘汰,可以使用佇列實現。
- LRU,Least Recently Used,最近最少使用演算法。判斷最近被使用的時間,目前最遠的資料優先被淘汰,可以使用雙向連結串列和雜湊表實現。
- LFU,Least Frequently Used,最不經常使用演算法,在一段時間內,資料被使用次數最少的,優先被淘汰,可以使用雙雜湊表實現。
竟然已經看到這裡了,你我定是有緣人,留下你的點贊和關注,他日必成大器。
微信公眾號:萬貓學社
微信掃描二維碼
關注後回覆「電子書」
獲取12本Java必讀技術書籍