在日常开发过程中,Unicode & UTF-8 并不是很受关注的知识,但在阅读源码或文章时,出现频率很高。如果你没有理解清楚 Unicode、UTF-8、UTF-16 和 UTF-32 之前的关系,会带来阅读障碍。在这篇文章里,我将带你理解 Unicode 字符集的原理,希望能帮上忙。
字符(Character) 是对文字和符号的总称,例如汉字、拉丁字母、emoji 都是字符。在计算机中,一个字符由 2 部分组成:
你经常会在很多词语上看到 “编码” 这个单词,对初学者来说很容易混淆。今天我列举出 “编码” 常见的 3 层解释,希望能帮助你以后在阅读文章时快速理解作者的意思。
字符集(Character Set) 是多个字符与字符编码组成的系统,由于历史的原因,曾经发展出多种字符集,例如:
字符集一多起来,就容易出现兼容问题:即同一个字符在不同字符集上对应不同的字符编码。 例如,最早的 emoji 在日本的一些手机厂商创造并流行起来,使得 emoji 在不同厂商的设备间无法兼容。要想正确解析一个字符编码,就需要先知道它使用的字符编码集,否则用错误的字符集解读,就会出现乱码。想象一下,你发送的一个在女朋友的手机上看到的是另一个 emoji,是一件多么可怕的事情。
为了解决字符集间互不兼容的问题,包罗万象的 Unicode 字符集出场了。Unicode(统一码)由非营利组织统一码联盟负责,整理了世界上大部分的字符系统,使得计算机可以用更简单统一的方式来呈现和处理文字。
Unicode 字符集与 ASCII 等字符集相比,在概念上相对复杂一些。我们需要从 2 个维度来理解 Unicode 字符集:编码标准 + 编码格式。
关键理解 2 个概念:码点 + 字符平面映射:
U+[XX]XXXX
,具体可表示的范围为 U+0000 ~ U+10FFFF
(所需要的空间最大为 3 个字节的空间),例如 U+0011
。这个范围可以容纳超过 100 万个字符,足够容纳目前全世界已创造的字符。完整的 unicode 码点列表可以参考:unicode.org
Unicode 本身只定义了字符与码点的映射关系,相当于定义了一套标准,而这套标准真正在计算机中落地时,则有多种编码格式。目前常见到的有 3 种编码格式:UTF-8、UTF-16 和 UTF-32。UTF ****是英文 Unicode Transformation Format 的缩写,意思是 Unicode 字符转换为某种格式。
别看编码格式五花八门,本质上只是出于空间和时间的权衡,对同一套字符标准使用不同的编码算法而已。举个例子,字符 A 的 Unicode 码点和编码如下:
当你根据 UTF-8、UTF-16 和 UTF-32 的编码规则进行解码后,你将得到什么结果呢?是的,它们的结果都是一样的 —— 0x41。懂了吗?
这一节,我们来讨论 Unicode 最常见的三种编码格式。
UTF-32 使用 4 个字节的定长编码, 前面说到 Unicode 码点最大需要 3 个字节的空间,这对于 4 个字节 UTF-32 编码来说就绰绰有余。
UTF-32 编码举例
U+0000 => 0x00000000
U+6C38 => 0x00006C38
U+10FFFF => 0x0010FFFF
UTF-16 是 2 个字节或 4 个字节的变长编码,结合了 UTF-8 和 UTF-32 两者的特点。 前面提到 Unicode 码点最大需要 3 个字节,那么当 UTF-16 使用 2 个字节空间时,岂不是不够用了?
先说 UTF-16 的编码规则:
U+0000 ~ U+FFFF
)使用 2 个字节表示。辅助平面的码点(编号范围在 U+10000 ~ U+10FFFF
的码点)使用 4 个字节表示;U+D800 ~ U+DBFF
,称为高位代理(high surrogate);U+DC00 ~ U+DFFF
,称为低位代理(low surrogate)。好复杂,为什么要这么设计?第一条规则比较好理解,1 个平面有最大的编码是 U+FFFF
,需要用 16 位表示,用 2 个字节表示正好。第二条规则就不好理解了,我们重点说一下。
辅助平面最大的字符是 U+10FFFF
,需要使用 21 位表示,用 4 个字节表示就绰绰有余了,例如说低 16 位 放在低 16 位,高 5 位放在高 16 位(不足位补零)。这样不是很简单也很好理解?
不行,因为前缀有歧义。 这种方式会导致辅助平面编码的每 2 个字节的取值范围都与基本平面的取值范围重复,因此,解码程序在解析一段 UTF-16 编码的字符流时,就无法区分这 2 个字节是属于基本平面字符,还是属于辅助平面字符。
为了解决这个问题,必须实现前缀无歧义编码(PFC 编码,类似的还有哈弗曼编码)。UTF-16 的方案是将用于基本平面字符编码的取值范围与辅助平面字符编码的取值范围错开,使得两者不会出现歧义(冲突)。这么做的前提,就需要在基本平面中提前空出一段区域,这就是上文提到基本平面故意空出一段区域的原因。
如下图所示,在基础平面中,浅灰色的 D8 ~ DF
为 UTF-16 代理区:
—— 图片引用自维基百科
UTF-16 编码举例
到这里,UTF-16 的设计思路就说完了,下面就会解释具体的计算规则,不感兴趣可以跳过。
U+10000 ~ U+10FFFF
,换句话说,第一个辅助平面字符是 U+10000
。那么就可先把每个码点减去 0x10000
,映射到 U+0000 ~ U+0AFFFF
,这样的好处是只需要 20 位就能表示所有辅助平面字符(否则需要 21 位);0xD800
,low
偏移 0xDC00
)。至此,UTF-16 字符编码完成。计算公式总结:
w
我们在 Java 源码中寻找一下这套计算规则,具体在 String 和 Character 中:
String.java
public String(int[] codePoints, int offset, int count) {
// 0. 前处理:参数不合法的情况
final int end = offset + count;
// 1. 计算总共需要的char数组容量
int n = count;
for (int i = offset; i < end; i++) {
int c = codePoints[i];
// 分析点 1.1
if (Character.isBmpCodePoint(c))
continue;
// 分析点 1.2
else if (Character.isValidCodePoint(c))
n++; // 每个辅助平面字符需要多一个char
else throw new IllegalArgumentException(Integer.toString(c));
}
// 2. 分配数组并填充数据
final char[] v = new char[n];
for (int i = offset, j = 0; i < end; i++, j++) {
int c = codePoints[i];
// 分析点 2.1
if (Character.isBmpCodePoint(c))
v[j] = (char)c;
else
// 分析点 2.2
Character.toSurrogates(c, v, j++);
}
// 结束
this.value = v;
}
编码计算:
Character.java
// 分析点 1.1:判断码点是否处于基本平面
public static boolean isBmpCodePoint(int codePoint) {
return codePoint >>> 16 == 0;
}
// 分析点 1.2:判断码点是否处于辅助平面
public static boolean isValidCodePoint(int codePoint) {
int plane = codePoint >>> 16;
return plane < ((0x10FFFF + 1) >>> 16);
}
// 分析点 2.2:辅助平面字符 - 规则2
static void toSurrogates(int codePoint, char[] dst, int index) {
// high在高位,low在低位,是大端序
dst[index+1] = lowSurrogate(codePoint);
dst[index] = highSurrogate(codePoint);
}
// 计算高位代理
public static char highSurrogate(int codePoint) {
return (char) ((codePoint >>> 10) + (0xDBFF - (0x010000 >>> 10)));
}
// 计算低位代理
public static char lowSurrogate(int codePoint) {
return (char) ((codePoint & 0x3ff) + 0xDC00);
}
解码计算:
Character.java
public static int toCodePoint(char high, char low) {
// 源码有算术表达式优化,此处为等价逻辑
return ((high - 0xD800) << 10) + (low - 0xDC00) + 0x010000;
}
UTF-8 是 1~4 个字节的变长编码,相对来说最节省空间。 下述规则表述与你在任何文章 / 百科里看到的规则表述不一样,但是逻辑上是一样的。因为我认为按照 “前缀无歧义” 的概念来理解最易懂。
0
、总长度为 2 时前缀为 110
、总长度为 3 时前缀为 1110
、总长度为 4 时前缀为 11110
;10
。可以看到,这种编码方式是不会存在前缀歧义的,也比较好理解。
UTF-8 编码举例
因为 UTF-8 编码相对来说是最节省空间的,因此在很多存储和传输的场景中,都会选择使用 UTF-8 编码。例如:
1、XML文件的编码: 在文件头定义了编码格式。
<?xml version="1.0" encoding="utf-8"?>
2、Java 字节码中字符串常量的编码: 可以看到,Class 文件中的字符串常量是 UTF-8 编码的,并且长度最大只支持 u2(65535 个字符),这就是在 Java 中定义的变量名标识符或方法名标识符过长(超过 64 KB)将无法通过编译的根本原因。
类型 | 标识 | 描述 |
---|---|---|
CONSTANT_Utf8_info | 1 | UTF-8 编码的字符串 |
CONSTANT_String_info | 8 | 字符串类型字面量 |
其中CONSTANT_Utf8_info
常量的结构:
名称 | 类型 | 数量 |
---|---|---|
tag | u1 | 1 |
length | u2 | 1 |
bytes | u1 | length |
Content-Type
可以指定字符编码方式。在 OkHttp 源码中,当响应报文首部字段 Content-Type 缺省时,默认按 UTF-8 解码,看源码:Http 报文示例
HTTP/1.1 200 OK
... 省略
Content-Type:text/html; charset=UTF-8
[报文主体]
OkHttp 源码摘要:
ResponseBody.java
public final String string() throws IOException {
BufferedSource source = source();
try {
// 分析点 1
Charset charset = Util.bomAwareCharset(source, charset());
return source.readString(charset);
} finally {
Util.closeQuietly(source);
}
}
// 分析点1:获得解码需要的charset
private Charset charset() {
// contentType为null时,使用 UTF_8
MediaType contentType = contentType();
return contentType != null ? contentType.charset(UTF_8) : UTF_8;
}
用一张表总结一下 3 种编码格式:
ASCII | UTF-8 | UTF-16 | UTF-32 | |
---|---|---|---|---|
编码空间 | 0~7F | 0~10FFF | 0~10FFF | 0~10FFF |
最小存储占用 | 1 | 1 | 2 | 4 |
最大存储占用 | 1 | 4 | 4 | 4 |