阅读 575

计算机组成原理 | Unicode 和 UTF-8是什么关系?

前言

  • 在日常开发过程中,Unicode & UTF-8并不是很受关注的知识,但在阅读源码或文章时,出现频率很高;
  • 在这篇文章里,我将带你理解Unicode 字符集的原理。如果能帮上忙,请务必点赞加关注,这真的对我非常重要。

目录


1. 字符编码简介

  • 字符(character) 是文字和符号的总称,例如汉字、拉丁字母、emoji 都是字符。一个字符由两个要素组成:一个是用户看到的图画,另一个则是图画背后的编码
# 咬文嚼字 #

很多名词都可以带上“编码”两个字,容易混淆。这里列举出“编码”的三层含义,以后在文章中看到“编码”再结合上下文即可理解作者的意思。

  • 作为动词,表示把一个字符转换为一个二进制机器数的过程,这个机器数才是字符在计算机中真实存储/传输的格式。例如把 A 转换为65(ASCII)的动作,就是编码;
  • 作为名词,可以表示字符转换为机器数之后的那个值,对于 A 来说,65(ASCII)就是 A 的编码(值),有时会称为编号;
  • 作为名词,可以表示把字符转换为机器数的编码方案,例如 ASCII 编码、GBK 编码、UTF-8 编码。
  • 字符集(character set) 是多个字符与字符编码组成的系统称为,由于历史的原因,曾经发展出多种字符集,具体如下:

常见字符集

  • 兼容性问题:字符相同但编码不同

正是因为历史上出现多种字符编码集,相互之间无法相互兼容,甚至连 emoji 最初也不具备兼容性。 例如,最早的 emoji 在日本的一些手机厂商创造并流行起来,使得 emoji 在不同厂商的设备间无法兼容。要想正确解析一个字符编码,就需要先知道它使用的字符编码集,否则用错误的字符集解读,就会出现乱码。想象以下,你发送的一个在女朋友的手机上看到的是另一个 emoji,是一件多么可怕的事情。


2. Unicode 的编号规则

为了解决字符集间互不兼容的问题,包罗万象的 Unicode 字符集出场了,要点如下:

  • 码点

从0开始编号,每个字符都分配一个唯一的码点(code point),例如U+0011。完整的十六进制格式是U+[XX]XXXX,具体可表示的范围为 U+0000 ~ U+10FFFF,所需要的空间最大为3个字节的空间。这个范围可以容纳超过100万个字符,全世界已创造的字符都包含在里面。完整的unicode码点列表可以参考:unicode.org

  • 字符平面映射

这么多字符并不是一次性定义完成的,而是采用了分组的方式。每一个组称为一个平面(Plane),每个平面有216=655362^{16} = 65536个数。从U+[00]XXXXU+[10]XXXX一共有17个平面,其中第一个平面为基本多文种平面(Basic Multilingual Plane, BMP),后面的16个平面称为辅助平面(Supplementary Plane),辅助平面目前只使用了一小部分。


3. Unicode 的三种实现方式

Unicode的实现方式不同于编码方式。一个字符的 Unicode 编码是确定的,但是在实际存储 / 传输中,处于节省空间或运算效率的考量,使用的 Unicode 编码的实现方式有所不同。Unicode 的实现方式称为 Unicode转换格式(Unicode Transformation Format,简称为 UTF),常见的有 UTF-8、UTF-16 和 UTF-32。

3.1 UTF-32

UTF-32使用4个字节的定长编码,前面说到Unicode码点最大需要3个字节的空间,这对于4个字节UTF-32编码来说绰绰有余。

  • 规则:
  1. 编码值是码点的原码,例如:
U+0000   => 0x00000000
U+6C38   => 0x00006C38
U+10FFFF => 0x0010FFFF
复制代码
  • 缺点:

任何一个码点编码后都需要4个字节的空间,每个字符都会浪费 1~3 个字节的存储空间

  • 优点:

解码方式的转换规则简单,编解码效率更快

3.2 UTF-16

UTF-16是2个字节或4个字节的变长编码,结合了UTF-8UTF-32两者的特点

  • 规则:
  1. 编号范围在U+0000 ~ U+FFFF的码点(基本平面)使用2个字节表示;
  2. 编号范围在U+10000 ~ U+10FFFF的码点(辅助平面)使用4个字节表示:

16个辅助平面总共有2202^{20}个字符,需要20bits的空间才能区分。UTF-16将这20位拆成两半,高10位映射在U+D800 ~ U+DBFF,称为高位代理(high surrogate),低10位映射在U+DC00 ~ U+DFFF,称为低位代理(low surrogate)

第一条规则比较好理解,怎么理解第二条规则呢?

我们知道辅助平面字符的范围是U+10000 ~ U+10FFFF,转换为二进制一共需要21 bits,1个char只有16位肯定是不够的,那么用2个char该如何表示呢?

最简单的方法一个char表示低16位,另一个char表示高5位(多余11位置零),如下图所示:

怎么理解前缀有歧义?假如给到一个char,它的机器数范围是0 ~ 0xFFFF,那么它既可能是基本平面字符的前缀,也可能是辅助平面字符的前缀。那么对于一串字符流(字节流同理),我们就无法区分出哪一个char应该单独解析为基本平面字符,哪一个char应该和后继的一个char合起来解析为辅助平面字符。

为了解决这个问题,必须实现前缀无歧义编码(PFC编码,类似的还有哈弗曼编码)。UTF-16的方案是将用于基本平面字符char和辅助平面字符的char的机器数范围错开,这个方案的前提就是在基本平面中有一段区域是专门空出一段区域作为UTF-16的代理:

Plane 0中,浅灰色的 D8 ~ DF 为 UTF-16 代理区 —— 引用自维基百科:

具体解释如下:

    1. 辅助平面字符的范围是U+10000 ~ U+10FFFF,换句话说,第一个辅助平面字符是0x10000。那么就可先把每个码点减去0x10000,映射到U+0000 ~ U+0AFFFF,这样的好处是只需要20 bits就能表示所有辅助平面字符(否则需要21 bits)。
    1. 20 bits依旧是超过了char的表示范围,那就用两个char吧,平分正好是10 bits为一组:用一个char表示低10位,另一个char表示高10位(多余位置零),即codepoint=high<<10+low+0x10000code point = high << 10 + low + 0x10000
    1. 分别给highhighlowlow一个偏移量,使得char的机器数落在代理区(high偏移0xD800,low偏移0xDC00),即codepoint=((high0xD800)<<10)+low0xDC00+0x10000code point = ((high - 0xD800)<< 10 ) + low - 0xDC00 + 0x10000

反过来,则有: high=(codepoint0x10000)>>>10+0xD800high = (codepoint - 0x10000) >>>10 + 0xD800 low=(codepoint & 0x3FFF)+0xDC00low = (codepoint\ \& \ 0x3FFF) + 0xDC00

下表举例了一起字符的转换过程(UTF-16 示例 —— 引用自维基百科):

Java的String的内存表示基于UTF-16 BE编码,我们可以在StringCharacter中找到相应的支持:

// 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);
}
复制代码

反过来,从UTF-16编码解码出codepoint的代码:

// Character.java
public static int toCodePoint(char high, char low) {
    // 源码有算术表达式优化,此处为等价逻辑
    return ((high - 0xD800) << 10) + (low - 0xDC00) + 0x010000;
}
复制代码

3.3 UTF-8

UTF-8是1~4个字节的变长编码

  • 规则:

下述规则表述与你在任何文章 / 百科里看到的规则表述不一样,但是逻辑上是一样的。因为笔者更倾向于使用前缀无歧义的概念理解UTF-8的编码规则。

    1. 不同范围的码点值使用不同长度的编码
    1. 1字节编码前缀为0、2字节编码前缀为110、3字节编码前缀为1110、4字节编码前缀为11110
    1. 每个码元的非首字节前缀为10

Unicode 和 UTF-8 之间的转换关系表 ( x 字符表示码点占据的位 ) 引用自维基百科:

UTF-8是常用的Unicode编码方式,很多地方都会发现它的身影,例如:

  • 1. XML文件的编码
    <?xml version="1.0" encoding="utf-8"?>
    复制代码
  • 2. Java 字节码中字符串常量的编码
类型标示描述
CONSTANT_Utf8_info1UTF-8编码的字符串
CONSTANT_String_info8字符串类型字面量

其中CONSTANT_Utf8_info常量的结构:

类型名称数量
u1tag1
u2length1
u1byteslength

可以看到,Class文件中的字符串只支持基本平面字符,同时length的值说明UTF-8编码的字符串常量的字节数,u2能表达的最大值是65535,所以Java中定义的变量名和方法名超过64KB将无法通过编译。

  • 3. HTTP报文主体的编码

HTTP报文首部字段Content-Type可以使用charset参数指定字符编码方式:

HTTP/1.1 200 OK
... 省略
Content-Type:text/html; charset=UTF-8

[报文主体]
复制代码

OkHttp源码中,当响应报文首部字段content-type缺省时,默认按UTF-8解码,看源码:

// ResponseBody.java
// ResponseBody实例化的过程可以看下BridgeInterceptor.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.4 小结

ASCIIUTF-8UTF-16UTF-32UCS-2
编号空间0-7F0-10FFFF0-10FFFF0-10FFFF0-FFFF
最少字节11242
最多字字节14442
字节序×\times×\times\checkmark\checkmark\checkmark

参考资料


推荐阅读

感谢喜欢!你的点赞是对我最大的鼓励!欢迎关注彭旭锐的Github!