前言
近日遇到一个神奇的问题,在github上找了开源库,使用如下两种不同的运行方式得到的运行结果也不同,但理论上运行结果应该是一致的才是。
方式一: 将源码编译为jar包,使用java -jar xxx 的方式运行得到的运行结果是正常的。(源码作者使用的方式)
-
方式二: 将源代码导入IDEA中,使用IDEA来运行main()的方式得到的运行结果是异常的。
以上两种运行的方式从理论上没有什么差别的,那么为何会得出不同的运行结果呢?在经过查阅资料和同事的讨论以及一系列的验证后终于找出问题的根本原因----Java编译、运行过程中的字符编码导致的。
1、字符编码与解码
1.1、基本概念
在了解字符编码与解码之前我们先需要知道字符集的概念。字符是用户可以读写的最小单位,计算机所能支持的字符组成的集合,就叫做字符集。字符集通常以二维表的形式存在。二维表的内容和大小是由使用者的语言而定。
顾名思义,编码就是把一个字符编码成二进制码存起来的方式。相应的,将编码的字节还原成字符的操作就叫做解码。编码和解码都是需要按照一定的规则,把字符集中的字符编码为特定的二进制数的规则就是字符编码。
1.2、为什么要编码?
由于人类的语言太多,因而表示这些语言的符号太多,无法使用计算机中一个基本存储单位--字节 来表示,因而必须要经过拆分或一些翻译工作,才能让计算机理解。计算机中一个字节所能表示的字符范围是0-255个。人类要表示的符号太多,无法使用一个字节来完全表示,这就需要使用编码来解决这个问题。
1.3、常见的编码格式
1.3.1、 ASCII码
上世纪60年代,美国制定了一套字符编码,对英语字符与二进制之间的关系,做了统一规定。这被称为ASCII码。ASCII码一共规定了128个字符的编码,用一个字节的低7位表示,最高位统一规定为0。0-31是控制字符如换行、回车等;32-126是打印字符,可以通过键盘输入并且能够显示出来。
1.3.2、 ISO-8859-1
128个字符显然是不够用的,于是ISO组织在ASCII码基础上又制定了一系列标准用来扩展ASCII码,ISO-8859-1涵盖了大多数西欧语言字符。ISO-8859-1仍然是单字节编码,它总共能表示256个字符。
1.3.3、 GBK
全称叫《汉字内码扩展规范》,是国家技术监督局为windows95制定的新的汉字内码规范,它的出现是为了扩展GB2312,加入更多的汉字。
1.3.4、 Unicode
随着计算机的发展,各国都推出各自的编码标准,互不兼容,非常不利于全球化发展。于是Unicode诞生了,它为每种语言中的每个字符设定了统一并且唯一的二进制编码,计算机只要支持这一个字符集,就能显示所有的字符,再也不会有乱码了。Unicode理论上最多能表示2的31次方个字符,完全可以涵盖一切语言所用的符号。
对于 Unicode 有一些误解,它仅仅只是一个字符集,规定了符合对应的二进制代码,至于这个二进制代码如何存储则没有任何规定,所以这也造成了一些问题。比如,汉字"严"的 Unicode 是十六进制数4E25,转换成二进制数足足有15位(100111000100101),也就是说,这个符号的表示至少需要2个字节。表示其他更大的符号,可能需要3个字节或者4个字节,甚至更多。
因为至少需要2个字节来表示更大的符号,这就导致了两个问题:
1.如何区别该编码是Unicode还是ASCII,计算机怎么知道该字符是2个字节还是3个字节甚至更多。
2.众所周知,英文字母只需要一个字节来进行编码,但是如果用2个字节3个字节甚至更多字节来表示这就会造成相应倍数的存储空间的增加,造成了存储空间上的极大浪费。
所以最后也出现了Unicode的多种存储方式,也就是说有许多种不同的二进制格式来表示Unicode。
Unicode的实现方式也称为Unicode转换格式(Unicode Transformation Format,简称UTF),目前主流的实现方式有UTF-8和UTF-16。以下就分别介绍UTF-8和UTF-16。
1.3.5、 UTF-8
UTF-8是针对Unicode的一种可变长度字符编码。它可以用来表示Unicode标准中的任何字符,因而其编码中的第一个字节仍与ASCII相容。UTF-8使用1~4字节为每个字符编码,根据不同的字符而变化字节长度。
编码规则如下:
Unicode 十六进制码点范围 | UTF-8二进制 |
---|---|
0000 0000 - 0000 007F | 0xxxxxxx |
0000 0080 - 0000 07FF | 110xxxxx 10xxxxxx |
0000 0800 - 0000 FFFF | 1110xxxx 10xxxxxx 10xxxxxx |
0001 0000 - 0010 FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
根据上表可得出如下结论:
1.如果一个字节,最高位(第八位)为0,表示这是一个ASCII字符。可见,所有的ASCII编码已经是UTF-8编码。
2.如果一个字节以11开头,连续的1的个数暗示这个字符的字节数,例如:110xxxxx代表它是双字节UTF-8字符的首字节。
3.如果一个字节,是以10开始,表示它不是首字节,需要向前查找才能得到当前字符的首字节。
例子:
- "汉"的 Unicode 码点是 0x6c49(110 1100 0100 1001),通过上面的对照表可以发现,0x0000 6c49 位于第三行的范围,那么得出其格式为 1110xxxx 10xxxxxx 10xxxxxx。接着,从“汉”的二进制数最后一位开始,从后向前依次填充对应格式中的 x,多出的 x 用 0 补上。这样,就得到了“汉”的 UTF-8 编码为 11100110 10110001 10001001。由此可以看出”汉“使用UTF-8编码后是使用三个字节表示。
- "A" 的 Unicode 码点是 0x0041 ( 0100 0001),通过上面的对照表可以发现,0x0041位于第一行的范围,那么得出其格式为0xxxxxx。故”A"的UTF-8编码为01000001。可以看出“A"使用UTF-8编码后是使用单字节表示。
1.3.4.2、 UTF-16
UTF-16是Unicode字符编码表的一种实现方式。即把Unicode字符集的抽象码位映射为16位长的整数(即码元,长度为2byte)的序列引用,用于数据存储或传递。Unicode字符码位,需要1个或者2个16位长的码元表示,因此这是一个变长表示。
在了解 UTF-16 编码方式之前,先了解一下另外一个概念——"平面"。
在上面的介绍中,提到了 Unicode 是一本很厚的字典,她将全世界所有的字符定义在一个集合里。这么多的字符不是一次性定义的,而是分区定义。每个区可以存放 65536 个(2^16) 字符,称为一个平面(plane)。目前,一共有17个(2^5)平面,也就是说,整个 Unicode 字符集的大小现在是 2^21。
最前面的 65536 个字符位,称为基本平面(简称 BMP ),它的码点范围是从 0 到 2^16-1,写成 16 进制就是从 U+0000 到 U+FFFF。所有最常见的字符都放在这个平面,这是 Unicode 最先定义和公布的一个平面。剩下的字符都放在辅助平面(简称 SMP ),码点范围从 U+010000 到 U+10FFFF。
基本了解了平面的概念后,再说回到 UTF-16。UTF-16 编码介于 UTF-32 与 UTF-8 之间,同时结合了定长和变长两种编码方法的特点。它的编码规则很简单:基本平面的字符占用 2 个字节,辅助平面的字符占用 4 个字节。也就是说,UTF-16 的编码长度要么是 2 个字节(U+0000 到 U+FFFF),要么是 4 个字节(U+010000 到 U+10FFFF)。那么问题来了,当我们遇到两个字节时,到底是把这两个字节当作一个字符还是与后面的两个字节一起当作一个字符呢?
这里有一个很巧妙的地方,在基本平面内,从 U+D800 到 U+DFFF 是一个空段,即这些码点不对应任何字符。因此,这个空段可以用来映射辅助平面的字符。
辅助平面的字符位共有 2^20 个,因此表示这些字符至少需要 20 个二进制位。UTF-16 将这 20 个二进制位分成两半,前 10 位映射在 U+D800 到 U+DBFF,称为高位(H),后 10 位映射在 U+DC00 到 U+DFFF,称为低位(L)。这意味着,一个辅助平面的字符,被拆成两个基本平面的字符表示。
因此,当我们遇到两个字节,发现它的码点在 U+D800 到 U+DBFF 之间,就可以断定,紧跟在后面的两个字节的码点,应该在 U+DC00 到 U+DFFF 之间,这四个字节必须放在一起解读。
UTF-16编码以16位无符号整数为单位。我们把Unicode编码记作U。编码规则如下:
平面 | Unicode 十六进制码点范围 | UTF-16 二进制 |
---|---|---|
基本平面 | 0000 0000 - 0000 FFFF | xxxx xxxx xxxx xxxx |
增补平面 | 0001 0000 - 0010 FFFF | 1101 10yy yyyy yyyy 1101 11xx xxxx xxxx |
- 如果U<0x10000,U的UTF-16编码就是U对应的16位无符号整数。
- 如果U≥0x10000,我们先计算U'=U-0x10000,然后将U'写成二进制形式:yyyy yyyy yyxx xxxx xxxx,U的UTF-16编码(二进制)就是:110110yyyyyyyyyy 110111xxxxxxxxxx。
对于辅助平面的字符,由于超过了一个16位可以表示的长度,所以需要两个16位来表示。处于前面的16位被称为前导,而后面的被称为后缀。所以UTF-16要么是2字节,要么是4字节。
例子:
- 𠮷 (不是吉)的Unicode码点为 0x20BB7,该码点显然超出了基本平面的范围(0x0000 - 0xFFFF),因此需要使用四个字节表示。首先用 0x20BB7 - 0x10000 计算出超出的部分,然后将其用 20 个二进制位表示(不足前面补 0 ),结果为0001000010 1110110111。接着,将前 10 位映射到 U+D800 到 U+DBFF 之间,后 10 位映射到 U+DC00 到 U+DFFF 即可。这样得到的结果就是1101 1000 0100 0010 1101 1111 1011 0111。由此可以看出”吉“使用UTF-16编码后是使用四个字节。
- 什 的Unicode码点为0x4EC0,该码点在基本平面(0x0000 - 0xFFFF),因此仅需要两个字节表示。即为0100 1110 1100 0000。
2、出现乱码的原因
出现乱码问题唯一的原因都是在char到byte或byte到char转换中编码和解码的字符集不一致导致的,由于往往一次操作涉及到多次编解码,所以出现乱码时很难查找到底是哪个环节出现了问题。下面是几种常见的现象:
- 现象一: 解码时用的字符集与编码字符集不一致导致乱码。
如上图所示:字符串"淘!我喜欢!"编码是采用GBK,但解码时采用ISO-8859-I,这样就会导致乱码。
- 现象二: 字符在编码时采用错误的字符集编码导致乱码。(如中文采用ISO-8859-I编码方式)
这种情况比较复杂,中文经过多次编码,但其中有一次编码或解码不对,就会出现乱码。
3、java编译、运行过程中的字符编码
java在编译和运行的整个过程中编码转化大概如下图:
可以看到Java运行时主要的两个编码就是UTF-8和UTF-16,而编译的开始,就是将各种不同编码的源代码文件转换成UTF-8。
这里其实并不是UTF-8,是一种modified UTF-8,这里就姑且认为是UTF-8。
从上图可以理解不管采用那种格式的源文件,只要正确告诉编译器源文件的编码格式,编译器就会得到正确的结果。同时只要告诉JVM正确的输出流需要的编码格式,JVM就可以返回正确编码格式的输出流。
那么要想不产生乱码就需要注意如下两个环节:
- 告诉编译器你java源文件的编码格式。
- 告诉jvm你显示或者构造字符串输出流时的希望的编码。
3.1、编译时的编码转换
众所周知,java源文件可以是任意的源码,但是在编译的时候,javac编译器默认会使用操作系统平台的编码进行解析字符。在简体中文的Windows上,平台默认编码会是GBK,那么javac就会默认假定输入的Java源文件是以GBK编码的。
要想正确编译,需要使用 -encoding指定输入的java源文件的编码。
-encoding encoding Set the source file encoding name, such as EUC-JP and UTF-8. If -encoding is not specified, the platform default converter is used.
导致乱码的不是Java源码编译器的“编码”(写出UTF-8格式到class文件中)的过程,而是“解码”(读入Java源码内容)的过程。
3.2、运行时的编码转换
JVM中运行时数据都是使用UTF-16进行编码的。为什么JVM使用的是UTF-16,而不适用兼容性更好的UTF-8呢?这是因为历史原因导致JVM运行时的数据使用UTF-16的编码。
由于成本问题不能放弃UTF-16,但是UTF-8的兼容性和流行程度,又使得JVM必须做点什么来使得其内部数据不会被编码方式影响,于是就有了这个modified UTF-8。
modified UTF-8是对UTF-16的再编码,所以JVM无需解码UTF-16的数据,modified UTF-8代理码元会处理这个映射关系。
可以在启动JVM时使用-Dfile.encoding=xxx来设置。这个属性决定了JVM输出的字节流编码格式。
3.3、 Java的 file.encoding和sun.jun.encoding的属性
Java的file.encoding属性的设置Jvm运行过程中默认的字符编码,比如:new String(bytes)、String.getBytes()、IO操作过程中等所用的默认编码格式都是file.encoding属性的所决定。
Java的sun.jun.encoding属性的主要设置下面三个地方的编码:
- 命令行参数
- 主类名称
- 环境变量
4、开发中关于编码的建议
在我们了解常用的编码格式以及Java编译、运行过程中的默认编码后,下面是关于实际开发中关于编码的一些建议:
- 项目源文件编码应统一为UTF-8,从兼容性、存储效率、存储容量等因素考虑UTF-8是最合适。(据情形而定,对于含有大量中文或者其他二字节长的字符流来说,UTF-16可以节省大量的存储空间)。
- 项目源文件的编码应统一,即A.java和B.java的编码格式应统一。若不一致很容易造成乱码。
- char与byte彼此转换中应指定编码格式,不应该使用默认的编码格式。如:new String(bytes,"UTF-8")、String.getBytes("UTF-8")、IO操作中涉及到字符编码等。
上述三个建议,我们最容易忽略的是最后一个,最后一个也是我们就容易出现的一个。
5、总结
本文最开始描述了工作中遇到的一个神奇的问题,使得我想去探究Java编译、运行过程中的编码格式。正文中首先介绍了字符编码与解码的基本概念,接着介绍了常用的几种编码格式。然后分析了平常出现乱码问题的原因。最后是本文的核心介绍了Java在编译、运行过程中编码格式以及如何保证不出现乱码。