文本在内存中的字符集编码(1)--String 的本质--乱码探源(4)

摘要: 文本在内存中的编码以及 String 类型的本质.

目录

让我们从一个故事开始说起. 话说北大是很有哲学传统的, 当你准备踏进北大校门时, 连门卫都会连问你三个终极哲学问题:

你是谁? 你从哪里来? 你要到哪里去?

那么这与我们的问题又有何关系呢? 我觉得理解内存中的编码的关键在于理解 String 类型, 因此我们也来探讨一下 String 的前世今生:

  • String 是谁(什么)?
  • String 从哪里来?
  • String 到哪里去?

当我们能够清晰地回答这三个终极问题时, 对文本在内存中的编码也算理解得差不多了.

注: 文中将用 Java 平台为例来探讨这些问题.

String 是什么?

要回答这个问题, 源码当然是最好的参考.

字符序列(CharSequence)

如果看 String 类型的声明(jdk8):

public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
    
    private final char value[];
    // ...
}

可以看到它实现了所谓的 CharSequence 接口, 所以它是一个 char 序列, 内部实质是一个 char 数组.

也即上述代码中的 char value[], (也许你觉得 char[] value 的写法更习惯一些, 两者是等价的)

如果再看 String 的 length 方法, 事实就更清楚了, 实际上取的是 char 数组的长度:

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    
    private final char value[];
    // ...
    
    public int length() {
        return value.length; 
    } 
}

到现在为止, 可以这样看 String:

string as char

char

现在新的问题是: 什么是 char 呢? Java 中的 char 是一种基础的数据类型, 用于表示字符, 长度为 16 位.

可以把 char 看作是无符号的 16 位短整型(unsigned short).

一个 byte 是 8 位, 那么一个 char 就相当于两个 byte 了, 所以也可以把 String 视作为 byte 数组. (这是毫无疑问的, 事实上整个内存就是一个大大的 byte 数组)

string as char and byte

那么一个 String 在内存中总是占据偶数个字节, 具体地说是占用 length()×2个字节.

当然, 单独地拿出 String 中的一个 byte 出来是没有意义的, 你总是需要两个两个一起地操作它们, 即所谓的一个 code unit, 但这并不妨碍我们把它看成 byte 数组, 它可以说是有点特殊的 byte 数组.

另: String 在内部存储时有可能启用压缩选项, 不过这点对用户而言是透明的. 从理论上讲 String 总是占用 length()×2 个内存字节, 但在具体实践上, 对于一个开启了 String 压缩的 JVM 而言, 这点并不成立.

容量问题

显然, 由于 char 是 16 位固定长度, 它的容量总是有限的, 上限是 216=65536, 能表示 0-65535(0x0000-0xFFFF).

即便满打满算它也只能表示 6 万多个不同字符而已, 另一方面, Unicode 规划的字符空间高达 100 万以上, 最新版本已经定义的字符也超过了 10 万.

规划的码点范围具体为 U+0000 ~ U+10FFFF. char 能表示前面的 U+0000 ~ U+FFFF, 对于 U+10000 ~ U+10FFFF 则无能为力.

所以一个显然的事实就是单个的 char 无法表示所有的字符. 比如下面的这个音乐符:

U+1D1E 音乐符

它的十进制的码点为 119070, 已经远超过 65535, 肯定不能放到 char 里, 实验也可证实这一点:

Invalid character constant

萝卜太多, 坑位不够, 怎么办呢?

解决方案

一种方式自然是对 char 进行扩展, 比如弄成 32 位的, 不过这样会造成很大的内存浪费.

另一种方式就是对后面的那些字符使用两个 char 来表示, 也即是所谓的 代理对 方式, 需要注意的是不能跟单个 char 表示的字符冲突.

Java 采用的就是 代理对 方式, 其后果是使得 char 无法与"抽象的字符"这一概念划上等号, 一一对应的关系被打破了.

我们具体来看下是怎么做的. 首先 char 有 256×256=65536 个空间:

BMP

常用的字符都可以在这个空间内表示, 包括绝大多数的汉字.

比如"a"分配到的编码是"0061", 而"你"分配到的是"4F60".

那么一个字符串, 比如"a你"就有两个 char, 内存中占 4 个字节:

string example in BMP

然后对于那个音乐符而言, 它的码点为 U+1D11E, 有 5 位, 当然不能简单直接地分成 0001 和 D11E 两部分.

这样会与 U+0001 和 U+D11E 冲突.

所以首先要保留一些 char, 它们单个而言不代表任何抽象的字符, 具体地说保留了 D800-DFFF 共 2048 个位置:

BMP surrogate pair

然后横竖弄成一张表, 能够形成 100 多万种组合(1K=1024):

utf16 surrogate pair

在这种表示方式下, U+1D11E 对应的是 D834 和 DD1E 两个 char:

sp char in utf16

具体的转换方式可见: 字符集与编码(四)--Unicode

我们就用这两个 char 一起来表示这个字符. 这个字符无法放到单个的 char 中, 但它可以放到 String 中, 因为 String 是 char 数组.

string sp example

综述

以上其实就是 UTF-16 的编码方式. 你经常能听到这样的说法, 比如: Java 平台在内存中使用 Unicode 编码. 这其实说得很笼统, 让我们把它说得更具体一些:

Java 中的 String 类型(以及 char 类型)在内存中使用 UTF-16 编码.

String 以 char 作为它的构成单元, 这样一个 16 位的 char 也称为 UTF-16 编码的一个 代码单元(code unit).

通常, 一个 char 对应一个抽象的字符, 但也可能需要两个 char 构成一个所谓的代理对才能表示一个抽象的字符.

所以这也导致了一些尴尬的情况, 对于一些抽象字符它的长度是 2.

length of bmp and sp char

这与我们的直觉不符, 又如下面的情况:

string of both bmp and sp char

两个抽象字符, 内部为 3 个 char, 所以长度是 3, 在内存中则占据了 6 个字节. 你可能不是很喜欢这样的 String 类型, 但事实就是这样.

另可见 字符集与编码(五)--代码单元及 length 方法

其它选择

自然, 你有很多的选择. 如果你自己去实现一个语言平台, 你当然也可以选择一个其它的编码, 比如 UTF-8, 甚至是 UTF-32 作为 String 的内部编码.

考虑到 UTF-32 用四字节表示一个字符, 通常一个 int 类型也是 4 字节, 那么这种方式几乎可以认为是用一个 int 数组来保存字符. (普通的 int 是分正负的, UTF-32 可以视作为 unsigned int)

明白了 String 是什么之后, 在下一篇再继续探讨 String 从哪里来的问题. 我们将深入探讨 String 的构造, 字节流和字符流以及编码间的转换等问题.