乱码这两个字一直被当成一种"玄学故障",好像只能靠不断改编码碰运气。其实它只有一句话:写的时候用了一张表,读的时候用了另一张表。 我把这句话跑成了字节,看得见的那种。
一、先看这台机器的四个编码参数
JVM = 21.0.12.1
默认 charset = UTF-8
native.encoding = GBK
file.encoding = UTF-8
stdout.encoding = GBK
有意思的是这四行:Java 的默认编码是 UTF-8,但系统原生编码和控制台输出还是 GBK。
这不是矛盾,是两个不同层面的东西。Charset.defaultCharset() 从 JDK 18 起固定返回 UTF-8(JEP 400),它管的是"你没指定时,String.getBytes() 和 new String(byte[]) 用什么"。而 native.encoding、stdout.encoding 是操作系统那一层,Windows 中文版就是 GBK。
所以同一台机器上会同时存在"默认 UTF-8"和"控制台打印中文变成问号"这两件事——它们根本不归同一个开关管。
二、同一个字,在不同表里的字节
=== 1. 同一个字符串,不同编码的字节 ===UTF-8 6 字节 E4 B8 AD E6 96 87GBK 4 字节 D6 D0 CE C4UTF-16BE 4 字节 4E 2D 65 87ISO-8859-1 2 字节 3F 3F
前面四个都是十六进制。"中文"这两个字:
- UTF-8 每个常用汉字 3 字节,共 6 字节
- GBK 每个汉字 2 字节,共 4 字节
- UTF-16BE 每字符固定 2 字节,共 4 字节
- ISO-8859-1 里根本没有汉字,编不出来,退化成两个
0x3F,也就是两个问号
同一个字符串,在这张表里是 6 个字节,在那张表里是 4 个字节。 一旦读写用了不同的表,字节就对不上号了。
三、乱码是这么被造出来的
UTF-8 字节 = E4 B8 AD E6 96 87 (6 个字节)
用 GBK 去解这串字节 = 涓枃 (3 个字符)
它的长度 = 3(原始是 2 个字符)
6 个字节被 GBK 两两配对,解成了 3 个字符。原来 2 个字的"中文",变成了 3 个莫名其妙的字。
这就是最常见的乱码形态:字节数是 6,但 GBK 按 2 字节一组去读,硬凑出 3 个字。
反方向也一样会乱——GBK 写的 4 个字节,用 UTF-8 去读,会因为字节模式不合法而出现替换符 �。
四、它还能救回来
=== 4. 救回来(ISO-8859-1 中转法)===乱码按 GBK 还原成字节 = E4 B8 AD E6 96 87再用 UTF-8 解 = 中文救回来了吗: true
因为"用 GBK 解码"这一步虽然解错了,但没丢信息——6 个字节原封不动地还原回来了,再拿正确的表一解就对了。
这个"ISO-8859-1 中转"的老办法到处见(Tomcat 的 URIEncoding 那类问题里全是它),原理就是这个:先用一个不丢字节的编码把字符串"降级"回字节,再用正确的编码重新解码。
但要注意它是有条件的:中转用的表必须做到"字节 → 字符 → 字节"完全无损。 ISO-8859-1 覆盖 0x00–0xFF,所以无损。换成 UTF-8 中转就可能救不回来,因为非法字节序列会被替换成 �,那一步就把信息丢了。
五、按字节截断,截掉半个字
原字节 E4 B8 AD E6 96 87
截前 2 字节 E4 B8 -> 解出来是「�」,长度 1
E4 B8 只是"中"字三个字节里的前两个,不成一个完整字符,解码时只能变成替换符。
这就是为什么"数据库字段 varchar(10) 存 10 个中文"这种需求必须按字符数算,不能按字节数算;也是为什么按字节切割日志、切割 HTTP 报文时会把中文切坏。
六、写文件时的差别
=== 6. 写文件不指定 charset 会怎样 ===文件里的字节 = E4 B8 AD E6 96 87用 UTF-8 读 = 中文 对不对:true用 GBK 读 = 涓枃 对不对:false
同一个文件,同一批字节,读法不同结果就不同。文件本身没有"编码"这个属性,它只有字节;编码是"怎么解释这些字节"的约定。
所以那些"这个文件是 UTF-8 还是 GBK"的讨论,本质是在问"当初写它的人用的是哪张表"。
七、还有一件容易忽略的事
看这张表:
abc UTF-8=3 字节 GBK=3 字节中文 UTF-8=6 字节 GBK=4 字节中文abc UTF-8=9 字节 GBK=7 字节😀 UTF-8=4 字节 GBK=1 字节
前两列都符合直觉,最后一行不对劲:emoji 在 GBK 里只有 1 个字节。
因为 GBK 根本编不出 emoji,getBytes("GBK") 编不出来的字符会被替换成 0x3F(问号)——它不是压缩了,是丢了。 4 个字节的信息变成了 1 个问号,再也还原不回来。
这也是"用 GBK 存用户昵称"会出事的原因:里面只要有 emoji 或生僻字,就会静默地变成问号。它不报错。
八、结论
编码不是"哪个更高级",是字符和字节之间的一张映射表。表本身没有对错,用错才出问题。
乱码的唯一成因是:写的时候用表 A,读的时候用表 B。修法也就一条——在写和读的两端明确指定同一个 charset,不要依赖默认值。
String.getBytes() 不带参数、new String(bytes) 不带参数、FileReader 不带参数,这三个都是"用默认值"的写法。默认值在这台机器上是 UTF-8,在另一台机器上可能是 GBK,在控制台里又是另一个值。
附:验证代码
String s = "中文";// 1. 同一个字符串,不同表的字节
byte[] u8 = s.getBytes(StandardCharsets.UTF_8); // E4 B8 AD E6 96 87
byte[] gbk = s.getBytes(Charset.forName("GBK")); // D6 D0 CE C4// 2. 造出乱码:UTF-8 的字节用 GBK 解
String wrong = new String(u8, Charset.forName("GBK"));// 3. 救回来:GBK 还原成字节,再按 UTF-8 解
String fixed = new String(wrong.getBytes(Charset.forName("GBK")), StandardCharsets.UTF_8);// 4. 按字节截断会切出半个字符
byte[] cut = new byte[2];
System.arraycopy(u8, 0, cut, 0, 2);
String half = new String(cut, StandardCharsets.UTF_8); // "�"