字符编码:UTF-8/GBK/乱码根源
凌晨 2 点,你被一个电话叫醒:
「线上日志全是乱码,用户名字显示成 ???」
这不是代码 bug,这是编码问题。
「明明存的是中文,打开怎么是乱码?」——这个问题折磨过无数 Java 程序员。今天我们彻底讲清楚。
字符编码的本质
字符 → 编码(数字)→ 字节序列 → 解码 → 字符编码和解码必须配套,否则就乱码。
"中" → UTF-8 → [228, 184, 173] → UTF-8 解码 → "中" ✅
"中" → UTF-8 → [228, 184, 173] → GBK 解码 → 乱码 ❌乱码的本质:用错误的编码表解读字节序列。
常见字符编码一览
| 编码 | 一个中文占几个字节 | 特点 |
|---|---|---|
| ASCII | 1 字节 | 只覆盖英文字母和符号 |
| ISO-8859-1 | 1 字节 | 西欧字符,不能存中文 |
| GBK | 2 字节 | 中文扩展,Windows 中文系统默认 |
| UTF-8 | 3 字节(中文) | 变长编码,互联网最通用 |
| UTF-16 | 2~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,永远不要依赖系统默认。
