让我们从一个故事开始说起. 话说北大是很有哲学传统的, 当你准备踏进北大校门时, 连门卫都会连问你三个终极哲学问题:
你是谁? 你从哪里来? 你要到哪里去?
那么这与我们的问题又有何关系呢? 我觉得理解内存中的编码的关键在于理解 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:
char
现在新的问题是: 什么是 char 呢? Java 中的 char 是一种基础的数据类型, 用于表示字符, 长度为 16 位.
可以把 char 看作是无符号的 16 位短整型(unsigned short).
一个 byte 是 8 位, 那么一个 char 就相当于两个 byte 了, 所以也可以把 String 视作为 byte 数组. (这是毫无疑问的, 事实上整个内存就是一个大大的 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 无法表示所有的字符. 比如下面的这个音乐符:
它的十进制的码点为 119070, 已经远超过 65535, 肯定不能放到 char 里, 实验也可证实这一点:
萝卜太多, 坑位不够, 怎么办呢?
解决方案
一种方式自然是对 char 进行扩展, 比如弄成 32 位的, 不过这样会造成很大的内存浪费.
另一种方式就是对后面的那些字符使用两个 char 来表示, 也即是所谓的 代理对 方式, 需要注意的是不能跟单个 char 表示的字符冲突.
Java 采用的就是 代理对 方式, 其后果是使得 char 无法与"抽象的字符"这一概念划上等号, 一一对应的关系被打破了.
我们具体来看下是怎么做的. 首先 char 有 256×256=65536 个空间:
常用的字符都可以在这个空间内表示, 包括绝大多数的汉字.
比如"a"分配到的编码是"0061", 而"你"分配到的是"4F60".
那么一个字符串, 比如"a你"就有两个 char, 内存中占 4 个字节:
然后对于那个音乐符而言, 它的码点为 U+1D11E, 有 5 位, 当然不能简单直接地分成 0001 和 D11E 两部分.
这样会与 U+0001 和 U+D11E 冲突.
所以首先要保留一些 char, 它们单个而言不代表任何抽象的字符, 具体地说保留了 D800-DFFF 共 2048 个位置:
然后横竖弄成一张表, 能够形成 100 多万种组合(1K=1024):
在这种表示方式下, U+1D11E 对应的是 D834 和 DD1E 两个 char:
具体的转换方式可见: 字符集与编码(四)--Unicode
我们就用这两个 char 一起来表示这个字符. 这个字符无法放到单个的 char 中, 但它可以放到 String 中, 因为 String 是 char 数组.
综述
以上其实就是 UTF-16 的编码方式. 你经常能听到这样的说法, 比如: Java 平台在内存中使用 Unicode 编码. 这其实说得很笼统, 让我们把它说得更具体一些:
Java 中的 String 类型(以及 char 类型)在内存中使用 UTF-16 编码.
String 以 char 作为它的构成单元, 这样一个 16 位的 char 也称为 UTF-16 编码的一个 代码单元(code unit).
通常, 一个 char 对应一个抽象的字符, 但也可能需要两个 char 构成一个所谓的代理对才能表示一个抽象的字符.
所以这也导致了一些尴尬的情况, 对于一些抽象字符它的长度是 2.
这与我们的直觉不符, 又如下面的情况:
两个抽象字符, 内部为 3 个 char, 所以长度是 3, 在内存中则占据了 6 个字节. 你可能不是很喜欢这样的 String 类型, 但事实就是这样.
其它选择
自然, 你有很多的选择. 如果你自己去实现一个语言平台, 你当然也可以选择一个其它的编码, 比如 UTF-8, 甚至是 UTF-32 作为 String 的内部编码.
考虑到 UTF-32 用四字节表示一个字符, 通常一个 int 类型也是 4 字节, 那么这种方式几乎可以认为是用一个 int 数组来保存字符. (普通的 int 是分正负的, UTF-32 可以视作为 unsigned int)
明白了 String 是什么之后, 在下一篇再继续探讨 String 从哪里来的问题. 我们将深入探讨 String 的构造, 字节流和字符流以及编码间的转换等问题.