乱码这两个字一直被当成一种"玄学故障",好像只能靠不断改编码碰运气。其实它只有一句话:写的时候用了一张表,读的时候用了另一张表。 我把这句话跑成了字节,看得见的那种。

一、先看这台机器的四个编码参数

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);   // "�"