编码、解码与乱码问题 | JavaSE

编码、解码与乱码问题

一、学习目标

学完本章,你应该能够:

  1. 能够准确解释编码(Encoding)与解码(Decoding)分别解决什么问题。
  2. 能够画出 String → byte[] → String 的完整转换链路。
  3. 能够使用 String.getBytes(...) 将字符串按照指定字符集编码为字节数组。
  4. 能够使用 new String(byte[], ...) 按照指定字符集将字节数组解码为字符串。
  5. 能够解释为什么编码字符集和解码字符集不匹配会产生乱码。
  6. 能够使用 CharsetStandardCharsets 明确指定 UTF-8 等字符集。
  7. 能够区分“平台默认字符集”和“显式指定字符集”,避免依赖运行环境产生隐蔽 Bug。
  8. 能够分析文件乱码、控制台乱码、网络乱码等问题到底发生在哪个转换环节。
  9. 能够理解乱码不一定可以无损恢复,并能够说明原因。

二、核心知识

2.1 从上一章继续:字符最终必须变成字节

上一章建立了:

字符
↓
Unicode 码点
↓
编码形式
↓
字节序列

例如 Java 程序内部有:

String text = "我爱Java";

此时我们面对的是:

Java 字符串

但是如果准备:

写入文件
发送网络
保存到某些二进制介质

最终需要的是:

byte[]

因此必须发生:

String
↓
编码
↓
byte[]

反过来,从文件或网络获得:

byte[]

如果它代表文本,就必须:

byte[]
↓
解码
↓
String

所以本章的核心只有一条:

字符  ⇄  字节

左 → 右:编码
右 → 左:解码

2.2 什么是编码

编码(Encoding):

按照某种字符编码规则,把字符数据转换成字节序列。

例如:

"我"

如果采用:

UTF-8

就会根据 UTF-8 规则转换成对应的若干字节。

因此:

编码

字符 / String
    ↓
Charset
    ↓
byte[]

Java 中典型操作:

byte[] bytes =
        text.getBytes(...);

所以可以记:

String
    --encode-->
byte[]

2.3 什么是解码

解码(Decoding):

按照某种字符编码规则,把字节序列解释成字符数据。

因此:

byte[]
    ↓
Charset
    ↓
String

Java 中:

String text =
        new String(bytes, ...);

可以记:

byte[]
    --decode-->
String

2.4 编码和解码是一对逆向过程

正确的数据链路:

原始字符串

"我爱Java"
    │
    │ UTF-8 编码
    ▼
UTF-8 字节数组
    │
    │ UTF-8 解码
    ▼
"我爱Java"

即:

字符集 A 编码
+
字符集 A 解码
=
正常还原

最重要原则:

解码端必须知道这些字节最初是按照什么规则生成的。


2.5 什么是乱码

假设:

"你好"

首先按照:

UTF-8

编码成字节。

但是解码时错误地认为这些字节属于:

GBK

那么流程变成:

"你好"
   │
   │ UTF-8
   ▼
一串 UTF-8 字节
   │
   │ 错误使用 GBK 解释
   ▼
错误字符

这就是典型:

乱码(Mojibake)。

本质不是:

中文坏掉了

而是:

同一组字节被使用了错误的字符编码规则解释。


2.6 一个非常重要的类比

假设数字:

10

如果解释为:

十进制

表示十。

如果解释成:

二进制 10

表示二。

原始符号没变:

10

但是:

解释规则不同
↓
含义不同

乱码也是类似问题:

同一组 byte
↓
不同 Charset 解码
↓
得到不同字符

所以字符编码问题,本质是:

数据 + 解释协议必须匹配。


2.7 Java 中的编码 API

原课程给出的核心 API 是:

byte[] getBytes()

和:

byte[] getBytes(String charsetName)

完整理解如下。

使用平台默认字符集

byte[] bytes =
        text.getBytes();

含义:

String
↓
JVM 默认 Charset
↓
byte[]

指定字符集名称

例如:

byte[] bytes =
        text.getBytes("UTF-8");

或者:

byte[] bytes =
        text.getBytes("GBK");

含义:

String
↓
指定 Charset
↓
byte[]

使用字符串字符集名称的方法可能涉及:

UnsupportedEncodingException

等问题。


2.8 JDK 21 更推荐 Charset 对象

除了原课程中的字符串形式:

text.getBytes("UTF-8");

还可以使用:

text.getBytes(Charset charset);

例如:

import java.nio.charset.StandardCharsets;

byte[] bytes =
        text.getBytes(
                StandardCharsets.UTF_8
        );

这种方式的优势:

无需手写 "UTF-8"
↓
避免字符集名称拼错
↓
无需处理 UnsupportedEncodingException
↓
类型更加明确

所以在 JDK 21 教程中,UTF-8 推荐:

StandardCharsets.UTF_8

2.9 StandardCharsets

Java 提供:

java.nio.charset.StandardCharsets

其中定义了标准字符集常量,例如:

StandardCharsets.UTF_8

StandardCharsets.UTF_16

StandardCharsets.UTF_16BE

StandardCharsets.UTF_16LE

StandardCharsets.US_ASCII

StandardCharsets.ISO_8859_1

例如:

import java.nio.charset.StandardCharsets;

String text = "星雨笔录";

byte[] bytes =
        text.getBytes(
                StandardCharsets.UTF_8
        );

相比:

text.getBytes("UTF-8");

实际项目通常更推荐:

StandardCharsets.UTF_8

2.10 Java 中的解码 API

原课程给出的基础构造器:

new String(byte[] bytes)

表示:

使用 JVM 默认字符集解码。

例如:

String text =
        new String(bytes);

指定字符集名称:

String text =
        new String(
                bytes,
                "UTF-8"
        );

现代写法还可以:

String text =
        new String(
                bytes,
                StandardCharsets.UTF_8
        );

因此完整对称关系:

编码:

String.getBytes(Charset)

String
→
byte[]


解码:

new String(byte[], Charset)

byte[]
→
String

三、使用方法

3.1 UTF-8 正确编码和解码

import java.nio.charset.StandardCharsets;

public class CharsetDemo {

    public static void main(String[] args) {

        String text =
                "我爱Java";

        byte[] bytes =
                text.getBytes(
                        StandardCharsets.UTF_8
                );

        String result =
                new String(
                        bytes,
                        StandardCharsets.UTF_8
                );

        System.out.println(result);
    }
}

输出:

我爱Java

整个过程:

"我爱Java"
      │
      │ UTF-8 编码
      ▼
    byte[]
      │
      │ UTF-8 解码
      ▼
"我爱Java"

字符集一致,因此正确恢复。


3.2 使用 GBK 编码和解码

GBK 不属于 StandardCharsets 中定义的标准常量,因此可以通过:

Charset.forName("GBK")

获取:

import java.nio.charset.Charset;

public class GbkDemo {

    public static void main(String[] args) {

        Charset gbk =
                Charset.forName("GBK");

        String text =
                "我爱你中国abc666";

        byte[] bytes =
                text.getBytes(gbk);

        String result =
                new String(bytes, gbk);

        System.out.println(result);
    }
}

核心仍然:

GBK 编码
+
GBK 解码
=
正确

3.3 故意制造乱码

import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;

public class GarbledTextDemo {

    public static void main(String[] args) {

        String text =
                "我爱Java";

        byte[] bytes =
                text.getBytes(
                        StandardCharsets.UTF_8
                );

        String wrong =
                new String(
                        bytes,
                        Charset.forName("GBK")
                );

        System.out.println(wrong);
    }
}

问题发生在:

UTF-8 编码
↓
获得 UTF-8 字节

但是

GBK 解码
↓
按照另外一套规则解释字节

因此:

编码规则
≠
解码规则

产生乱码。


3.4 比较 UTF-8 与 GBK 的字节长度

例如:

import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;

public class CharsetLengthDemo {

    public static void main(String[] args) {

        String text = "中国ABC";

        byte[] utf8 =
                text.getBytes(
                        StandardCharsets.UTF_8
                );

        byte[] gbk =
                text.getBytes(
                        Charset.forName("GBK")
                );

        System.out.println(
                "UTF-8:" + utf8.length
        );

        System.out.println(
                "GBK:" + gbk.length
        );
    }
}

对于常见汉字:

UTF-8
通常 3 byte / 汉字

GBK
通常 2 byte / 汉字

而:

A B C

在这两个 ASCII 兼容编码中:

通常均为 1 byte

因此同一个字符串:

字符完全相同

但:

编码方式不同
↓
byte[] 内容不同
↓
byte[] 长度也可能不同

3.5 字符串相同不代表字节相同

这是字符编码中非常重要的思想。

例如:

String text = "中国";

逻辑字符是:

中国

但:

UTF-8

生成一套字节。

GBK

生成另一套字节。

所以:

String 相同

≠

编码后的 byte[] 相同

这也是为什么:

文本文件

虽然肉眼看到:

同样的文字

底层字节仍可能完全不同。


3.6 什么是平台默认字符集

如果:

text.getBytes();

没有指定字符集,那么 Java 使用:

Charset.defaultCharset()

可以查看:

import java.nio.charset.Charset;

public class DefaultCharsetDemo {

    public static void main(String[] args) {

        System.out.println(
                Charset.defaultCharset()
        );
    }
}

3.7 JDK 21 的默认字符集

在当前课程的:

JDK 21

基线下,Java 默认字符集通常为:

UTF-8

除非运行环境通过实现相关机制进行了覆盖。

所以很多时候:

text.getBytes();

和:

text.getBytes(
        StandardCharsets.UTF_8
);

可能得到相同结果。

但是工程上依然应该理解:

“碰巧使用默认 UTF-8”

和

“协议明确要求 UTF-8”

不是同一件事。


3.8 为什么推荐显式指定字符集

假设代码:

byte[] bytes =
        text.getBytes();

你真正表达的是:

请使用当前 JVM 默认字符集

而如果协议明确规定:

这个文件必须 UTF-8

代码应该表达:

byte[] bytes =
        text.getBytes(
                StandardCharsets.UTF_8
        );

这样代码本身就说明:

业务协议 = UTF-8

而不是:

希望当前环境刚好也是 UTF-8

这就是:

显式优于隐式。


四、原理与进阶

4.1 乱码到底发生在哪里

标准链路:

字符
↓
编码
↓
byte[]
↓
传输 / 保存
↓
byte[]
↓
解码
↓
字符

乱码通常发生在:

解码阶段使用错误 Charset

例如:

原始:
中国

UTF-8 编码
↓
UTF-8 bytes

错误:
GBK 解码
↓
乱码

所以 Debug 乱码时最重要的问题不是:

“为什么中文这么奇怪?”

而是问:

这些 byte 最初是什么编码?

现在使用什么编码解码?

中间有没有发生重新编码?

4.2 乱码问题的三个核心变量

看到乱码时,先锁定:

① 原始字符是什么?

② 原始 byte[] 是用什么 Charset 编出来的?

③ 当前 byte[] 又使用什么 Charset 解码?

如果:

② == ③

通常才能正确还原。


4.3 为什么英文有时正常、中文却乱码

原教学资料强调:

英文、数字一般不容易乱码

其背后的真正原因通常是:

很多常见字符集都兼容 ASCII 范围。

例如:

UTF-8
GBK

对于基础 ASCII:

A
B
C
1
2
3

都采用兼容的单字节表示。

于是错误解码时:

ABC123

可能看起来仍然正常。

但是中文:

中国

在 UTF-8 和 GBK 中:

字节结构不同

于是乱码首先暴露出来。

必须注意:

不能把它推广成“英文和数字在任何字符集中绝不会乱码”。

例如 UTF-16 的字节组织方式就不同。

准确说法应是:

在大量 ASCII 兼容的常用编码之间,基础英文和数字可能仍能正确显示。


4.4 乱码和数据损坏不是完全一回事

假设:

正确 UTF-8 bytes

仍然完整存在。

只是:

暂时使用错误字符集显示

那么:

原始数据可能并没有真正损坏

只要:

重新使用正确 Charset 解码

仍有可能恢复。

但是如果:

错误解码
↓
出现无法映射字符
↓
被替换成 � 或 ?
↓
再次编码保存

这时:

原始字节信息可能已经丢失

就不一定能够无损恢复。

因此:

显示乱码

和:

原始信息已经永久损坏

需要区分。


4.5 为什么“修改文件编码”有两种完全不同的含义

IDE 中经常看到:

UTF-8
GBK

切换操作。

这里可能存在两种行为:

重新解释

原来的 byte[]
不变
↓
换一个 Charset 解码

相当于:

Reload / Reopen

真正转换编码

旧 Charset 解码
↓
得到字符
↓
新 Charset 编码
↓
生成新的 byte[]

相当于:

Convert

这两个操作完全不同。

如果对乱码文件操作不当:

可能把原本还能恢复的数据真正保存坏

所以处理乱码时应该先明确:

现在是在“改变解释方式”,还是“重新编码并写回文件”?


4.6 文本文件本质上仍然是字节文件

这是非常关键的思想。

磁盘没有所谓:

这里存 char
那里存 String

磁盘最终仍然只有:

byte

所谓:

文本文件

其实就是:

这些字节按照某个字符编码协议可以解释成人类文字。

因此:

UTF-8 文本文件

真正表示:

文件中的 byte[]
按照 UTF-8 解码
能够得到目标文本

这直接连接到后面的 IO 流:

字节流
→ 负责 byte

字符流
→ 帮我们处理字符和字符编码

五、实践应用

5.1 Java 源代码乱码

例如:

Hello.java

实际保存:

UTF-8

但是某个工具:

按照错误字符集读取

可能导致:

中文注释乱码
中文字符串乱码
甚至编译问题

所以:

IDE
构建工具
源文件

应该统一编码约定。


5.2 Web 系统中的乱码

未来 Java Web 开发中:

浏览器
↓
HTTP
↓
Tomcat / Spring Boot
↓
Java String
↓
MySQL

每一个边界都可能涉及:

字符
⇄
字节

如果其中某一层使用错误编码:

前端正常
↓
接口乱码

或

接口正常
↓
数据库乱码

都有可能发生。

因此本章实际上是:

Java Web 字符编码问题的基础理论。


5.3 文件上传和下载

一个 Markdown 文件:

tutorial.md

如果是 UTF-8:

读取时
必须按照 UTF-8 解释

写回:

也应该按照约定 UTF-8 编码

否则就可能出现:

本地打开正常
↓
服务器重新处理
↓
中文乱码

六、常见问题

6.1 编码到底是什么方向?

字符
→
字节

Java:

String
→
byte[]

6.2 解码是什么方向?

字节
→
字符

Java:

byte[]
→
String

6.3 getBytes()getBytes(UTF_8) 有什么区别?

getBytes()

使用:

默认 Charset

而:

getBytes(StandardCharsets.UTF_8)

明确要求:

UTF-8

工程协议明确时推荐显式指定。


6.4 为什么编码字符集和解码字符集必须匹配?

因为:

编码

决定:

字符如何转换成字节。

而:

解码

必须使用对应规则:

把这些字节还原成字符。

规则不一致就可能得到错误字符。


6.5 英文和数字是不是永远不会乱码?

不是绝对规则。

更准确:

在 UTF-8、GBK 等大量兼容 ASCII 的常见编码之间,基础英文和数字经常仍能正常显示。

不能推广成:

任何 Charset
+
任何错误解码
=
英文永远正常

6.6 为什么乱码有时出现

通常是:

Unicode Replacement Character(替换字符)

表示:

解码器遇到无法按照当前规则正确解释的字节

于是使用替代字符表示问题。

一旦原字节经过错误解码、替换并再次保存:

原始信息可能已经丢失

6.7 GBK 为什么没有 StandardCharsets.GBK?

StandardCharsets 中定义的是:

Java 平台保证每个实现都支持的标准字符集常量。

其中不包括 GBK。

如果运行环境支持:

Charset.forName("GBK");

即可获得相应 Charset。


七、练习与验收

7.1 知识问答

  1. 什么是编码?
  2. 什么是解码?
  3. 编码的输入和输出分别是什么?
  4. 解码的输入和输出分别是什么?
  5. 为什么编码与解码字符集不一致可能出现乱码?
  6. getBytes() 使用什么字符集?
  7. 如何查看当前 JVM 默认字符集?
  8. JDK 21 默认字符集通常是什么?
  9. StandardCharsets.UTF_8 相比 "UTF-8" 有什么优势?
  10. new String(byte[]) 使用什么字符集?
  11. 为什么同一个 String 在 UTF-8 和 GBK 中产生的字节可能不同?
  12. 为什么基础英文在 UTF-8 与 GBK 之间通常不乱码?
  13. “英文永远不会乱码”是否准确?
  14. 乱码与永久数据损坏有什么区别?
  15. 为什么文本文件本质仍然是字节文件?

7.2 代码阅读

不要运行:

import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;

public class EncodingRead {

    public static void main(String[] args) {

        String text = "中国ABC";

        byte[] bytes =
                text.getBytes(
                        StandardCharsets.UTF_8
                );

        String result =
                new String(
                        bytes,
                        Charset.forName("GBK")
                );

        System.out.println(result);
    }
}

回答:

  1. 编码使用什么字符集?
  2. 解码使用什么字符集?
  3. 两者是否匹配?
  4. 中文部分可能发生什么?
  5. ASCII 部分为什么可能仍然正常?
  6. 如何修复?

7.3 手写代码

任务一:正确编码解码

使用:

StandardCharsets.UTF_8

完成:

String
↓
byte[]
↓
String

要求最终字符串与原字符串相同。


任务二:乱码实验

使用:

UTF-8 编码
GBK 解码

故意产生乱码。

然后说明:

哪一步错了

任务三:编码长度比较

比较:

"中国ABC123"

使用:

UTF-8
GBK

编码后的:

byte[].length

解释为什么长度不同。


任务四:默认字符集

编写代码输出:

Charset.defaultCharset()

并说明:

为什么业务协议不应该完全依赖默认值

7.4 Debug

下面程序为什么可能乱码?

byte[] bytes =
        "星雨笔录"
                .getBytes(
                        StandardCharsets.UTF_8
                );

String text =
        new String(
                bytes,
                Charset.forName("GBK")
        );

要求:

  1. 标出编码阶段;
  2. 标出解码阶段;
  3. 标出 Charset;
  4. 修复代码;
  5. 画出正确数据流。

7.5 综合训练

假设收到一份文件的数据:

byte[]

已知该文件声明为:

UTF-8

请设计处理流程:

byte[]
↓
正确解码
↓
String
↓
修改文本
↓
重新 UTF-8 编码
↓
byte[]

回答:

  1. 哪一步属于解码?
  2. 哪一步属于编码?
  3. 为什么两个边界都应该明确 UTF-8?
  4. 如果第一次错误使用 GBK 解码后立即保存,会有什么风险?

7.6 本章验收

必须能够闭卷口述:

Java 字符串是字符数据。

要把文本保存或传输为字节,
必须进行编码:

String
↓
Charset
↓
byte[]

从字节恢复文本,
必须进行解码:

byte[]
↓
Charset
↓
String

如果字节原本按照 UTF-8 生成,
却按照 GBK 等其他规则解释,
就可能出现乱码。

因此乱码的核心不是
“中文坏掉了”,
而是:

生成字节的规则
与
解释字节的规则
不一致。

JDK 21 中可以使用:

StandardCharsets.UTF_8

明确表达编码协议,
避免不必要地依赖默认字符集。

最终验收:

  • [ ] 能定义编码与解码。
  • [ ] 能闭卷写 getBytes(Charset)
  • [ ] 能闭卷写 new String(byte[], Charset)
  • [ ] 能使用 StandardCharsets.UTF_8
  • [ ] 能使用 Charset.forName("GBK")
  • [ ] 能解释乱码根因。
  • [ ] 能解释为什么 ASCII 字符有时仍然正常。
  • [ ] 不再说“英文在任何字符集中永远不会乱码”。
  • [ ] 能解释默认 Charset 与显式 Charset。
  • [ ] 能从 String ↔ byte[] 的角度 Debug 乱码。