Java Tips | コレクション:LRUキャッシュ

Java Java
スポンサーリンク

LRUキャッシュは「よく使うものだけを手元に置き、古いものから捨てる」箱

業務システムでは、
「毎回DBに取りに行くのは重いけど、全部メモリに載せるのもキツい」
みたいな場面がよく出てきます。

そこで出てくるのがキャッシュです。
その中でも LRU(Least Recently Used)キャッシュは、
「最近使ったものは残し、しばらく使っていないものから捨てる」 というルールを持ったキャッシュです。

イメージとしては、「よく開くファイルは机の上、しばらく触ってないファイルは棚に戻す」感じです。
今日は、Java の LinkedHashMap を使った LRUキャッシュの作り方を、初心者向けにかみ砕いていきます。


LRUキャッシュの基本ルールを言葉で整理する

何を「最近使った」とみなすか

LRUキャッシュのルールはシンプルです。

「最近アクセスされた(get された or put された)ものほど“新しい”」
「容量を超えたら、“一番古く使われていないもの”から捨てる」

ここで大事なのは、
「順番の基準が“追加された順”ではなく“アクセスされた順”」 という点です。

単なる FIFO(先入れ先出し)ではなく、
「最近触ったかどうか」で生き残りが決まる、というところが LRU の肝です。


Javaでの定番実装:LinkedHashMap を継承する

LinkedHashMap には「順番付きのMap」という性質がある

LinkedHashMap は、「順番を覚えている Map」です。
そして、コンストラクタで accessOrdertrue にすると、
「アクセス順(get/put された順)」で要素を並べてくれる という機能があります。

さらに、removeEldestEntry というメソッドをオーバーライドすると、
「一番古い要素を自動的に捨てる」ことができます。

これを組み合わせると、かなり簡単に LRUキャッシュが作れます。


最小限の LRUキャッシュ実装

コード全体をまず見てみる

import java.util.LinkedHashMap;
import java.util.Map;

public class LruCache<K, V> extends LinkedHashMap<K, V> {

    private final int maxSize;

    public LruCache(int maxSize) {
        // initialCapacity, loadFactor, accessOrder
        super(16, 0.75f, true);
        this.maxSize = maxSize;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > maxSize;
    }
}
Java

使い方の例です。

LruCache<String, String> cache = new LruCache<>(3);

cache.put("A", "Apple");
cache.put("B", "Banana");
cache.put("C", "Cherry");

cache.get("A");        // A をアクセス(A が“新しい”扱いになる)
cache.put("D", "Durian"); // ここで容量オーバー → 一番古い B が捨てられる

System.out.println(cache.keySet()); // [C, A, D] のような順番
Java

ここから、重要なポイントを一つずつ分解していきます。


重要ポイント1:accessOrder = true で「アクセス順」になる

コンストラクタの第三引数に注目する

super(16, 0.75f, true);
Java

LinkedHashMap のこのコンストラクタは、
(初期容量, 負荷係数, accessOrder) という意味です。

accessOrdertrue にすると、
「get された要素も“最後尾”に移動する」 ようになります。

つまり、

cache.put("A", ...);
cache.put("B", ...);
cache.put("C", ...);
// この時点の順番: A, B, C

cache.get("A");
// アクセス順: B, C, A(A が“最近使われた”扱い)
Java

この「アクセスするたびに後ろに回る」という性質が、
「最近使ったものほど新しい」という LRU のルールを自然に実現してくれます。


重要ポイント2:removeEldestEntry で「古いものを自動削除」

size() > maxSize になった瞬間に、先頭が捨てられる

@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
    return size() > maxSize;
}
Java

LinkedHashMap は、put のたびにこのメソッドを呼んで、
true が返ってきたら「一番古い要素(eldest)を削除」します。

ここで size() > maxSize としているので、
「最大サイズを超えた瞬間に、古いものから1件ずつ捨てる」 挙動になります。

アクセス順で並んでいるので、
「一番古い=しばらく使われていない」要素が自動的に消えていきます。


重要ポイント3:get も put も「利用」とみなされる

読み取りも「最近使った」として扱われる

accessOrder = true の場合、
get(key)put(key, value) も、そのキーを「最後尾」に移動させます。

つまり、

「よく読み取られるデータ」
「よく更新されるデータ」

どちらも「最近使われている」とみなされ、キャッシュに残りやすくなります。

逆に、
「一度入れたけど、その後まったくアクセスされないデータ」は、
新しいデータが入ってくるたびに、じわじわと追い出されていきます。


スレッドセーフにしたい場合の考え方

単純に synchronizedMap で包む方法

上の LruCache はスレッドセーフではありません。
複数スレッドから同時にアクセスするなら、同期を考える必要があります。

一番簡単なのは、Collections.synchronizedMap で包む方法です。

import java.util.Collections;
import java.util.Map;

Map<String, String> cache =
        Collections.synchronizedMap(new LruCache<>(100));
Java

これで、cache.getcache.put は同期付きになります。

ただし、
「高負荷なマルチスレッド環境でガチなキャッシュを作る」なら、
ConcurrentHashMap ベースの実装や、Caffeine などの専用ライブラリを検討した方がいいです。

ここでの LRU は、
「まずは仕組みを理解する」「単純な用途で使う」 くらいの位置づけで捉えておくといいです。


具体例:DBアクセス結果を LRUキャッシュする

「同じキーの問い合わせが何度も来る」ケース

例えば、「商品IDから商品情報を取ってくる」処理があるとします。

public class ProductService {

    private final LruCache<String, Product> cache = new LruCache<>(1000);

    public Product findById(String id) {
        Product cached = cache.get(id);
        if (cached != null) {
            return cached; // キャッシュヒット
        }
        Product p = loadFromDb(id); // 重い処理
        if (p != null) {
            cache.put(id, p);
        }
        return p;
    }

    private Product loadFromDb(String id) {
        // 実際のDBアクセス
        ...
    }
}
Java

ここでのポイントは二つです。

一つ目は、「キャッシュにあるなら DB に行かない」という、
“読み取りの高速化” を LRU で実現していること。

二つ目は、「キャッシュのサイズを 1000 件に制限している」ので、
“メモリを無限に食い続けない” という安全弁になっていること。

よくアクセスされる商品はキャッシュに残り、
滅多に見られない商品は、他のデータに押し出されて消えていきます。


まとめ:LRUキャッシュで身につけてほしい感覚

LRUキャッシュは、
単に「Map をちょっと工夫したもの」ではなく、
「限られたメモリの中で、“よく使うものだけを手元に残す”ための設計」 です。

LinkedHashMapaccessOrder = true で「アクセス順」を維持する。
removeEldestEntry をオーバーライドして、「容量を超えたら古いものから捨てる」ルールを埋め込む。
get/put のたびに「最近使った」として順番が更新されることを理解する。
まずは単純な用途で使い、ガチなキャッシュが必要になったら専用ライブラリも検討する。

もしあなたのコードのどこかで、
「Map にどんどん詰め込んで、いつの間にかメモリを食い潰している」ような箇所があれば、
そこを一度「サイズ制限付きの LRUキャッシュ」にできないか眺めてみてください。

その小さな一歩が、
「性能とメモリのバランスを意識して設計できるエンジニア」への、
かなり大きな前進になります。

タイトルとURLをコピーしました