この記事は、セゾンテクノロジー Advent Calendar 2025 の最終日 25 日目の記事です。
シリーズ2では HULFT・DataSpider の開発メンバーによる投稿をお届けします🕊️
はじめに
Java 開発者のみなさん、今年の 9 月にリリースされた新しい LTS の Java 25 はもう試したのでしょうか?
2年一度の LTS なので、これを機に新しいバージョンへの移行を検討するのもいいかもしれませんね。
その検討の手助けに少しでも役に立つように、Java 22 から Java 25 までの新機能を振り返りながら紹介していきたいと思います。
4バージョン分の変更量はそこそこ多いので、この記事では正式な機能に絞って、言語仕様変更点をバージョン順と対応 JEP の番号順で紹介したあとに、その他の変更点についてざっくりまとめる構成にしたいと思います。
言語仕様と API の変更点
22: 外部関数およびメモリー API
JEP 454: Foreign Function & Memory API
外部関数およびメモリー(Foreign Function & Memory, FFM)API というネイティブコードを利用するための新しい API が追加されました。
これは従来の Java Native Interface(JNI)の抱えている問題点(可読性、保守性、安全性、パフォーマンスなど)を解決するために導入したものです。
FFM API はその名前通り、メモリ構造を理解した上、関数と連携する機能を提供します。
そのためのクラスやインターフェースを java.lang.foreign パッケージ内で定義しています。
- 外部メモリの管理(割当や割当解除など)するためのもの
-
MemorySegment,Arena,SegmentAllocator
-
- 構造化された外部メモリを操作やアクセスするためのもの
-
MemoryLayout,VarHandle
-
- 外部関数を呼び出すためのもの
-
Linker,SymbolLookup,FunctionDescriptor,MethodHandle
-
すべて説明するには余白が足りない(そして私の実力も足りない…
)ので、ここでは FFM API のメモリ管理の雰囲気を知ってもらうために MemoryLayoutの一例だけをあげます。
以下は C 言語の socket.h に定義されている msghdr 構造体です。
struct msghdr {
void *msg_name;
socklen_t msg_namelen;
struct iovec *msg_iov;
unsigned int msg_iovlen;
void *msg_control;
socklen_t msg_controllen;
int msg_flags;
};
FFM API で使用する際のメモリ構成のイメージは以下のように定義されます。
MemoryLayout layout = MemoryLayout.structLayout(
ValueLayout.ADDRESS.withName("msg_name"),
ValueLayout.JAVA_INT.withName("msg_namelen"),
ValueLayout.ADDRESS.withName("msg_iov"),
ValueLayout.JAVA_INT.withName("msg_iovlen"),
ValueLayout.ADDRESS.withName("msg_control"),
ValueLayout.JAVA_INT.withName("msg_controllen"),
ValueLayout.JAVA_INT.withName("msg_flags"));
なぜこれを「イメージ」と表現したかというと、実際のメモリ構成は OS、CPU、ネイティブ側のデータ型モデルによって、フィールドのオフセットやパディングが入るからです。
手書きで正確に表現するのは難しいので、実務では手書きよりもツールによる生成のほうが現実的だと思います。
JEP 454 のスコープ外になりますが、Project Panama で開発された補助ツール jextract を利用すると、FFM API をより簡単に利用できます。
22: 無名変数およびパターン
JEP 456: Unnamed Variables & Patterns
変数宣言やパターンマッチングなどにおいて、宣言のみでコード内で利用しないものを、無名変数や無名パターンとして _ で表現できるようになりました。
識別子の宣言が必要なシーンにおいて、「宣言された識別子は関心事ではない」という開発者の意図をより正確に表すために使用できます。
言語仕様では利用箇所に応じ呼び名が変わります。
- ローカル変数では unnamed local variable
- catch 句での例外は unnamed exception parameter
- ラムダ式の引数名は unnamed lambda parameter
- データ型のパターンマッチングでは unnamed pattern variable
利用箇所問わず、識別子 _ で定義したものには値がバインドされないので、それに対する参照はコンパイルエラーとなります。
そして無名変数・パターンはお互い干渉しないので、以下のように同じブロックに複数の無名変数・パターンを定義できます。
switch (collection) {
case List<?> _, Queue<?> _ -> ...
case Set<?> _, -> ...
case null, default -> ...
}
23: マークダウン記法のドキュメンテーション
JEP 467: Markdown Documentation Comments
今まで HTML と JavaDoc の @ タグでしか使用できない JavaDoc のドキュメントコメントが、多くの開発者にとっておなじみの Markdown 記法で書けるようになりました。
Markdown 記法のドキュメンテーションコメントと従来の JavaDoc と主な違いは以下の通りです。
- 行頭に
///をつける(/** ... */ではない) - 段落の区切りは空行で表す(
<p>タグは不要) - 箇条書きの項目は
-で表す(<ul>と<li>タグは不要) - 強調は
_で囲みで表す(<em>タグは不要) -
{@code ...}は`...`に置き換わる -
{@link ...}は Markdown の参照リンク[...]に置き換わる -
@のブロックタグ(例えば@param@return@seeなど)はそのまま利用できる
基本的な文法は CommonMark に準拠していて、テーブルに関しては GitHub Flavored Markdown の文法も利用可能です。
24: クラスファイル API
JVM の仕様に定まった class ファイルのフォーマットを扱うための標準 API (クラスファイル API) が java.lang.classfile パッケージとして追加されました。
クラスファイル API を利用することで、サードパーティのライブラリがなくても簡単にクラスファイルを処理できます。
簡単な使用例
例えば、以下のようなシンプルな Sample クラスがあるとします。
public class Sample {
public String greetings() {
return "hello!";
}
}
ソースコードに手を加えずに、クラスファイルの変更で greetings() の振る舞いを変えたい場合、以下のようなコードで実現できます。
static byte[] modifyClassFile(byte[] original) {
// クラスファイルから Sample クラスと greetings メソッドのモデルを取得
ClassModel classModel = ClassFile.of().parse(original);
MethodModel methodModel = classModel.methods().stream()
.filter(model -> model.methodName().equalsString("greetings"))
.findFirst()
.orElseThrow();
// クラスとメソッドのメタ情報を再利用しつつ、メソッドのボディだけ変更
byte[] modified = ClassFile.of().build(
classModel.thisClass(),
ConstantPoolBuilder.of(classModel),
classBuilder -> classBuilder.withMethodBody(
methodModel.methodName(),
methodModel.methodType(),
methodModel.flags().flagsMask(),
codeBuilder -> {
// 定数「こんにちは!」を戻り値として返す
codeBuilder.loadConstant("こんにちは!");
codeBuilder.areturn();
}));
return modified;
}
生成されたクラスファイルをデコンパイルして中身を見ると、メソッドの戻り値だけ変わったことが分かります。
public class Sample {
public String greetings() {
return "こんにちは!";
}
}
なぜクラスファイル API が?
今まで JDK ではにクラスファイルを扱うために、jar や jlink などのツールの中に ASM というサードパーティ製のライブラリを含めてきたが、この構図は JVM の仕様にクラスファイル関連の変更が入った場合に不都合な状況を招いてしまいます。
例えば JDK のバージョン N 向けの ASM の仕上がりには JDK バージョン N 完了するのを待つ必要があるが、JDK のツール(主に javac)がクラスファイルを扱うのにも ASM が必要なので、結果的に JDK のバージョン N は次バージョン N+1 が出るまでにクラスファイル関連の新機能を安全に扱うことができません。
そいて Java のエコシステム内の数多くのフレームワークにおいても、似ているような問題に悩まされることがあります。
フレームワークの目的を達成するのにクラスファイルの処理が必要な場合には、それぞれの需要や要件に合わせてクラスファイル処理用のライブラリ(前述の ASM や、BCEL、Javaassist など)を同梱しています。
その結果、Java の新バージョンにクラスファイル関連の変更が入る場合、フレームワーク側の対応以外に、ライブラリ側の対応も待つ必要があります。
これらの問題点は Java 新バージョンへの移行の妨げにもなりかねないので、解決の手段としてクラスファイル API が導入されました。
クラスファイル API の追加は通常の Java 開発者にとって直接な影響はあまりないと思いますが、外部ツール(例えば Mockito、Byte Buddy など)のフレームワーク追従が早くなるので、Java バージョンアップのコストが下がることにつながります。
また、他の JVM 言語にとってもクラスファイル API は JVM 新バージョン対応の追い風になるので、ツールチェーンにまつわる事情や Java との相互運用性はより良い方向に変わると思います。
24: Stream Gatherers
Stream API の拡張として、中間処理をカスタムで定義するための仕組み Gatherer が追加され、従来の中間処理だけでは表現しづらかった操作をより簡単に実現できるようになりました。
Stream に追加された gather(Gatherer<? super T, ?, R> gatherer) メソッドを呼び出すと、Stream の処理に Gatherer を組み込むことができます。
標準な Gatherer として、Gatherers クラスでは以下のものを提供しています。
ほとんど場合はこれらの処理で事足りるが、どうしても必要がある場合はカスタムの Gatherer も定義できます。
-
windowSliding(int):「窓」に含まれる一連の要素に対して、窓を後続要素のほうにずらしながらの順次処理 -
windowFixed(int):データ要素を固定サイズで区切って、区切ったグループごとにバッチ処理 -
fold(...):順序に依存する、状態を持つ畳み込み処理 -
scan(...):要素を逐次に蓄積するための累積計算 -
mapConcurrent(...): 仮想スレッドでの関数計算
ここではコードの実例として、 List<Double> 内の一連の数値に対して5要素分の移動平均を求める計算で説明します。
Stream を使わない場合
Stream を使わない場合、一つの値を計算するために複数要素へのアクセスが必要なので、入れ子のループでの実装になります。
List<Double> calculateMovingAverage(List<Double> values) {
List<Double> movingAverages = new ArrayList<>();
for (int i = 0; i + 4 < values.size(); i++) {
double sum = 0;
for (int j = 0; j < 5; j++) {
sum += values.get(i + j);
}
movingAverages.add(sum / 5);
}
return movingAverages;
}
このコードは手続き的な処理に慣れている人にとっては素直な書き方だけど、再利用性が低いです。
そして添字に対する操作が多い分、可読性も保守性も悪くなります。
Stream を使う場合:リストの添字に基づく処理
前述のループをそのまま Stream に置き換える場合、添字を対象とする入れ子の Stream となります。
List<Double> calculateMovingAverage(List<Double> values) {
return IntStream.range(0, values.size() - 4) // リスト内のデータ要素ではなく、添字を対象とした Stream
.mapToDouble(index ->
values.subList(index, index + 5) // 5要素分の部分リストを抽出して移動平均を計算
.stream()
.mapToDouble(Double::doubleValue)
.average()
.orElseThrow()
)
.boxed()
.toList();
}
データ要素ではなくリストの添字を対象とする処理なので、Stream としては不自然な形だし、コードも意図の読み取りづらいものになっています。
Stream を使う場合:データ要素に基づく処理
移動平均を計算するための「窓」に着目する場合は、データ要素を対象とする Stream で実現できます。
List<Double> calculateMovingAverage(List<Double> values) {
Queue<Double> window = new LinkedList<>(5); // 移動平均を計算するためのデータ範囲(いわゆる「窓」)
return values.stream()
.map(value -> {
window.offer(value);
if (window.size() < 5) { // 境界付近では窓の要素が足りないので特殊値の NaN で弾く
return Double.NaN;
}
double average = window.stream() // 窓に含まれた値の平均を求める
.mapToDouble(Double::doubleValue)
.average()
.orElseThrow();
window.poll();
return average;
})
.filter(average -> !Double.isNaN(d))
.toList();
}
リストの添字を対象とする Stream よりは幾分意図が読み取りやすいが、map の処理には平均の計算だけでなく境界値のケアも含まれているので、複数の関心事が入り込んでいます。
さらに Stream の内部状態(ここでの Queue)を外に保持しているので、意図せずに変更してしまう可能性があります。
JEP 485 の Gatherer を使う場合
データ要素を対象にした Stream を Gatherer で表現すると以下のようになります。
List<Double> calculateMovingAverage(List<Double> values) {
return values.stream()
.gather(Gatherers.windowSliding(5))
.map(window ->
window.stream()
.mapToDouble(Double::doubleValue)
.average()
.orElseThrow()
)
.toList();
}
一つ前の例にあった Queue で表していた窓はそのまま Gatherers.windowSliding(int) に置き換わって、境界付近のケアも不要になったので、かなりシンプルな表現になります。
25: スコープ値
JEP 506 で導入したスコープ値は、スレッドとその子スレッド内で不変データを安全かつ効率的に共有するための仕組みです。
スコープ値は読み取り専用で、かつ生存期間もデータの流れに沿ったスコープ内に限られるので、既存の ThreadLocal よりも扱いやすいです。
特に仮想スレッド1と構造化並行性2との併用において、ThreadLocal よりも優れた効率を発揮します。
スコープ値を利用する場合は、static final なフィールドに ScopedValue.newInstance() を アサインする形で宣言してから、処理が必要な際に ScopedValue.where(...).run(...) で宣言したフィールドに値をバインドする流れになります。
// スコープ値フィールドの宣言
static final ScopedValue<...> SCOPED_VALUE = ScopedValue.newInstance();
// どこかのメソッドでのバインド
ScopedValue.where(SCOPED_VALUE, ...).run(() -> { ... SCOPED_VALUE.get() ... });
スコープ値自体はスレッドとその子スレッドとのデータ共有するために設計したものですが、Java 25 までの正式な API にバインド済みのスコープ値を子スレッドに自動的に伝搬させる仕組がない 3 ので、子スレッドへのバインド済みスコープ値を伝搬すには手動で再バインドするしかありません。
class Sample {
private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
static void main() {
ScopedValue.where(REQUEST_ID, "request-12345").run(() -> handleRequest());
}
static void handleRequest() {
doSomeTask("task in caller thread");
// 値を取り出す
var requestId = REQUEST_ID.get();
Thread child = Thread.startVirtualThread(() ->
// 子スレッドに向けて再バインド
ScopedValue.where(REQUEST_ID, requestId).run(() -> doSomeTask("task in child thread")));
try {
child.join();
} catch (InterruptedException _) {
Thread.currentThread().interrupt();
}
}
static void doSomeTask(String taskName) {
System.out.println(REQUEST_ID.get() + ":" + taskName);
}
}
共有する値の子スレッドへの自動伝搬がない分、使用する際に若干の不便さがあるかもしれないが、値の有効スコープが明示できる点だけを考えても、ThreadLocal より安全というメリットがあります。
構造化並列性2が正式な機能になったら、スコープ値も今より使いやすいものになるでしょう。
25: モジュールインポート宣言
JEP 511: Module Import Declarations
従来のパッケージ単位の import 文に加えて、モジュールのエクスポートしている全パッケージを対象にまとめてインポートできるようになりました。
モジュールインポート宣言は import module M; の形で行います。
この宣言によって、モジュール M のエクスポートしているパッケージと、モジュール M の依存関係まで含めて見えるパッケージを、まとめてインポートすることになります。
例えば以下のような依存関係となっている複数のパッケージ A、 B、 C があるとします。
module A {
exports a.api;
}
module B {
requires A;
exports b.api;
}
module C {
requires B;
}
モジュール A B C にそれぞれを対象にモジュールをインポートする場合、可視になるパッケージは以下のようになります。
| import | 使えるパッケージ | 説明 |
|---|---|---|
import module A; |
a.api.* |
A の exports a.api
|
import module B; |
b.api.*a.api.*
|
B の exports b.apiB の requires A かつ A の exports a.api
|
import module C; |
なし |
C は exportsしてない |
モジュールの組み合わせによって、モジュールインポートだけでクラスを解決できないあいまいな状況に遭遇する可能性があります。
import module java.base; // java.util パッケージに List と Date がある
import module java.desktop; // java.awt パッケージに List がある
import module java.sql; // java.sql パッケージに Date がある
List list = ... // 解決できない
Date date = ... // 解決できない
その場合はパッケージ、あるいはクラス・インターフェースを直接インポートすることで、解決するための範囲を狭めることができます。
import module java.base;
import module java.desktop;
import module java.sql;
import java.util.*;
import java.sql.Date;
List list = ... // java.util.List
Date date = ... // java.sql.Date
25: 単純なソースファイルおよびインスタンスのメインメソッド
JEP 512: Compact Source Files and Instance Main Methods
Java 言語を学び初心者や慣れない構文や処理を軽く試したい場合など、小さなプログラムを書くシーンにおいて、「定型コード」(いわゆるボイラープレート)を省略した「コンパクトなソースファイル」が書けるできるようになりました。
プログラムを書くための最初の一歩が簡単になったのだけでなく、そこから必要に応じて内容を書き足すことで、シームレスに通常の Java ソースコートに移行することもできます。
ソースコード周りの主な変更点は以下の通りです。
- コンパクトソースファイル
- クラス・パッケージ・モジュール宣言を省いて、メソッド定義だけで Java ソースが書けるようになった
- インスタンス
mainメソッド-
public static void main(String[] args)のような定型的なコードを省略し、引数なしのvoid main()でプログラムのエントリポイントが書けるようになった
-
-
java.lang.IOクラスの導入- 新しい I/O ユティリティ
java.lang.IOが追加され、かつすべてのソースファイルに暗黙的にインポートされるので、コンソールの入出力はIO.println("Hello, world!)のように書けるようになった
- 新しい I/O ユティリティ
初心者の二番目4に書くプログラムとして定番なコンソールでの名前入力と挨拶出力を、これらの変更点を全部取り込んだコードで書くと以下のようになります。
void main() {
IO.println("What's your name?");
IO.println("Greetings, " + IO.readln() + "!");
}
コンパクトソースファイルに合わせて、ツールのほうも初心者が使いやすいように変更されました。主な変更点は以下の通りです。
-
javac- クラス宣言なしのソースをサポート(暗黙のクラス生成、インスタンス
mainメソッドをエントリポイントとして解決)
- クラス宣言なしのソースをサポート(暗黙のクラス生成、インスタンス
-
java- 単一ソースファイル指定で、コンパクトソースのインスタンス
mainを実行可能、クラス名指定も不要
- 単一ソースファイル指定で、コンパクトソースのインスタンス
-
javadoc- コンパクトソースの解析とドキュメント生成
コンパクトソースを拡張して通常コードに移行する際は、段階的になにかを書き足していくことになるので、その移行途中の状態にあるコードも同じようにサポートされます。
// パッケージとインポート宣言を省略
class HelloWorld {
// インスタンス main メソッド
void main() {
System.out.println("Hello, World!");
}
}
25: 柔軟なコンストラクタ本体
JEP 513: Flexible Constructor Bodies
コンストラクタの中にある super(...) や this(...) の前に、任意のコードを置けるようになりました。
ただし、そのコードの中では初期化中のインスタンス(=this)にアクセスしてはいけないが、引数チェックやフィールド代入のような安全な処理が可能です。
この変更によって、サブクラス内のフィールドを先に初期化して、そのあとにスーパークラスの呼び出したメソッド内で参照するようなことができるようになります。
典型な適用パターン
例えば、以下のように継承関係にある二つのクラス Person と Employee があるとします。
class Person {
int age;
...
Person(..., int age) {
if (age < 0) { throw new IllegalArgumentException(...); }
this.age = age;
... // その他いろいろの処理
}
}
従来のコンストラクタの制限として一番最初 super(...) を呼び出す必要があるが、後続のサブクラス側の初期化処理で例外が発生してしまうと super(...) の処理が全部無駄になります。
class Employee extends Person {
String officeID;
Employee(..., int age, String officeID) {
super(..., age); // 後続のチェックで弾かれると、処理が無駄になる
if (age < 18 || age > 70) { throw new IllegalArgumentException(...); }
this.officeID = officeID;
}
}
JEP 513 の導入で super(...) の前にサブクラス側の処理を書けるようになったので、このようなサブクラス側の初期化で発生した例外によってスーパークラス側の処理が無駄になる状況が避けられるようになります。
class Employee extends Person {
String officeID;
Employee(..., int age, String officeID) {
if (age < 18 || age > 70) { throw new IllegalArgumentException(...); } // 先にチェックするから無駄が少ない
this.officeID = officeID; // スーパークラスのコンストラクタよりも先に初期化できる
super(..., age);
}
}
その他の変更点諸々
上で紹介した言語仕様と API の変更点以外にも多くの変更点がありました。
ざっくりに分類すると、開発や運用の効率を向上させるためのものと、プラットフォームの安全性と整合性を高めるためのもの、という二つの大きなカテゴリに分けられます。
- 開発や運用の効率を向上させるもの
- GC の実行効率改善
- AOT(Ahead-of-Time)による起動時間短縮
- メモリフットプリントの削減
- JFR(Java Flight Recorder)の機能強化
- その他ツール類の利便性向上や機能強化
- プラットフォームの安全性と整合性を高めるもの
- JNI や Unsafe などの安全でない機能に対する制限と警告
- セキュリティマネージャを恒久的無効化(削除に向けて準備)
- その他
- セキュリティ機能の改善(JEP 510 のキー導出関数)
変更点をこうやってカテゴリで分けると、なんとなく Java の今後の方向性が窺えますね。
まとめ
Java 22 から Java 25 までの変更点を、言語仕様と API 周りの変更に絞って一通り紹介しましたが、如何だったでしょうか?
個人的な感想ですが、ここ数バージョンの変更点から、言語の使いやすさとプラットフォームの堅牢性、両方の底上げをバランスよく目指している雰囲気が感じ取れます。
この記事で取り上げた新機能の中に、Stream Gatherer のように標準機能を使う分は簡単だけどカスタムしようとするとかなり複雑になるものや、ソースコードの構文に加えてツール周りにも変更が入るものなどがありました。
これらの変更点は説明だけで全容を掴みづらいので、可能であればここまで呼んだ方にぜひ手を動かして試していただきたいと思います。
もし Java 21 までの機能まだ把握できていないのであれば、去年自分の Advent Calendar 記事「Java 8 ユーザ向けの Java 21 までの新機能ガイド」を一読していただければ、なにか参考になるかもしれません。
この記事のつたない文章が Java 新機能を理解するために少しでもお役に立てたら幸いです。
-
Java 21 に含まれた JEP 444: Virtual Thread ↩
-
Java 25 に含まれた JEP 505: Structured Concurrency (Fifth Preview) ↩ ↩2
-
スコープ値を自動的に伝搬させる仕組は JEP 505: Strucured Concurrency に含まれているので、Preview 機能を有効にしないと使えません。 ↩
-
一番最初に書くプログラムのはもちろん、みんな大好き「Hello World」です。 ↩