Skip to content

字符编码:UTF-8/GBK/乱码根源

凌晨 2 点,你被一个电话叫醒:

「线上日志全是乱码,用户名字显示成 ???」

这不是代码 bug,这是编码问题。

「明明存的是中文,打开怎么是乱码?」——这个问题折磨过无数 Java 程序员。今天我们彻底讲清楚。

字符编码的本质

字符 → 编码(数字)→ 字节序列 → 解码 → 字符

编码和解码必须配套,否则就乱码。

"中" → UTF-8 → [228, 184, 173] → UTF-8 解码 → "中" ✅
"中" → UTF-8 → [228, 184, 173] → GBK 解码 → 乱码 ❌

乱码的本质:用错误的编码表解读字节序列。

常见字符编码一览

编码一个中文占几个字节特点
ASCII1 字节只覆盖英文字母和符号
ISO-8859-11 字节西欧字符,不能存中文
GBK2 字节中文扩展,Windows 中文系统默认
UTF-83 字节(中文)变长编码,互联网最通用
UTF-162~4 字节Java 内部字符表示

UTF-8 变长编码规则

0xxxxxxx                          → 1 字节(ASCII)
110xxxxx 10xxxxxx                 → 2 字节
1110xxxx 10xxxxxx 10xxxxxx       → 3 字节(中文)
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx → 4 字节(emoji)

这个规则意味着:

  • 英文字母只需要 1 字节(和 ASCII 兼容)
  • 中文需要 3 字节
  • emoji 需要 4 字节

具体转换示例:

'A' → 0x41 → [0x41]                         → 1 字节
'中' → Unicode 20013 → 1110-0100 10-111011 10-101101 → [0xE4, 0xBD, 0xA0] → 3 字节

Java 中的编码转换

编码:String → byte[]

java
// 字符串 → UTF-8 字节数组
byte[] utf8 = "你好".getBytes(StandardCharsets.UTF_8);
// [0xE4, 0xBD, 0xA0, 0xE5, 0xA5, 0xBD]

// 字符串 → GBK 字节数组
byte[] gbk = "你好".getBytes(Charset.forName("GBK"));
// [0xD6, 0xD0, 0xCE, 0xC4]

解码:byte[] → String

java
// UTF-8 字节数组 → 字符串
String s1 = new String(utf8, StandardCharsets.UTF_8); // "你好"

// GBK 字节数组 → 字符串
String s2 = new String(gbk, Charset.forName("GBK")); // "你好"

三个乱码场景

场景 1:IDE 默认编码不一致

这是最常见的坑。源代码文件保存的编码,和 javac 编译时用的编码不一致。

java
// 源代码里的中文字符串
String s = "你好";

// 编译时:javac 用 UTF-8 或系统编码读取 .java 文件
// 运行时:字符串常量已经是 Unicode 字符,和编码无关
System.out.println(s); // 正常打印

这个问题在编译阶段就处理了,但如果你用 javac -encoding GBK,就可能出问题。

场景 2:文件读写编码不一致(最常见)

java
// ❌ 写文件用默认编码,读文件用另一个编码
try (FileWriter fw = new FileWriter("output.txt")) {
    fw.write("你好"); // 用系统编码写入(可能是 GBK)
}

try (BufferedReader reader = new BufferedReader(
        new InputStreamReader(
            new FileInputStream("output.txt"), StandardCharsets.UTF_8))) {
    reader.readLine(); // 用 UTF-8 读 → 乱码!
}

// ✅ 正确:写和读用同一个编码
try (BufferedWriter writer = new BufferedWriter(
        new OutputStreamWriter(
            new FileOutputStream("output.txt"), StandardCharsets.UTF_8))) {
    writer.write("你好");
}

try (BufferedReader reader = new BufferedReader(
        new InputStreamReader(
            new FileInputStream("output.txt"), StandardCharsets.UTF_8))) {
    reader.readLine(); // 正常
}

场景 3:HTTP 请求/响应编码不一致

java
// 发送请求时指定编码
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8");

// 读取响应时指定编码
try (BufferedReader reader = new BufferedReader(
        new InputStreamReader(
            conn.getInputStream(), StandardCharsets.UTF_8))) {
    reader.readLine();
}

正确处理文件编码

java
// ❌ 错误:FileReader/FileWriter 不能指定编码
FileReader fr = new FileReader("data.txt"); // 依赖系统编码

// ✅ 正确:用 InputStreamReader / OutputStreamWriter
try (BufferedReader reader = new BufferedReader(
        new InputStreamReader(
            new FileInputStream("data.txt"), StandardCharsets.UTF_8))) {
    reader.readLine();
}

try (BufferedWriter writer = new BufferedWriter(
        new OutputStreamWriter(
            new FileOutputStream("data.txt"), StandardCharsets.UTF_8))) {
    writer.write("你好");
}

// ✅ JDK 11+ 最简写法
String content = Files.readString(Path.of("data.txt"), StandardCharsets.UTF_8);
Files.writeString(Path.of("data.txt"), "你好", StandardCharsets.UTF_8);

乱码检测:补救措施

有时候拿到一个已经是乱码的字节数组,不知道原本是什么编码:

java
// 常用检测策略:尝试常见编码
public static String decode(byte[] bytes) {
    Charset[] charsets = {
        StandardCharsets.UTF_8,
        Charset.forName("GBK"),
        StandardCharsets.ISO_8859_1
    };
    for (Charset cs : charsets) {
        String s = new String(bytes, cs);
        if (!s.contains("\ufffd")) { // 没有替换字符(通常是正确解码)
            return s;
        }
    }
    return new String(bytes, StandardCharsets.ISO_8859_1);
}

记住这个铁律

编什么码,解什么码。 永远显式指定 UTF-8,永远不要依赖系统默认。

基于 VitePress 构建