Jakob Jenkov 2022-08-07
UTF-8 是一种用于编码 Unicode 字符的字节编码方式。UTF-8 使用 1、2、3 或 4 个字节来表示一个 Unicode 字符。请记住,Unicode 字符由 Unicode 码点(code point) 表示。因此,UTF-8 使用 1 到 4 个字节来表示一个 Unicode 码点。
UTF-8 是 Web 上最常用的文本编码之一,因此非常流行。Web 浏览器支持 UTF-8。许多编程语言也允许你在代码中使用 UTF-8,并能轻松地导入和导出 UTF-8 文本。多种文本数据格式和标记语言通常也采用 UTF-8 编码,例如 JSON、XML、HTML、CSS、SVG 等。
UTF-8 的标记位与码点位
在将 Unicode 码点转换为一个或多个 UTF-8 编码字节时,每个字节都由**标记位(marker bits)和码点位(code point bits)**组成。标记位用于指示如何解释该字节,而码点位则用于表示码点的值。
在以下各节中,标记位用 0 和 1 表示,码点位用字符 Z、Y、X、W 和 V 表示。每个字符代表一个比特位。
UTF-8 中使用的 Unicode 码点区间
对于十六进制区间 U+0000 到 U+007F 的 Unicode 码点,UTF-8 使用 1 个字节 表示字符。此区间的码点与 ASCII 字符相同,并使用相同的整数值(码点)表示。其二进制形式如下:
0ZZZZZZZ其中,标记位为最高位的
0,Z表示码点值的位。对于区间 U+0080 到 U+07FF 的码点,UTF-8 使用 2 个字节 表示字符。二进制形式如下:
110YYYYY 10ZZZZZZ标记位为
110和10,Y和Z表示码点值的位。左边是高位字节(most significant byte)。对于区间 U+0800 到 U+FFFF 的码点,UTF-8 使用 3 个字节 表示字符。二进制形式如下:
1110XXXX 10YYYYYY 10ZZZZZZ标记位为
1110和两个10,X、Y、Z表示码点值的位。对于区间 U+10000 到 U+10FFFF 的码点,UTF-8 使用 4 个字节 表示字符。二进制形式如下:
11110VVV 10WWXXXX 10YYYYYY 10ZZZZZZ标记位为
11110和三个10。V和W表示字符所在的 Unicode 平面(plane),其余X、Y、Z表示码点的其余部分。
读取 UTF-8
读取 UTF-8 编码字节并还原为字符时,需先判断一个字符(码点)是由 1、2、3 还是 4 个字节表示的。方法是检查第一个字节的位模式:
- 若第一个字节形如
0ZZZZZZZ(最高位为 0),则该字符仅由这一个字节表示。 - 若第一个字节形如
110YYYYY(前三位为 110),则该字符由两个字节表示。 - 若第一个字节形如
1110XXXX(前四位为 1110),则该字符由三个字节表示。 - 若第一个字节形如
11110VVV(前五位为 11110),则该字符由四个字节表示。
确定字节数后,提取所有携带码点信息的位(即 V、W、X、Y、Z 所代表的位),将其组合成一个 32 位整数(例如 Java 中的 int)。例如,读取一个 4 字节 UTF-8 字符后的 32 位结构如下:
000000 000VVVWW XXXXYYYY YYZZZZZZ
注意:所有标记位(如 11110 和 10)已被移除,仅保留原始码点位。
写入 UTF-8
写入 UTF-8 文本时,需要将 Unicode 码点转换为 UTF-8 编码的字节序列。步骤如下:
- 根据码点值确定所需字节数(参考上文的区间划分)。
- 将码点的位按规则填入对应字节的码点位中,并添加相应的标记位。
例如,一个需要 4 字节表示的码点,其抽象位模式为:
000000 000VVVWW XXXXYYYY YYZZZZZZ
对应的 UTF-8 字节序列为:
11110VVV 10WWXXXX 10YYYYYY 10ZZZZZZ
在 Java 中读写 UTF-8
Java 提供了多种读写 UTF-8 字节的方式。
将 UTF-8 读入 Java String
byte[] utf8 = ... // 从文件、URL 等获取 UTF-8 字节数组
String string = new String(utf8, StandardCharsets.UTF_8);
从 Java String 获取 UTF-8 字节
byte[] utf8 = string.getBytes(StandardCharsets.UTF_8);
一个可读写 UTF-8 码点的 Utf8Buffer 类
以下是一个既能写入也能读取 UTF-8 码点(以 Java int 表示)的 Utf8Buffer 类:
public class Utf8Buffer {
public byte[] buffer;
public int offset;
public int length;
public int endOffset;
public int tempOffset;
public Utf8Buffer(byte[] data, int offset, int length) {
this.buffer = data;
this.offset = offset;
this.tempOffset = offset;
this.length = length;
this.endOffset = offset + length;
}
public void reset() {
this.tempOffset = this.offset;
}
public void calculateLengthAndEndOffset() {
this.length = this.tempOffset - this.offset;
this.endOffset = this.tempOffset;
}
public int writeCodepoint(int codepoint) {
if (codepoint < 0x00_00_00_80) {
buffer[this.tempOffset++] = (byte) (0xFF & codepoint);
return 1;
} else if (codepoint < 0x00_00_08_00) {
buffer[this.tempOffset] = (byte) (0xFF & (0b1100_0000 | (0b0001_1111 & (codepoint >> 6))));
buffer[this.tempOffset + 1] = (byte) (0xFF & (0b1000_0000 | (0b0011_1111 & codepoint)));
this.tempOffset += 2;
return 2;
} else if (codepoint < 0x00_01_00_00) {
buffer[this.tempOffset] = (byte) (0xFF & (0b1110_0000 | (0b0000_1111 & (codepoint >> 12))));
buffer[this.tempOffset + 1] = (byte) (0xFF & (0b1000_0000 | (0b0011_1111 & (codepoint >> 6))));
buffer[this.tempOffset + 2] = (byte) (0xFF & (0b1000_0000 | (0b0011_1111 & codepoint)));
this.tempOffset += 3;
return 3;
} else if (codepoint < 0x00_11_00_00) {
buffer[this.tempOffset] = (byte) (0xFF & (0b1111_0000 | (0b0000_0111 & (codepoint >> 18))));
buffer[this.tempOffset + 1] = (byte) (0xFF & (0b1000_0000 | (0b0011_1111 & (codepoint >> 12))));
buffer[this.tempOffset + 2] = (byte) (0xFF & (0b1000_0000 | (0b0011_1111 & (codepoint >> 6))));
buffer[this.tempOffset + 3] = (byte) (0xFF & (0b1000_0000 | (0b0011_1111 & codepoint)));
this.tempOffset += 4;
return 4;
}
throw new IllegalArgumentException("Unknown Unicode codepoint: " + codepoint);
}
public int nextCodepoint() {
int firstByteOfChar = 0xFF & buffer[tempOffset];
if (firstByteOfChar < 0b1000_0000) {
tempOffset++;
return firstByteOfChar;
} else if (firstByteOfChar < 0b1110_0000) {
int nextCodepoint = 0b0001_1111 & firstByteOfChar;
nextCodepoint <<= 6;
nextCodepoint |= 0b0011_1111 & (0xFF & buffer[tempOffset + 1]);
tempOffset += 2;
return nextCodepoint;
} else if (firstByteOfChar < 0b1111_0000) {
int nextCodepoint = 0b0000_1111 & firstByteOfChar;
nextCodepoint <<= 6;
nextCodepoint |= 0x3F & buffer[tempOffset + 1];
nextCodepoint <<= 6;
nextCodepoint |= 0x3F & buffer[tempOffset + 2];
tempOffset += 3;
return nextCodepoint;
} else if (firstByteOfChar < 0b1111_1000) {
int nextCodepoint = 0b0000_0111 & firstByteOfChar;
nextCodepoint <<= 6;
nextCodepoint |= 0x3F & buffer[tempOffset + 1];
nextCodepoint <<= 6;
nextCodepoint |= 0x3F & buffer[tempOffset + 2];
nextCodepoint <<= 6;
nextCodepoint |= 0x3F & buffer[tempOffset + 3];
tempOffset += 4;
return nextCodepoint;
}
throw new IllegalStateException("Codepoint not recognized from first byte: " + firstByteOfChar);
}
}
使用示例:
Utf8Buffer utf8Buffer = new Utf8Buffer(new byte[1024], 0, 0);
utf8Buffer.writeCodepoint(0x7F);
// 写入后需计算长度和偏移量;若要读取,需重置 tempOffset
utf8Buffer.calculateLengthAndEndOffset();
utf8Buffer.reset();
int nextCodePoint = utf8Buffer.nextCodepoint();
在 UTF-8 中向前搜索
在 UTF-8 中向前搜索相对直接:逐个解码字符并与目标字符比较即可。
在 UTF-8 中向后搜索
UTF-8 编码的一个优点是支持向后搜索。通过检查每个字节的标记位,可以判断它是否是一个字符的起始字节:
起始字节的标记模式:
0xxxxxxx:1 字节字符(也是 ASCII)110xxxxx:2 字节字符开头1110xxxx:3 字节字符开头11110xxx:4 字节字符开头
非起始字节的标记模式:
10xxxxxx:2/3/4 字节字符的后续字节
因此,在向后搜索时,只需不断回退,直到找到一个起始字节,然后向前解码整个字符并进行比对。