Java 逆引き集 | キャッシング Collector(memoizing)パターン — 重い計算の最適化

Java Java
スポンサーリンク

目的と前提

「重い計算」をストリームの中で何度も呼ぶと、CPUが燃え、待ち時間が伸び、並列化しても効果が出ません。キャッシング(memoizing)パターンは、同じ入力に対する結果を再利用して、計算を最小化する設計です。コア発想はシンプルです。キー(入力)→値(結果)のマップを用意し、計算前に「既にあるか」を必ず確認します。ストリームでは Function のメモ化、Collector の中でのメモ化、並列時のスレッド安全性の確保が重要ポイントになります。


基本形:Function のメモ化で重い map を最適化

再利用可能な memoize ユーティリティ

まずは関数を包んでキャッシュする汎用形です。順次ストリームなら普通の HashMap、並列ストリームなら ConcurrentHashMap を使います。

import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;

public class Memo {
    // 順次用(非スレッドセーフ)
    public static <T,R> Function<T,R> memoize(Function<T,R> f) {
        Map<T,R> cache = new HashMap<>();
        return t -> cache.computeIfAbsent(t, f);
    }

    // 並列対応(スレッドセーフ)
    public static <T,R> Function<T,R> memoizeConcurrent(Function<T,R> f) {
        Map<T,R> cache = new ConcurrentHashMap<>();
        return t -> cache.computeIfAbsent(t, f);
    }
}
Java

これで「重い計算」を 1 回目だけ実行し、以降はキャッシュから即返却できます。ストリームの map(memoizedFn) に差し替えるだけで効果が出ます。

例題:ファイル読み込み+パースの結果を再利用

同じファイルを何度も読む処理を、メモ化で最適化します。

import java.nio.file.*;
import java.io.IOException;
import java.util.function.Function;

Function<Path, String> read = p -> {
    try { return Files.readString(p); }
    catch (IOException e) { throw new UncheckedIOException(e); }
};

Function<Path, String> memoRead = Memo.memoizeConcurrent(read); // 並列でも安全

var paths = List.of(Paths.get("a.txt"), Paths.get("b.txt"), Paths.get("a.txt")); // a.txt が重複

List<Integer> sizes = paths.stream()
    .map(memoRead)           // a.txt は1回だけ読む
    .map(String::length)
    .toList();
Java

同じ Path は 1 回しか読まれず、2 回目以降は即返ります。I/Oが多いテストデータ生成や前処理で劇的に効きます。


Collector 内でのメモ化:集約しながらキャッシュを共有

Collector.of にキャッシュを同居させる

「集約しながら同じ計算を何度も使う」なら、Collector の状態にキャッシュを抱かせます。accumulator と combiner の整合性を保ち、並列時はスレッドセーフなマップを使います。

import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.*;
import java.util.stream.Collector;

public class MemoCollectors {
    // 入力Tを重い計算fでRに変換して、最終的にList<R>へ集約。キャッシュ共有版
    public static <T,R> Collector<T, ?, List<R>> memoizingToList(Function<T,R> f, boolean parallelSafe) {
        class Acc {
            final Map<T,R> cache = parallelSafe ? new ConcurrentHashMap<>() : new HashMap<>();
            final List<R> list = new ArrayList<>();
        }
        BiConsumer<Acc, T> acc = (a, t) -> a.list.add(a.cache.computeIfAbsent(t, f));
        BinaryOperator<Acc> comb = (a, b) -> {
            // キャッシュを統合してから list を構築(重複計算を避ける)
            b.cache.forEach((k, v) -> a.cache.putIfAbsent(k, v));
            a.list.addAll(b.list);
            return a;
        };
        return Collector.of(Acc::new, acc, comb, a -> a.list);
    }
}
Java

これにより、並列でも部分集計の間でキャッシュが共有され、同一キーの計算を抑えられます。ポイントは、combiner で「部分キャッシュのマージ」を必ず行うことです。

例題:画像メタデータ抽出をキャッシュしつつ一覧化

Function<Path, Map<String,String>> extractMeta = p -> /* 重い抽出 */ Map.of("name", p.getFileName().toString());

List<Path> files = List.of(Paths.get("img1.jpg"), Paths.get("img2.jpg"), Paths.get("img1.jpg"));

List<Map<String,String>> metas = files.parallelStream()
    .collect(MemoCollectors.memoizingToList(extractMeta, true));
Java

img1.jpg のメタ抽出は一度きり。並列でも combiner でキャッシュが集約され、重複計算が抑制されます。


深掘り:正しさ・性能・スレッド安全の勘所

キーの設計が命(equals/hashCode と正規化)

キャッシュのキー比較は equals/hashCode に依存します。Path なら正規化(toRealPath、区切り統一)、生文字列ならトリムや小文字化などを前段で揃えましょう。異なる表記が「同じもの」と認識されないとキャッシュが効かず、性能が崩れます。

並列ストリームでは ConcurrentHashMap を使う

並列で HashMap を使うと壊れます。ConcurrentHashMapcomputeIfAbsent で競合を安全に処理し、計算の二重実行を最小限にします。重い計算が非再入可能(スレッド安全でない)なら、キャッシュの外で同期を取るか、並列化を避けて順次に切り替えます。

作用の純粋性(副作用のない関数にメモ化を適用)

メモ化は「同じ入力→同じ出力」が前提です。外部状態の変化やランダム性がある関数にメモ化を掛けると、期待と違う挙動になります。重い計算はできる限り純粋関数に寄せ、I/Oは結果を別途識別子付きでキャッシュするなど設計を分けます。

キャッシュ生存期間とリーク対策

無制限キャッシュはリークの原因になります。テストの一回限り、ジョブ単位、ページ単位などの「スコープ」を決め、構造体(Collectorの状態やユーティリティ)をスコープ外で捨てることで寿命を管理します。必要なら LinkedHashMap を使って LRU を自作するか、専用キャッシュ(例:Caffeine)を使う判断もありです。

// 簡易LRU(アクセス順、最大N)
int N = 10_000;
Map<String, String> lru = new LinkedHashMap<>(16, 0.75f, true) {
    @Override protected boolean removeEldestEntry(Map.Entry<String,String> e) { return size() > N; }
};
Java

例外伝搬と再計算の扱い

computeIfAbsent 中で例外が投げられると、そのキーは未キャッシュになります。次回同じキーで再試行される点を理解しておきましょう。I/O系ではリトライ回数やフォールバックを別途設計し、キャッシュには「成功結果のみ」を入れるのが安全です。


実例:重い正規表現・外部 API・JSON のパースを最適化

正規表現の前コンパイル+結果メモ化

import java.util.regex.*;

Pattern p = Pattern.compile("([A-Z]{2})(\\d{3})");

Function<String, Matcher> memoMatch = Memo.memoize(s -> p.matcher(s));

List<String> input = List.of("AB123", "CD456", "AB123");

List<Boolean> hits = input.stream()
    .map(memoMatch)
    .map(Matcher::matches)
    .toList();
Java

Matcher 生成や結果判定が繰り返されるなら、入力文字列をキーにしてメモ化すると効きます。

外部 API レスポンスのデコードをキャッシュ

record User(long id, String name) {}

Function<Long, User> fetch = id -> /* 重いHTTP+デコード */ new User(id, "u" + id);
Function<Long, User> memoFetch = Memo.memoizeConcurrent(fetch);

List<Long> ids = List.of(1L, 2L, 1L, 3L);

List<User> users = ids.parallelStream()
    .map(memoFetch)
    .toList();
Java

同じIDの呼び出しが再利用され、ネットワーク負荷が下がります。並列でも ConcurrentHashMap による共有で安全です。

JSON パース結果の再利用

Function<String, Map<String,Object>> parseJson = json -> /* 重いパース */ Map.of("len", json.length());
Function<String, Map<String,Object>> memoParse = Memo.memoize(parseJson);

List<String> jsons = List.of("{}", "{\"a\":1}", "{\"a\":1}");

List<Map<String,Object>> out = jsons.stream()
    .map(memoParse)
    .toList();
Java

同じ文字列は一度しか解析されません。入る前に正規化(キー順序の統一など)があるとより効きます。


テンプレート:すぐ使えるメモ化の組み込み

関数メモ化(順次/並列)

Function<T,R> memo = Memo.memoize(f);            // 順次
Function<T,R> memoPar = Memo.memoizeConcurrent(f); // 並列
stream.map(memo).forEach(...);
Java

Collector と併用(共有キャッシュ)

List<R> out = stream.collect(MemoCollectors.memoizingToList(f, /*parallelSafe=*/true));
Java

LRU 付きメモ化(簡易版)

Function<T,R> lruMemo(Function<T,R> f, int maxSize) {
    Map<T,R> lru = new LinkedHashMap<>(16, 0.75f, true) {
        @Override protected boolean removeEldestEntry(Map.Entry<T,R> e) { return size() > maxSize; }
    };
    return t -> lru.computeIfAbsent(t, f);
}
Java

まとめ

キャッシング Collector(memoizing)パターンの本質は「同じ入力に同じ計算をさせない」こと。関数をメモ化して map に挿す、Collector の状態にキャッシュを抱かせて共有する、並列なら ConcurrentHashMap を使う。キーの正規化、寿命管理、例外時の再試行方針を整えれば、I/O・デコード・重い計算のすべてで無駄が消え、ストリーム処理が安定して速くなります。

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