jakarta.servlet へ移行して、自分のコードから javax.* の import を全部消した。コンパイルも通る。
それでも実行時に次のエラーで落ちることがあります。
Exception in thread "main" java.lang.NoClassDefFoundError: javax/servlet/Filter
at java.base/java.lang.ClassLoader.defineClass1(Native Method)
...
at java.base/java.lang.Class.forName(Class.java:377)
at App.main(App.java:3)
Caused by: java.lang.ClassNotFoundException: javax.servlet.Filter
at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:641)
... 21 more
javax.servlet.Filter は、自分のコードが1文字も参照していないクラスです。
直し方の要点を先に
要求している側のライブラリを、Jakarta 世代に対応した版へ上げます。
<!-- 旧:Spring 5 系。GenericFilterBean が javax.servlet.Filter を実装している -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
<version>6.1.15</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>6.1.15</version>
</dependency>
関連する Spring の module は揃えて上げます(片方だけ上げると版が混在します)。Spring Boot を使っているなら、
個別の版を指定するのではなく Boot / BOM が管理する世代へ上げるのが筋です。上の 6.1.15 はこの記事が
検証で通した一点であって、恒久的に固定する版という意味ではありません。利用中の BOM と、Servlet / JDK の
baseline に合う世代を選んでください。
javax.servlet-api を足して黙らせない
依存に javax.servlet-api を足すと、クラスは見つかるようになります(実測:この記事の最小構成に
javax.servlet-api:4.0.1 を足すと終了コード 0 でロードが通ります)。それでも移行にはなりません。
javax.servlet.Filter と jakarta.servlet.Filter は無関係な型なので、Spring 5 のフィルタは
javax.servlet.Filter の実装のままです。Jakarta 世代のコンテナが呼ぶのは jakarta.servlet の型なので、
そのフィルタはフィルタチェーンに参加できません(ロードは通るのに機能しない、という形に問題が移ります)。
なぜコンパイルが通るのに実行時に落ちるのか
javax.servlet.Filter を要求しているのは、自分のコードではなく、依存しているライブラリです。
Spring 5 の OncePerRequestFilter は、superclass の GenericFilterBean を介して javax.servlet.Filter を
実装しています(OncePerRequestFilter が GenericFilterBean を継承し、GenericFilterBean が interface の
Filter を実装する、という連鎖です)。Spring 6 の同じ連鎖では、GenericFilterBean が
jakarta.servlet.Filter を実装します。
- 落ちるのはクラスをロードした瞬間です。 JVM が
OncePerRequestFilterを定義するには、その superclass と、 そこが実装する interface を解決する必要があります。classpath にはjakarta.servlet-apiしか無いので、javax.servlet.Filterを解決できずに落ちます。 - この最小構成でコンパイルが通るのは、クラス名を文字列で渡しているからです。 再現コードは
Class.forName("org.springframework.web.filter.OncePerRequestFilter")と書いており、Spring の型を ソース上で参照していません。javac が解決するのは、ソースが参照・継承・実装する型です。OncePerRequestFilterを継承した自作フィルタを書けば、javax.servlet.Filterが無い時点でコンパイルが落ちます ——つまり「実行時まで露見しない」のは、そのクラスを静的に参照していない経路に限った話です。
同じアプリ(依存は jakarta.servlet:jakarta.servlet-api:6.0.0)で、Spring の版だけを変えた実測です。
| Spring Web / Context | 結果 |
|---|---|
| 5.3.39 | NoClassDefFoundError: javax/servlet/Filter(終了コード 1) |
| 6.1.15 | 成功(終了コード 0) |
アプリのソースは2回とも同一で、変えたのは Spring の版だけです。
この時間差が、次の形で表面化します。
- 自分のコードの移行が済んでも終わらない。 import を全部書き換えてビルドが緑になっても、依存が旧世代のままなら 実行時に落ちます。ビルドの緑は、移行の完了を意味しません。
- CI がユニットテストしか回していないと、デプロイ先で初めて落ちる。 そのクラスをロードする経路を通ったときだけ 例外になるので、ロードしないテストは緑のまま通ります。
直っていないのはどのライブラリか
エラーメッセージは「javax.servlet.Filter が無い」としか言いません。要求している側の名前は、そこには出ません。
スタックトレースにも、要求元のクラス名が出るとは限りません。 上のトレースがその例です——フレームは
クラスローダの内部と App.main だけで、OncePerRequestFilter はどこにも現れません。ロードに失敗したクラスの
コードは一度も実行されないので、フレームには残らないためです。「Caused by の上を見れば要求元が分かる」とは
限らない、ということです。
代わりに、次の順で当たります。
- ロードの起点を見る。 トレースのアプリ側フレーム(上の例なら
App.main)は、どこでロードが始まったかを 示します。実際のアプリなら、そこに Spring の起動やフィルタ登録のフレームが並ぶので、経路の手掛かりになります。 上の最小構成では、ロード対象はClass.forNameに渡した文字列そのものなので最初から分かっています。 - 依存ツリーから探す。
mvn dependency:treeで、Jakarta 世代に上げ忘れているライブラリを探します。 名前空間の世代はライブラリごとに上がるので、1つでも旧世代が残っていると、その1つが要求します。 - 要求している jar を直接特定する。 どの jar が
javax.servletを参照しているかは、jdepsなどで classpath 上の jar を調べると分かります。トレースに名前が出ない場合はこちらが確実です。
切り分け(うまくいかないとき)
- コンパイルの時点で
package javax.persistence does not existが出る:それは自分のコードがまだ古い名前空間を 参照しています。実行時まで届いていません。 package javax.persistence does not exist の直し方(Spring Boot 3) を参照してください。 package javax.mail does not existが出る:依存の版で名前空間が変わる罠です(Maven 座標が先にjakartaへ改名されている)。 package javax.mail does not exist の直し方 を参照してください。- 落ちているのが
javax/xml/bind/JAXBException:それは Jakarta EE の改名ではなく、JDK が JAXB の同梱をやめた話です (Java 11 / JEP 320)。同じNoClassDefFoundErrorでも、直し方は依存を足すことです。 NoClassDefFoundError: javax/xml/bind/JAXBException の直し方 を参照してください。 - ライブラリに Jakarta 対応版が無い:そのライブラリを置き換えるか、移行を待つ判断になります。
javax.servlet-apiを 足す回避は、上に書いたとおりロードは通るがフィルタが機能しない形に問題を移すだけです。 - 上げたら別のクラスで同じ形の例外が出た:世代はライブラリごとなので、旧世代が他にも残っています。 同じ手順で要求元を特定します。
検証環境
eclipse-temurin:17-jdkの中で Maven とjavaを実行(依存の取得にのみネットワークを使用)- 再現:
jakarta.servlet:jakarta.servlet-api:6.0.0を依存に持つアプリで、Spring Web / Context 5.3.39 を classpath に置き、OncePerRequestFilterをロードするとNoClassDefFoundError: javax/servlet/Filter(Caused by: ClassNotFoundException: javax.servlet.Filter)で終了コード 1 - 修正:Spring Web / Context を 6.1.15 へ上げると、終了コード 0・シグネチャ消滅
再現から修正までは errfix の検証ハーネスが機械的に確認しています。reproduce と fix の差は Spring の版だけで、
アプリのソースは同一です(jakarta.servlet 側も変えていません)。
ケースは最小化のため、Class.forName("org.springframework.web.filter.OncePerRequestFilter") でクラスをロードしています。
実際のアプリでこの例外に当たるのは、Spring がフィルタを実体化する起動時など、そのクラスがロードされる経路を
通ったときです。Class.forName は「ロードした瞬間に superclass と、そこが実装する interface の解決が走る」という
機構を、最小の構成で証明するための形です。本文に載せたスタックトレースはこの最小構成の実測で、フレームが
クラスローダ内部と App.main だけになるのはそのためです(実アプリなら、そこに Spring 側のフレームが並びます)。
ケースが spring-context も明示しているのは、spring-web だけでは OncePerRequestFilter のロードに必要なクラスが
揃わないためで、これも実測して確かめました。
javax.servlet-api を足すと「ロードは通る」ことも実測しました——同じ最小構成に javax.servlet-api:4.0.1 を
足すと終了コード 0 になります。上でこの回避を勧めないのは、エラーが消えないからではなく、
消えた先が「フィルタがチェーンに参加しない」状態だからです。各版・各構成の結果はケースの
verification/probes.txt に記録しています。