Javaのラムダ式((引数) -> 処理)は、どこでも使える書き方ではありません。関数型インターフェースを受け取るAPIへ、名前を付けるほどでもない短い処理を渡すための構文です。この記事では、ラムダ式が効く5つの場面と、使うとかえって読みにくく危なくなる4つの場面を整理します。載せたコードはTemurin 25.0.4.1(Java 25 LTS)で実行し、結果とコンパイルエラーの文面は実物を写しました。Java 8から最新のJava 27までの版の違いも書き添えています。
まとめ:ラムダ式を使う場面と避ける場面
ラムダ式を使う基準は「その処理を引数として渡すAPIがあり、1〜2行で書けるか」です。使いどころは次の5つです。
- Stream APIの
filter・map・collectに条件と変換を渡す - コレクションの
removeIf・forEach、Mapのmerge・computeIfAbsentでループを1行にする Comparatorの並べ替え基準をcomparingとthenComparingで組む- 遅延評価:
Optional.orElseGetやログ出力で、必要なときだけ値を作る - コールバックと処理の差し替え:
Runnable・イベントリスナー・戦略の切り替え
避けるのは、検査例外を投げる処理、外側の変数を書き換えたい処理、並列Streamから共有のコレクションを触る処理、数行を超えるロジックの4つです。このうち、未処理の検査例外を投げるラムダ式(Callableのようにthrowsを宣言した型へ渡す場合を除く)と、外側のローカル変数への再代入は、コンパイルが通りません。並列Streamの共有状態はコンパイルが通るのに結果が壊れるので、いちばん危険です。数行を超えるロジックは名前付きメソッドに切り出してメソッド参照で渡します。
実測で分かった落とし穴が2つあります。降順ソートでよく見る(a, b) -> b - aは、Integer.MIN_VALUEを含むと並びが逆になります。Comparator.reverseOrder()を使ってください。また、Java 16以降のStream.toList()が返すリストは変更できません。後からaddするなら、collect(Collectors.toCollection(ArrayList::new))で変更可能なリストを作ります。
ラムダ式の書き方と関数型インターフェース
基本構文と省略のルール
ラムダ式は「引数」「->」「本体」の3つでできています。Java 8(2014年3月リリース)で導入され、仕様はJava言語仕様(JLS)の15.27節に定められています。
// 引数の型を書く形
(int a, int b) -> a + b
// 型は代入先から推論されるので省略できる
(a, b) -> a + b
// 引数が1つで、型や var を書かない場合は括弧も省略できる
name -> name.toUpperCase()
// 引数なし
() -> System.out.println("実行")
// 本体が複数の文ならブロックにして return を書く
(a, b) -> {
int sum = a + b;
return sum;
}
本体が式1つならreturnも波括弧も要りません。ブロックにしたら、値を返すときはreturnが必須です。引数の型は書くなら全部、省くなら全部で、(int a, b)のような混在はできません。
関数型インターフェースの条件と主な型
ラムダ式は単独では値になれず、代入先や引数の型として「関数型インターフェース」が必要です。JLSの9.8節は、関数型インターフェースを「sealedで宣言されておらず、Objectのpublicインスタンスメソッドに対応するものを除き、論理的に1つの抽象メソッドを持つインターフェース」と定義しています。defaultメソッドやstaticメソッドはいくつあっても構いません。
代入先が関数型インターフェースでないとコンパイルエラーになります。Object o = () -> {};をJDK 25でコンパイルするとerror: incompatible types: Object is not a functional interfaceが出ます。
よく使う型はjava.util.functionパッケージにあります。
| インターフェース | 抽象メソッド | 主な渡し先 |
|---|---|---|
| Predicate<T> | boolean test(T) | filter・removeIf |
| Function<T,R> | R apply(T) | map・computeIfAbsent |
| Consumer<T> | void accept(T) | forEach |
| Supplier<T> | T get() | orElseGet・Logger |
| BiFunction<T,U,R> | R apply(T,U) | Map.merge・replaceAll |
| Comparator<T> | int compare(T,T) | sort・sorted |
| Runnable | void run() | Thread・Executor |
自分で関数型インターフェースを作るときは@FunctionalInterfaceを付けます。付けなくても動きますが、付けておくと抽象メソッドを2つにしたときにコンパイラが止めてくれます。
引数のボクシングには注意が要ります。Consumer<Integer> c = (int i) -> ...はerror: incompatible types: incompatible parameter types in lambda expressionになります。型を書くならInteger、intのまま受けたいならIntConsumerを使います。
匿名クラスとの違いはthisの指す先
ラムダ式は匿名クラスを短く書く記法として説明されることが多いのですが、thisの意味が違います。次のコードはJDK 25.0.4とJDK 27の両方で同じ結果になりました。
public class This {
String name = "外側のThis";
public String toString() { return name; }
void run() {
Runnable lambda = () -> System.out.println("ラムダ式のthis = " + this);
Runnable anon = new Runnable() {
public String toString() { return "匿名クラス自身"; }
public void run() { System.out.println("匿名クラスのthis = " + this); }
};
lambda.run();
anon.run();
}
public static void main(String[] a) { new This().run(); }
}
ラムダ式のthis = 外側のThis
匿名クラスのthis = 匿名クラス自身
ラムダ式のthisは外側の文脈と同じ意味です。この例では囲んでいるクラスのインスタンスを指し、staticな文脈では使用できません。抽象メソッドが2つ以上あるインターフェースや抽象クラスを実装したいとき、実装側に状態(フィールド)を持たせたいときは、今でも匿名クラスを使います。外側の変数をどう取り込むか、バイトコード上でどう違うかはキャプチャリングとは?Javaのラムダ式・匿名クラスと実質finalの制約で詳しく扱っています。
ラムダ式の使いどころ5場面
Stream APIのfilter・map・collectによる絞り込みと変換
ラムダ式がいちばん効くのはStream APIです。「絞り込む」「変換する」「集める」を上から順に並べて書けるので、ループの中にifと一時リストが入り組んだコードより処理の流れを追いやすくなります。
List<String> names = List.of("Alice", "Bob", "Anna");
// for文で書く
List<String> result = new ArrayList<>();
for (String name : names) {
if (name.startsWith("A")) {
result.add(name.toUpperCase());
}
}
// Stream APIとラムダ式で書く
List<String> result2 = names.stream()
.filter(name -> name.startsWith("A")) // Predicate
.map(String::toUpperCase) // Function(メソッド参照)
.toList(); // [ALICE, ANNA]
filterやmapは中間操作で、toListやcountなどの終端操作を呼ぶまで実行されません。java.util.streamパッケージの仕様は、filterとmapを、前の要素の状態を持たない stateless な操作として分類しています。渡すラムダ式では、処理中に変化する外部状態への依存や、ストリームの処理対象への干渉を避けます。変化しない設定値などの参照は可能です。
終端操作の選び方で1つ注意があります。Stream.toList()(Java 16以降)が返すリストは変更できません。JDK 25でStream.of("a", "b").toList().add("c")を実行するとUnsupportedOperationExceptionになりました。collect(Collectors.toList())は今のJDK 25の実装ではjava.util.ArrayListを返してaddも通りますが、Javadocは返すリストの型と変更の可否を保証していません。変更する前提ならCollectors.toCollection(ArrayList::new)で型を指定するのが確実です。
コレクションを直接操作するremoveIf・forEach・Map.merge
Streamを作らず、コレクション自身のメソッドにラムダ式を渡すほうが短く書ける場面もあります。
List<Integer> nums = new ArrayList<>(List.of(5, 2, 8, 1, 9));
nums.removeIf(n -> n < 3); // [5, 8, 9]
nums.replaceAll(n -> n * 10); // [50, 80, 90]
Map<String, Integer> count = new HashMap<>();
for (String w : List.of("a", "b", "a")) {
count.merge(w, 1, Integer::sum); // 無ければ1、あれば足す
}
// {a=2, b=1}
Map<String, List<String>> groups = new HashMap<>();
groups.computeIfAbsent("java", k -> new ArrayList<>()).add("lambda");
removeIfは、Iteratorを回してremoveする定型コードを置き換えます。ArrayListのforEachの中でlist.removeを呼ぶと、JDK 25ではConcurrentModificationExceptionが出ました。removeIfは削除をAPI側に任せられます。ただし、判定用ラムダ式や別スレッドから同じコレクションを構造変更すると、この例外が発生することがあります。
ただし、対象がList.of(...)のような変更不可のリストだと、removeIfはUnsupportedOperationExceptionを投げます。JDK 25で確かめたところ、条件に合う要素が1つも無い場合でも例外になりました。「削除対象が無ければ大丈夫」とは考えないでください。
computeIfAbsentに渡すラムダ式は、キーが無いとき、またはキーに対応する値がnullのときに呼ばれます。キャッシュやグループ分けで、既にある値を作り直す無駄を省けます。
Comparatorによる並べ替えと引き算のオーバーフロー
並べ替えの基準は、ラムダ式で比較を書くよりComparatorのファクトリメソッドにメソッド参照を渡すほうが安全で読みやすくなります。
// 年齢の昇順、同じ年齢なら名前の辞書順
users.sort(Comparator.comparingInt(User::age)
.thenComparing(User::name));
// 年齢の降順
users.sort(Comparator.comparingInt(User::age).reversed());
降順の例としてよく見る(a, b) -> b - aは、引き算がintの範囲を超えると符号が反転します。JDK 25で実行した結果です。
Comparator<Integer> bad = (x, y) -> y - x;
bad.compare(Integer.MIN_VALUE, 1); // -2147483647(正の値になるべきところが負)
List<Integer> l = new ArrayList<>(List.of(1, Integer.MIN_VALUE));
l.sort(bad); // [-2147483648, 1] ← 降順になっていない
l.sort(Comparator.reverseOrder()); // [1, -2147483648]
l.sort((x, y) -> Integer.compare(y, x)); // [1, -2147483648]
IDや金額、タイムスタンプの差分など、大きな値や負の値が入りうる比較では、引き算ではなくInteger.compare・Long.compareかComparator.reverseOrder()を使ってください。TreeSetやTreeMapに渡すComparatorも同じです(【Java】TreeSetとHashSetの違いと使い分け|順序・速度・null・範囲検索を実行結果で解説)。User::ageのようなアクセサは、Javaのレコードクラス(record)で定義すると自動で作られます。
orElseGet・Loggerによる遅延評価と不要な処理の省略
ラムダ式は「値」ではなく「値を作る手順」を渡します。値が要らなければ手順は実行されません。この性質がいちばん効くのは、重い処理を「必要なときだけ」動かしたい場面です。呼ばれたら1行出力するメソッドload(コードの1行目で定義)を使い、JDK 25で確かめました。
static String load(String tag) { System.out.println(" load(" + tag + ") 呼び出し"); return "v-" + tag; }
Optional<String> present = Optional.of("cached");
present.orElse(load("orElse")); // load が呼ばれる
present.orElseGet(() -> load("orElseGet")); // load は呼ばれない
Logger log = Logger.getLogger("app");
log.setLevel(Level.INFO);
log.fine("詳細: " + load("fine-string")); // 出力されないのに load は呼ばれる
log.fine(() -> "詳細: " + load("fine-supplier")); // load は呼ばれない
load(orElse) 呼び出し
load(fine-string) 呼び出し
orElseの引数はメソッドを呼ぶ前に評価されるので、値が既にあってもDBアクセスやファイル読み込みが走ります。orElseGetはSupplierを受け取り、空のときだけ呼びます。java.util.logging.LoggerのSupplier<String>版(Java 8で追加)も同じで、ログレベルで捨てられるメッセージの組み立てを省けます。SLF4J 2.xでも、log.atDebug().setMessage(() -> ...).log()のようにSupplierを渡せます。最後のlog()が実際のログ出力に必要です。
コールバックと処理の差し替え(Runnable・リスナー・戦略)
「後で呼ばれる処理」や「場面によって入れ替える処理」を渡すのもラムダ式の定番です。
// 別スレッドで実行する処理
executor.submit(() -> sendMail(order));
// イベントが起きたときの処理
button.addActionListener(e -> save());
// 割引の計算方法を差し替える
Map<String, UnaryOperator<BigDecimal>> discounts = Map.of(
"member", p -> p.multiply(new BigDecimal("0.9")),
"none", p -> p);
BigDecimal price = discounts.get(type).apply(base);
最後の例は、GoFのStrategyパターンをクラスを作らずに書いたものです。戦略が1メソッドで状態を持たないならラムダ式で足ります。戦略ごとに設定値や複数のメソッドを持つなら、インターフェースと実装クラスに戻したほうが見通しが良くなります(Strategyパターンとは?PHP・Java・TypeScriptの実装例と使いどころ)。リスナーの登録と通知の仕組みはObserverパターンとは?Java・TypeScriptの実装例とPub/Subとの違いで扱っています。
メソッド参照に置き換える基準
ラムダ式が既存のメソッドを1回呼んで、受け取った引数をそのまま渡すだけなら、メソッド参照(::)に置き換えます。書き方は4種類です。
| 種類 | メソッド参照 | 同じ意味のラムダ式 |
|---|---|---|
| staticメソッド | Integer::parseInt | s -> Integer.parseInt(s) |
| 特定オブジェクトのメソッド | System.out::println | x -> System.out.println(x) |
| 引数のメソッド | String::toUpperCase | s -> s.toUpperCase() |
| コンストラクタ | ArrayList::new | () -> new ArrayList<>() |
s -> s.trim().toUpperCase()のように2つ以上のメソッドを呼ぶ、引数に手を加える、という場合はメソッド参照にできません。処理が長くなったら、次の章で書くように名前付きメソッドに切り出してから参照で渡します。
ラムダ式を避ける場面と書き換え先
検査例外を投げる処理
FunctionやConsumerの抽象メソッドにはthrows句が無いので、ラムダ式の中からIOExceptionなどの検査例外を外へ投げられません。JDK 25では次のエラーになります。
paths.forEach(p -> Files.readAllLines(p));
// error: unreported exception IOException; must be caught or declared to be thrown
// エラー: 例外IOExceptionは報告されません。スローするには、捕捉または宣言する必要があります
ラムダ式の中でtry-catchしてUncheckedIOExceptionに包むと動きますが、ループごとに4〜5行の定型が入り、例外の扱いが呼び出し側から見えなくなります。ファイルやネットワークを扱う処理は、拡張for文に戻してthrows IOExceptionを素直に宣言するのが読みやすい書き方です。
外側のローカル変数を書き換えたい処理
ラムダ式から参照できるローカル変数は、finalか実質的にfinal(宣言後に再代入されない変数)に限られます。合計や件数をラムダ式の中で数えようとすると、コンパイルが通りません。
int count = 0;
List.of(1, 2).forEach(n -> count++);
// error: local variables referenced from a lambda expression must be final or effectively final
// エラー: ラムダ式から参照されるローカル変数は、finalまたは事実上のfinalである必要があります
配列やAtomicIntegerに入れ替えればコンパイルは通りますが、それは制約を回避しているだけです。集計はcount()・sum()・Collectors.groupingByで行い、ラムダ式には値を返す処理だけを書きます。英語のエラー文で調べたいときはjavac -J-Duser.language=enで英語表示に切り替えられます。この制約がある理由と回避策の良し悪しはキャプチャリングの記事にまとめています。
並列Streamで共有のコレクションを触る処理
parallelStream()や.parallel()にすると、要素はForkJoinPoolの共通プールで分割して処理されます。手元のMac(論理CPU 16)ではForkJoinPool.getCommonPoolParallelism()が15でした。OpenJDK 25の実装では、並列度を指定しない場合は利用可能なプロセッサ数から1を引いた値になります。並列にしたうえでラムダ式から共有のArrayListへ追加すると、コンパイルも実行も通るのに結果が壊れます。
List<Integer> shared = new ArrayList<>();
IntStream.range(0, 100_000).parallel().forEach(shared::add);
// JDK 25で20回実行 → 20回とも件数が100000にならないか、例外で停止
List<Integer> ok = IntStream.range(0, 100_000).parallel().boxed().toList();
// size = 100000
java.util.streamの仕様は、ストリーム操作に渡す処理の副作用を「in general, discouraged」とし、スレッド安全性の問題につながると書いています。並列にするなら、結果はtoList・collect・reduceで受け取り、ラムダ式からは外の状態を触らないようにします。
並列化そのものも、まず直列で書いて計測してから判断します。共通プールはアプリ全体で共有されるので、ブロッキングするI/O処理を並列Streamで回すと、ほかの並列処理まで待たされます。I/O待ちが主なら、Java 21で正式化された仮想スレッドのExecutors.newVirtualThreadPerTaskExecutor()を検討してください。
数行を超えるロジックと、スタックトレースで追いたい処理
ラムダ式の本体が数行を超えたら、名前付きメソッドに切り出してメソッド参照で渡します。名前が処理の意図を説明し、単体テストも書けるうえに、障害時の追跡でも差が出ます。同じ変換で例外を起こしたときのスタックトレースをJDK 25で比べました。
// ラムダ式で書いた場合
in.stream().map(s -> Integer.parseInt(s.strip()) * 110 / 100).toList();
// at Trace.lambda$main$0(Trace.java:6)
// 名前付きメソッドに切り出した場合
static int toPrice(String s) { return Integer.parseInt(s.strip()) * 110 / 100; }
in.stream().map(Trace::toPrice).toList();
// at Trace.toPrice(Trace.java:3)
ラムダ式の本体は、javacがlambda$main$0のような連番の名前を付けたメソッドになります。行番号は残りますが、名前からは何の処理か分かりません。本番のログやAPMで頻繁に見るStreamの処理ほど、名前付きメソッドにしておく価値があります。業務システムでこうした書き方の基準をチームで揃えたい場合は、弊社のフルスクラッチ開発でもJavaの設計からご相談を受けています。
Java 9以降に増えたラムダ式とStreamの書き方
ラムダ式の「引数・矢印・本体」という基本形はJava 8から共通ですが、引数に使える構文やStream APIは版ごとに増えています。現在の長期サポート版はJava 25(2025年9月)、最新版はJava 27(2026年9月15日)です。
| 版 | 追加された機能 | 使いどころ |
|---|---|---|
| Java 9 | takeWhile・dropWhile・Stream.ofNullable | 条件までの切り出し |
| Java 11 | ラムダ引数のvar(JEP 323) | 引数にアノテーション |
| Java 16 | Stream.toList()・mapMulti | 変更不可のリストで受ける |
| Java 22 | 無名変数 _(JEP 456) | 使わない引数を明示 |
| Java 24 | Stream Gatherers(JEP 485) | 独自の中間操作 |
// Java 11:var にアノテーションを付ける
// @NonNull は標準JDKには無い。使用するライブラリからインポートするか、適用先に合う注釈型を別途定義する
BiFunction<String, String, String> f = (@NonNull var a, @NonNull var b) -> a + b;
// Java 22:使わない引数は _ にする
map.forEach((key, _) -> System.out.println(key));
cache.computeIfAbsent(id, _ -> new ArrayList<>());
// Java 24:固定サイズの塊に分ける
Stream.of(1, 2, 3, 4, 5).gather(Gatherers.windowFixed(2)).toList();
// [[1, 2], [3, 4], [5]]
無名変数_はJEP 456でJava 22から正式機能になりました。Java 17で同じコードをコンパイルするとerror: '_' used as an identifierになります。Java 21以前(プレビュー機能を有効にした場合を除く)でも動かすコードでは、ignoredなどの名前を使います。Stream GatherersはJEP 485でJava 24から正式機能になり、windowFixed・windowSliding・fold・scan・mapConcurrentが標準で用意されています。Java 8で止まっている現場が多い場合は、どの版で動かすかを先に決めてから書き方を選んでください。Java 25でStream・Optional・ファイル入出力を通しで書いた例はJavaのコード例|Hello World・入力・条件分岐から文字列比較・ループ・Streamまで【Java 25対応】にあります。
よくある質問
Javaで「->」とは何ですか?
ラムダ式の引数と本体を区切る記号です。(a, b) -> a + bは「aとbを受け取ってa + bを返す処理」を表します。switch式のcase 1 -> ...(Java 14で正式化)にも同じ記号が使われますが、こちらは分岐の書き方で、ラムダ式ではありません。
ラムダ式はいつ使うべきですか?
関数型インターフェースを受け取るAPI(Streamのfilter・map、removeIf、Comparator、Optional.orElseGet、Runnableなど)へ、1〜2行で書ける処理を渡すときです。検査例外への対応で処理が長くなる場合や、外側のローカル変数へ再代入したい場合は、通常のループを選びます。
ラムダ式と匿名クラスはどう違いますか?
ラムダ式は抽象メソッドが1つのインターフェースにしか使えず、thisは囲んでいるクラスのインスタンスを指します。匿名クラスは抽象メソッドが複数あるインターフェースや抽象クラスも実装でき、フィールドを持てます。thisは匿名クラス自身を指します。
ラムダ式はどのJavaバージョンから使えますか?
Java 8(2014年3月)からです。引数のvarはJava 11、無名変数_はJava 22から使えます。Java 7以前ではコンパイルできないので、匿名クラスで書きます。
並列Streamにすれば速くなりますか?
必ず速くなるわけではありません。分割と結合のコストがあり、共通プールはアプリ全体で共有されます。要素数が多く、1要素あたりの計算が重く、副作用が無い処理で、計測して効果を確かめてから使います。