CSV が別のツールで開けない。ログの日本語が化ける。取り込みバッチが弾く。書き出しているアプリのほうは JDK を 17 から 18 以降へ上げたあとも、今までどおり終了コード 0 で終わっています。
引き金になるのは、charset を指定していないこの書き方です。
try (FileWriter writer = new FileWriter(new File(path))) {
writer.write("出荷指示 1件\n");
}
例外も警告も出ないまま、同じソースが、同じ文字列を、JDK 17 と JDK 18 以降で違うバイト列で書きます。 症状が出るのはファイルを読む工程だけです。
直し方の要点を先に
FileWriter に charset を明示します。
try (FileWriter writer = new FileWriter(new File(path), StandardCharsets.UTF_8)) {
writer.write("出荷指示 1件\n");
}
実測では、同一ソースを JDK 17 と JDK 25 の両方で走らせたとき、charset を明示しない版は出力ファイルが
一致せず、明示した版は両方の JDK で同一のバイト列になりました。動かしたのは FileWriter の引数だけです。
明示する charset は、そのファイルを読む側に合わせて選びます。 検証したのは UTF-8 ですが、既存のファイルや 取り込み先が Shift_JIS 系を前提にしているなら、そちらを明示するほうが整合します。ここで決めるべきなのは 「UTF-8 にするか」ではなく「どちらであれ、JDK の版に決めさせるのをやめる」ことです。
FileWriter に Charset を渡すコンストラクタは Java 11 で追加されたものです。Java 8 のままのコードを直すなら
OutputStreamWriter を使います。
try (Writer writer = new OutputStreamWriter(new FileOutputStream(path), StandardCharsets.UTF_8)) {
writer.write("出荷指示 1件\n");
}
なぜ JDK 18 以降で出るのか:既定文字コードが UTF-8 に変わった
JDK 18 の JEP 400 が、既定文字コード(file.encoding)を OS のロケール由来から UTF-8 固定へ変えました。
charset を指定しない FileWriter / FileReader / new String(byte[]) は、この既定文字コードを使います。
つまりコードを1行も変えていなくても、JDK を上げた時点で書き出すバイト列が変わります。
同一のプログラムで 日本語—café\n を出力し、実バイトを採取した実測です(Eclipse Temurin 各版、
LANG=C / LC_ALL=C)。UTF-8 の期待値は 19 バイト、US-ASCII に置換された結果は 9 バイトになります。
| JDK | file.encoding | 既定 FileWriter の出力 | UTF-8 を明示した出力 |
|---|---|---|---|
| 8 | ANSI_X3.4-1968 | 3f3f3f3f6361663f0a | UTF-8 |
| 11 | ANSI_X3.4-1968 | 3f3f3f3f6361663f0a | UTF-8 |
| 17 | ANSI_X3.4-1968 | 3f3f3f3f6361663f0a | UTF-8 |
| 18 | UTF-8 | UTF-8 | UTF-8 |
| 21 | UTF-8 | UTF-8 | UTF-8 |
| 25 | UTF-8 | UTF-8 | UTF-8 |
境界は 17 と 18 の間の1本だけで、以降は動きません。右端の列のとおり、charset を明示した出力は 8 から 25 まで
全部同じです。ロケールを C.UTF-8 にした場合は 8 の時点から UTF-8 なので、この境界は現れません
(=ロケールが UTF-8 系の環境では踏みません)。
上の表は LANG=C の実測なので、旧既定は US-ASCII への置換になっています。JEP 400 は、この既定文字コードが
Windows では windows-31j(日本語)などロケール由来になることを挙げています。その環境では置換ではなく、
その文字コードのバイト列が書かれます。変わるという事実と境界の位置は同じで、移行前の中身だけが環境で
違います。本記事のバイト列はすべて LANG=C の実測で、Windows での採取はしていません。
追記モードでは、1つのファイルに2つの文字コードが並ぶ
new FileWriter(f, true) で同じファイルに書き足し続けている場合、JDK を上げた前後の書き込みが1つのファイルに
連結されます。同じ文字列を JDK 17 で 1 行、続けて JDK 25 で 1 行追記した結果です(LANG=C)。
3f3f3f3f20313f0a e587bae88db7e68c87e7a4ba2031e4bbb60a
前半(3f3f3f3f20313f)が JDK 17 の書き込み、後半(e587ba…)が JDK 25 の書き込みです。この 1 ファイルを
UTF-8 として読むと、2 行目は 出荷指示 1件 に戻りますが、1 行目は ???? 1? にしかなりません。
LANG=C では旧既定が US-ASCII なので、1 行目は書いた時点ですでに ? に置き換わっています——読み方を
変えても戻りません。日本語 Windows のように旧既定が文字を表現できる環境では、1 行目には元の文字を表す
バイト列が残るので、行ごとに違う文字コードで読み分ければ内容は取り出せます。どちらの場合も、
ファイル全体を 1 つの文字コードとして読む限り、JDK を上げた日を境に前後どちらかが読めません。
既存の出力を変えずに時間を稼ぐ
JEP 400 は、既定文字コードを JDK 17 以前のアルゴリズムに戻す指定を用意しています。
-Dfile.encoding=COMPAT
実測では、JDK 18 / 19 / 21 / 25 のいずれでもこの指定は受理され(終了コード 0)、file.encoding が
ロケール由来の値(LANG=C なら ANSI_X3.4-1968)に戻りました。JEP 400 から数世代あとの LTS でも
利用できます。
これは JVM 全体の既定を戻すので、FileWriter に限らず既定文字コードを使うすべての箇所が旧挙動になります。
移行のための手段として用意されているもので、charset の指定漏れを解消するわけではありません。使うなら、
「どのクラスの出力が旧既定に依存しているか」を洗い出して charset を明示し終えた時点で外す、という条件を
一緒に決めておきます。
切り分け
- 読み込み側が壊れた(書き出しではない):
FileReaderやnew String(byte[])も同じ既定文字コードを使います。 JDK 18 以降は、MS932 で書かれた既存ファイルを既定のまま読むと UTF-8 として解釈されます。直し方は同じで、 読む側にも charset を明示します。 - コンソールの出力だけが化ける/レポートファイルは無事:それは同じ JEP 400 のもう一方の側です。 標準出力と標準エラーは JEP 400 の対象外で、UTF-8 化されていません。 テストの失敗メッセージだけ文字化けする を参照してください。
-Dfile.encoding=UTF-8を渡していたのに変わった:それは JDK 17 以前で既定を UTF-8 に寄せる指定で、 18 以降では既定と同じ値を明示していることになります。この指定がある環境では境界を踏みませんが、 指定が無い経路(別のバッチ、別の起動スクリプト)だけが変わるので、環境ごとに症状が割れます。- ソースコード中の日本語リテラル自体が壊れている:それはコンパイル時の話で、
javacの-encodingが 決めます。検証ではjavac -encoding UTF-8を固定しているため、この記事の境界とは別の層です。 - JDK 17 に戻したら直った:旧既定に戻っただけで、charset は依然として JDK の版とロケールが決めています。 ロケールの違う環境(開発機と CI)へ移せば同じ形で再発します。
- テストが書き出すファイルだけが変わった:テスト実行の toolchain だけが 18 以降へ動いています。 Robolectric の SDK 36 対応で Java 21 へ上げた構成がこの形になるので、心当たりがあれば Robolectric の SDK 36 が Java 21 を要求する を見てください。
検証環境
-
image:
eclipse-temurinの JDK 25(digest 固定sha256:68868d04...)。同じコンテナにopenjdk-17-jdk-headlessを追加し、1つのコンテナの中で JDK 17 と JDK 25 を実行して対照した -
LANG=C/LC_ALL=C。コンパイルはjavac --release 17 -encoding UTF-8で両者共通 -
再現:charset 未指定の
new FileWriter(file)で出荷指示 1件\nを書き、JDK 17 の出力ファイルと JDK 25 の出力ファイルをcmpで比較 → 不一致(終了コード 1) -
修正:
new FileWriter(file, StandardCharsets.UTF_8)に変えて同じ比較 → 一致(終了コード 0) -
版とロケールのバイト表は、Eclipse Temurin の 8 / 11 / 17 / 18 / 21 / 25 を
LANG=CとLANG=C.UTF-8で それぞれ実行し、既定FileWriterと UTF-8 を明示したOutputStreamWriterの出力バイトを採取したもの -
-Dfile.encoding=COMPATは Eclipse Temurin の 17.0.19 / 18.0.2.1 / 19.0.2 / 21.0.11 / 25.0.3 で それぞれ起動し、受理されること(終了コード 0)とfile.encodingの解決値を採取した -
追記モードのバイト列は、同じコンテナ内で JDK 17 → JDK 25 の順に同一ファイルへ
new FileWriter(path, true)で 1 行ずつ追記し、ファイル全体を採取したもの
再現から修正までは errfix の検証ハーネスが機械的に確認しています。reproduce と fix の差は
FileWriter の引数1箇所だけで、JDK・ロケール・ソースの他の部分・コンパイル方法はすべて同一です。
判定は文字列の表示ではなくファイルのバイト比較で行っています。既定文字コードの UTF-8 化と
-Dfile.encoding=COMPAT の位置づけは JEP 400 本文で確認し、そのオプションが現行の LTS でも受理されることは
上記のとおり実測しています。