写在前面

不知道你在学习字符编码的时候,会不会被网上的文章,书上的教诲搞的晕头转向?有时他们看似自洽,却又自己打自己脸。有些文章中说,Unicode 只要 16 位 (2 字节)就可以表示,但是 UTF-8 的长度为 1-3 字节,为什么我们要用更长的 UTF-8 呢?另外一些文章又说,Unicode 扩充为 16 个平面,以表达更多的字符,那扩充之后还是 2 字节表示吗? Java 的 char 还是 16 位,怎么和 Unicode 的字符兼容呢?Unicode 的 16 平面要怎么表示呢?还有一些问题,比如 UTF-8 3 字符够用吗?为什么 Mysql 要有 utf8mb4 格式呢?Java String 可以表示所有 Unicode 字符吗?这些其实都是我以前学习的时候想过的问题。如果你在互联网翻阅过足够多的文章,那么,很有可能按下葫芦浮起瓢,这个问题没解决,那个问题又出来。我在这篇文章里面试图用简单的自洽的逻辑,来梳理一下字符编码。

字符编码

简单来说,字符编码的本质建立整数和字符的映射。从而使得字符可以在计算机内以整数的形式表示,方便传输。比如,我们可以定义 ‘a’ = 1,’b’ = 2,’c’ = 3,就是在进行字符编码。

当然,我们自己定的标准没人会遵守。国际上对字符编码的标准主要有两个,分别是 ASCII 和 Unicode,由于 Unicode 是 ASCII 的超集,所以 Unicode 是事实上的字符编码国际标准

所以这意味着什么呢?意味着,每个整数都可能代表一个字符,所以对于字符来说,整数本身就是一种资源,开发完就没有了。

ASCII

ASCII(American Standard Code for Information Interchange,美国信息交换标准代码)是最早的通行标准,规定了 0-127 的对应的字符。这里节选了一部分。

Unicode

Unicode 中文翻译也叫统一码、万国码、单一码。Unicode 首先承认了 ASCII 占用 0-127 整数资源的合法性,之后又一次占用了 128-65535 的整数资源,有了这么多的整数资源,我们就可以把世界各种文字的每一种字符分配一个整数来表示了。比如‘中’这个字符,Unicode 就把整数 20013(十六进制表示为 4E2D) 分配给他了。

之后,Unicode 联盟发现 65536 个整数也不够分配的,于是就索性一次性又把之后的 16 个 65536 的数字即 65536-1114111 的整数资源给占了(谁叫他家里有矿呢),然后把多占的 16 个 65536 的段分别命名为 16 个平面,加上原来的 0-65535 平面,Unicode 总共有 17 个平面。比如第 1 平面就是 65536-131072。当然,到目前为止,还只分配了 7 个平面出去。不过保不准将来还有外星人语言加入,可能 17 个平面也不够用,再申请整数资源也是情理之中。

65535 之后分配的字符大多数是 emoji 表情,比如 😺 是 127850(1F36A)

所以,重点是什么呢?重点就是 Unicode 没有所谓的占用多少字节一说。因为 Unicode 本质上是整数,问你 Unicode 占用多少个字节,就等于问你存整数占用多少个字节。我们要用多少字节表示整数,完全取决于整数本身是多大。比如现在 int 是 32 位,可以存 0 - 2^31 这么多整数。而 long 则可以存 0-2^63 这么多。理论上,对于 17 个平面的 Unicode 要完整一次性表达,我们需要 20 位就可以了,如果只要表达一个 Unicode 平面,则只要 16 位。如果只要表达前 128 位,则只需要 8 位空间。

字符串表达

我们前面知道了字符编码是字符对数字的映射,那么,我们要怎么表达一个字符串呢?

char[]

内存中,一般通过 char 数组 来保存字符串的每个字符。每个 char 就是对应一个 Unicode 整数,然而,不同语言对于 char 的长度规定却不一样,比如 Java 定义 char 只有 16 位,所以只能表达 Unicode 0-65535 之间的字符,后面的字符就无法表示了。

Java 的 String 也是基于 char[] ,那么是不是意味着 Java 的 String 不能含有 65535 之后的 Unicode 字符呢?不是的。Java 在处理字符串 String 时,并不是完全按照原始的 char[] 来保存每个字符,对于 65535 之后的字符会启用两个 char 对应一个字符。所以,正确遍历 Java String 的方法是用 String#codePoints() ,Java 把所有字符串转换成了一个 IntStream,所以 String 的底层虽然是 char[],但是实际上,你可以把它理解为 int[] 。所以,往 String 里面存取 65535 之后的字符是没有问题的。但是你如果直接用 String#toCharArray 就有大问题,因为有的字符实际上用了两个 char 来表示。

1
2
3
StringBuffer sb = new StringBuffer();
sb.append(Character.toChars(127850));
System.out.println(sb); // 输出 😺

定长组合分割

数组的方式一般只能在内存中使用,我们要传输或保存一个字符串,则需要转成字节流的格式。

我们假设定义一个编码标准 ‘a’ = 0,’b’ = 1,’c’ = 10,那我要表达 abc ,最无误的方法是用数组[0,1,10] 。要转成字节流,一种自然的方法是直接拼成 0110,但是到时候再想变回数组的时候,就无法正确分割了。对于这个问题,有定长和不定长两种思路。

定长的思路就是先规定我一次截取多少个字节作为一个字符,比如对于上面的例子,我规定这个分割长度为 2,那么,在组合时,应该拼成 000110 ,就可以直接把原来的 [0,1,10] 读取出来。

UTF-16

UTF-16 直接规定这个分割长度为每字符16 位,所以,这意味着只能表示 0-65535 的 Unicode 字符,之后的就不能表达了。
比如 “中国”的 Unicode 分别是 20013 和 22269
我们用 UTF-16 就是把上面的十进制转成 16 位的二进制,直接拼接在一起,读取的时候一次读 16 位
01001110 00101101 01010110 11111101

GBK

GBK 全称汉字内码扩展规范,和 UTF-16 很像,也是以 16 位为单位进行合并和切割,但是,除了 0-127 继承了 ASCII 外,具体的 128-65535 的数字分配和 Unicode 则完全不一致(毕竟要有中国特色)。所以,只用分配中日韩文字的话,那就随便我们怎么玩都行,只要不超过 65536 个,都没有问题。

比如“中国”,GBK 码分别是 54992 47610 ,转换二进制后和 UTF-16 格式一致。

不定长 UTF-8

定长组合分割优点是简单,缺点是需要定义一个单位长度,在表示 ASCII 的时候会补很多个 0 浪费空间。而且无法应对将来数字长度扩容时候错误分割的问题。

这就是 UTF-8 为什么诞生的原因。UTF-8 直接用开头几位告诉你这个整数的位数,再把整数自身告诉你,这样就可以应对 Unicode 扩容的问题。同时,又可以减少占位符 0 的使用。

UTF-8 已经事实上成为字符串表达的通用标准。因为他可以适应 Unicode 的变化。提供可伸缩的表达方法。

具体的规则如下:
1)对于单字节的符号,字节的第一位设为 0,后面 7 位为这个符号的 unicode 码。因此对于英语字母,UTF-8 编码和 ASCII 码是相同的。
2)对于 n 字节的符号(n>1),第一个字节的前 n 位都设为 1,第 n+1 位设为 0,后面字节的前两位一律设为 10。剩下的没有提及的二进制位,全部为这个符号的 unicode 码。

这还跟 TCP 传输字节流的分片原理有点像。

具体的规则如下:

Unicode 符号范围 UTF-8 编码方式 (二进制)
0-127 0xxxxxxx
128-2047 110xxxxx 10xxxxxx
2048-65535 1110xxxx 10xxxxxx 10xxxxxx
65536-1114111 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

xxx 则是该字符的整数二进制表示。

如:
的 Unicode 是 20005 (4E25)(100111000100101),根据上表,可以发现20005处在第三行的范围内,因此的 UTF-8 编码需要三个字节,即格式是1110xxxx 10xxxxxx 10xxxxxx。然后,从的最后一个二进制位开始,依次从后向前填入格式中的x,多出的位补0。这样就得到了,的 UTF-8 编码是11100100 10111000 10100101

对单字符进行转换之后,字符串传输的时候直接拼接即可,切割的时候则先读取第一位的 1 的数量,来判断后面多少字节都是同一个字的,再进行切割。这样,如果中间有漏字符,也可以发现。

比如 “中国”的 UTF-8 表示为:
11100100 10111000 10101101 11100101 10011011 10111101

其实你可以发现,因为 UTF-8 加入了位数提示,所以会占用更多的长度来表达字符串。比如中文通常是 2048-65535 之间,所以一个中文在 UTF-8 会占用 3 个 8 位(3 字节)。而更加节约的 UTF-16 只用占用 2 个字节。但是 UTF-8 可以无误的表达 65535 之后的字符,这是 UTF-16 和 GBK 无法做到的。

在过去的标准里,UTF-8 最多可以用 6 个 8 位(6 字节)表示表示一个字符,然而 Unicode 也只能表示到 1114111,所以 UTF-8 也只需 4 位就足够了。

另外,因为用到 65536 之后的机会并不多。一些数据库 ,比如 Mysql,默认储存 UTF-8 时,就只给每个字符留了最多 3 位的空间。后面 Emoji 兴起后,Mysql 为了兼容之前的版本,不得不新增了一个数据类型 utf8mb4 来支持 4 位的 UTF8,这个功能在 Mysql 5.5.3 中加入。我们应该优先设定 Mysql 数据类型为 utf8mb4 。

写在后面

本文简单梳理下了各种字符编码和字符串表达的方法。当然,在具体 Socket 传输或者保存为 txt 文件时,仍需要考虑很多因素。比如字节的顺序是大端(从左到右拼接)还是小端(从右到左拼接),存成 txt 文件需不需要文件头部一个字符来表示文件的类型和文件编码等。具体可以参考下面阮一峰老师的文章,我也不再赘述。

参考资料

http://www.ruanyifeng.com/blog/2007/10/ascii_unicode_and_utf-8.html