前言

其实原本是想写一个 Java 脚本来帮我处理下载视频的名字更改,但是因为编码的问题让我非常生气,Windows 文件用 GBK 编码,再加上和 char 的 Unicode、String 的 byte[] 以及输入法输入的编码问题,直接把我搞蒙了,String 自带的一些方法用的云里雾里,能否正确全看玄学。为了能够一劳永逸的解决中文编码问题,于是此文就这样诞生了。

编码发展历史

ASCII 编码时代

最开始的开始,因为计算机发明在美国,所以计算机的字符是使用 ASCII 作为编码方式的,这让英语系国家们用的很开心,只需要 1 个字节就可以轻松的表示自己所有的字符,只占用 0-127,其中有 33 个控制字符,94 个可显字符。

后来,为了加入一些其他必要的符号,比如带重音的字母(法国人狂喜),希腊字母(希腊人狂喜),特殊的拉丁符号(罗马人狂喜),特殊的计算符号(科学家狂喜)等等等等。欧洲的发达国家开始打 ASCII 没有使用到的 128-255 这个区间的主意,出现了一堆各种各样的 EASCII 编码方式,即拓展 ASCII,用的比较多的有两个:OEM EASCII 和 ANSI EASCII 。

虽说扩展了整整一倍,但是因为所能表示的字符还是太少了,其他国家并不乐意,所以 EASCII 就很快的退出历史舞台,现在搜索 ASCII 一般搜索的都是 0-127 的初始版本。

ANSI 编码时代

而非英语国家的人们为了使用计算机,也纷纷开始开发自己本国的文字编码方式,在 ANSI (American National Standards Institute) 的牵头下,各国的文字编码方式被 ANSI 承认之后,会作为该国文字的国际标准编码,叫作 ANSI 编码。 ANSI 编码是一种基于 ASCII 的变长编码,而且是固定的两个字节大小,前 128 个是 ASCII ,后面的是本国的文字。

虽然不同的国家都有了自己文字的编码方式也都快乐的用上了计算机,但各个文字的 ANSI 编码并不互通,导致了一种 ANSI 文字编码的文件里面不可以有其他国家的文字,否则就会乱码。

GB2312

中国显然也参与了这个过程中来,在 1980 年的时候,中国指定了汉字的 ANSI 编码:GB2312 即国标 2312 ,一共收录了 6763 个汉字,一级汉字 3755 个,二级汉字 3008 个,同时收录了包括拉丁字母、希腊字母、日文平假名及片假名字母、俄语西里尔字母在内的 682 个字符。这个字符集基本上覆盖了中国大陆 99.75% 的使用频率(因为繁体中文不在里面)。而 GB2312 的编码方式如下:

  • GB2312 将稀少的两个字节分为了区字节(0xB0-0xF7 87个区)和位字节(0xA1-0xFE 94个位),用一个区字节和一个位字节结合起来来表示一个汉字。

  • 之所以从 0x80 128 和 0xA1 161 开始表示区字节和位字节,就是为了照顾 ASCII 字符可以被单个字节表示,只要检测到字符大小小于 128 ,那么这就是一个 ASCII 字符,读取一个字节即可。如果大于等于 128,那么就说明这是一个汉字,读取两个字节,并按照组合出来的编号显示汉字。

GBK

但是,可怜的 GB2312 当时设置的比较草率,还有很多的空间没有使用,再加上 6763 个汉字真的是太少了,于是在 1995 年的时候,又搞了一个汉字的 ANSI 编码,叫作 GBK 编码,国(G)标(B)扩展(K)。这个 GBK 编码覆盖了 21886 个字符,增加了额外的汉字,繁体字,日文假名等等(但是不支持朝鲜字),现在还是 Windows 默认的文字编码方式。

  • GBK 完全兼容 GB2312。并将位字节从 0x00 开始表示汉字,因为只要区字节大于 128 即可确定后面跟着的就是位字节而不是 ASCII,然后很好的利用了 GB2312 没有分配的空间,成功的容纳了绝大部分日常使用汉字。

GB18030

但是,两个字节的极限就是容纳 2^16 个字符,是不可能容纳所有的汉字的,所以后来又改良 GBK 出来了一个 GB18030 ,这玩意终于将整个汉字+少数民族文字全部都编到了一起。

  • GB18030 之所以能把中国所有的文字都搞到一起,是因为它最大长度是四个字节,其中一个字节和两个字节和 GBK 基本兼容。四个字节扩充了 6k+ 的字符,完成了汉字的所有收录(注意 GB18030 是没有三个字节的情况的)。

Unicode 时代

虽说 ANSI 解决了非英语国家使用计算机的问题,但是对于网上传输其他国家的文字,因为使用 ANSI 编码不同,会导致无法解析,比如韩国电脑解析不出来 GBK 编码的文字,打开直接乱码。

随着计算机存储元器件价格的大幅度下降和全球互联网的快速发展,统一文字编码方式让不同文字的展现无障碍越来越重要。为了将所有的文字都制定一个标准之中,让所有人都能解析所有文字,Unicode 字符集就诞生了,立志于给所有文字都编上号。最初版本的 Unicode 只有两个字节,而分给 CJK 系列字符的只有两万个,导致只有最常用的 CJK 字符才能被编写到 Unicode 里面,后来经过一段曲折与斗争,Unicode 终于扩展字节,发展成为了真正的万国码。

  • 现在 Unicode 字符集的空间规划是按照空间平面的方式进行的,为 0-16 平面,每个平面占用两个字节,可以表示 2^16 个字符。其中最初版本的 Unicode 表示的字符为 0 号平面,被称为 BMP Basic Multilingual Plane 基本语言平面,表示范围为:U+0000 到 U+FFFF,可以省略最前面的平面编号,占用两个字节。而剩下的字符都在辅助平面 SMP 上面,从 U+010000 一直到 U+10FFFF,占用三个字节。

  • 基本上 BMP 就覆盖了世界上大多数语言的绝大多数使用情景,但是要明白的是 SMP 的产生,对于 CJK 国家来说意义重大。这意味着在通用的语言字符集里面,东亚文化可以完整的保留下来,一个汉字的不同书写、演化方式,各种生僻字,这些都是绝对不允许被科技的发展而被忽略和抛弃的。

需要注意的是,Unicode 并不是一种编码方式,而是一个字符集,它只会给字符一个独一无二的编号,而不会规定这个字符如何在计算机种存储。规定如何在计算机中存储的是编码方式,如比较早的 UTF-16 编码,它使用两个字节或者四个字节来编码字符,对于 BMP 平面上的字符,使用两个字节,对于 SMP 平面上的字符,使用四个字节。

  • UTF-16 中,两个字节和四个字节读取区分很简单,首先都按照两个字节读取,如果读取结果在 BMP 上面有实际的字符,那么就断定这是一个 BMP 字符。如果没有实际的字符,即 U+D800 到 U+DBFF(此区间为空区间),那么就四个字节连在一起读取,判断为 SMP 平面字符。四个字节一共保存了 2^20 位有效信息,对应着 16 个 2^16 个 SMP 字符。

Unicode 并没有在英语系国家快速发展开来,因为他们的大量内容都是使用 ASCII 来编码的,UTF-16 不但不兼容 ASCII,还要让他们白白的多使用一个字节的空间来存储一个字符,于是基于 Unicode 字符集的新编码方式: UTF-8 就诞生了。

UTF-8 的最大特性就是可变字符长度,对于 ASCII 的字符,编码方式和 ASCII 相同,都只占用一个字节的空间。非 ASCII 的字符也根据 Unicode 编码占用 2-4 个字符,这样就不但不需要改变原来使用 ASCII 编码的信息,还符合和其他文字一起用的标准,并且能节省大量的空间。由于这些优良的特性,使得 UTF-8 编码快速成为了互联网上最流行的文字编码形式。

  • UTF-8 规定,对于 ASCII 的字符,占用一个字节,开头标识为 0,后面 7 位存储数据。对于非 ASCII 字符,占用几个字节,那么第一位字节开头就有几个 1,用 0 标识结束,后面跟着的字节开头设为 10。比如三字节的汉字:1110XXXX 10XXXXXX 10XXXXXX 。

Windows 编码

因为计算机的内存中需要使用固定长度来保存字符,所以使用了 Unicode 字符集的 UTF-16 来编码存储数据,而对于内容的存储,则会将内存中的 UTF-16 转化为 GBK/UTF-8/GB18030 这些编码方式然后保存,使用 UTF-16 一是因为当时最新的 Unicode 标准就是 UTF-16 ,其次是当时 UTF-16 定长编码,无论是什么数字,都是两个字节,对于在内存中快速定位非常方便(当然现在随着 Unicode 的扩充,UTF-16 还有四个字节的),使用 GBK/UTF-8/GB18030 存储是为了节省存储空间和方便网上传输。

在 Windows 里面,打开文档的时候,会将硬盘里面用 GBK/UTF-8/GB18030 存储的数据转化为 UTF-16 ,然后放进内存里。我们看到的东西其实都是存在于内存中的。互联网上的网页也是,返回请求的数据使用 UTF-8 编码,浏览器接收之后,会解码为 UTF-16 并放到内存中,然后才能展示给我们看。我们在复制显示的数据的时候,其实都是 UTF-16 编码,粘贴到文档里面的时候,也是将 UTF-16 编码转移到对应应用程序的内存区中,最后这个应用程序将数据保存到硬盘的时候,才会将内存的 UTF-16 数据转化为定义的保存编码格式。

所以根本不需要担心复制粘贴的可以看见的数据的编码问题,只要我们能看见(打开到了内存中),不是乱码,就全部都是正确的 UTF-16。我们唯一需要担心的是保存在硬盘里面的数据,因为不知道保存的时候是按照 GBK/UTF-8/GB18030 等等的哪种编码方式保存,所以打开的时候选择的解码方式,复制的时候也需要注意前后文件存储编码是否相同(因为直接复制文本,没有经过内存的 UTF-16 转化)。

输入法原理

输入法作为一个应用程序,本质上还是向另外一个应用程序的内存中写入 UTF-16 编码,因为其他的应用程序用拿到的都是确定的 UTF-16 所以输入法输入时不需要担心编码问题的。

Java 中的编码

Java 中的 Char 使用的是两个字节的 UTF-16 作为编码,而 String 使用的是两个字节或者四个字节的完全版本的 UTF-16 作为编码(通过 bytes[] 来存储)。

在创造 Java 的时候,当时最流行的 Unicode 编码就是两个字节的 UTF-16,所以就给 Java 的 char 类型设置了两字节的空间,并使用 UTF-16 作为 char 类型的存储方式,所以 Char 只能标识 BMP 范围里面的字符。

对于 String 来说,同样是使用 UTF-16 作为编码方式,不过内部存储时使用 byte[] 而不是 char[]。这是因为以前使用 char[] 来实现 String 的时候,对于 SMP 的字符因为受 char 两个字节大小的制约而部分四字节编码无法显示,改为 byte[] 之后,就没有这个问题了,即使是四字节的 SMP 字符,一样可以很好的处理。

最需要注意的是,String 保存的 UTF-16 格式的开头,会有一个 BOM byte-order mark 字节顺序标记,用一 0xff 和 0xfe 的顺序标识。0xfe 0xff 标识大端序(位数大的在左边,适合人阅读),反之标识小端序(位数小的在左边,适合计算机阅读)。我们平时处理获取的 UTF-16 编码一般来说是大端序,直接忽略最前面返回的 0xfe 和 0xff 两个字符即可。

而 JVM 打开 .java 文件进行编译运行的时候,会使用系统默认的编码方式打开文件,比如 Windows 存储中文到硬盘里面的默认编码方式就是 GBK,使用 Vscode 写的 UTF-8 编码的中文无法让 JVM 正确解码,就会出错。要么更改 VScode 保存编码为 GBK,要么编译加参数更改打开文件编码为 UTF-8。或者直接用 IDEA,保存打开方式都是 UTF-8,不存在编码问题。


因为后面又学了 Java 的字节码文件,所以来讨论一下 Java 的内码和外码的问题:

对于内码来说,就是上面的 char 和 String 两者使用的 UTF-16,用于实际 JVM 运行时候的内部存储规则。对于外码(字节码文件里面的编码方式)来说则使用的是 MUTF-8。

MUTF-8 指的是 Modified UTF-8,他和 UTF-8 只有两处不同:

  1. 对于 UTF-8 的一位的空字符 0x00,在 MUTF-8 使用 0xC080 两位表示,也就是:11000000 1000000(UTF-8 的两位 0x00 表示方法)

  2. 对于 UTF-8 的四字节字符,使用双字符表示。

剩下的单字符则,双字符,三字符和 UTF-8 一模一样。