
はじめに:Java 25で変わる開発スタイル
今回は、新たな長期サポート(LTS)バージョンであるJava 25、そしてその礎となったJava 21からの進化が、私たちの開発スタイルに与える影響と、その活用法について解説します。
Javaは進化を続けており、特にJava 21から25にかけて、開発者の生産性向上とアプリケーションのパフォーマンス最適化に貢献する機能が次々と導入されました。
仮想スレッドによるシンプルな並行処理、プレビューが続く構造化並行性による堅牢なエラーハンドリング、パターンマッチングの表現力向上、そしてJava 25で導入された柔軟なコンストラクタやインスタンスmainメソッドなどです。
これらの新機能は、Javaアプリケーションの設計や構築における基本的な考え方にも影響を与えます。
この記事では、Java 21から25までの進化の軌跡を追いながら、これらの新機能がもたらす設計への影響を具体的に解説し、それらを活用して効率的で堅牢、かつ保守性の高いモダンJavaアプリケーションを開発するための実践的な方法を紹介します。
目次
対象読者
- Javaでの実務経験があり、Java 21から25にかけての主要な新機能に関心がある開発者の方
- 仮想スレッド、構造化並行性といったモダンな並行処理モデルを自身のプロジェクトに活用したい方
- パターンマッチング等を駆使し、コードの表現力と保守性を高めたいと考えている方
- 最新LTSであるJava 25の導入を検討しており、その設計思想やベストプラクティスを学びたいITアーキテクトおよびチームリーダーの方
動作検証環境
この記事は、以下の環境で検証しています。
- OS : macOS Tahoe Version 26.1
- ハードウェア : MacBook Air 2024 M3 24GB
- VS Code: 最新版 (記事執筆時点)
- Java Extension Pack: 最新版 (記事執筆時点)
- Java: OpenJDK 25.0.1 LTS (Temurin)
1. 新機能がもたらす設計への影響
Java 21から25にかけて導入された新機能は、アプリケーションの設計に大きな影響を与えます。特に注目すべき点を見ていきましょう。
1-1. 仮想スレッドとリアクティブプログラミングの融合
Java 21で導入された仮想スレッド(Virtual Threads)は、軽量なスレッドであり、従来のプラットフォームスレッドに比べて少ないリソースで大量の並行処理を可能にします。
これにより、I/Oバウンドな処理が多いアプリケーションにおいて、スループットの向上が期待できます。
これまで高スループットを実現するために多用されてきたリアクティブプログラミングは、強力である一方、学習コストやデバッグの複雑さといった課題がありました。
仮想スレッドの登場により、ブロッキングI/Oを伴う従来の同期的なコードスタイルを維持しつつ、リアクティブに近い性能を得られるようになります。
これにより、開発者は複雑な非同期モデルを強く意識することなく、より直感的で読みやすいコードで高性能なアプリケーションを構築できるようになります。
ただし、synchronizedブロック内でI/O処理を行うと、仮想スレッドがピニング(プラットフォームスレッドに固定)され、スケーラビリティが損なわれる可能性があります。
このような場合は、java.util.concurrent.locks.ReentrantLock の使用が推奨されます。
// 仮想スレッドを使ったシンプルな例
import java.time.Duration;
import java.util.concurrent.Executors;
public class VirtualThreadExample {
public static void main(String[] args) throws InterruptedException {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10; i++) {
int taskNumber = i;
executor.submit(() -> {
System.out.println("仮想スレッド " + taskNumber + " でタスクを開始");
try {
Thread.sleep(Duration.ofSeconds(1)); // 擬似的なI/Oブロッキング
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("仮想スレッド " + taskNumber + " でタスクを終了");
});
}
} // executor.close() が呼ばれ、すべてのタスクが完了するまで待機
System.out.println("すべてのタスクが完了しました。");
}
}実行結果
仮想スレッド 1 でタスクを開始
仮想スレッド 0 でタスクを開始
仮想スレッド 3 でタスクを開始
仮想スレッド 2 でタスクを開始
仮想スレッド 5 でタスクを開始
仮想スレッド 4 でタスクを開始
仮想スレッド 7 でタスクを開始
仮想スレッド 6 でタスクを開始
仮想スレッド 9 でタスクを開始
仮想スレッド 8 でタスクを開始
仮想スレッド 0 でタスクを終了
仮想スレッド 1 でタスクを終了
仮想スレッド 2 でタスクを終了
仮想スレッド 3 でタスクを終了
仮想スレッド 5 でタスクを終了
仮想スレッド 4 でタスクを終了
仮想スレッド 6 でタスクを終了
仮想スレッド 7 でタスクを終了
仮想スレッド 9 でタスクを終了
仮想スレッド 8 でタスクを終了
すべてのタスクが完了しました。※仮想スレッドの実行順序は非決定的であるため、スレッドの開始・終了のメッセージ順序は実行ごとに異なる場合があります。
【コラム】仮想スレッドを効果的に使うための設計指針
仮想スレッドは万能薬ではありません。そのメリットを活かすには、「どのような処理に適しているか」を理解し、既存のコードベースに潜む「アンチパターン」を特定することが重要です。
1. CPUバウンド vs I/Oバウンド:正しい使い分け
- I/Oバウンドな処理(仮想スレッドの得意分野):
- 概要: 処理時間の大半を、ネットワーク通信、データベースへの問い合わせ、ファイル読み書きなどのI/O待ちが占める処理。
- 具体例: 外部APIの呼び出し、DBのクエリ実行、メッセージキューの送受信など。
- なぜ得意か: I/O待ちでスレッドがブロックされると、仮想スレッドは土台となるプラットフォームスレッドから切り離され(アンマウント)、そのプラットフォームスレッドは他の仮想スレッドを実行できます。これにより、少数のプラットフォームスレッドで大量のI/Oバウンドなタスクを効率的に捌けます。
- CPUバウンドな処理(プラットフォームスレッドが適任):
- 概要: 処理時間の大半を、計算処理そのものが占める処理。
- 具体例: 大規模な数値計算、動画のエンコーディング、複雑なアルゴリズムの実行など。
- なぜ不向きか: CPUバウンドな処理はプラットフォームスレッドを計算のために占有し続けます。仮想スレッドで実行しても、結局その下のプラットフォームスレッドが解放されないため、仮想スレッドの利点である「スレッドの切り替えによる多重化」が活かせません。このような処理には、従来通り限られた数のプラットフォームスレッドを割り当てるのが適切です。
2. 既存コードのリファクタリング戦略:注意すべき2つのポイント
既存のアプリケーションで仮想スレッドの利用を検討する際は、以下の点に注意してください。
synchronizedブロックの置き換え:synchronizedブロック内でブロッキングI/O処理を行うと、仮想スレッドがプラットフォームスレッドに「ピニング(固定)」されてしまい、スケーラビリティが損なわれます。これは避けるべき代表的なアンチパターンです。- 対策:
synchronizedをjava.util.concurrent.locks.ReentrantLockに置き換えましょう。ReentrantLockはピニングを引き起こしません。
// Before: synchronizedによるピニングのリスク
public synchronized void performBlockingOperation() {
// ... I/O処理 ...
}
// After: ReentrantLockによる改善
private final ReentrantLock lock = new ReentrantLock();
public void performBlockingOperation() {
lock.lock();
try {
// ... I/O処理 ...
} finally {
lock.unlock();
}
}ThreadLocalの見直し:ThreadLocalは、スレッドごとに独立した変数を提供する仕組みですが、何百万もの仮想スレッドが生成される環境では問題になり得ます。各仮想スレッドがThreadLocal変数を持つと、その分メモリを消費し、親スレッドの値を継承する設計になっている場合はさらに大きなフットプリントとなる可能性があります。- 対策: スコープ値(
ScopedValue)への置き換えを検討してください。スコープ値は不変(イミュータブル)であり、必要な範囲にのみデータを効率的に共有するよう設計されているため、仮想スレッド環境に最適です。
1-2. パターンマッチングによるドメインモデルの表現力向上
Java 21で正式導入されたレコードパターンとswitchのパターンマッチングは、データの分解と条件分岐をより簡潔かつ安全に記述できます。
これにより、特に複雑なドメインモデルを扱う際に、コードの可読性と保守性が向上します。
さらに、Java 25ではプレビュー機能としてJEP 507: パターンマッチングにおけるプリミティブ型が導入され、intやdoubleなどのプリミティブ型をswitchで直接扱えるようになりました。
これにより、ラッパークラスへのボクシングが不要になり、より直感的で効率的なコードが記述できます。
// Java 25のプリミティブ型パターンマッチング (プレビュー)
public class PrimitivePatternMatching {
static String checkType(Object obj) {
// caseの順序が重要。より範囲の狭い型から評価されるように記述する
return switch (obj) {
case String s -> "文字列: " + s;
// JEP 507: ラッパー型のcaseがないため、アンボクシングされてプリミティブ型にマッチ
case int i -> "intプリミティブ: " + i;
case long l -> "longプリミティブ: " + l; // doubleより先に評価
case double d -> "doubleプリミティブ: " + d; // int, long を包含する
default -> "不明な型";
};
}
public static void main(String[] args) {
System.out.println(checkType(10));
System.out.println(checkType(3.14));
System.out.println(checkType("Hello"));
System.out.println(checkType(100L)); // longのリテラルでテスト
}
}実行結果
intプリミティブ: 10
doubleプリミティブ: 3.14
文字列: Hello
longプリミティブ: 100【解説】なぜプリミティブ型にマッチするのか?(caseの順序の重要性)
このコードは、Javaのオートボクシング/アンボクシングと、caseの評価順序の挙動を示す例です。
checkType(Object obj)にint型の10を渡すと、Object型に合わせるため、まずIntegerオブジェクトへのオートボクシングが発生します。switch文はcaseを上から順に評価します。case Integer iが存在しないため、評価を続け、Integerオブジェクトをint型へアンボクシングできるcase int iにマッチさせます。
caseの順序について:case long lがcase double dの前に記述されている点に注意してください。もし順序が逆だと、long型の値はより範囲の広いdouble型へ安全に変換(拡大変換)できるため、case double dに先にマッチしてしまい、後続のcase long lが到達不能コードとしてコンパイルエラーになります。このように、プリミティブ型を扱うパターンマッチでは、より範囲の狭い型から順にcaseを記述することが重要です。
sealedインターフェースと組み合わせることで、コンパイラが網羅性をチェックしてくれるため、default句が不要になり、より安全なコード設計が可能になります。
1-3. 構造化並行性によるエラーハンドリングの改善
Java 25では、構造化並行性(Structured Concurrency)が5回目のプレビュー機能(JEP 505) として提供されており、正式化に向けてAPIの改良が進んでいます。
これにより、並行処理におけるエラー処理やキャンセル処理が簡素化され、より堅牢なアプリケーションを構築しやすくなります。
JEP 505では、これまでのプレビューからAPIが大きく見直されました。
Joinerインターフェースの新設:- タスクの成功・失敗ポリシーを定義する
Joinerが導入されました。
- タスクの成功・失敗ポリシーを定義する
- インスタンス化の変更:
newによるコンストラクタ呼び出しに代わり、StructuredTaskScope.open(Joiner)という静的ファクトリメソッドで、ポリシーを指定してスコープを生成するようになりました。
- 例外処理の単純化:
join()メソッドがJoinerポリシーに基づき失敗時に直接StructuredTaskScope.FailedExceptionをスローするようになり、throwIfFailed()メソッドが不要になりました。
これらの変更により、コードはより直感的で読みやすくなりました。
// 構造化並行性の例 (Java 25 プレビュー)
// コンパイル・実行には --enable-preview フラグが必要です
import java.time.Duration;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;
public class StructuredConcurrencyGuide {
// 【成功ケース】
public void runSuccessCase() throws InterruptedException, ExecutionException {
try (var scope = StructuredTaskScope.open(StructuredTaskScope.Joiner.allSuccessfulOrThrow())) {
Subtask<String> user = scope.fork(() -> findUser(true));
Subtask<Integer> order = scope.fork(() -> fetchOrder(true));
scope.join();
System.out.println("User: " + user.get() + ", Order: " + order.get());
}
}
// 【失敗ケース】
public void runFailureCase() throws InterruptedException, ExecutionException {
try (var scope = StructuredTaskScope.open(StructuredTaskScope.Joiner.allSuccessfulOrThrow())) {
Subtask<String> user = scope.fork(() -> findUser(true));
Subtask<Integer> order = scope.fork(() -> fetchOrder(false)); // 失敗をシミュレート
scope.join(); // ここでFailedExceptionがスローされる
} catch (StructuredTaskScope.FailedException e) {
System.err.println("Operation failed: " + e.getCause().getMessage());
}
}
private String findUser(boolean success) throws InterruptedException {
System.out.println("Finding user...");
Thread.sleep(Duration.ofMillis(100));
if (!success) throw new RuntimeException("Failed to find user");
System.out.println("Found user.");
return "John Doe";
}
private Integer fetchOrder(boolean success) throws InterruptedException {
System.out.println("Fetching order...");
Thread.sleep(Duration.ofMillis(200));
if (!success) throw new RuntimeException("Failed to fetch order");
System.out.println("Fetched order.");
return 12345;
}
}実行結果
runSuccessCase() を呼び出した場合:
【成功ケース】
Finding user...
Fetching order...
Found user.
Fetched order.
User: John Doe, Order: 12345※
Finding user...とFetching order...の表示順は、実行タイミングによって入れ替わる可能性があります。
runFailureCase() を呼び出した場合:
【失敗ケース】
Finding user...
Fetching order...
Found user.
Failed to fetch order.
Operation failed: Failed to fetch order※
fetchOrderタスクの失敗によりfindUserタスクはキャンセルされるため、Found user.が出力される前に処理が終了することもあります。エラーメッセージは標準エラー出力に表示されます。
この設計により、fetchOrderタスクが失敗すると、scope.join()がFailedExceptionをスローします。allSuccessfulOrThrowポリシーに基づき、スコープは即座に残りのタスク(この場合はfindUser)をキャンセルするため、リソースリークや無駄な処理を防ぐことができます。
【コラム】構造化並行性を活用:ExecutorServiceとの違いとキャンセル戦略
構造化並行性は、単に ExecutorService を置き換えるものではなく、並行処理の管理に関する従来の手法とは異なるアプローチです。
1. ExecutorServiceとの思想的な違い
| 特徴 | ExecutorService (従来) | StructuredTaskScope (構造化並行性) |
|---|---|---|
| 管理スタイル | 起動したら放置 (Fire-and-Forget) | 構造化されたライフサイクル管理 |
| タスクの親子関係 | 親タスクは子タスクの完了を待たない。Future.get()で個別に同期が必要。 | 親タスク(スコープ)は、全ての子タスクが完了するまで終了しない。 |
| エラーハンドリング | try-catch で各 Future の例外を個別に処理する必要があり、複雑化しやすい。 | scope.join() で例外が一箇所に集約され、エラー処理が簡潔かつ明確。 |
| リソースリーク | 親タスクが終了しても、子タスクが動き続ける可能性がある(タスクリーク)。 | スコープを抜けると、全ての子タスクの完了が保証されるため、リークの心配がない。 |
ExecutorService がタスクをバラバラに管理するのに対し、StructuredTaskScope はタスクの集まりを一つの作業単位として扱い、その全体のライフサイクルをコードブロックに一致させます。これにより、並行処理のコードが、まるで単一スレッドの同期処理のように読みやすく、かつ安全になります。
2. 実践的なキャンセル処理パターン
構造化並行性の利点の一つは、信頼性の高いキャンセル処理です。allSuccessfulOrThrow ポリシーは基本ですが、より複雑な要件にも対応できます。
- タイムアウトを設ける:
特定の時間内に処理が終わらない場合にタスクを中断させたいケースは頻繁にあります。joinUntil()メソッドを使えば、これを簡潔に実現できます。
// 500ミリ秒のタイムアウトを設定する例
try (var scope = StructuredTaskScope.open(...)) {
scope.fork(() -> someLongRunningTask());
// ... 他のタスクをフォーク ...
scope.joinUntil(Instant.now().plusMillis(500)); // タイムアウトを設定
} catch (TimeoutException e) {
// タイムアウト発生時の処理
System.err.println("Operation timed out.");
}タイムアウトが発生すると、スコープ内の未完了タスクは自動的にキャンセルされます。
- 「一つでも成功すればOK」なシナリオ (Race Condition):
複数の情報源から同じ情報を取得し、最も速く応答したものだけを使いたい場合があります。このようなシナリオは、カスタムのJoinerを作成するか、StructuredTaskScopeを工夫することで実現できますが、今後のプレビューでより直接的なAPI(例:anySuccessfulOrThrowのようなポリシー)が提供される可能性にも期待したいところです。現時点では、Subtask.get()で個別に結果を取得し、最初に成功したものを採用するアプローチが考えられます。
1-4. スコープ値による効率的なデータ共有
同じくJava 23で正式化されたスコープ値(Scoped Values)は、スレッド間でデータを安全かつ効率的に共有するための新しい仕組みです。
スレッドローカル変数の代替として設計されており、特に仮想スレッド環境で優れたパフォーマンスと安全なデータ伝播を実現します。
リクエストスコープのデータ(例: ユーザーID、トランザクションID)を、メソッドのシグネチャを変更することなく、アプリケーションの異なる層や並行処理間で安全かつ透過的に共有できます。
// スコープ値の例 (Java 23以降)
import java.util.concurrent.StructuredTaskScope;
public class ScopedValueExample2 {
private static final ScopedValue<String> USER_ID =
ScopedValue.newInstance();
public static void main(String[] args) throws Exception {
ScopedValue.where(USER_ID, "user-123").run(() -> {
System.out.println("メインスレッドのユーザーID: " + USER_ID.get());
// JDK 25の新しいAPIを使用
try (
var scope = StructuredTaskScope.open(
StructuredTaskScope.Joiner.awaitAllSuccessfulOrThrow()
)
) {
scope.fork(() -> {
System.out.println(
"仮想スレッドのユーザーID: " + USER_ID.get()
);
return null;
});
scope.join(); // ポリシーに違反した場合、ここで例外がスローされる
} catch (Exception e) {
e.printStackTrace();
}
});
System.out.println(
"スコープ外のユーザーID: " +
(USER_ID.isBound() ? USER_ID.get() : "未設定")
);
}
}実行結果
メインスレッドのユーザーID: user-123
仮想スレッドのユーザーID: user-123
スコープ外のユーザーID: 未設定1-5. 柔軟なコンストラクタによる堅牢なオブジェクト生成
Java 25で導入されたJEP 513: 柔軟なコンストラクタ本体は、オブジェクトの初期化処理を、より安全で直感的にします。
これまで、コンストラクタの最初の行は super() または this() の呼び出しでなければならないという制約がありました。
この制約が緩和され、super() の呼び出し前に引数の検証などのロジックを記述できるようになりました。
これにより、親クラスのコンストラクタに無効な値を渡すことを防ぎ、より堅牢な不変オブジェクトを設計しやすくなります。
例として、正の整数のみを許容するクラス階層を見ていきましょう。
親クラス (PositiveNumber)
まず、正の値を保持することを期待する親クラスを定義します。
// 柔軟なコンストラクタの例 (Java 25) - 親クラス
class PositiveNumber {
protected final int value;
PositiveNumber(int value) {
// このコンストラクタは value > 0 を期待する
this.value = value;
System.out.println("PositiveNumberが初期化されました: " + value);
}
}子クラス (MorePositiveNumber)
次に、この親クラスを継承し、Java 25の新しいコンストラクタ機能を使ってsuper()呼び出しの前に引数を検証する子クラスを定義します。
// 柔軟なコンストラクタの例 (Java 25) - 子クラス
class MorePositiveNumber extends PositiveNumber {
MorePositiveNumber(int value) {
// super() の前に引数を検証できる
if (value <= 0) {
throw new IllegalArgumentException("値は正でなければなりません");
}
super(value); // 検証済みの値を渡す
}
}実行クラス (FlexibleConstructorExample)
最後に、この子クラスの動作を確認するための実行クラスです。
// 柔軟なコンストラクタの例 (Java 25) - 実行クラス
public class FlexibleConstructorExample {
public static void main(String[] args) {
try {
new MorePositiveNumber(10); // 成功
new MorePositiveNumber(-5); // IllegalArgumentException
} catch (Exception e) {
System.err.println("エラー: " + e.getMessage());
}
}
}実行結果
PositiveNumberが初期化されました: 10
エラー: 値は正でなければなりません2. Java 25時代のコーディング規約とスタイルガイド
新機能の導入は、コーディング規約やスタイルガイドの見直しも促します。
2-1. インスタンスmainメソッドによる学習やスクリプティングの簡易化
Java 25で導入されたJEP 512: インスタンスmainメソッドは、Javaの学習しやすさを向上させ、簡単なプログラムを書きやすくします。
従来の public static void main(String[] args) という定型句と比較してみましょう。
従来のmainメソッド (OldMain.java)
// 従来のmainメソッド
public class OldMain {
public static void main(String[] args) {
System.out.println("Hello from old main!");
}
}実行結果
Hello from old main!インスタンスmainメソッド (NewMain.java)
Java 25からは、以下のように static や public、String[] args なしで main メソッドを記述できます。
// インスタンスmainメソッド (Java 25の正式機能)
// コンパイル: javac NewMain.java
// 実行: java NewMain
class NewMain {
void main() {
System.out.println("Hello from new main!");
}
}実行結果
Hello from new main!この変更は、Javaを初めて学ぶ人や、Pythonのように手軽にスクリプトを書きたい開発者にとって役立ちます。
2-2. 可読性と保守性を高めるためのプラクティス
- 仮想スレッドの適切な利用:
- CPUバウンドな処理には従来のプラットフォームスレッドを、I/Oバウンドな処理には仮想スレッドを、と使い分けを明確にします。
synchronizedの代わりにReentrantLockを使うなど、ピニングを避ける工夫をします。
- 構造化並行性の導入:
- 複数の並行タスクを扱う際には、構造化並行性を導入し、タスクのライフサイクルとエラー処理を明確にすることで、コードの複雑性を管理します。
- スコープ値によるコンテキスト伝播:
- スレッド間でコンテキスト情報を共有する際には、スレッドローカル変数ではなくスコープ値を優先し、安全かつ効率的なデータ伝播を実現します。
3. パフォーマンスを意識した設計と実装
Java 21から25にかけて、パフォーマンスに関する重要な改善も行われています。
3-1. GCの選択肢の広がりと自動的なメモリ効率向上
Java 21で導入されたGenerational ZGCに加え、Java 25ではJEP 521: Generational Shenandoahが登場しました。これにより、アプリケーションの特性に応じて、ミリ秒単位の低停止時間を目指せるGCの選択肢がさらに広がりました。
また、JEP 519: コンパクトオブジェクトヘッダーにより、オブジェクトのメモリオーバーヘッドが削減されます。これは開発者が意識することなく自動的に適用され、特に大量のオブジェクトを生成するアプリケーションにおいて、メモリ使用量の削減とパフォーマンスの向上につながります。
まとめ:進化するJavaと共に成長する開発者へ
Java 25は、多くの新機能を含むLTSバージョンであり、Java開発に新たな選択肢をもたらします。
Java 21から続く仮想スレッドや、プレビュー段階で成熟を続ける構造化並行性といった並行処理の改善、パターンマッチングによるコードの簡潔化、そしてJava 25自身の柔軟なコンストラクタやインスタンスmainメソッドといった改善点は、私たちがより効率的で、堅牢で、保守性の高いアプリケーションを構築する上で役立ちます。
これらの新機能を単に「知っている」だけでなく、「どのように活用するか」という設計思想やプラクティスを身につけることが、モダンJava開発者として成長する上で重要です。
常に新しい技術にアンテナを張り、積極的に学び、実践することで、Javaの進化と共に成長していきましょう。
免責事項
- 本記事の情報は、記事公開時点のものです。将来のJavaバージョンアップにより、内容が実情と異なる場合があります。
- 紹介しているサンプルコードは、機能の概念を説明することを主目的としています。本番環境へ適用する際は、十分なテストとセキュリティ検証を実施してください。
- 記事内で言及しているプレビュー機能は、今後のリリースで仕様が変更、あるいは削除される可能性があります。ご利用の際は、公式ドキュメントを必ずご確認ください。
- 本記事の内容を適用した結果生じたいかなる損害についても、筆者は一切の責任を負いかねます。あらかじめご了承ください。
