1
0

JavaでLRUキャッシュの実装

Posted at

LRUキャッシュの実装

LRU (Least Recently Used) キャッシュは、最近使用されていないデータを優先的に削除することでメモリの効率を高めるキャッシュアルゴリズムです。これは、高度なシステム設計において、メモリとディスクストレージの間でデータアクセスの速度差を埋めるために使用されます。

メモリとストレージの基本

  • メモリ (RAM):

    • 速い: データアクセスが非常に高速
    • 小さい: 容量が限られている
    • 高い: コストが高い
    • 揮発性: 電源を切るとデータが消える
  • ディスクストレージ (SSD):

    • 大容量: 容量が大きい
    • 安い: コストが低い
    • 永続性: 電源を切ってもデータが残る
    • 遅い: メモリに比べてデータアクセスが遅い

速さの違い

  • メモリ: 非常に高速、例:50ナノ秒
  • SSD: メモリより遅い、例:50マイクロ秒(メモリの1000倍遅い)

キャッシュの必要性

多くのアプリケーションのパフォーマンス向上には、メモリキャッシュが重要です。しかし、メモリ容量が限られているため、効率的なアルゴリズムが必要で、どのデータを保持し、どのデータを削除するかを決定する必要があります。

キャッシュの種類

  • L1キャッシュ: 非常に高速で、メモリより数百倍速い
  • L2キャッシュ: L1キャッシュより遅いが、それでもメモリより数十倍速い

※ メモリキャッシュを使用するとアプリケーションの動作が高速になりますが、メモリには限りがあるため、効率的なデータ管理が必要です。

メモリキャッシュの仕組み

  1. メモリキャッシュの役割:
    メモリ上にデータをキャッシュすることで、CPUはストレージからデータを取り出すよりも早くアクセスできます

  2. キャッシュの容量設定:
    キャッシュに使用するメモリの量を決定します

  3. キャッシュが満杯になった場合:
    キャッシュがいっぱいになると、新しいデータを入れるために古いデータを削除する必要があります

効率的なメモリキャッシュの使用はアプリケーションの動作を高速化しますが、賢いデータ管理が必要です

LRUキャッシュの実装

LRUキャッシュは、コンピュータがデータを効率的に管理するための仕組みです。LRUは「Least Recently Used(最近使われていない)」の略です。

ハッシュマップとの組み合わせ

ハッシュマップを使用すると、データの検索が迅速に行えます。ハッシュマップはキーと値のペアを保持し、キーに基づいて対応する値を効率的に検索することができます。ハッシュマップを用いることで、キーが存在するかどうかの判定や値の取得が高速に行えます。

実装方法

ハッシュマップを用いたLRUキャッシュの実装では、キーを使用して対応するノードをハッシュマップで検索し、双方向リストでデータの順序を管理します。これにより、データの追加、削除、検索が効率的に行えます。

時間計算量

ハッシュマップと双方向リストを組み合わせたLRUキャッシュの操作の時間計算量は次の通りです:

  • get(key) 操作: O(1)

    • ハッシュマップを使用してキーに対応するノードを検索し、双方向リストでそのノードを先頭に移動するため、平均時間計算量はO(1)です
  • put(key, value) 操作: O(1)

    • ハッシュマップを使用してキーの存在を確認し、双方向リストに新しいノードを追加するか、既存のノードを更新して先頭に移動するため、平均時間計算量はO(1)です

このように、ハッシュマップを使用することで、キーの検索やデータの追加・削除操作を効率的に行うことができます。

実装例

Main.java

public class Main {
    public static void main(String[] args) {
        LRUCache<Integer, String> lruCache = new LRUCache<>(2);

        // キャッシュに値を追加
        lruCache.put(1, "One");
        lruCache.put(2, "Two");

        // キャッシュから値を取得
        System.out.println(lruCache.get(1)); // 出力: One

        // 新しい値を追加して古い値を削除
        lruCache.put(3, "Three");
        System.out.println(lruCache.get(2)); // 出力: null, 2は削除されたため

        // さらに新しい値を追加
        lruCache.put(4, "Four");
        System.out.println(lruCache.get(1)); // 出力: null, 1は削除されたため
        System.out.println(lruCache.get(3)); // 出力: Three
        System.out.println(lruCache.get(4)); // 出力: Four
    }
}

LRUCache.java

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

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

    // ノードクラス: キーと値、前後のポインタを持つ
    private static class Node<K, V> {
        K key;
        V value;
        Node<K, V> prev;
        Node<K, V> next;

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

    // コンストラクタ: キャッシュの容量を設定し、ダミーノードを初期化
    public LRUCache(int capacity) {
        this.capacity = capacity;
        this.cache = new HashMap<>(capacity);
        this.head = new Node<>(null, null);
        this.tail = new Node<>(null, null);
        head.next = tail;
        tail.prev = head;
    }

    // 値を取得: ノードをリストの先頭に移動し、値を返す
    public V get(K key) {
        Node<K, V> node = cache.get(key);
        if (node == null) {
            return null;
        }
        moveToHead(node);
        return node.value;
    }

    // 値を追加: 新しいノードをリストの先頭に追加し、必要に応じて末尾のノードを削除
    public void put(K key, V value) {
        Node<K, V> node = cache.get(key);
        if (node == null) {
            node = new Node<>(key, value);
            cache.put(key, node);
            addToHead(node);
            if (cache.size() > capacity) {
                Node<K, V> tail = removeTail();
                cache.remove(tail.key);
            }
        } else {
            node.value = value;
            moveToHead(node);
        }
    }

    // ノードをリストの先頭に追加
    private void addToHead(Node<K, V> node) {
        node.prev = head;
        node.next = head.next;
        head.next.prev = node;
        head.next = node;
    }

    // ノードをリストの先頭に移動
    private void moveToHead(Node<K, V> node) {
        removeNode(node);
        addToHead(node);
    }

    // ノードをリストから削除
    private void removeNode(Node<K, V> node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }

    // 末尾のノードを削除し、削除したノードを返す
    private Node<K, V> removeTail() {
        Node<K, V> res = tail.prev;
        removeNode(res);
        return res;
    }
}

テスト結果と解説

テストを実行した結果は以下の通りです:

One // lruCache.get(1)の結果。キー1に対応する値"One"がキャッシュに存在するため出力されます。
null // lruCache.get(2)の結果。キャッシュの容量が2のため、新しく3を追加した際に、最も古い2が削除されました。
null // lruCache.get(1)の結果。キャッシュに新しく4を追加した際に、最も古い1が削除されました。
Three // lruCache.get(3)の結果。キー3
Four

データの順序

データの使用順序を保持することが重要です。これを実現するために、双方向リストを使用します。リストの先頭には一番古いデータが、末尾には一番新しいデータが配置されます。

双方向リスト

双方向リストは前後に移動できるリストで、データの追加や削除が容易です。

まとめ

  • LRUキャッシュは、最近使用されたデータを記憶し、古いデータを削除する仕組みです。これにより、データの読み書き速度が大幅に向上します
  • 双方向リストと配列を使用して効率的にデータを管理します
  • 配列のサイズは固定で、2つのデータ構造を使用するためにメモリが必要です

LRUキャッシュの理解と実装により、アプリケーションのパフォーマンスを最適化することができます

1
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0