LRUキャッシュは「よく使うものだけを手元に置き、古いものから捨てる」箱
業務システムでは、
「毎回DBに取りに行くのは重いけど、全部メモリに載せるのもキツい」
みたいな場面がよく出てきます。
そこで出てくるのがキャッシュです。
その中でも LRU(Least Recently Used)キャッシュは、
「最近使ったものは残し、しばらく使っていないものから捨てる」 というルールを持ったキャッシュです。
イメージとしては、「よく開くファイルは机の上、しばらく触ってないファイルは棚に戻す」感じです。
今日は、Java の LinkedHashMap を使った LRUキャッシュの作り方を、初心者向けにかみ砕いていきます。
LRUキャッシュの基本ルールを言葉で整理する
何を「最近使った」とみなすか
LRUキャッシュのルールはシンプルです。
「最近アクセスされた(get された or put された)ものほど“新しい”」
「容量を超えたら、“一番古く使われていないもの”から捨てる」
ここで大事なのは、
「順番の基準が“追加された順”ではなく“アクセスされた順”」 という点です。
単なる FIFO(先入れ先出し)ではなく、
「最近触ったかどうか」で生き残りが決まる、というところが LRU の肝です。
Javaでの定番実装:LinkedHashMap を継承する
LinkedHashMap には「順番付きのMap」という性質がある
LinkedHashMap は、「順番を覚えている Map」です。
そして、コンストラクタで accessOrder を true にすると、
「アクセス順(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);
JavaLinkedHashMap のこのコンストラクタは、(初期容量, 負荷係数, accessOrder) という意味です。
accessOrder を true にすると、
「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;
}
JavaLinkedHashMap は、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.get や cache.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 をちょっと工夫したもの」ではなく、
「限られたメモリの中で、“よく使うものだけを手元に残す”ための設計」 です。
LinkedHashMap の accessOrder = true で「アクセス順」を維持する。removeEldestEntry をオーバーライドして、「容量を超えたら古いものから捨てる」ルールを埋め込む。
get/put のたびに「最近使った」として順番が更新されることを理解する。
まずは単純な用途で使い、ガチなキャッシュが必要になったら専用ライブラリも検討する。
もしあなたのコードのどこかで、
「Map にどんどん詰め込んで、いつの間にかメモリを食い潰している」ような箇所があれば、
そこを一度「サイズ制限付きの LRUキャッシュ」にできないか眺めてみてください。
その小さな一歩が、
「性能とメモリのバランスを意識して設計できるエンジニア」への、
かなり大きな前進になります。
