疑惑来源


啃书的时候,读到 String 这一节注意到了这样的一个细节:

调用 s.charAt(index) 可以得到 index 对应的代码单元,而如果想要得到第 i 个码点,应该使用这两个语句: int index = s.offsetByCodePoints(0, i); // 获取第i个代码单元所在码点的索引 int cp = s.codePointAt(index); // 得到index位置的码点 因为对于一些需要两个代码单元的字符,如果通过 charAt() 方法,将只能得到其中的某个代码单元。所以为了避免这个问题,不要使用 char 类型,它太底层了。 ——Java 核心技术 卷I(11版) P48

这就很有意思了,似乎在我日常学习的时候,charAt() 方法帮我解决了很多需求,即便我在字符串中使用了汉字。
于是我进行了这样的代码实验:

  1. // File Encoding: UTF-8
  2. public class TestChar {
  3. public static void main(String[] args) {
  4. String str = "我是汉字";
  5. System.out.println(str.length()); // 4
  6. System.out.println(str.charAt(2)); // 汉
  7. }
  8. }

可以看到,charAt() 方法可以很好地应用到汉字中。但我的文档编码设置的是 UTF-8 编码,对于这一格式而言,汉字应该是占3字节的。而一个代码单元(也就是一个 char)只有两个字节大小,汉字理应占用两个代码单元,如果按照书中所说,charAt() 方法应当提取的是”是”这个字符的第一个代码单元,而不是”汉”这个字符。为什么会出现这种情况呢?

文档编码的含义


要理解出现上个原因,需要先搞清楚这个 File Encoding 到底是什么意思。

对于 IDE 而言,设置 File Encoding 是为了更方便程序员采用更加普适的方式进行编程,而不需要思考采用何种编码。在保存源码的过程中,IDE 会根据设置的 File Encoding 将源码中的字符按照对应编码保存。若想证明这一观点,可以参考如下代码实验:

在 windows 系统中,分别将 File Encoding 设置为 GBK 和 UTF-8 进行保存,然后在 dos 环境中编译运行。

由于 windows 系统默认的编码是 GBK 编码,对于 GBK 编码和 UTF-8 编码会分别产生如下输出:

  1. // File Encoding: GBK
  2. 4
  3. // File Encoding: UTF-8
  4. TestChar.java:7: 错误: 编码 GBK 的不可映射字符 (0x89)
  5. System.out.println(str.charAt(2)); // 姹?
  6. ^
  7. 1 个错误

可以看到使用 GBK 编码的 windows 系统在编译 File Encoding 设置为 UTF-8 格式下保存的源码时会出现编译错误。这个错误是什么含义呢,我们可以深挖一下。

查找 Unicode 对照表可以看到”我是汉字”四个字符的 Unicode 码分别是 0x6211 0x662F 0x6C49 0x5B57,可以看到其 Unicode 码都在 0x000800-0x00FFFF 范围内,根据 RFC 3629 标准规定,这一个范围属于 UTF8-3,采用3个字节保存。因此二进制格式为 1110xxxx 10xxxxxx 10xxxxxx。把四个汉字的 Unicode 码分别写成二进制形式并补充到 x 中可以得到其 UTF-8 编码:
11100110 10001000 10010001 -> 0xE6 0x88 0x91
11100110 10011000 10101111 -> 0xE6 0x98 0xAF
11100110 10110001 10001001 -> 0xE6 0xB1 0x89
11100101 10101101 10010111 -> 0xE5 0xAD 0x97

可以看到,这个字符串一共有12个字节。

charAt() 方法在访问的时候,会按照规则访问到对应位置的码点”汉”这个字,并对其对应的代码进行输出。但是在把字节码映射为字符的时候,由于采取了错误的规则,按照 GBK 编码的规则进行处理,因此把 0xE6B1 当作一个字符,把 0x89 当作另一个字符。但对于 GBK 编码而言,其所有的字符都是2字节的且高位取1。0x89 不对应其编码表的任何一个字符,从而产生编译错误。

P.S.如果感兴趣,可以尝试把字符串初始化为任意奇数个汉字并以UTF-8保存,这种情况下编译报错会发生在String 初始化的那一行。

看起来已经搞明白了 File Encoding 的含义,它用来设置源码保存时候采取的格式,在编译的时候应当选择与其相同的编码格式转换为 class 文件,才能保证在运行时候不出现乱码。(在编译的过程中把对应的编码都转换为了 Unicode 编码格式)

charAt()方法


在前面的实验中,我们得知了 File Encoding 的作用以及编码的时机是在编译时。可以看到,似乎 charAt(2) 方法依然能够正确地找到第三个码点”汉”,只是在编译的过程中由于这个码点对应的编码不符合 GBK 规范才产生报错。那么是书中错了或者过时了吗?

查看 String 类在 Jdk 16 中的源代码可以看到如下代码:

  1. public char charAt(int index) {
  2. return this.isLatin1() ?
  3. StringLatin1.charAt(this.value, index) : StringUTF16.charAt(this.value, index);
  4. }

可以看到,在 String 类中的 charAt() 方法是根据这个字符串是不是拉丁字母而选择调用哪个类中的对应方法。由于我们考察的是汉字,所以追溯到 StringUTF16 中的方法。

  1. public static char charAt(byte[] value, int index) {
  2. checkIndex(index, value);
  3. return getChar(value, index);
  4. }
  5. static char getChar(byte[] val, int index) {
  6. assert index >= 0 && index < length(val) : "Trusted caller missed bounds check";
  7. index <<= 1;
  8. return (char)((val[index++] & 255) << HI_BYTE_SHIFT | (val[index] & 255) << LO_BYTE_SHIFT);
  9. }
  10. static {
  11. if (isBigEndian()) {
  12. HI_BYTE_SHIFT = 8;
  13. LO_BYTE_SHIFT = 0;
  14. } else {
  15. HI_BYTE_SHIFT = 0;
  16. LO_BYTE_SHIFT = 8;
  17. }
  18. }

以上是 StringUTF16 类中对应方法的定义。可以看到,对于 UTF16 字符串,会先对 index 进行边界判断,然后调用该类中的 getChar() 方法,依据一个静态代码块确定编码是头大码还是头小码,并依据其规则进行拼合运算。

可以看到,对于 charAt() 方法对于 UTF16 编码而言确实是调用了 byte 数组中的两个字节码拼合起来的,只可能输出2个字节的信息量,也即一个代码单元

那么为什么对汉字来说其 charAt() 方法就可以直接输出一个字符,也就是把整个码点输出了呢?汉字在 UTF-8 中可是占3个字节啊。

这是因为大多数汉字在 UTF-16 中是占据2字节的。对于 Java 而言,String 的底层实现是一个 final 的 byte 数组,这个数组中存储的数据是按照 UTF-16 中规定的编码存储的,而对保存时候采用什么编码并不关心。File Encoding 就像给源码打上了一个标签,只是在需要重新编码(如转存或显示)的时候,会按照这个标签对数据进行重新解读。

因此可以想到,对于某些 UTF-16 中规定需要用4字节存储的字符而言,charAt() 不能够正常地把整个字符取出,而是只能获得其中的一个代码单元。这就是原书中所说明的需要我们注意的地方。

P.S.至于 String 是如何判断一个字符串是一个纯拉丁字符串还是一个 UTF-16 字符串,其利用了一个 coder 字段对字符串进行了标识,coder 为1表示 UTF-16 字符串,coder 为0表示纯拉丁字符串。而 coder 值的更新,追根溯源是通过 StringLatin1 类中给出的 canEncode() 方法判断的,它会根据这个字符串中的码点是不是都是8位来进行判断。

小结


  1. File Encoding 设置了源码保存时候的编码格式。
  2. charAt() 方法只能读取代码单元,无法获取整个码点(码位)。该方法是否有意义与 UTF-16 中规定该字符由几个字节存储有关,若为2字节存储的一般字符,则该方法有意义,否则该方法没有意义。
  3. TODO: 笔者为Java初学者,对编码的理解还不够深,文中关于报错的解读可能存在误导。

2021.10.27