闭门造车姚师傅

Java 的字符编解码及CodePoint

字数统计: 3k阅读时长: 11 min
2018/11/27 Share

主要内容:字符集相关概念、编解码的演进过程、Java 编解码兼容性的实现、Code Point 和 char 的关系

Java Encoding Compatibility

编码的基本概念

字符编码与解码是什么?

计算机自己能理解的“语言”是二进制数,最小的信息标识是二进制位,8个二进制位表示一个字节;而我们人类所能理解的语言文字则是一套由英文字母、汉语汉字、标点符号字符、阿拉伯数字等等很多的字符构成的字符集。如果要让计算机来按照人类的意愿进行工作,则必须把人类所使用的这些字符集转换为计算机所能理解的二级制码,这个过程就是编码,他的逆过程称为解码。

ASCII到Unicode的演进过程

计算机最开始在美国被发明使用,需要编码的字符集并不是很大,无外乎英文字母、数字和一些简单的标点符号,因此采用了一种单字节编码系统。在这套编码规则中,人们将所需字符集中的字符一一映射到128个二进制数上,这128个二进制数最高位为0,利用剩余低7位组成00000000~01111111(0X00~0X7F)。

随着计算机被迅速推广使用,欧洲非英语国家的人们发现这套由美国人设计的字符集不够用了,比如一些带重音的字符、希腊字母等都不在这个字符集中,于是扩充了ASCII编码规则,将原本为0的最高位改为1,因此扩展出了10000000~11111111(0X80~0XFF)这128个二进制数。这其中,最优秀的扩展方案是ISO 8859-1,通常称之为Latin-1。Latin-1利用128~255这128个二进制数,包括了足够的附加字符集来涵盖基本的西欧语言,同时在0~127的范围内兼容ASCII编码规则。

随着使用计算机的国家越来越多,自然而然需要编码的字符集就越来越庞大,早先的ASCII编码字符集由于受到单字节的限制,其容量就远远不够了,比方说面对成千上万的汉字,其压力可想而知。因此中国国家标准总局发布了一套《信息交换用汉字编码字符集》的国家标准,其标准号就是GB 2312—1980。这个字符集共收入汉字6763个和非汉字图形字符682个,采用两个字节对字符集进行编码,并向下兼容ASCII编码方式。简言之,整个字符集分成94个区,每区有94个位,分别用一个字节对应表示相应的区和位。每个区位对应一个字符,因此可用所在的区和位来对汉字进行两字节编码。再后来生僻字、繁体字及日韩汉字也被纳入字符集,就又有了后来的GBK字符集及相应的编码规范,GBK编码规范也是向下兼容GBK2312的。

在中国发展的同时,计算机在全世界各个国家不断普及,不同的国家地区都会开发出自己的一套编码系统,因此编码系统五花八门,这时候问题就开始凸显了,特别是在互联网通信的大环境下,装有不同编码系统的计算机之间通信就会彼此不知道对方在“说”些什么,按照A编码系统的编码方式将所需字符转换成二进制码后,在B编码系统的计算机上解码是无法得到原始字符的,相反会出现一些出人意料的古怪字符,这就是所谓的乱码。

那么统一字符编码的需求就迫切摆在了大家眼前,为了实现跨语言、跨平台的文本转换和处理需求,ISO国际标准化组织提出了Unicode的新标准,这套标准中包含了 Unicode 字符集和一套编码规范。Unicode字符集涵盖了世界上所有的文字和符号字符,Unicode 编码方案为字符集中的每一个字符指定了统一且唯一的二进制编码,这就能彻底解决之前不同编码系统的冲突和乱码问题。这套编码方案简单来说是这样的:编码规范中含有17个组(称为平面),每一个组含有 65536 个码位(例如组0就是0X0000~0XFFFF),每一个码位就唯一对应一个字符,大部分的字符都位于字符集平面0的码位中,少量位于其他平面。

字符编码和字符代码的概念区分

既然提到了Unicode编码,那么常常与之相伴的 UTF-8,UTF-16 编码方案又是什么?其实到目前为止我们都一直混淆了两个概念,即字符代码字符编码,字符代码是特定字符在某个字符集中的序号(即 Code Point),而字符编码是在传输、存储过程当中用于表示字符的以字节为单位的二进制序列。ASCII编码系统中,字符代码和字符编码是一致的,比如字符A,在ASCII字符集中的序号,也就是所谓的字符代码是65,存储在磁盘中的二进制比特序列是01000001(0X41,十进制也是65),另外的,如在GB2312编码系统中字符代码和字符编码的值也是一致的,所以无形之中我们就忽略了二者的差异性。

而在Unicode标准中,我们目前使用的是UCS-4,即字符集中每一个字符的字符代码都是用4个字节来表示的,其中字符代码0~127兼容ASCII字符集,一般的通用汉字的字符代码也都集中在65535之前,使用大于65535的字符代码,即需要超过两个字节来表示的字符代码是比较少的。因此,如果仍然依旧采用字符代码和字符编码相一致的编码方式,那么英语字母、数字原本仅需一个字节编码,目前就需要4个字节进行编码,汉字原本仅需两个字节进行编码,目前也需要4个字节进行编码,这对于存储或传输资源而言是很不划算的。

因此就需要在字符代码和字符编码间进行再编码,这样就引出了UTF-8、UTF-16等编码方式。基于上述需求,UTF-8 就是针对位于不同范围的字符代码转化成不同长度的字符编码,同时这种编码方式是以字节为单位,并且完全兼容ASCII编码,即 0X00-0X7F 的字符代码和字符编码完全一致,也是用一个字节来编码 ASCII 字符集,而常用汉字在 Unicode 中的字符代码是 4E00-9FA5。UTF-16 同理,就是以 16位二进制数为基本单位对 Unicode 字符集中的字符代码进行再编码,原理和 UTF-8 一致。

#Java 编码黑盒

分别打印 [ 12 , 中国 , Hi锅炉工🐒 ] 在默认字符集下的二进制编码,以及该二进制编码在 [ASCII, UTF-8, UTF-16, Unicode, ISO-8859-1] 编码下对应的字符串。对 Java 来说,Unicode 字符集 和 UTF-16 字符集相同。

环境如下:

1
2
3
JRE: 1.8.0_152-release-1248-b8 amd64
JVM: OpenJDK 64-Bit Server VM by JetBrains s.r.o
OS: Windows 10 10.0

默认编码为 UTF-8

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Default Encoding is UTF-8
Checking: 12
00110001 00110010
US-ASCII : 12
UTF-8 : 12
UTF-16 : ㄲ
UTF-16 : ㄲ
ISO-8859-1 : 12
Checking: 中国
11100100 10111000 10101101 11100101 10011011 10111101
US-ASCII : ������
UTF-8 : 中国
UTF-16 : 귥鮽
UTF-16 : 귥鮽
ISO-8859-1 : 中国
Checking: Hi锅炉工🐒
01001000 01101001 11101001 10010100 10000101 11100111 10000010 10001001 11100101 10110111 10100101 11110000 10011111 10010000 10010010
US-ASCII : Hi�������������
UTF-8 : Hi锅炉工🐒
UTF-16 : 䡩藧芉ꗰ龐�
UTF-16 : 䡩藧芉ꗰ龐�
ISO-8859-1 : Hi锅炉工🐒

使用 JVM Flag -Dfile.encoding=utf-16 可以将默认编码改为 UTF-16

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Default Encoding is UTF-16
Checking: 12
11111110 11111111 00000000 00110001 00000000 00110010
US-ASCII : �� 1 2
UTF-8 : �� 1 2
UTF-16 : 12
UTF-16 : 12
ISO-8859-1 : þÿ 1 2
Checking: 中国
11111110 11111111 01001110 00101101 01010110 11111101
US-ASCII : ��N-V�
UTF-8 : ��N-V�
UTF-16 : 中国
UTF-16 : 中国
ISO-8859-1 : þÿN-Vý
Checking: Hi锅炉工🐒
11111110 11111111 00000000 01001000 00000000 01101001 10010101 00000101 01110000 10001001 01011101 11100101 11011000 00111101 11011100 00010010
US-ASCII : �� H i�p�]��=�
UTF-8 : �� H i�p�]��=�
UTF-16 : Hi锅炉工🐒
UTF-16 : Hi锅炉工🐒
ISO-8859-1 : þÿ H i•p‰]åØ=Ü

完整的测试代码可以在这找到:Java Encoding Test Code

字符集与 Java 兼容性

Java Encoding Transfer

Backward vs. Forward

Backward:(adv.) 向后、落后、往后;(adj.) 后进、保守、向后的

“向前” / “向后”

维基百科对“前”的部分释义

所以语义上来讲,向前指的是未来。在时间轴上的,不能抽象为“左前右后”。而是更感性地认知为:历史的履带滚滚向前,人们身处其中。面对并且即将抵达的未来为“前”,背对并且已经过去的为“后”。

JVM、JRE 与编码

向后兼容是对自身的历史版本兼容,新版本能兼容旧版本的数据格式。

对于JDK5 编译出来的 class 文件,在 JRE8 和 JRE5 上运行会有相同的行为,因此我们说 JRE 是向后兼容的。向后兼容是 Java 适用于企业级应用必要条件,而友商(eg. Python, Swift)很少能做到这一点。

第一版 Unicode 发布于 1991年,略晚于 Java 项目启动。在第一版 Unicode 设计中, 16 位 (两字节) 被认为足够用来表达世界上所有字符,后来才发现不够用。在 1996 年发布的 Unicode 2.0,不再限制字符代码(code point)为 16位(现在的 Unicode 实际为 32位)。但是 Java 已经在 1995 年发布了,所以 JVM 内部还是沿用 UTF-16。

为了保持兼容,CONSTANT_Unicode_info 这个结构体一直被保存下来。一般情况下现代虚拟机默认使用 CONSTANT_Utf8_info

1
2
3
4
5
6
7
8
9
10
CONSTANT_Unicode_info {
u1 tag;
u2 length;
u2 bytes[length];
}
CONSTANT_Utf8_info {
u1 tag;
u2 length;
u1 bytes[length];
}

同理: JDK 5 编译的字节码可以在 Java 5+ 版本运行,这是向前兼容。

因此,对于Java来说。低版本的 JDK 编译的字节码可以在高版本虚拟机运行,编译器 和 JVM 分别是 向前兼容 和 向后兼容 的。

Java API Code Point

经过前面层层铺垫,我们发现 Code Point 这个东西其实就是一个数,即该字符在字符集中的 id。我们还可以把 Code Point 叫做 码点、字符代码、字符序号,反正都是同一个东西。

Java 中的 code point 是用 int 表示的,刚好对应 Unicode 四字节的最大长度。

Java 的 char 类型是 16位的,就 Unicode 来说,大多数常用字符是在这个范围内的。

但是对于 "锅炉🐷咦丶恸".length() 来说,你会发现这个由六个字符组成的字符串的长度竟然是 7

1
2
3
4
5
6
7
8
/** The value is used for character storage. */
// java.lang.String#value
private final char value[];

// java.lang.String#length
public int length() {
return value.length;
}

打印 name.toCharArray() 的内容:{锅: 38149, 炉: 28809, ?: 55357, ?: 56375, 咦: 21670,丶: 20022, 恸: 24696} (即 char[] value 的内容,#toCharArray 方法的实现为 复制 value 数组)

打印 name.codePoints()[38149, 28809, 128055, 21670, 20022, 24696] 。发现原因在于 🐷 emoji 的编码大于 65536,因此被放到两个 char 中存储,即占用四字节。

具体的拼装操作在 java.lang.Character#codePointAtImpl

其中把两个 char 拼成一个 Code Point 的操作如下:

1
2
3
4
5
6
7
8
9
10
// java.lang.Character#toCodePoint
public static int toCodePoint(char high, char low) {
// Optimized form of:
// return ((high - MIN_HIGH_SURROGATE) << 10)
// + (low - MIN_LOW_SURROGATE)
// + MIN_SUPPLEMENTARY_CODE_POINT;
return ((high << 10) + low) + (MIN_SUPPLEMENTARY_CODE_POINT
- (MIN_HIGH_SURROGATE << 10)
- MIN_LOW_SURROGATE);
}

Ref:

给妹子讲python-S01E07字符编码历史观-从ASCII到Unicode

java编译器编码和JVM编码问题? - RednaxelaFX的回答 - 知乎

https://simplicable.com/new/backward-compatibility-vs-forward-compatibility

https://www.javaspecialists.eu/archive/Issue209.html

CATALOG
  1. 1. 编码的基本概念
    1. 1.1. 字符编码与解码是什么?
    2. 1.2. ASCII到Unicode的演进过程
    3. 1.3. 字符编码和字符代码的概念区分
  2. 2. 字符集与 Java 兼容性
    1. 2.1. Backward vs. Forward
    2. 2.2. JVM、JRE 与编码
  3. 3. Java API Code Point