字符编码、UTF-16、UTF-8
字符编码与字符串表达式
背景
最近在看基本类型时,发现char类型是2个字节,也就是16bit,最多只能表达2^16的字符,显然字符是不止这么多的,也就意味着在Java中使用char可能存在精度丢失,且String中底层同样是用char[]进行来进行维护的,会不会同样存在丢失的问题呢?
字符编码
简单来说,字符编码的本质是建立整数和字符的映射。从而使得字符可以在计算机内以整数的形式表示,方便传输。比如,我们可以定义 ‘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 个平面出去。
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 来表示。
代码语言:javascript复制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
参考
- 谈谈字符编码:Unicode、UTF-8 和 char[]