快取演算法:LRU、LFU、隨機替換等常見演算法簡介

程式設計碼農發表於2023-05-01

快取演算法

快取演算法是程式設計中的基本演算法,用於解決快取的更新和替換問題,透過合理地選擇快取中資料的儲存位置,可以提高系統的訪問速度和效能。本文介紹幾個通用的快取演算法,這些演算法適用於多種應用場景的快取策略,其目標是在限定的快取空間內,最大化快取命中率,同時最小化快取淘汰率。

  1. 隨機替換 (Random Replacement,RR):隨機選擇一個資料項淘汰。
  2. 先進先出(First In First Out, FIFO):根據資料項進入快取的時間先後,淘汰最早進入快取的資料項。
  3. 最近最少使用(Least Recently Used, LRU):根據資料項最近被訪問的時間,淘汰最久未被使用的資料項。
  4. 最少使用(Least Frequently Used, LFU):根據資料項被訪問的頻率,淘汰訪問次數最少的資料項。

衡量指標

衡量一個快取演算法的質量,通常看以下指標:

  1. 命中率(Hit Rate):即快取中已快取的資料被訪問的次數與所有訪問次數的比值,反映了快取演算法對於熱點資料的快取效果。
  2. 快取空間利用率(Cache Space Utilization):即快取中已經佔用的空間與總空間的比值,反映了快取演算法對於快取空間的利用效率。
  3. 替換次數(Replacement Count):即快取中資料被替換的次數,反映了快取演算法對於快取資料的保護能力。
  4. 快取訪問速度(Cache Access Speed):即快取中資料被訪問的速度,反映了快取演算法對於訪問速度的提升效果。

不過值得注意的是,不同應用場景和需求會對快取演算法的指標有不同的要求,比如某些場景可能更注重命中率和訪問速度,而另一些場景則可能更注重快取空間利用率和替換次數。因此,在選擇快取演算法時,需要根據實際情況進行權衡和選擇。

隨機替換 (RR)

隨機替換 (Random Replacement,RR) 演算法的核心思想是隨機選擇要被替換的快取塊,從而保證所有快取塊被替換的機率相等。在快取空間有限的情況下,當需要替換快取中的某個資料塊時,RR 演算法會從當前快取中隨機選擇一個資料塊進行替換。

優點:

  • 實現簡單,容易理解和實現。
  • 在快取大小較大時表現良好,能夠減少快取替換的次數,提高快取命中率。

缺點:

  • 演算法效能不穩定,在快取大小較小時,表現較差,因為隨機替換可能導致頻繁的快取替換,降低了快取的命中率。
  • 無法適應不同資料訪問模式的需求,不能利用資料區域性性進行快取最佳化。

適用場景: 隨機替換演算法適用於資料訪問模式比較隨機的場景,快取大小比較大,快取替換代價比較高的場景。例如,在記憶體比較充足的情況下,使用隨機替換演算法可以提高快取命中率,減少快取替換的次數,提高系統效能。但是,在快取容量較小、資料訪問模式具有明顯區域性性的場景下,隨機替換演算法的表現會較差。

下面是一個Java 例子:

import java.util.HashMap;
import java.util.LinkedList;
import java.util.Queue;
import java.util.Random;

public class CacheRR<K, V> {

    private int capacity; // 快取容量
    private HashMap<K, V> map; // 用於儲存快取資料
    private Queue<K> queue; // 用於儲存快取資料的key,以便進行隨機替換

    public CacheRR(int capacity) {
        this.capacity = capacity;
        map = new HashMap<>();
        queue = new LinkedList<>();
    }

    /**
     * 從快取中獲取資料
     * @param key 快取資料的key
     * @return 快取資料的value,若不存在則返回null
     */
    public synchronized V get(K key) {
        return map.get(key);
    }

    /**
     * 往快取中新增資料
     * @param key 快取資料的key
     * @param value 快取資料的value
     */
    public synchronized void put(K key, V value) {
        // 如果快取已滿,則進行隨機替換
        if (map.size() == capacity) {
            K randomKey = queue.poll();
            map.remove(randomKey);
        }
        // 新增新資料
        map.put(key, value);
        queue.offer(key);
    }
    
    /**
     * 獲取快取的大小
     * @return 快取的大小
     */
    public synchronized int size() {
        return map.size();
    }

}
這段程式碼實現了一個基於隨機替換(RR)演算法的快取,它使用了HashMap來儲存快取資料,並使用Queue來儲存快取資料的key。當快取達到容量上限時,會從佇列中隨機選擇一個key進行替換,以保證替換的公平性。

先進先出(FIFO)

先進先出(First-In-First-Out, FIFO)快取演算法是一種比較簡單的快取淘汰演算法,它將最早進入快取的資料先出去,也就是先進入快取的資料先被淘汰。

FIFO 演算法的實現很簡單,只需要使用一個佇列來記錄進入快取的順序,每次新的資料被加入快取時,將它放到佇列的尾部,淘汰資料時,從佇列的頭部取出即可。

優點:

  1. 實現簡單,易於理解和部署;
  2. 適用於大多數場景,特別是短期的快取資料;
  3. 快取命中率高,因為先進入快取的資料會更早的被使用。

缺點:

  1. 不適用於長期儲存資料的場景,因為快取中的資料可能已經過時;
  2. 當快取大小不足時,容易產生替換過多的情況,從而降低了快取的效率;
  3. 快取的命中率不如其他高階演算法,如LRU和LFU。

適用的場景:FIFO快取演算法適用於對快取資料更新不頻繁、快取大小要求不高的場景

下面是一個使用 Java 實現 FIFO 快取演算法的示例程式碼:

import java.util.*;

public class FIFOCache<K, V> {
    private final int capacity;
    private final Queue<K> queue;
    private final Map<K, V> cache;

    public FIFOCache(int capacity) {
        this.capacity = capacity;
        this.queue = new LinkedList<>();
        this.cache = new HashMap<>();
    }

    public void put(K key, V value) {
        // 如果快取已滿,先淘汰最早加入的資料
        if (cache.size() == capacity) {
            K oldestKey = queue.poll();
            cache.remove(oldestKey);
        }

        // 加入新資料
        queue.offer(key);
        cache.put(key, value);
    }

    public V get(K key) {
        return cache.get(key);
    }
}
上面程式碼使用了一個 Queue 來記錄進入快取的順序,使用了一個 Map 來記錄快取的資料。當快取已滿時,從佇列頭部取出最早加入的資料,並從快取中移除;當需要獲取資料時,直接從快取中獲取即可。

最近最少使用 (LRU)

這種演算法是根據資料項的歷史訪問記錄來選擇替換掉最近最少被使用的資料項。其核心思想是:如果一個資料在最近一段時間內沒有被訪問,那麼它在未來被訪問的機率也相對較低,可以考慮將其替換出快取,以便為後續可能訪問的資料騰出快取空間。

LRU演算法有多種實現方式,其中一種比較簡單的實現方式是使用雙向連結串列和雜湊表,其中雙向連結串列用來記錄快取資料的訪問順序,雜湊表用來實現對資料項的快速訪問。

演算法實現過程如下:

  1. 如果某個資料項被訪問,那麼它就被移動到連結串列的頭部(表示最近被使用),如果資料項不在快取中,則將其新增到連結串列的頭部。
  2. 當快取達到容量限制時,將連結串列尾部(表示最近最少被使用)的資料項從快取中刪除。

優點:

  1. 可以儘可能地保留最常用的資料,減少快取的命中率,提高快取的效率。
  2. 實現簡單,適用於大多數場景,比較容易理解和實現。
  3. 適用於記憶體有限的情況下,可以避免記憶體溢位的問題。
  4. 對於熱點資料可以快速快取,避免多次查詢,提高系統的效能。

缺點:

  1. 不能保證最佳效能,可能會出現快取命中率不高的情況。
  2. 當快取大小達到一定閾值時,需要清除舊資料,如果清除不當可能會導致效能下降。
  3. 實現過程中需要維護一個連結串列和雜湊表,佔用一定的記憶體空間。

LRU快取演算法適用於訪問模式比較穩定的場景,例如:熱門新聞、熱門影片等。同時也適用於記憶體有限的場景,可以快取最常用的資料,避免記憶體溢位的問題。但是對於訪問模式變化頻繁的場景,LRU演算法可能無法實現最優的快取效果,需要根據具體場景選擇不同的快取演算法。

下面是一個使用 Java 實現LRU快取演算法的示例程式碼:

import java.util.HashMap;
import java.util.Map;

public class LRUCache<K, V> {
    
    private final Map<K, Node> cache;
    private final int capacity;
    private Node head;
    private Node tail;

    public LRUCache(int capacity) {
        this.capacity = capacity;
        this.cache = new HashMap<>(capacity);
    }

    public V get(K key) {
        Node node = cache.get(key);
        if (node == null) {
            return null;
        }
        moveToHead(node);
        return node.value;
    }

    public void put(K key, V value) {
        Node node = cache.get(key);
        if (node == null) {
            node = new Node(key, value);
            cache.put(key, node);
            addNode(node);
            if (cache.size() > capacity) {
                Node removed = removeTail();
                cache.remove(removed.key);
            }
        } else {
            node.value = value;
            moveToHead(node);
        }
    }

    private void moveToHead(Node node) {
        if (node == head) {
            return;
        }
        removeNode(node);
        addNode(node);
    }

    private void addNode(Node node) {
        if (head == null) {
            head = node;
            tail = node;
        } else {
            node.next = head;
            head.prev = node;
            head = node;
        }
    }

    private void removeNode(Node node) {
        if (node == head) {
            head = node.next;
        } else if (node == tail) {
            tail = node.prev;
        } else {
            node.prev.next = node.next;
            node.next.prev = node.prev;
        }
        node.next = null;
        node.prev = null;
    }

    private Node removeTail() {
        Node removed = tail;
        if (head == tail) {
            head = null;
            tail = null;
        } else {
            tail = tail.prev;
            tail.next = null;
        }
        return removed;
    }

    private class Node {
        private final K key;
        private V value;
        private Node prev;
        private Node next;

        public Node(K key, V value) {
            this.key = key;
            this.value = value;
        }
    }
}
該實現使用了一個雙向連結串列和一個HashMap來儲存快取資料,其中雙向連結串列用於維護快取資料的訪問順序。在訪問快取資料時,透過將訪問到的節點移動到雙向連結串列的頭部來表示該節點最近被訪問過;而在新增新的快取資料時,如果快取已經滿了,則會先移除雙向連結串列尾部的節點。

最少使用(LFU)

該演算法會優先淘汰最近使用次數最少的資料。LFU不同於LRU,它是根據資料的歷史訪問頻率來進行淘汰資料,而LRU是根據資料最近的訪問時間來進行淘汰資料

優點:

  1. 可以有效地利用快取空間,因為會淘汰使用頻率最低的快取資料,使快取中儲存的資料總是最常用的。
  2. 相對於其他快取演算法,LFU演算法更加智慧化,因為它可以動態調整使用頻率,確保每個快取資料都是最優的。
  3. LFU演算法不會因為某個資料使用頻率突然增加而誤判,因為它記錄的是資料被使用的總次數。

缺點:

  1. LFU演算法的實現比較複雜,需要對快取中的每個資料記錄使用的次數。
  2. 需要維護每個資料的使用次數,因此在高併發場景下可能會導致效能問題。
  3. 如果快取中存在某個資料長時間沒有被使用,但是一旦被使用就會頻繁地被使用,那麼LFU演算法可能會將它誤判為頻繁使用的資料,從而導致快取淘汰出現問題。

LFU 演算法適用於具有以下特點的場景:

  1. 訪問頻率較高的資料在短時間內仍然有很大機率被再次訪問。
  2. 有一部分資料的訪問頻率特別高,其他資料的訪問頻率相對較低。
  3. 資料的訪問模式具有一定的區域性性,即訪問一些資料之後,在接下來的一段時間內仍然有較大機率訪問與這些資料相關的資料。

以下是一個簡單的 LFU 快取演算法的 Java 實現示例:

import java.util.HashMap;
import java.util.LinkedHashSet;

public class LFUCache<K, V> {
    private final int capacity;
    private final HashMap<K, V> keyToVal;
    private final HashMap<K, Integer> keyToFreq;
    private final HashMap<Integer, LinkedHashSet<K>> freqToKeys;
    private int minFreq;

    public LFUCache(int capacity) {
        this.capacity = capacity;
        this.keyToVal = new HashMap<>();
        this.keyToFreq = new HashMap<>();
        this.freqToKeys = new HashMap<>();
        this.minFreq = 0;
    }

    public V get(K key) {
        if (!keyToVal.containsKey(key)) {
            return null;
        }
        increaseFreq(key);
        return keyToVal.get(key);
    }

    public void put(K key, V value) {
        if (capacity <= 0) {
            return;
        }
        if (keyToVal.containsKey(key)) {
            keyToVal.put(key, value);
            increaseFreq(key);
            return;
        }
        if (keyToVal.size() >= capacity) {
            removeMinFreqKey();
        }
        keyToVal.put(key, value);
        keyToFreq.put(key, 1);
        freqToKeys.putIfAbsent(1, new LinkedHashSet<>());
        freqToKeys.get(1).add(key);
        minFreq = 1;
    }

    private void increaseFreq(K key) {
        int freq = keyToFreq.get(key);
        keyToFreq.put(key, freq + 1);
        freqToKeys.get(freq).remove(key);
        freqToKeys.putIfAbsent(freq + 1, new LinkedHashSet<>());
        freqToKeys.get(freq + 1).add(key);
        if (freqToKeys.get(freq).isEmpty() && freq == minFreq) {
            minFreq = freq + 1;
        }
    }

    private void removeMinFreqKey() {
        LinkedHashSet<K> keyList = freqToKeys.get(minFreq);
        K deletedKey = keyList.iterator().next();
        keyList.remove(deletedKey);
        if (keyList.isEmpty()) {
            freqToKeys.remove(minFreq);
        }
        keyToVal.remove(deletedKey);
        keyToFreq.remove(deletedKey);
    }
}

關於LRU 和 LFU 的應用

根據具體的應用場景和快取需求,如果資料的使用頻率比較均勻,沒有明顯的熱點資料,那麼 LRU 演算法比較適合。例如,一個線上書店的圖書搜尋頁面,使用者搜尋圖書的請求會比較頻繁,但是對於每本書的訪問並沒有特別的頻繁,這時 LRU 演算法就能夠很好地滿足需求。

如果資料有明顯的熱點,即某些資料被頻繁訪問,而其他資料則很少被訪問,那麼 LFU 演算法比較適合。例如,一個影片網站的首頁,某些熱門影片會被很多使用者頻繁地訪問,而其他影片則很少被訪問,這時 LFU 演算法就能夠更好地滿足需求。

這些演算法有一些實際的應用例子:

  1. 作業系統中的頁面置換演算法:在虛擬記憶體中,作業系統需要根據頁面的訪問情況進行置換,常用的演算法包括 LRU 和 LFU。
  2. Web 伺服器中的快取演算法:對於一些靜態內容,如圖片、CSS 檔案等,Web 伺服器可以使用 LRU 或 LFU 演算法進行快取,以提高響應速度和併發能力。
  3. 資料庫中的快取演算法:資料庫可以使用 LRU 或 LFU 演算法來快取一些常用的資料塊,以減少磁碟 I/O 操作,提高訪問速度。
  4. 程式語言中的垃圾回收演算法:程式語言需要對記憶體進行垃圾回收,常用的演算法包括 LRU 和 LFU。其中 LRU 演算法被用來確定哪些物件是最近使用過的,而 LFU 演算法被用來確定哪些物件是最頻繁使用的。

相關文章