FileWriter の出力が文字化けする(JDK 18 以降・エラーは出ない)

出力ファイルの文字コードが JDK 18 以降で変わる(charset 未指定の FileWriter・エラーは出ません)

FIX SUMMARY verified
Applies when
eclipse-temurin@sha256:68868d04fa9cfd5f5c6abec0b5cef86d8de2bf9c62c37c7d3e4f0f80f5cfd7ffjava.io.FileWriter / FileReader with no Charset argument (JEP 400 changed the JDK default charset to UTF-8 in JDK 18)upgrading from JDK 17 to JDK 18 or later on a host whose locale is not UTF-8 — the same source writes different bytes with no exception and no warning, so the breakage only shows up in whatever reads the file

Verified: reproduced in eclipse-temurin@sha256:68868d04fa9cfd5f5c6abec0b5cef86d8de2bf9c62c37c7d3e4f0f80f5cfd7ff, then the FILEWRITER_DEFAULT_CHARSET_CHANGED signature was gone after the fix (exit 0).

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 の版に決めさせるのをやめる」ことです。

FileWriterCharset を渡すコンストラクタは 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 バイトになります。

JDKfile.encoding既定 FileWriter の出力UTF-8 を明示した出力
8ANSI_X3.4-19683f3f3f3f6361663f0aUTF-8
11ANSI_X3.4-19683f3f3f3f6361663f0aUTF-8
17ANSI_X3.4-19683f3f3f3f6361663f0aUTF-8
18UTF-8UTF-8UTF-8
21UTF-8UTF-8UTF-8
25UTF-8UTF-8UTF-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 を明示し終えた時点で外す、という条件を 一緒に決めておきます。

切り分け

  • 読み込み側が壊れた(書き出しではない)FileReadernew 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=CLANG=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 でも受理されることは 上記のとおり実測しています。

検証(machine-verified)

この修正は eclipse-temurin@sha256:68868d04fa9cfd5f5c6abec0b5cef86d8de2bf9c62c37c7d3e4f0f80f5cfd7ff のバージョン固定コンテナ内で再現し、修正後に FILEWRITER_DEFAULT_CHARSET_CHANGED のシグネチャが消えることを機械で確認しています。

verify — run-case.mjs
$ node run-case.mjs jvm/filewriter-default-charset-jep400
● reproduce FILEWRITER_DEFAULT_CHARSET_CHANGED present ✓
● apply fix exit 0
● re-run FILEWRITER_DEFAULT_CHARSET_CHANGED gone ✓
PASS verified · eclipse-temurin@sha256:68868d04fa9cfd5f5c6abec0b5cef86d8de2bf9c62c37c7d3e4f0f80f5cfd7ff · signature gone

確認したのは上のイメージの中だけです。別の環境で直らなかった、記述が違う、という場合は 報告してください(対象と検証イメージは件名・本文に入ります)。