编码、解码与乱码问题 | JavaSE
编码、解码与乱码问题
一、学习目标
学完本章,你应该能够:
- 能够准确解释编码(Encoding)与解码(Decoding)分别解决什么问题。
- 能够画出
String → byte[] → String的完整转换链路。 - 能够使用
String.getBytes(...)将字符串按照指定字符集编码为字节数组。 - 能够使用
new String(byte[], ...)按照指定字符集将字节数组解码为字符串。 - 能够解释为什么编码字符集和解码字符集不匹配会产生乱码。
- 能够使用
Charset、StandardCharsets明确指定 UTF-8 等字符集。 - 能够区分“平台默认字符集”和“显式指定字符集”,避免依赖运行环境产生隐蔽 Bug。
- 能够分析文件乱码、控制台乱码、网络乱码等问题到底发生在哪个转换环节。
- 能够理解乱码不一定可以无损恢复,并能够说明原因。
二、核心知识
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 知识问答
- 什么是编码?
- 什么是解码?
- 编码的输入和输出分别是什么?
- 解码的输入和输出分别是什么?
- 为什么编码与解码字符集不一致可能出现乱码?
getBytes()使用什么字符集?- 如何查看当前 JVM 默认字符集?
- JDK 21 默认字符集通常是什么?
StandardCharsets.UTF_8相比"UTF-8"有什么优势?new String(byte[])使用什么字符集?- 为什么同一个 String 在 UTF-8 和 GBK 中产生的字节可能不同?
- 为什么基础英文在 UTF-8 与 GBK 之间通常不乱码?
- “英文永远不会乱码”是否准确?
- 乱码与永久数据损坏有什么区别?
- 为什么文本文件本质仍然是字节文件?
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);
}
}
回答:
- 编码使用什么字符集?
- 解码使用什么字符集?
- 两者是否匹配?
- 中文部分可能发生什么?
- ASCII 部分为什么可能仍然正常?
- 如何修复?
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")
);
要求:
- 标出编码阶段;
- 标出解码阶段;
- 标出 Charset;
- 修复代码;
- 画出正确数据流。
7.5 综合训练
假设收到一份文件的数据:
byte[]
已知该文件声明为:
UTF-8
请设计处理流程:
byte[]
↓
正确解码
↓
String
↓
修改文本
↓
重新 UTF-8 编码
↓
byte[]
回答:
- 哪一步属于解码?
- 哪一步属于编码?
- 为什么两个边界都应该明确 UTF-8?
- 如果第一次错误使用 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 乱码。