概要

「本番環境でだけ文字化けする」「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 からのデータ取得で文字化けを回避する

コード例

MojibakeTroubleshooting.java
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();
    }
}

Java 8 / 17 / 21 の完全なサンプルコードは GitHub リポジトリ で確認できます。

Version Coverage

JEP 400 の準備段階として UTF-8 がデフォルトに近づく。ただし -Dfile.encoding を明示しない場合、まだ OS 依存の挙動が残るため過信は禁物。

Java 17
// 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

標準 API(Charset / StandardCharsets)エンコーディングが既知で、明示的に Charset を指定して読み書きする場合。依存ゼロで Java 8 以降どの環境でも動く。エンコーディングの自動判定機能はない。入力データの文字コードが不明な場合は、別途判定ロジックが必要になる。
ICU4J(CharsetDetector)テキストのエンコーディングが不明で、バイト列から自動判定したいとき。多言語対応の正規化や変換テーブルも充実している。JAR サイズが大きく(10MB超)、単純なエンコーディング指定だけなら過剰。判定精度も100%ではなく、短いテキストでは誤判定が起きる。
juniversalchardet(Mozilla 由来)ファイルやストリームの文字コードを自動判定したい場合。ICU4J より軽量で、日本語テキストの判定精度が比較的高い。メンテナンス状況にばらつきがあり、フォークが複数存在する。自動判定はあくまで推測であり、業務データでは明示指定のほうが確実。

注意点

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 と Shift_JIS の違いは何ですか。

MS932(Windows-31J)は Microsoft が Shift_JIS を拡張したもので、丸数字(① 等)やローマ数字(Ⅰ 等)、波ダッシュ(〜/~)を含みます。Windows 環境で作られたファイルは Shift_JIS ではなく MS932 で読むのが安全です。

Java の内部文字エンコーディングは何ですか。

Java は内部的に UTF-16 で文字列を保持しています(Java 9 以降は Compact Strings により Latin-1 も使用)。外部との入出力時に Charset を指定して変換する設計のため、内部表現と外部表現の区別を意識することが重要です。

関連書籍

この記事のテーマをさらに深く学びたい方へ。

※ Amazon アソシエイトリンクを含みます