Javaモジュールシステム(JPMS:Java Platform Module System)は、Java 9で導入された、複数のパッケージを「モジュール」という単位に束ね、外部に公開するパッケージと依存先のモジュールをmodule-info.javaで宣言する仕組みです。宣言していないパッケージはpublicクラスであってもほかのモジュールから使えず、コンパイル時点でエラーになります。この記事では、書き方とパッケージとの違いに加え、JDK 25.0.4.1(Temurin)とJDK 27(Zulu 27.28)で2つのモジュールを実際にコンパイル・実行したときのエラー文、モジュールが解決される流れ、既存JARとの共存、jdepsとjlinkによる移行と配布の手順までをまとめます。
まとめ:Javaモジュールシステムの要点
- モジュールは
module-info.javaで「何を公開するか(exports)」と「何に依存するか(requires)」を宣言する単位。Java 9(2017年9月21日GA)で導入された。 - パッケージは名前空間の単位、JARは配布ファイルの形式。モジュールはその上で公開範囲と依存を強制する層にあたる。
- モジュールを別々にコンパイルすると、exportsしていないパッケージの参照は
package ... is not visible、requiresの書き忘れはdoes not read itというエラーになる(--module-source-pathで一括コンパイルするとどちらもpackage ... does not exist)。 - 起動時は
-mで指定したモジュールを根にrequiresとサービスをたどってモジュールグラフを作る。2モジュールのアプリでも、JDKのサービス実装が引き込まれて46モジュールが解決された。 - リフレクションで非公開メンバーに触るには
opensか--add-opensが必要。JDK 17以降は--illegal-accessで一括解除できない。JDK 26からはfinalフィールドの書き換えに警告が出る(JEP 500)。 - クラスパス上のコードは無名モジュールとして従来どおり動くため、モジュール化は必須ではない。既存JARはモジュールパスに置くと自動モジュールとして扱われる。
- jlinkで必要なモジュールだけのランタイムを作れる(アプリもモジュール化すればアプリごと1イメージにできる)。検証ではJDK本体235MBに対し、2モジュールのアプリ用ランタイムは34MBだった。
Javaモジュールシステム(JPMS)の定義と導入の経緯
「モジュール」という言葉の一般的な意味とJavaでの意味
ソフトウェアで「モジュール」は、ひとまとまりの機能を持ち、ほかの部分と決められた窓口(インターフェース)だけでやり取りする部品を指します。Javaのモジュールはこの考え方を言語とJVMの仕組みとして強制するもので、「どのパッケージを外に見せるか」「どのモジュールに依存するか」をソースコードに書き、コンパイラと実行時の両方で検査します。
Java 8までは、JARをクラスパスに並べると中のpublicクラスはすべて誰からでも参照できました。ライブラリ作者が内部用のつもりで作ったクラスでも、利用者が直接呼び出せてしまい、バージョンアップで壊れる原因になっていました。JDK自身の内部APIであるsun.misc.Unsafeも、多くのライブラリから直接使われていました(JEP 260)。モジュールシステムは、この「publicなら全部見える」状態に公開範囲の境界を引くために導入されています。
Java 9とProject Jigsawで入った6つのJEP
モジュールシステムはProject Jigsawの成果として、JDK 9(2017年9月21日GA)で次の6つのJEPにより導入されました。仕様はJSR 376で、コマンドラインオプションを含む実装の詳細はJEP 261: Module Systemにまとまっています。
| JEP | 名称 | 内容 |
|---|---|---|
| JEP 200 | The Modular JDK | JDK自体をモジュールに分割 |
| JEP 201 | Modular Source Code | JDKのソースをモジュール単位に再編 |
| JEP 220 | Modular Run-Time Images | rt.jarを廃止し実行イメージを再構成 |
| JEP 260 | Encapsulate Most Internal APIs | JDK内部APIの大半をカプセル化 |
| JEP 261 | Module System | JSR 376の実装本体 |
| JEP 282 | jlink: The Java Linker | 必要なモジュールだけの実行イメージを作成 |
JDK自身もモジュールの集まりです。JDK 25.0.4.1(Temurin)でjava --list-modulesを実行すると69個のモジュールが表示され、java.langやjava.utilを含むjava.baseはすべてのモジュールが暗黙に依存します。数は版と配布元で変わり、Temurin 26.0.2.1ではjdk.jsobjectが無くなって68個、Zulu 27ではjdk.graal.compiler・jdk.graal.compiler.management・jdk.internal.vm.ciの3つが無く65個でした。2026年10月時点の最新LTSはJDK 25、最新版は2026年9月15日GAのJDK 27です。JDK 27の位置づけはJava 27の非LTSの半年運用とG1・オブジェクトヘッダ既定化の影響で解説しています。
パッケージ・ライブラリ(JAR)・モジュールの違い
3つは役割の層が違います。パッケージは名前の整理、JARは配布の形式、モジュールは公開範囲と依存関係の強制を担います。
| 単位 | 役割 | 宣言の場所 | アクセス制御 |
|---|---|---|---|
| パッケージ | クラスの名前空間 | 各ソースのpackage文 |
public/パッケージプライベート |
| JAR(ライブラリ) | 配布ファイル | MANIFEST.MF | なし(クラスパスでは全public公開) |
| モジュール | 公開範囲と依存の境界 | module-info.java |
exportsしたパッケージだけ公開 |
1つのモジュールは複数のパッケージを含み、配布時はmodule-info.classを最上位に持つJAR(モジュラーJAR)になります。つまり「モジュール化されたライブラリ」は、JARの中身にモジュール宣言が1つ加わったものです。同じパッケージを2つのモジュールに分けて置くこと(スプリットパッケージ)は禁止されており、後述のエラーになります。
module-info.javaの書き方とディレクティブ一覧
最小構成:2モジュールの例とディレクトリ配置
公開API(apiパッケージ)と内部実装(internalパッケージ)を持つcom.example.greetingと、それを使うcom.example.appの2モジュールで構成します。module-info.javaは各モジュールのソースルート直下に置きます。
src/
├── com.example.greeting/
│ ├── module-info.java
│ └── com/example/greeting/
│ ├── api/Greeter.java
│ └── internal/Formatter.java
└── com.example.app/
├── module-info.java
└── com/example/app/Main.java
// src/com.example.greeting/module-info.java
module com.example.greeting {
exports com.example.greeting.api;
}
// src/com.example.app/module-info.java
module com.example.app {
requires com.example.greeting;
}
Mainはcom.example.greeting.api.Greeterを呼び出すだけのクラスです。--module-source-pathで複数モジュールをまとめてコンパイルし、-p(--module-path)と-mで起動します。
$ javac -d out --module-source-path src -m com.example.app
$ java -p out -m com.example.app/com.example.app.Main
[Hello, module]
$ java -p out --describe-module com.example.greeting
com.example.greeting file:///private/tmp/jm/out/com.example.greeting/
exports com.example.greeting.api
requires java.base mandated
contains com.example.greeting.internal
--describe-moduleの出力で、internalパッケージはcontains(含むが公開しない)に分類されています。requires java.base mandatedは、書かなくても自動で付く依存です。
ディレクティブ一覧と使いどころ
| ディレクティブ | 意味 | 使う場面 |
|---|---|---|
requires M |
モジュールMに依存する | 基本の依存宣言 |
requires transitive M |
自分を使う側にもMを読ませる | 公開APIの型がMにある |
requires static M |
コンパイル時のみ必須 | 任意の依存・注釈処理 |
exports P |
パッケージPを公開する | 公開API |
exports P to M |
Mにだけ公開する | 自社モジュール間の内部API |
opens P |
実行時のリフレクションを許可 | DI・ORM・JSON変換 |
open module |
全パッケージをopens | フレームワーク任せのアプリ |
uses S |
サービスSを利用する | ServiceLoaderで実装を探す |
provides S with C |
Sの実装Cを提供する | プラグインの実装側 |
exportsはコンパイル時と実行時の通常アクセスを許可し、opensは実行時のリフレクション(setAccessible(true)による非公開メンバーへのアクセスを含む)だけを許可します。両者は独立しているため、APIとして公開しつつフレームワークにも触らせたいパッケージには両方を書きます。
requires transitiveが要る場面
公開APIのメソッドが別モジュールの型を返すときは、requires transitiveにしないと、利用者側でその型を名前で使う箇所(importやメソッド呼び出し)がコンパイルエラーになります。com.example.coreのUsers.find()がcom.example.model.Userを返す構成で、coreがrequires com.example.model;だけを書いた場合の結果です。
$ javac -Xlint:exports -d out --module-source-path src -m com.example.app
Main.java:3: error: package com.example.model does not exist
Users.java:1: warning: [exports] record User in module com.example.model is not indirectly exported using 'requires transitive'
(出力は一部省略しています)-Xlint:exportsを付けると提供側に警告が出るため、ライブラリを作る側はこのオプションを常に有効にしておくと漏れに気づけます。coreの宣言をrequires transitive com.example.model;に変えると、appに依存を追記しなくてもコンパイル・実行が通りました。
uses/providesによる実装の差し替え
インターフェースを持つモジュールと実装を持つモジュールを分け、java.util.ServiceLoaderで実行時に実装を探す仕組みです。利用側は実装モジュールをrequiresしないため、実装の追加・交換が利用側の再コンパイルなしでできます。
module svc.api { exports svc.api; }
module svc.impl { requires svc.api; provides svc.api.Plugin with svc.impl.CsvPlugin; }
module svc.app { requires svc.api; uses svc.api.Plugin; }
この構成でServiceLoader.load(Plugin.class)を回すとcsvが出力されました。appがsvc.implをrequiresしていなくても、モジュールパス上にある提供モジュールは解決時の「サービスバインディング」で自動的に読み込まれます。
モジュールパスの指定とコンパイル・実行コマンド
モジュールシステムでは、クラスパス(-cp)の代わりにモジュールパス(-p/--module-path)にモジュールのディレクトリやJARを置きます。よく使うオプションは次のとおりです。
| オプション | 対象 | 役割 |
|---|---|---|
--module-source-path |
javac | 複数モジュールのソースを一括コンパイル |
-p/--module-path |
javac・java・jlink | モジュールの探索場所 |
-m/--module |
javac・java | コンパイル対象/起動するモジュール |
--add-modules |
javac・java・jlink | ルートモジュールを追加 |
--list-modules |
java | 利用できるモジュールの一覧 |
--describe-module |
java・jar | モジュールの宣言内容を表示 |
--show-module-resolution |
java | 起動時の解決過程を表示 |
--limit-modules |
javac・java・jlink | 観測できるモジュールを絞る |
Gradleでモジュールパスに載る条件
ビルドツールを使う場合はmodule-info.javaをsrc/main/java直下に置きます。Gradle 7.0以降は、次の3条件がそろった依存JARを自動でモジュールパスに載せます(Gradle公式ドキュメント)。
java.modularity.inferModulePathが無効化されていない- ビルド中のプロジェクトが
module-info.javaを持つ(またはAutomatic-Module-Nameを設定している) - 依存JARが
module-info.classかAutomatic-Module-Nameを持つ
3つ目の条件により、どちらも持たない古いJARはモジュールパスではなくクラスパスに置かれます。クラスパス上のJARは無名モジュールに入るため、requiresで名前を指定しても解決できません。javaコマンドで-pに置いた場合はmodule-info.classを持たないJARがすべて自動モジュールになるのに対し、Gradleは条件を満たさないJARをクラスパスに回す点が違います。
モジュール解決の流れ:ルートモジュールからグラフができるまで
起動時、モジュールシステムは「ルートモジュール」からrequiresを順にたどり、さらにusesに対応するprovidesを持つモジュールを加えて(サービスバインディング)、必要なモジュールの集合=モジュールグラフを作ります。モジュールパスに置いてあっても、この過程で到達しないモジュールは読み込まれません。
--show-module-resolutionで解決の過程を見る
前章の2モジュール構成を--show-module-resolution付きで起動すると、解決の順番がそのまま出力されます(JDK 25.0.4.1、先頭のみ。binds行の順序は実行ごとに変わります)。
$ java --show-module-resolution -p out -m com.example.app/com.example.app.Main
root com.example.app file:///private/tmp/jm2/out/com.example.app/
com.example.app requires com.example.greeting file:///private/tmp/jm2/out/com.example.greeting/
java.base binds jdk.security.auth jrt:/jdk.security.auth
java.base binds java.management jrt:/java.management
java.base binds java.desktop jrt:/java.desktop
...
自作の2モジュールしかrequiresしていないのに、java.base binds ...の行が36行続き、最終的に46モジュールが解決されました。java.base自身が多数のサービスをusesしており、その実装をprovidesするJDKのモジュールがバインディングで引き込まれるためです。JDK 27でも同じ46モジュールでした。観測できるモジュールを--limit-modulesで絞ると、解決されるのは自作の2つとjava.baseだけになります。
$ java --show-module-resolution --limit-modules com.example.app -p out -m com.example.app/com.example.app.Main
root com.example.app file:///private/tmp/jm2/out/com.example.app/
com.example.app requires com.example.greeting file:///private/tmp/jm2/out/com.example.greeting/
[Hello, module]
この差は、後述のjlinkで作るイメージが小さくなる理由と同じです。jlinkは既定でサービスバインディングをしないため、requiresでたどれるモジュールだけがイメージに入ります。
クラスパス起動の既定ルートと--add-modulesの特殊値
クラスパスからメインクラスを起動した(無名モジュールで動かした)場合、根になるのはJDKが決めた既定のルートモジュール集合です。java.lang.moduleパッケージのJavadocは、JDKでは「アップグレード・モジュール・パスかシステムモジュールのうち、少なくとも1つのパッケージを修飾なしでexportsするすべてのモジュール」と定めています。同じクラスを-cpで起動して数えると、JDK 25.0.4.1ではルートが43個、解決されたモジュールは52個でした。この規則はJDK 11のリリースノートで変わったもので、JDK 10までと違いjava.seモジュールは既定では解決されません。Java 9〜10時代の解説を読むときは、この違いに注意してください。
ルートに入らないモジュールを使いたいときは--add-modulesで足します。モジュール名のほかに、次の3つの特殊値を指定できます(JEP 261)。
| 指定値 | ルートに加わるもの | 使う場面 |
|---|---|---|
ALL-DEFAULT |
無名モジュールの既定ルート集合 | 他のアプリを載せるコンテナ |
ALL-SYSTEM |
すべてのシステムモジュール | テストハーネス |
ALL-MODULE-PATH |
モジュールパス上の全モジュール | ビルドツール・自動モジュール |
ALL-SYSTEMは多くのモジュールを解決させるため、JEP 261は通常ALL-DEFAULTを優先するよう書いています。クラスパスのアプリから、モジュールパスに置いたJARを読ませたいときはALL-MODULE-PATHを使います(JEP 261は自動モジュールをルートに加える手段としても挙げています)。-p out --add-modules ALL-MODULE-PATH -cp cpで起動すると、root com.example.greetingとroot com.example.appの2行が追加されました。
ModuleLayerで実行時にプラグインを読み込む
起動時に作られるモジュールグラフ(ブートレイヤー)には、後からモジュールを足せません。プラグインのように実行中にモジュールを読み込むには、Configurationで解決し直した結果から新しいModuleLayerを作ります。svc.apiとsvc.appだけで起動し、pluginsディレクトリに置いたsvc.implをレイヤーとして足す例です。
ModuleLayer parent = ModuleLayer.boot();
ModuleFinder finder = ModuleFinder.of(Path.of(args[0])); // plugins ディレクトリ
Configuration cf = parent.configuration()
.resolveAndBind(finder, ModuleFinder.of(), Set.of()); // サービスもバインドする
ModuleLayer layer = parent.defineModulesWithOneLoader(cf, ClassLoader.getSystemClassLoader());
ServiceLoader.load(layer, Plugin.class).forEach(p -> System.out.println("layer: " + p.name()));
$ java -p out -m svc.app/svc.app.Main plugins
layer: csv
layer modules: [svc.impl]
ブートレイヤーに対するServiceLoader.load(Plugin.class)は何も返さず、新しいレイヤーを渡したときだけcsvが見つかりました。resolveではなくresolveAndBindを使うと、providesを持つモジュールがルートに無くてもバインディングで取り込まれます。アプリサーバーやIDEのように、プラグインをレイヤー単位で別のクラスローダーに分離したい場合の仕組みで(モジュールごとにローダーを分けるならdefineModulesWithManyLoadersを使います)、一般的なアプリで直接書く機会は多くありません。
モジュール化で出る5つのエラーと原因(JDK 25で実測)
JDK 25.0.4.1のjavac・javaで、よくある誤りを1つずつ再現した結果です。メッセージは-J-Duser.language=enを付けた英語版で、日本語環境の既定では訳文が表示されます。最初の2つは、greetingを先にビルドしてからappだけをコンパイルした場合の出力で、JDK 27のjavacでも同じis not visibleのエラーになりました。--module-source-pathで両モジュールを一括コンパイルすると、どちらの誤りもpackage ... does not existになり、括弧内の文で原因を区別できません。
$ javac -d lib/com.example.greeting $(find src/com.example.greeting -name '*.java')
$ javac -p lib -d out $(find src/com.example.app -name '*.java')
exports漏れのパッケージ参照(is not visible)
error: package com.example.greeting.internal is not visible
(package com.example.greeting.internal is declared in module com.example.greeting, which does not export it)
日本語環境では「パッケージcom.example.greeting.internalは表示不可です」と表示されます。使う側を直すのではなく、提供側で公開すべきパッケージかを判断し、公開するならexports、特定モジュールだけならexports ... toを足します。
requires漏れ(does not read it)
error: package com.example.greeting.api is not visible
(package com.example.greeting.api is declared in module com.example.greeting, but module com.example.app does not read it)
括弧内がdoes not read itなら、使う側のmodule-info.javaにrequiresが足りません。前のエラーとは直す場所が逆になるので、括弧内の文で判別します。
モジュール間の循環依存(cyclic dependence)
error: cyclic dependence involving com.example.app
モジュール間のrequiresは循環を許しません。両方から使う型を第3のモジュールに切り出すか、片方向をuses/providesに置き換えて依存を断ちます。
同一パッケージの重複(スプリットパッケージ)
error: module app reads package p from both m1 and m2
m1とm2が同じパッケージpをexportsし、appが両方をrequiresした構成です。クラスパスでは問題にならなかった「同名パッケージが複数のJARに分かれている」構成が、モジュールパスではエラーになります。既存ライブラリの移行で最も手間がかかる点で、パッケージ名を変えるか、1つのモジュールにまとめるしかありません。
非公開フィールドへのリフレクション(InaccessibleObjectException)
appのMainを、JDK内部のStringの非公開フィールドを開く次のコードに差し替えて実行しました。
Field f = String.class.getDeclaredField("value");
f.setAccessible(true);
System.out.println("opened");
Exception in thread "main" java.lang.reflect.InaccessibleObjectException: Unable to make field private final byte[] java.lang.String.value accessible: module java.base does not "opens java.lang" to module com.example.app
これはコンパイルでは検出されず、実行時の例外として出ます。対処は次の章で扱います。
リフレクションと強いカプセル化:opensと--add-opens
JDK 17で一括解除できなくなった経緯
JDKの内部(java.langの非公開フィールドやsun.*)へのリフレクションは、段階的に禁止されてきました。
| JDK | 既定の挙動 | 一括解除 |
|---|---|---|
| 9〜15 | 警告を出して許可 | --illegal-accessで調整 |
| 16(JEP 396) | 既定で拒否 | --illegal-access=permitで可 |
| 17以降(JEP 403) | 拒否 | 不可(オプションは無視) |
JDK 25とJDK 27で--illegal-access=permitを付けると、Ignoring option --illegal-access=permit; support was removed in 17.0という警告が出て無視されます。例外として、sun.misc.Unsafeのような重要な内部APIはjdk.unsupportedモジュール経由で引き続き使えます。ただしUnsafeのメモリアクセス系メソッドはJDK 23のJEP 471で削除予定となり、JDK 24以降(JEP 498)は初回呼び出し時に警告が出ます。
--add-opensでパッケージ単位に開く
現在の対処は、パッケージ単位で個別に開くことです。自分のモジュールならmodule-info.javaにopensを書き、JDKや他者のモジュールなら起動オプションで開きます。
前章のリフレクション版Mainをout5へコンパイルし、起動時にjava.langを開くと例外が消えます。
$ java -p out5 --add-opens java.base/java.lang=com.example.app -m com.example.app/com.example.app.Main
opened
書式は--add-opens 対象モジュール/パッケージ=開く先モジュールで、クラスパス上のコードに開くときは開く先をALL-UNNAMEDにします。--add-opensが起動スクリプトに増えていくのは、そのライブラリが内部APIに依存している兆候なので、ライブラリの更新で解消できないかを先に確認してください。setAccessibleやMethod.invokeの基本はJavaリフレクションとは?Method.invokeの使い方とJava 17以降に禁止される操作で解説しています。
フレームワークが要求するopens:JavaFXのFXMLの例
自分のモジュールの中をフレームワークがリフレクションで触る場合は、opens ... toでそのフレームワークのモジュールにだけ開きます。OpenJFXの公式サンプル(openjfx/samplesのhellofx)のmodule-info.javaは次のとおりで、FXMLローダーがコントローラーの@FXMLフィールドに値を入れるため、javafx.fxmlへ開いています。
module hellofx {
requires javafx.controls;
requires javafx.fxml;
opens org.openjfx to javafx.fxml;
exports org.openjfx;
}
JSON変換やORMも同じ考え方で、対象クラスのパッケージをそのライブラリのモジュールへ開きます。開く先を書かないopensやopen moduleはすべてのモジュールに開くため、境界を引く意味が薄れます。
JDK 26からのfinalフィールド書き換え警告(JEP 500)
JDK 26でJEP 500: Prepare to Make Final Mean Finalが入り、リフレクションでfinalフィールドを書き換えると警告が出るようになりました。自分のクラスのprivate final int xをsetAccessible(true)とField.setIntで書き換えるコードをクラスパスで実行した結果です。
$ java -cp out F # JDK 25.0.4.1
x=2
$ java -cp out F # JDK 26.0.2.1・JDK 27
WARNING: Final field x in class F has been mutated reflectively by class F in unnamed module @1bc6a36e (file:/private/tmp/jm3/fin/out/)
WARNING: Use --enable-final-field-mutation=ALL-UNNAMED to avoid a warning
WARNING: Mutating final fields will be blocked in a future release unless final field mutation is enabled
x=2
$ java --illegal-final-field-mutation=deny -cp out F # JDK 27
Exception in thread "main" java.lang.IllegalAccessException: class F (in unnamed module @4e0e2f2a) cannot set final field F.x (in unnamed module @4e0e2f2a), unnamed module @4e0e2f2a is not allowed to mutate final fields
書き換え自体は今も成功しますが、JEP 500は将来の版でdeny(例外)を既定にすると予告しています。警告は--enable-final-field-mutation=ALL-UNNAMED(名前付きモジュールならモジュール名)で消えることをJDK 26と27で確認しました。注意点は、--add-opensやopensでは消えないことです。JEP 500は、書き換えにはこのオプションでの許可に加えてフィールドのパッケージが開かれていることの両方を求めています。DIやシリアライズのライブラリがfinalフィールドに値を入れている場合は、警告の出る箇所を--illegal-final-field-mutation=debug(全件をスタックトレース付きで表示)か=deny(最初の1件で例外)で洗い出し、ライブラリの更新で解消できるかを確認してください。JDK 26のほかの変更はJava 26(JDK 26)の新機能10個のJEPと現在地にまとめています。
既存ライブラリとの共存:無名モジュールと自動モジュール
名前付き・自動・無名モジュールの違い
| 種類 | 条件 | 公開範囲 | 読めるモジュール |
|---|---|---|---|
| 名前付き | module-infoあり+モジュールパス | exportsのみ | requiresしたもの |
| 自動 | module-infoなし+モジュールパス | 全パッケージ | すべて(無名も含む) |
| 無名 | クラスパス上 | 全パッケージ | すべて |
クラスパス上のクラスはすべて1つの無名モジュールに入り、Java 8までと同じ感覚で動きます。一方、名前付きモジュールは無名モジュールをrequiresできません(名前が無いため。実行時に--add-reads モジュール名=ALL-UNNAMEDで読ませる逃げ道はあります)。そのため、モジュール化したアプリからモジュール化されていないJARを使うには、そのJARをモジュールパスに置いて自動モジュールにします。自動モジュールは全パッケージを公開し、ほかのすべてのモジュールを読むため、名前付きモジュールとクラスパスの橋渡しになります。
自動モジュール名の決まり方
自動モジュールの名前は、マニフェストにAutomatic-Module-Nameがあればその値、無ければJARのファイル名から導出されます(java.lang.module.ModuleFinderのJavadoc)。ファイル名からの導出は、.jarを除き、「-数字」の後にドットか末尾が続く最初の位置より前を名前に、後ろをバージョンにして、英数字以外をドットに置き換える規則です。jar --describe-moduleで確認した結果が次です。
| JARファイル名 | マニフェスト | 導出されたモジュール |
|---|---|---|
| acme-util-1.2.3.jar | なし | [email protected] automatic |
| acme-util2-1.2.3.jar | Automatic-Module-Name: com.acme.util | [email protected] automatic |
| my-lib-2.0.jar | なし | [email protected] automatic |
ファイル名由来の名前は、リネームや配布元の変更で変わります。requires acme.util;と書いた後でライブラリが正式にcom.acme.utilとしてモジュール化されると、利用側の宣言がすべて壊れます。ライブラリを配布する側は、モジュール化をまだしない場合でもAutomatic-Module-Nameだけは先に入れておくべきです。
既存アプリの移行手順:jdepsで依存を洗い出す
既存アプリのモジュール化は、依存の末端(ライブラリ側)から順に進めると、未対応のJARを自動モジュールで受けながら段階的に移せます。
- まずクラスパスのまま最新JDKで動かし、
--add-opensが必要な箇所を洗い出す(ここで止まる問題はモジュール化の前に解消する) jdepsで各JARの依存モジュールを調べる- 自社ライブラリに
module-info.javaを足し、未対応の外部JARはモジュールパスに置いて自動モジュールで受ける - 最後にアプリ本体をモジュール化し、
-pと-mで起動する
jdepsは依存の調査とmodule-info.javaの雛形作成の両方ができます。
$ jdeps --module-path out -s -m com.example.app
com.example.app -> com.example.greeting
com.example.app -> java.base
$ jdeps --print-module-deps --module-path out -m com.example.app
com.example.greeting,java.base
$ jdeps --generate-module-info gen acme-util-1.2.3.jar
writing to gen/acme.util/module-info.java
$ cat gen/acme.util/module-info.java
module acme.util {
exports com.acme.util;
}
--generate-module-infoの雛形は、全パッケージをexportsした状態で出力されます。内部用のパッケージまで公開したままになるので、そのまま使わず、公開APIだけを残すように削ってください。--print-module-depsの出力は、そのまま次章のjlinkの--add-modulesに渡せる形式です。
jlinkで必要なモジュールだけのランタイムを作る
jlinkは、アプリのモジュールと、そこから到達できるJDKのモジュールだけを含む実行イメージを作ります。2モジュールの例で作成し、サイズを比べました。
$ jlink -p out --add-modules com.example.app --output img \
--launcher hello=com.example.app/com.example.app.Main
$ img/bin/java --list-modules
com.example.app
com.example.greeting
[email protected]
$ img/bin/hello
[Hello, module]
$ jlink -p out --add-modules com.example.app --output img2 \
--strip-debug --no-header-files --no-man-pages --compress=zip-6
| 対象 | サイズ(du -sh) |
|---|---|
| JDK 25.0.4.1(Temurin・macOS x64) | 235MB |
| jlink(オプションなし) | 51MB |
| jlink(strip-debug・compress=zip-6ほか) | 34MB |
イメージに入ったのはjava.baseと自作の2モジュールだけで、JDKの69モジュールのうち残り68個は含まれていません。コンテナイメージの縮小や、JREを別途インストールさせない配布に向きます。ただしjlinkは自動モジュールを含められません。モジュール化していないJARに依存させて試すとError: automatic module cannot be used with jlink: acme.util(後ろにJARのパスが続く)で止まりました。この場合は、アプリをクラスパスのまま動かし、jdeps --print-module-depsで求めたJDKモジュールだけでjlinkする方法があります(アプリ自体はイメージに入らず、--launcherも作れません)。アプリごと1つのイメージにしたいなら、そのJARのモジュール化を待つか、jdepsの雛形でmodule-infoを補ったJARを作り直します。
もう1つの落とし穴がサービスです。jlinkは既定ではサービスバインディングをしないため、「uses/providesによる実装の差し替え」のsvc構成を--add-modules svc.appだけでリンクするとsvc.implがイメージに入らず、実行しても何も出力されませんでした。提供モジュールは--add-modules svc.app,svc.implのように明示します。--bind-servicesは、Temurin 25のようにJMODを含まないJDKではjava.baseのサービス経由でjdk.jlinkまで引き込もうとして、Error: This JDK does not contain packaged modules and cannot be used to create another image with the jdk.jlink moduleで止まりました。JMODを含むZulu 27では通りましたが、イメージに入ったモジュールは47個に増えました。
なお、今回使ったTemurin 25にはjmodsディレクトリがありません。JDK 24のJEP 493で、JMODファイルを使わず実行イメージ自体からリンクする機能が入ったためです。この機能はJDKのビルド時に有効化するもので、配布元によって有無が異なります。
モジュール化を見送るべきケース
モジュール化は、公開範囲の境界を型レベルで守りたいライブラリや、jlinkで配布サイズを削りたいアプリで効果が出ます。逆に、次のどれかに当てはまるアプリでは、クラスパスのまま運用するほうが保守コストは下がります。
- 依存JARの多くがモジュール化されておらず、ファイル名由来の自動モジュール名に頼ることになる
- DIやORMがリフレクションで広くクラスを走査し、
opensをほぼ全パッケージに書くことになる(境界を引く意味が薄れる) - スプリットパッケージを抱えた古いライブラリに依存しており、差し替えの見込みがない
モジュール境界を最初から設計に組み込めるのは、既存資産に縛られない新規開発です。依存JARの整理から作り直す規模なら、フルスクラッチでのシステム開発として構成から見直す選択肢もあります。
見送る場合でも、JDK 17以降の強いカプセル化はクラスパス上のコードにも効きます。「モジュール化しないから内部APIの制限と無関係」とはなりません(JDK 17以降、クラスパス上のコードでも--add-opensなしでJDK内部の非公開メンバーに触れるとInaccessibleObjectExceptionになります)。JVM側の仕組みはJava仮想マシン(JVM)とは?仕組み・メモリ構成・JITを実機の出力で解説が参考になります。
よくある質問
module-info.javaはどこに置き、module-info.classとはどう違いますか?
module-info.javaはモジュールのソースルート直下(パッケージのディレクトリの外)に置きます。コンパイルするとmodule-info.classになり、JARの最上位にこのファイルがあるものがモジュラーJARとして扱われます。
Java 9以降、モジュール化は必須ですか?
必須ではありません。module-info.javaを書かずクラスパスで実行すれば、コードは無名モジュールとして従来どおり動きます。ただしJDK内部APIへのリフレクションはクラスパス上でもJDK 17以降は拒否されるため、--add-opensが必要になる場合があります。JDK 26以降のfinalフィールドの書き換え警告も、クラスパス上のコードに出ます。
JMODファイルとは何ですか?
JDK 9で導入された、ネイティブライブラリや設定ファイルも含められるモジュールのパッケージ形式です。JEP 261では、JMODファイルはコンパイル時とリンク時(jlink)には使えるが、実行時には使えないと定められています。通常のアプリ配布にはモジュラーJARを使います。
package-info.javaとの違いは何ですか?
package-info.javaはパッケージ単位のJavadocや注釈を書くためのファイルで、アクセス制御の機能はありません。module-info.javaは複数のパッケージを束ねるモジュールの宣言で、公開範囲と依存関係を制御します。
JDK 25の「import module」はモジュールシステムと関係がありますか?
import module java.base;のように書くと、そのモジュールがexportsする全パッケージを1行でimportできる宣言で、JDK 25のJEP 511で正式機能になりました。モジュールの公開範囲の情報を使うimportの省略記法で、module-info.javaを書かないクラスパス上のコードでも使えます(JDK 25でListとHashMapを個別importなしで使えることを確認)。