关于字符编码的那些事儿
2024-09-18 02:46:04 # Technical # JavaBase

计算机是二进制的只认识 0 和 1,一个二进制位(bit)存在两种状态 0 或 1,八个二进制位可以表示 256 种状态,也称为一个字节(byte),所以一个字节能表示出 256 种字符

ASCII 码

上个世纪 60年代,美国制定了一套字符编码,对英语字符与二进制位之间的关系,做了统一规定。这被称为 ASCII 码,一直沿用至今

ASCII 码一共规定了 128 个字符的编码,比如 空格SPACE 是 32(00100000),大写的字母 A 是 65(01000001)。这 128 个符号(包括 32 个不能打印出来的控制符号),只占用了一个字节的后面 7 位,最前面的一位统一规定为 0

非 ASCII 码

英语用 128 个字符编码就够了,但是用来表示其他语言,128 个字符是不够的。比如,在法语中,字母上方有注音符号,它就无法用 ASCII 码表示。于是,一些欧洲国家就决定,利用字节中闲置的最高位编入新的字符。比如,法语中的 é 的编码为 130(10000010)。这样一来,这些欧洲国家使用的编码体系,可以表示最多 256 个字符

但是这里又出现了新的问题,不同的国家有不同的字母。因此,哪怕它们都使用 256 个字符的编码方式,代表的字母却不一样。比如,130 在法语编码中代表了 é,在希伯来语编码中却代表了字母 Gimel「ג」,在俄语编码中又会代表另一个字符。但是不管怎样,所有这些编码方式中,0 - 127 表示的字符是一样的,不一样的只是 128 - 255 的这一段

至于亚洲国家的文字,使用的字符就更多了,汉字就多达 10 万左右。一个字节只能表示 256 种字符,肯定是不够的,就必须使用 多个字节表达一个字符。比如,简体中文常见的编码方式是 GB2312,使用 两个字节表示一个汉字,所以理论上最多可以表示 256 x 256 = 65536 个字符

Unicode

正如上一节所说,世界上存在着多种编码方式,同一个二进制数字可以被解释成不同的字符。因此,要想打开一个文本文件,就必须知道它的编码方式,否则用错误的编码方式解读,就会出现乱码。为什么电子邮件常常出现乱码?就是因为发信人和收信人使用的编码方式不一样

可以想象,如果有一种编码,将世界上所有的字符都纳入其中。每一个字符都给予一个独一无二的编码,那么乱码问题就会消失。这就是 Unicode,就像它的名字都表示的,这是一种所有符号的编码。

Unicode 当然是一个很大的集合,现在的规模可以容纳 100 多万个字符。每个符号的编码都不一样,比如,U+0639 表示阿拉伯字母 AinU+0041 表示英语的大写字母 AU+4E25 表示汉字 。具体的符号对应表,可以查询 unicode.org,或者专门的 汉字对应表

Unicode 的问题

需要注意的是,Unicode 只是一个字符集,它只规定了符号的二进制代码,却没有规定这个二进制代码应该如何存储

比如,汉字 的 Unicode 是十六进制数 4E25,转换成二进制数足足有 15 位(100111000100101),也就是说,这个符号的表示至少需要 2 个字节。表示其他更大的符号,可能需要 3 个字节或者 4 个字节,甚至更多

这里就有两个严重的问题,第一个问题是,如何才能区别 Unicode 和 ASCII ?计算机怎么知道三个字节表示一个符号,而不是分别表示三个符号呢?第二个问题是,我们已经知道,英文字母只用一个字节表示就够了,如果 Unicode 统一规定,每个符号用三个或四个字节表示,那么每个英文字母前都必然有二到三个字节是 0,这对于存储来说是极大的浪费,文本文件的大小会因此大出二三倍,这是无法接受的

它们造成的结果是:

  • 出现了 Unicode 的多种存储方式,也就是说有许多种不同的二进制格式,可以用来表示 Unicode
  • Unicode 在很长一段时间内无法推广,直到互联网的出现

UTF-8

互联网的普及,强烈要求出现一种统一的编码方式。UTF-8 就是在互联网上使用最广的一种 Unicode 的实现方式。其他实现方式还包括 UTF-16(字符用两个字节或四个字节表示)和 UTF-32(字符用四个字节表示),不过在互联网上基本不用。重复一遍,这里的关系是,UTF-8 是 Unicode 的实现方式之一

UTF-8 最大的一个特点,就是它是一种变长的编码方式。它可以使用 1~4 个字节表示一个符号,根据不同的符号而变化字节长度

起初,UTF-8 使用 1~6 个字节为每个字符编码,2003年11月 UTF-8 被 RFC 3629 重新规范,只能使用原来Unicode 定义的区域,U+0000 到 U+10FFFF,也就是说最多四个字节 —— UTF-8 结构

UTF-8 的编码规则很简单,只有二条:

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

下表总结了编码规则,字母 x 表示可用编码的位

UTF-8 字节数 UTF-8 结构 Unicode 位数 Unicode 范围(十六进制)
1 byte 0xxxxxxx 7 bit 0000 0000 - 0000 007F
2 byte 110xxxxx 10xxxxxx 11 bit 0000 0080 - 0000 07FF
3 byte 1110xxxx 10xxxxxx 10xxxxxx 16 bit 0000 0800 - 0000 FFFF
4 byte 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx 21 bit 0001 0000 - 001F FFFF

以汉字 为例, 的 Unicode 是 4E2D(0100 1110 0010 1101),4E2D 在上面 Unicode 范围的第三行内,所以对应的 UTF-8 的结构是 1110xxxx 10xxxxxx 10xxxxxx

然后将 的 Unicode 编码从后往前依次填入 UTF-8 的结构中即可:11100100 10111000 10101101(E4 B82D)

再以特殊的 Emoji 为例,😀 的 Unicode 是 1 F600(0001 1111 0110 0000 0000),1 F600 对应上面的第四行,所以对应的 UTF-8 的结构是 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx,再将 Unicode 编码填入就得到了 😀 的 UTF-8 编码为:11110000 10011111 10011000 10000000(F09F 9880)

LE 与 BE

以上面的 字为例, 的 Unicode 是 4E2D,需要用两个字节来存储,一个是 4E,另一个是 2D。那么在存储的时候,如果 4E 在前,2D 在后,这就是 Big-endian 的方式,反之则是 Little-endian

这两个古怪的名称来自英国作家斯威夫特的《格列佛游记》。在该书中,小人国里爆发了内战,战争起因是人们争论,吃鸡蛋时究竟是从大头(Big-endian)敲开还是从小头(Little-endian)敲开。为了这件事情,前后爆发了六次战争,一个皇帝送了命,另一个皇帝丢了王位

所以,第一个字节在前就是 BE,最后一个字节在前就是 LE

那么很自然的,就会出现一个问题:计算机怎么知道某一个文件到底采用哪一种方式编码?

Unicode 规范定义,每一个文件的最前面都加入一个表示编码顺序的字符,这个字符的名字叫做「零宽度非换行空格」(zero width no-break space),用 FE FF 的顺序来表面存储的方式

如果一个文本文件的头两个字节是 FE FF,就表示该文件采用 BE 方式;如果头两个字节是 FF FE,就表示该文件采用 LE 方式

ANSI

通过一个虚构的故事来理解什么是 ANSI:

ANSI

  • ANSI 不是一种特定的字符编码
  • ANSI 能表示不同编码
  • ANSI 只存在于 Windows 下

Java 对 Unicode 的编解码

利用 char 存储的是 Unicode 字符的特点,可以轻松将一个字符转为 Unicode 编码

1
2
3
4
5
6
7
@Test
void unicodeEncodeTest() {
String s = "中";
char c = s.toCharArray()[0];
System.out.println(Integer.toBinaryString(c)); // 100111000101101
System.out.println(Integer.toHexString(c).toUpperCase()); // 4E2D
}

解码

1
2
3
4
5
@Test
void unicodeDecodeTest() {
String unicode = "4E2D";
System.out.println((char) Integer.parse(unicode, 16)); // 中
}

Java 对 UTF-8 的编解码

encode

1
2
3
4
5
6
7
8
9
10
@Test
void utf8EncodeTest() {
String s = "中";
for (byte b : s.getBytes(StandardCharsets.UTF_8)) {
binaryStr += Integer.toBinaryString(b & 0xFF);
}
System.out.println(binaryStr); // 111001001011100010101101
String hexStr = Integer.toHexString(Integer.parseInt(binaryStr, 2)).toUpperCase();
System.out.println(hexStr); // E4B8AD
}

decode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
void utf8DecodeTest() {
String utf8 = "E4B8AD";
String binaryStr = Integer.toBinaryString(Integer.parseInt(utf8, 16));
System.out.println(binaryStr); // 111001001011100010101101
int i = 0, j = binaryStr.length(), k = j / 8;
byte[] byteArr = new byte[k];
for (;i < k; i++) {
String byteStr = binaryStr.substring(i * 8, (i + 1) * 8);
byteArr[i] = (byte) Integer.parseInt(byteStr, 2);
}
String str = new String(byteArr, StandardCharsets.UTF_8);
System.out.println(str); // 中
}

进制与编码

进制 二进制范围
2 0 ~ 1
4 00 ~ 11
8 000 ~ 111
16 0000 ~ 1111
32 00000 ~ 11111
64 000000 ~ 111111
128 0000000 ~ 1111111
256 00000000 ~ 11111111

可以看出 1 个 16 进制可以表示 4 bit,所以,可以用 2 个 16 进制来表示 1 byte

简单理解Base64

Base64 是一种常用于数据编码的算法,主要用于将二进制数据转换为文本格式,以便通过文本系统(如电子邮件或网页)传输。Base64 的编码规则将 每三个字节的二进制数据编码为四个可打印的 ASCII 字符

数据块划分

  • 原始数据按 3 字节(24 位)一组进行处理
  • 如果数据长度不是 3 的倍数,则使用 0 补足,后续在编码中用 = 作为填充符标记

每组数据处理

  • 24 位的三字节数据被分成 4 个 6 位的块(6 位 × 4 = 24 位)
  • 每个 6 位块对应一个 Base64 字符表中的字符

Base64 字符表

  • Base64 编码使用的字符表包含 64 个字符:A-Za-z0-9+/。每个字符的索引(从 0 到 63)对应一个 6 位的二进制数
  • 例如,A 对应的索引是 0(000000),/ 对应的索引是 63(111111

填充规则

  • 如果原始数据长度不是 3 的倍数,编码后的 Base64 字符串末尾会使用一个或两个 = 号填充,以使最终输出的长度为 4 的倍数
  • 补充的 = 并不参与解码,实际上表示没有更多数据

Base64

编码示例

假设有以下三个字节的数据:Man,其 ASCII 值分别为 7797110,转换为二进制是:

1
2
3
77  -> 01001101
97 -> 01100001
110 -> 01101110

合并成 24 位的二进制数据:

1
01001101 01100001 01101110

将其分为四个 6 位的块:

1
010011 010110 000101 101110

这四个 6 位块分别对应 Base64 字符表的索引:

1
2
3
4
010011 -> 19 -> T
010110 -> 22 -> W
000101 -> 5 -> F
101110 -> 46 -> u

因此,Man 编码为 Base64 后的结果是 TWFu

填充示例

假设编码的数据是 Ma,只有两个字节:

1
2
77  -> 01001101
97 -> 01100001

合并成 16 位的二进制数据:

1
01001101 01100001

再补足 8 个 0 以凑满 24 位:

1
01001101 01100001 00000000

将其分为四个 6 位的块:

1
010011 010110 000100 000000

对应的 Base64 字符是:

1
2
3
4
010011 -> 19 -> T
010110 -> 22 -> W
000100 -> 4 -> E
000000 -> 0 -> A

由于原始数据只有两个字节,不足的部分用 = 填充,结果为 TWE=

中文编码示例

Base64 编码本身是一种针对二进制数据的编码方法,它将任意二进制数据转换为可打印的 ASCII 字符。Base64 不直接处理字符编码,而是处理字符编码后形成的字节数据。因此,处理中文等非 ASCII 字符时,需要先确定字符的编码方式(如 UTF-8、UTF-16 等),然后对编码后的字节数据进行 Base64 编码

假设要对中文字符串「你好」进行 Base64 编码

  • 「你」的 Unicode 代码点是 U+4F60,UTF-8 编码为三个字节:E4 B8 80
  • 「好」的 Unicode 代码点是 U+597D,UTF-8 编码为三个字节:E5 A5 BD

因此,「你好」在 UTF-8 编码下的字节序列为:E4 B8 80 E5 A5 BD

转为二进制:11100100 10111000 10000000 11100101 10100101 10111101

分组成每 6 位一组的二进制块:

1
2
3
4
5
6
7
8
111001 -> 36 -> e
001011 -> 11 -> L
100010 -> 34 -> i
000000 -> 00 -> A
111001 -> 36 -> e
011010 -> 26 -> a
010110 -> 22 -> W
111101 -> 61 -> 9

将这些 Base64 字符连接起来,得到最终的 Base64 编码字符串:5L2g5aW9

编码体积膨胀 33%

Base64 编码将原始数据转换成可打印的 ASCII 字符,这个过程会导致数据体积增加。具体来说,Base64 编码后的大小是原始数据的 4/3 倍(或 33% 的增长)。这是因为 Base64 编码将每 3 个字节(24 位)的二进制数据编码为 4 个可打印的字符,每个字符对应 6 位数据

原始数据以字节为单位,1 个字节等于 8 位。所以,Base64 编码处理的基本单位是 3 个字节,即 3 × 8 = 24 位

编码过程的核心就是把 3 个字节(24 位)数据变为 4 个字节(每个字节含有 Base64 字符,每个字符占用 1 个字节)的数据。因此,编码后的数据大小是原始数据的 4/3 倍

如果原始数据的长度不是 3 的倍数,则在编码过程中会用 = 字符填充。这些填充字符本身不增加有效数据的内容,但会增加编码后的总字符数,使得数据长度是 4 的倍数

虽然理论上编码后数据是原来的 4/3 倍,但由于填充字符的存在,实际增长比率可能略低于 33%。例如,对于非常小的数据块(少于 3 个字节),填充字符可能会导致总增量低于 33%

Thanks

字符编码笔记:ASCII,Unicode 和 UTF-8