概要
「本番環境でだけ文字化けする」「CSV を取り込んだら特定の文字だけ壊れた」――文字化けは Java の業務開発で繰り返し踏まれるトラブルの筆頭です。原因は単純に見えて、実際にはファイル・DB・HTTP レスポンス・JVM 起動オプションと複数のレイヤーが絡み合うため、切り分けに手間取ることが少なくありません。特に Shift_JIS(MS932)と UTF-8 の変換では、波ダッシュ(〜)やバックスラッシュ(¥)など「この文字だけ化ける」パターンが存在し、単体テストでは見落としがちです。この記事では、文字化けが起きる仕組みを byte 列レベルで確認し、InputStreamReader / OutputStreamWriter での明示的な Charset 指定、Charset.defaultCharset() に頼ることの危険性、-Dfile.encoding の影響範囲を実務の観点から整理します。
使いどころ
外部システムから受信した Shift_JIS の CSV ファイルを UTF-8 の DB に取り込む際、特定文字の文字化けを防ぐ
レガシーシステムとの連携で MS932 エンコーディングの固定長ファイルを読み書きする
HTTP レスポンスの Content-Type に charset 指定が漏れている API からのデータ取得で文字化けを回避する
コード例
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 文字化けの原因切り分けと対処のためのユーティリティ。
* バイト列のダンプ、エンコーディング推定、変換を行う。
*/
public class MojibakeTroubleshooting {
/** よく使うエンコーディングの一覧 */
private static final Charset[] CANDIDATE_CHARSETS = {
StandardCharsets.UTF_8,
Charset.forName("MS932"),
Charset.forName("EUC-JP"),
StandardCharsets.ISO_8859_1,
};
/**
* バイト列を16進数でダンプし、各エンコーディングでの解釈結果を表示する。
* 文字化けの原因を目視で切り分けるときに使う。
*/
public static Map<String, String> dumpWithCharsets(byte[] data) {
var result = new LinkedHashMap<String, String>();
var hex = HexFormat.ofDelimiter(" ").formatHex(data);
result.put("HEX", hex);
for (var charset : CANDIDATE_CHARSETS) {
result.put(charset.name(), new String(data, charset));
}
return result;
}
/**
* 文字列を指定エンコーディングのバイト列に変換し、
* さらに別のエンコーディングで文字列に戻すシミュレーション。
* 「この組み合わせで化けるか」を確認するのに使う。
*/
public static String simulateMojibake(String text,
Charset writeCharset, Charset readCharset) {
var bytes = text.getBytes(writeCharset);
return new String(bytes, readCharset);
}
/**
* InputStreamReader / OutputStreamWriter で
* Charset を明示指定した安全な変換を行う。
* Shift_JIS(MS932)ファイルを UTF-8 に変換する典型パターン。
*/
public static byte[] convertEncoding(byte[] sourceData,
Charset fromCharset, Charset toCharset) throws Exception {
var output = new ByteArrayOutputStream();
try (var reader = new BufferedReader(
new InputStreamReader(
new ByteArrayInputStream(sourceData), fromCharset));
var writer = new PrintWriter(
new OutputStreamWriter(output, toCharset))) {
String line;
while ((line = reader.readLine()) != null) {
writer.println(line);
}
}
return output.toByteArray();
}
/**
* 波ダッシュ問題のチェック。
* Shift_JIS と MS932 でマッピングが異なる代表的な文字を検証する。
*/
public static void checkWaveDash() {
var waveDash = "〜"; // WAVE DASH(Unicode 標準)
var fullwidthTilde = "~"; // FULLWIDTH TILDE(Windows 系)
System.out.println("=== 波ダッシュ問題の検証 ===");
System.out.println("WAVE DASH (U+301C): " + waveDash);
System.out.println("FULLWIDTH TILDE (U+FF5E): " + fullwidthTilde);
var ms932 = Charset.forName("MS932");
// MS932 でのバイト表現を比較
var waveDashBytes = waveDash.getBytes(ms932);
var tildaBytes = fullwidthTilde.getBytes(ms932);
var hex = HexFormat.ofDelimiter(" ");
System.out.println("WAVE DASH → MS932: " + hex.formatHex(waveDashBytes));
System.out.println("FULLWIDTH TILDE → MS932: " + hex.formatHex(tildaBytes));
}
/**
* defaultCharset() の確認。
* 環境依存の挙動を把握するためのチェック用。
*/
public static void showEnvironmentInfo() {
System.out.println("=== 環境のエンコーディング情報 ===");
System.out.println("Charset.defaultCharset(): "
+ Charset.defaultCharset());
System.out.println("file.encoding: "
+ System.getProperty("file.encoding"));
System.out.println("stdout.encoding: "
+ System.getProperty("stdout.encoding", "(未設定)"));
}
public static void main(String[] args) throws Exception {
showEnvironmentInfo();
System.out.println();
// 文字化けシミュレーション
var testText = "請求書 金額:¥1,000(税込)〜";
System.out.println("=== 文字化けシミュレーション ===");
System.out.println("元の文字列: " + testText);
System.out.println("UTF-8→MS932で読む: "
+ simulateMojibake(testText,
StandardCharsets.UTF_8, Charset.forName("MS932")));
System.out.println("MS932→UTF-8で読む: "
+ simulateMojibake(testText,
Charset.forName("MS932"), StandardCharsets.UTF_8));
System.out.println();
// バイト列ダンプ
var sampleBytes = testText.getBytes(StandardCharsets.UTF_8);
System.out.println("=== バイト列ダンプ(UTF-8 で書き込み) ===");
var dump = dumpWithCharsets(sampleBytes);
dump.forEach((charset, text) ->
System.out.printf(" %-12s: %s%n", charset, text));
System.out.println();
// エンコーディング変換
var ms932Data = testText.getBytes(Charset.forName("MS932"));
var utf8Data = convertEncoding(ms932Data,
Charset.forName("MS932"), StandardCharsets.UTF_8);
System.out.println("=== MS932 → UTF-8 変換 ===");
System.out.println("変換結果: "
+ new String(utf8Data, StandardCharsets.UTF_8));
System.out.println();
// 波ダッシュ問題
checkWaveDash();
}
}Version Coverage
JEP 400 の準備段階として UTF-8 がデフォルトに近づく。ただし -Dfile.encoding を明示しない場合、まだ OS 依存の挙動が残るため過信は禁物。
// Java 17: var + try-with-resources で簡潔に
var sjis = Charset.forName("MS932");
try (var reader = new BufferedReader(
new InputStreamReader(new FileInputStream("input.csv"), sjis))) {
String line;
while ((line = reader.readLine()) != null) {
var utf8Bytes = line.getBytes(StandardCharsets.UTF_8);
System.out.println(new String(utf8Bytes, StandardCharsets.UTF_8));
}
}Library Comparison
注意点
Charset.defaultCharset() は JVM の起動オプションや OS の設定に依存する。本番と開発環境で異なる値を返すことがあるため、コード中で直接使わず StandardCharsets.UTF_8 のように明示的に指定すること
Shift_JIS と MS932(Windows-31J)は別物。Shift_JIS では波ダッシュ(〜)やローマ数字(Ⅰ など)がマッピングされていないが、MS932 では対応している。Windowsで作られたファイルには MS932 を使うのが安全
String.getBytes() を引数なしで呼ぶと defaultCharset が使われる。環境によって結果が変わるため、必ず Charset 引数を渡すこと。レビューで見つけたら即修正すべきポイント
一度壊れたバイト列から元の文字を復元することは基本的にできない。文字化けの「修復」は、壊れ方のパターンから元のエンコーディングを推測して再変換する試行であり、確実ではないことを理解しておくこと
FAQ
UTF-8 のテキストを Shift_JIS として読むと「譁・蟄怜喧」のような漢字の羅列になります。逆に Shift_JIS を UTF-8 で読むと「�」(U+FFFD)に置換されるか、半端なバイトで例外が発生します。
MS932(Windows-31J)は Microsoft が Shift_JIS を拡張したもので、丸数字(① 等)やローマ数字(Ⅰ 等)、波ダッシュ(〜/~)を含みます。Windows 環境で作られたファイルは Shift_JIS ではなく MS932 で読むのが安全です。
Java は内部的に UTF-16 で文字列を保持しています(Java 9 以降は Compact Strings により Latin-1 も使用)。外部との入出力時に Charset を指定して変換する設計のため、内部表現と外部表現の区別を意識することが重要です。