字符、编码和Java中的编码

0.751字数 10138阅读 1989

字符是用户可以读写的最小单位。计算机所能支持的字符组成的集合,就叫做字符集。字符集通常以二维表的形式存在。二维表的内容和大小是由使用者的语言而定,是英语、是汉语、还是阿拉伯语。人类阅读的文章是由字符组成的,而计算机是通过二进制字节进行信息传输的。计算机无法直接传输字符,所以就需要将字符解析成字节,这个解析操作就叫做编码(encode),而相应的,将编码的字节还原成字符的操作就叫做解码(decode)。编码和解码都需要按照一定的规则,这种把字符集中的字符编码为特定的二进制数的规则就是字符编码(Character encoding)。网上也有称之为字符集编码的,其实大概就是一个意思,注意和后面的编码字符集区分。

为什么要编码

由于人类的语言有太多,因而表示这些语言的符号太多,无法用计算机中一个基本的存储单元——字节 来表示,因而必须要经过拆分或一些翻译工作,才能让计算机能理解。计算机中存储信息的最小单元是一个字节即8个位(bit),所以能表示的字符范围是0~255个。人类要表示的符号太多,无法用一个字节来完全表示,这就需要编码来解决这个问题。

早期字符编码发展

在计算机发展的早期,字符集和字符编码一般使用相同的命名,例如最早的字符集ASCII(American Standard Code for Information Interchange),它既代表了计算机所支持显示的所有字符(字符集),又代表了这个字符集的字符编码。ASCII字符集是一个二维表,支持128个字符。128个码位,用7位二进制数表示,由于计算机1个字节是8位二进制数,所以最高位为0,即00000000-011111110x00-0x7F。ASCII(1963年)和EBCDIC(1964年)这样的字符集逐渐成为计算机字符编码的早期标准。但这些字符集的局限很快就变得明显,于是人们开发了许多方法来扩展它们。

最初的拓展很简单,只是在原来ASCII的基础上,扩展为256个字符,成为EASCII(Extended ASCII)。EASCII有256个码位,用8位二进制数表示,即00000000`11111111`或`0x00`0xFF

当计算机传到了欧洲,EASCII也开始不能满足需求了,但是改改还能凑合。于是国际标准化组织在ASCII的基础上进行了扩展,形成了ISO-8859标准,跟EASCII类似,兼容ASCII,在高128个码位上有所区别。但是由于欧洲的语言环境十分复杂,所以根据各地区的语言又形成了很多子标准,如ISO-8859-1、ISO-8859-2、ISO-8859-3、……、ISO-8859-16。

后来计算机传入亚洲,由于亚洲语种和文字都十分丰富,256个码位就显得十分鸡肋了。于是继续扩大二维表,单字节改双字节,16位二进制数,65536个码位。在不同国家和地区又出现了很多编码,大陆的GB2312、港台的BIG5、日本的Shift JIS等等。

由于计算机的编码规范和标准在最初制定时没有意识到这将会是以后全球普适的准则,所以出现了各种各样的编码方式(也有了多种不同的字符集)。但是很多传统的编码方式都有一个共同的问题,即容许电脑处理双语环境(通常使用拉丁字母以及其本地语言),但却无法同时支持多语言环境(指可同时处理多种语言混合的情况)。而且不同国家和地区采用的字符集不一致,很可能出现无法正常显示所有字符的情况。

所以对于支持包括东亚CJK字符家族在内的写作系统的要求能支持更大量的字符,需要一种系统而不是临时的方法实现这些字符的编码。

Unicode字符编码五层次模型

为了解决传统的字符编码方案的局限,就引入了统一码(Unicode)和通用字符集(Universal Character Set, UCS)来替代原先基于语言的系统。通用字符集的目的是为了能够涵盖世界上所有的字符。由统一码(Unicode)和通用字符集(Universal Character Set, UCS)所构成的现代字符编码模型没有跟从简单字符集的观点。它们将字符编码的概念分为:有哪些字符、它们的编号、这些编号如何编码成一系列的代码单元,以及最后这些单元如何组成八位字节流。区分这些概念的核心思想是建立一个能够用不同方法来编码的一个通用字符集。

为了正确地表示Unicode和通用字符集模型,需要更多比“字符集”和“字符编码”更为精确的术语表示。在Unicode Technical Report (UTR) #17中,现代编码模型分为5个层次。

抽象字符表(Abstract character repertoire)

抽象字符表是操作系统支持的所有抽象字符的集合,决定了计算机能够展现表示的所有字符的范围。字符表中的字符可以决定如何划分输入的信息流。例如将一段中文划分成文字、标点、空格等,它们都能按照一种简单的线性序列排列。值得一提的是,对它们的处理需要另外的规则,如带有变音符号的字母这样的特定序列如何解释。为了方便起见,这样的字符表可以包括预先编号的字母和变音符号的组合。其它的书写系统,如阿拉伯语和希伯莱语,由于要适应双向文字和在不同情形下按照不同方式交叉在一起的字形,就使用更为复杂的符号表表示。

字符表可以是封闭的,即除非创建一个新的标准(ASCII和多数ISO/IEC 8859系列都是这样的例子),否则不允许添加新的符号;字符表也可以是开放的,即允许添加新的符号(统一码和一定程度上代码页是这方面的例子)。

编码字符集(CCS:Coded Character Set)

编码字符集是将字符集C中每个字符映射到1个坐标(整数值对:x, y)或者表示为1个非负整数NC中的每个字符对应的坐标或非负整数就称为码位(code position,也称码点:code point)。在ASCII中(这里的ASCII仅指编码字符集)的字符A对应一个数字65,这个数字就是A的码位;在GB 2312中字在45区82位,所以其码位是45 82。码位其实也就是编码空间中的一个位置(position)。一个字符所占用的码位称为码位值(code point value)。

由于GB 2312 用区和位来表示字符,因此也称为区位码

1个编码字符集就是把抽象字符映射为码位值,编码字符集就是字符集和码位的映射,也是一个元素为映射的集合。多个编码字符集可以表示同样的字符表,例如ISO-8859-1和IBM的代码页037和代码页500含盖同样的字符表但是将字符映射为不同的整数。这样就产生了编码空间(encoding space)的概念。

所谓的编码空间,就是包含所有字符的表的维度。如果字符集的字符映射到一个非负整数N,如ISO-8859-1字符集中有256个字符,那就需要256个数字,每个字符对应一个数字,这所有的256个数字就构成了编码空间 (Code space),即编码空间为256(256个码位)。如果字符集中的字符映射到一个坐标,那就用一对整数来描述,如GB 2312 的汉字编码空间是94 x 94。

编码空间也可以用字符的存储单元尺寸来描述,例如:ISO-8859-1是一个8比特的编码空间。编码空间还可以用其子集来表述,如行、列、面(plane)等。

字符编码表(CEF:Character Encoding Form)

字符编码表也称为"storage format",是将编码字符集的码位转换成码元(code units,也称“代码单元”)的序列。

码元指一个已编码的文本中具有最短的位(bit)组合的单元,是一个有限位长的整型值。对于UTF-8来说,码元是8位长;对于UTF-16来说,码元是16位长;对于UTF-32来说,码元是32位长[1]。码值(Code Value)是过时的用法。

定长编码的字符编码表是码位到自身的映射(null mapping),但变长编码中有些码位只映射到一个码元,而另一些码位会映射到多个码元(即由多个码元组成的序列)。例如,使用16位长的存储单元保存数字信息,系统每个单元只能够直接表示从0到65,535的数值,但是如果使用多个16位单元就能够表示更大的整数。这种映射可以使得在位长不变的情况下映射无限多的码元,这就是CEF的作用。

最简单的字符编码表就是单纯地选择足够大的单位,以保证编码字符集中的所有数值能够直接编码(一个码位对应一个码值)。这对于能够用使用八位元组来表示的编码字符集(如多数传统的非CJK的字符集编码)是合理的,对于能够使用十六位元来表示的编码字符集(如早期版本的Unicode)来说也足够合理。但是,随着编码字符集的大小增加(例如,现在的Unicode的字符集至少需要21位才能全部表示),这种直接表示法变得越来越没有效率,并且很难让现有计算机系统适应更大的码值。因此,许多使用新近版本Unicode的系统,或者将Unicode码位对应为可变长度的8位字节序列的UTF-8,或者将码位对应为可变长度的16位序列的UTF-16。

字符编码方案(CES:Character Encoding Scheme)

字符编码方案也称作"serialization format"。它将定长的整型值(即码元)映射到8位字节序列,以便编码后的数据的文件存储或网络传输。

Unicode仅使用一个简单的字符来指定字节顺序是大端序或者小端序(但对于UTF-8来说并不需要专门指明字节序)。有些复杂的字符编码机制(如ISO/IEC 2022)会使用控制字符转义序列在几种编码字符集或者压缩机制之间切换。压缩机制用于减小每个单元所用字节数,常见的压缩机制有SCSU、BOCU和Punycode。

传输编码语法(transfer encoding syntax)

传输编码语法用于处理上一层次的字符编码方案提供的字节序列。一般其功能包括两种:一是把字节序列的值映射到一套更受限制的值域内,以满足传输环境的限制,例如Email传输时Base64或者quoted-printable,都是把8位的字节编码为7位长的数据;另一是压缩字节序列的值,如LZW或者行程长度编码等无损压缩技术。

五层模型与传统编码的术语比较

历史上的术语字符编码(character encoding),字符映射(character map),字符集(character set)或者代码页往往是同义概念,即字符表(repertoire)中的字符如何编码为码元的流(stream of code units)。通常每个字符对应单个码元。

所以例如ASCII这个名称,可能表示抽象字符集(ACR)、编码字符集(CCS)、字符编码表(CEF)、字符编码方案(CES)的任意一个或多个层次,在传统编码概念中ASCII这个名称也本身就代表一种字符编码,或者是一种字符集。具体表示什么要看上下文语义。平常我们所说的编码都在第三步的时候完成了,都没有涉及到CES。

代码页(Codepage)通常意味着面向字节的编码,但强调是一套用于不能语言的编码方案的集合。著名的如"Windows"代码页系列,"IBM"/"DOS"代码页系列。

字符映射(character map)在Unicode中保持了其传统意义:从字符序列到编码后的字节序列的映射,包括了上述的CCS, CEF, CES层次。

高层机制(higher level protocol)提供了额外信息,用于选择Unicode字符的特定变种,如XML属性xml:lang

由于不同国家和地区采用的字符集不一致,很可能出现无法正常显示所有字符的情况。微软公司使用了代码页转换表的技术来过渡性的部分解决这一问题,即通过指定的转换表将非Unicode的字符编码转换为同一字符对应的系统内部使用的Unicode编码。

Unix或Linux不使用代码页概念,它们用charmap,比locales具有更广泛的含义。

与上文的编码字符集(Coded Character Set - CCS)不同,字符编码(character encoding)是从抽象字符到代码字(code word)的映射。

HTTP(与MIME)的用法中,字符集(character set)与字符编码同义,但与CCS不是一个意思。

Unicode

Unicode(万国码、国际码、统一码、单一码)是计算机科学领域里的一项业界标准。它并不是一种具体的字符编码,而是对世界上大部分的文字系统进行了整理、编码,使得电脑可以用更为简单的方式来呈现和处理文字。

Unicode伴随着通用字符集的标准而发展,至今仍在不断增修,每个新版本都加入更多新的字符。Unicode编码包含了不同写法的字,如“ɑ/a”、“強/强”、“戶/户/戸”。可以简单地把通用字符集理解为五层模型中的抽象字符集(ACR),而Unicode就是字符编码表(CCS)。—— Wikipadia: Unicode

编码方式

Unicode使用16位的编码空间也就是每个字符占用2个字节。这样理论上一共最多可以表示2的16次方(即65536)个字符。基本满足各种语言的使用。实际上当前版本的统一码并未完全使用这16位编码,而是保留了大量空间以作为特殊使用或将来扩展。

随着Unicode的拓展,又增加了16个这样的平面。一开始的平面就称为基本多文种平面,也称0号平面,剩余的叫做辅助平面。两者合起来至少需要占据21位的编码空间,比3字节略少。Unicode将0到140万的编码空间范围的每个码位映射到单个或多个在0到655356范围内的码元。

事实上辅助平面字符仍然占用4字节编码空间,与UCS-4保持一致。未来版本会扩充到ISO 10646-1实现级别3,即涵盖UCS-4的所有字符。UCS-4是一个更大的尚未填充完全的31位字符集,加上恒为0的首位,共需占据32位,即4字节。理论上最多能表示231个字符,完全可以涵盖一切语言所用的符号。

更详细请参考Unicode字符平面映射

实现方式

Unicode的实现方式不同于编码方式。一个字符的Unicode编码是确定的。但是在实际传输过程中,由于不同系统平台的设计不一定一致,以及出于节省空间的目的,对Unicode编码的实现方式有所不同。

Unicode的实现方式也称为Unicode转换格式(Unicode Transformation Format,简称为UTF),目前主流的实现方式有UTF-16和UTF-8。随着Unicode通用字符集的扩充,进而出现了UTF-32,但是由于占用空间太大,目前很少有系统选择使用utf-32作为系统编码。

下面简单介绍UTF-8和UTF-16的实现。

UTF-8

如果一个仅包含基本7位ASCII字符的Unicode文件,如果每个字符都使用2字节的原Unicode编码传输,其第一字节的8位始终为0,这就造成了比较大的浪费。对于这种情况,可以使用UTF-8编码,这是一种变长编码,它将基本7位ASCII字符仍用7位编码表示,占用一个字节(首位补0)。而遇到与其他Unicode字符混合的情况,将按一定算法转换。

UTF-8每个字符使用1-3个字节编码,并利用首位为0或1进行识别。

对于UTF-8编码中的任意字节 B:

  • 如果B的第一位为0,那么代表当前字符为单字节字符,占用一个字节的空间。0之后的所有部分(7个位)代表在Unicode中的序号。
  • 如果B以110开头,那么代表当前字符为双字节字符,占用2个字节的空间。110之后的所有部分(5个位)加上后一个字节的除10外的部分(6个位)代表在Unicode中的序号。且第二个字节以10开头
  • 如果B以1110开头,那么代表当前字符为三字节字符,占用3个字节的空间。1110之后的所有部分(4个位)加上后两个字节的除10外的部分(12个位)代表在Unicode中的序号。且第二、第三个字节以10开头
  • 如果B以11110开头,那么代表当前字符为四字节字符,占用4个字节的空间。11110之后的所有部分(3个位)加上后两个字节的除10外的部分(18个位)代表在Unicode中的序号。且第二、第三、第四个字节以10开头
  • 如果B以10开头,则B为一个多字节字符中的其中一个字节(非ASCII字符)

如下表:

码位的位数 码位起值 字节序列 Byte1 Byte2 Byte3 Byte4 Byte5 Byte6
7 U+0000 U+007F 0xxx xxxx - - - - -
11 U+0080 U+07FF 110x xxxx 10xx xxxx - - - -
16 U+0800 U+FFFF 1110 xxxx 10xx xxxx 10xx xxxx - - -
21 U+10000 U+1FFFFF 1111 0xxx 10xx xxxx 10xx xxxx 10xx xxxx - -
26 U+200000 U+3FFFFFF 1111 10xx 10xx xxxx 10xx xxxx 10xx xxxx 10xx xxxx -
31 U+4000000 U+7FFFFFFF 1111 110x 10xx xxxx 10xx xxxx 10xx xxxx 10xx xxxx 10xx xxxx

基本多文种平面之外字符,使用4字节形式编码。这些字符很多时候无法直接显示在文本编辑器里。如𢆥。有时候也会被用于区分错误的字符。

我们分别看三个从一个字节到三个字节的UTF-8编码例子:

实际字符 在Unicode字库序号的十六进制 在Unicode字库序号的二进制 UTF-8编码后的二进制 UTF-8编码后的十六进制
$ 0024 010 0100 0010 0100 24
¢ 00A2 000 1010 0010 1100 0010 1010 0010 C2 A2
20AC 0010 0000 1010 1100 1110 0010 1000 0010 1010 1100 E2 82 AC

在文本编辑器中,一般会使用\u将一个十六进制数字转换为Unicode字库序号,进而识别出Unicode对应的字符。如$可以表示为\u0024
UTF-8的优缺点,以及其他更详细的描述,可以参考Wikipedia: UTF-8

UTF-16

  • 如果字符编码U小于0x10000,也就是十进制的0到65535之内,则直接使用两字节表示;
  • 如果字符编码U大于0x10000,由于UNICODE编码范围最大为0x10FFFF,从0x10000到0x10FFFF之间共有0xFFFFF个编码,也就是需要20个bit就可以标示这些编码。用U'表示从0-0xFFFFF之间的值,将其前 10 bit作为高位和16 bit的数值0xD800进行 逻辑or 操作,将后10 bit作为低位和0xDC00做 逻辑or 操作,这样组成的 4个byte就构成了U的编码。

由于一开始的Unicode只需要两个字节,所以UTF-16虽然也是变长编码方式,但是在最初却可以当做定长编码方式使用。UTF-16每个字符都直接使用两个字节存储,所以就有字节顺序的问题,同一字节流可能会被解释为不同内容。如某字符为十六进制编码4E59,按两个字节拆分为4E59,在Mac中和Windows中会解析如下:

- 读取顺序 显示字符
Windows 4E 59
Mac 59 4E

在Mac上从低字节开始和在Windows上从高字节开始读取显示不同,从而导致在同一编码下的乱码问题。为了解决这个问题便引入了字节顺序标记(英语:byte-order mark,BOM)来标记是大端序还是小端序。

对于辅助平面的字符,由于超过了一个16位可以表示的长度,所以需要两个16位来表示。处于前面的16位被称为前导,而后面的被称为后缀。所以UTF-16要么是2字节,要么是4字节。

如何获取前导和后缀?基本多文种平面有一段代理区,不代表任何字符,通过对代理区的计算,高10位加上0xD800就是前导,低10位加上0xDC00就是后缀。前导和后尾组成的代理对表示SP里的一个码位。

很多人误以为UTF-16在早期是定长编码,其实它一开始就是变长的,同时期真正的二字节定长编码是UCS-2。

BOM

字节顺序标记(英语:byte-order mark,BOM)是一个有特殊含义的统一码字符,码点为U+FEFF。当以UTF-16或UTF-32来将UCS/统一码字符所组成的字符串编码时,这个字符被用来标示其字节序。经常被用于区分是否为UTF编码。

字符U+FEFF如果出现在字节流的开头,则用来标识该字节流的字节序,是高位在前还是低位在前。如果它出现在字节流的中间,则表达零宽度非换行空格的意义,用户看起来就是一个空格。从Unicode3.2开始,U+FEFF只能出现在字节流的开头,只能用于标识字节序,就如它的名称——字节序标记——所表示的一样;除此以外的用法已被舍弃。取而代之的是,使用U+2060来表达零宽度无断空白。

UTF-8以字节为编码单元,没有字节序的问题。但是某些操作系统也会使用带BOM的UTF-8,叫做UTF-8 with BOM。Python中叫utf-8-sig。Unicode规范中说明UTF-8不必也不推荐使用BOM。多数时候UTF-8都是不带BOM的,但是微软公司的某些软件(如Excel)打开某些不带BOM的utf8文件(如cvs文件)会乱码,需要转换成带BOM的utf8编码才能正常显示。

所以Java中获取以UTF-16编码的字符串字节个数时,总是会比实际含有字符的字节个数多2。不过目前已经有很多主流的文本编辑器支持不带BOM的UTF编码了,通过后缀(LE和BE)区分是小端还是大端。

详见Wikipedia: 字节顺序标记

"Use of BOM is neither required nor recommended for UTF-8" ( Unicode 5.0.0 Chapter 2.6)
更详细的区别请查阅知乎:「带 BOM 的 UTF-8」和「无 BOM 的 UTF-8」有什么区别?

IDEA的UTF-8没有BOM,但是如果打开(解码)某个已经存在的文件,该文件使用带BOM的UTF-8编码,BOM会被忽略,但依然会保留。

另外,维基百科中阐述UTF-8和UTF-16属于五层模型的字符编码表(CEF)。但是个人理解,由于BOM的加入,实际上也包含了字符编码方案(CES)一层。所以UTF-8和UTF-16的实现实际包含了CEF和CES两个层次。UTF-8和UTF-16也可以说是基于Unicode的字符编码。但和ASCII和GB 2312等字符编码不同的是,前者使用通用字符集作为其ACR,而后者的ACR是自身规定(可能不通用)的。

知乎专栏: 字符编码的那些事对字符编码的阐述非常易懂,值得一看。

该使用什么编码?

非Unicode编码转换不当会造成各种乱码问题。那么具体应该如何选用合适的编码?

存储容量

先说UTF-16,由于每个码位都使用2到4个字节来存储,对于含有大量中文或者其他二字节长的字符流来说,UTF-16可以节省大量的存储空间。因为UTF-16并不需要像UTF-8那样通过牺牲很多标记位来标识一个字节表示的是什么,它只需一个字符来表示是大端序和小端序。

但是对于有大量西文字符的字符流来说UTF-8的优势就变得十分明显:UTF-8只需要一个字节就能存储西文字符,这是UTF-16做不到的。所以在混合存储,或者是源代码、字节码文件等大量西文字符的文件,更倾向于UTF-8。

UTF-8存储中文比UTF-16要多出50%,不推荐要大量显示中文的程序使用。—— 知乎轮子哥

而由于UTF-8的兼容性和对西文的支持,所以西方都提倡统一使用UTF-8作为字符编码,这样也的确可以彻底根除乱码问题。目前基本上所有的开发环境和源代码文件也基本上是统一UTF-8。

但是统一使用UTF-8真的就没问题了吗?不是的。

国内网站也曾经掀起过一阵子UTF-8的热潮,小网站倒也没什么,但几个大型网站很快发现改用UTF-8之后流量费刷刷刷地往上涨,因为同样一个汉字在GB2312里只有2字节,单在UTF-8里变成了3字节,流量增加50%,对于展示大量中文内容的网站来说简直就是灾难,即使使用所以过了没多久大网站们纷纷打定主意坚守GB系编码。对于需要数据库需要存储大量中文的网站,例如淘宝、CSDN等博客网站,不合适的编码方式在流量峰值时会造成不小的流量开销,必须是一个值得考虑的问题。

存储效率

这里只从UTF-8和UTF-16两个编码来简单阐述下效率问题。

因为每个字符使用不同数量的字节编码,所以UTF-8编码的字符串,寻找串中第N个字符是一个O(N)复杂度的操作。即串越长,则需要更多的时间来定位特定的字符。同时,还需要位变换来把字符编码成字节,把字节解码成字符。

而从UTF-16编码规则来看,仅仅将字符的高位和地位进行拆分变成两个字节。规则非常简单,编码效率很高,单字节O(1)的查找效率也非常好。

所以选什么编码不是一句话、一篇文章可以决定的,必须通过一些流量测试和考量。目前大型的网站一般都不会只用一种单一的编码,而是多种编码混用,配合缓存和数据库的表压缩来减小流量压力。

不过值得一提的是,这种时间效率问题正在随着内存和CPU的发展而减小,现在已经不会作为主要考虑的问题了。

更多的讨论参考知乎:编程语言的字符编码选择UTF-8和UTF-16的优缺点

Java中的编码

由于Java高级语言的特性,如对字符串的封装、char、运行时VM环境等对底层的多种封装,Java的编码也有很多值得详谈的地方。以上所有都是字符及编码的基础,下面来结合Java分析Java中的编码。

Java外部的编码

Java运行时环境和外部环境使用的编码是不一样的。外部环境的编码可以使用Charset.defaultcharset()获取。如果没有指定外部环境编码,就是操作系统的默认编码。jvm操作I/O流时,如果不指定编码,也会使用这个编码,可以在启动Java时使用-Dfile.encoding=xxx设置。通过System.setProperty("file.encoding","GBK")能修改这个值,但由于jvm一旦启动就不能修改jvm默认字符集,所以修改这个值并没有什么卵用。

file.encoding参数需要和sun.jnu.encoding作区分,后者主要设置的是下面三个地方的编码:

  • 命令行参数
  • 主类名称
  • 环境变量

关于Java外部使用的编码,深入分析 Java 中的中文编码问题已经说的非常详细,所以本文在这里只讲述JavaVM内部的编码。

Java内部的编码体系大致如图:

Java编码体系
Java编码体系

可以看到Java运行时主要的两个编码就是UTF-8和UTF-16,而编译的开始,就要将各种不同编码的源代码文件的转码成UTF-8。

其实不是UTF-8,是一种modified UTF-8,这里姑且先这么称呼。

编译时的编码转换

众所周知,Java的源文件可以是任意的编码,但是在编译的时候,Javac编译器默认会使用操作系统平台的编码解析字符,如果Java源文件是UTF-8编码的话,会造成乱码并拒绝编译:

Javac拒绝编译
Javac拒绝编译

要想正确编译,需要使用-encoding指定输入的Java源码文件的编码:

-encoding encodingSet the source file encoding name, such as EUC-JP and UTF-8. If -encoding is not specified, the platform default converter is used.

Javac默认是使用操作系统平台的编码进行编译,在简体中文的Windows上,平台默认编码会是GBK,那么javac就会默认假定输入的Java源码文件是以GBK编码的。

如果要想正确将源文件编译,即从橙色的源码文件到蓝色的编译器之间的箭头上,需要一个“桥梁”,而这个“桥梁”就是这个-encoding参数。通过这个参数javac能够正确读取文件内容并将其中的字符串以UTF-8输出到Class文件里,就跟自己写个程序以GBK读文件以UTF-8写文件一样。

当编译期正确解码源代码字符后进行编译操作,生成token和抽象语法树,这时候就不再直接对源代码文件的字符进行操作了,编译器已经将其编码为modified UTF-8的字节流并对字节流进行操作。导致乱码的不是Java源码编译器的“编码”(写出UTF-8)的过程,而是“解码”(读入Java源码内容)的过程。

JVM 规范中提到的modified UTF-8: Chapter 4. The class File FormatString content is encoded in modified UTF-8. Modified UTF-8 strings are encoded so that code point sequences that contain only non-null ASCII characters can be represented using only 1 byte per code point, but all code points in the Unicode codespace can be represented. Modified UTF-8 strings are not null-terminated. The encoding is as follows: ...

运行时数据中的UTF-16

JVM中运行时数据都是使用UTF-16进行编码的。可能有个疑问,既然UTF-8兼容性那么好,为何不统一使用UTF-16,而使用UTF-8?

于是又要开始讲历史了。在Unicode最初诞生的时候,由于当时只有一个16位长的基本多文种平面,也就是只有0~65535的空间,两个字节刚好够用。所以UTF-16相比UTF-8来说也是有很多优势的。当时很多比较主流的OS或者VM都是使用UTF-16作为默认字符编码,例如Windows NT和Java VM的runtime data,这也解释了为什么Java中char是两个字节。

Windows中的Unicode实际上代表UTF-16 LE,Unicode并不是一种编码方式。如果还不理解Unicode是什么,就把它当成一个协议,而UTF-XX是对协议的一种实现吧 ..

但是到了2001年,中国人大举入侵ISO和Unicode委员会,用已经颁布的GB18030-2000为基础,在Unicode 3.1标准中一口气加入了42711个CJK扩展字符,整个Unicode字符集一下增大到94205个字符,2个字节放不下了,UTF-16原来是变长编码的事也被人想起来了(中国人偷笑,GB系列从第一天起就是变长编码)。从此UTF-16就变得很尴尬,它一来存储空间利用率不高,二来又是个变长编码无法直接访问其中的码元。但是完全放弃UTF-16成本太高,所以现在JVM的运行时数据依然是UTF16编码的。

由于成本问题不能放弃UTF-16,但是UTF-8的兼容性和流行程度,又使得JVM必须做点什么来使得其内部数据不会被编码方式影响,于是就有了这个modified UTF-8。

那么modified UTF-8究竟是什么?

modified UTF-8

在通常用法下,Java程序语言在通过InputStreamReader和OutputStreamWriter读取和写入串的时候支持标准UTF-8。在Java内部,以及Class文件里存储的字符串是以一种叫modified UTF-8的格式存储的。DataInput和DataOutput的实现类也使用这种稍作修改的UTF-8来编码Unicode字符串,在使用中一般不会获取到modified UTF-8编码的字符串,但也有例外,例如readUTF方法。

String.getBytes("UTF-8") //拿到的字符串的标准UTF-8编码的字节数组
new String(bytes, "UTF-8") //是使用标准UTF-8的字节流构造String.

modified UTF-8 大致和 UTF-8 编码相同,但是有以下三个不同点:

  • 空字符(null character,U+0000)使用双字节的0xc0 0x80,而不是单字节的0x00。
  • 仅使用1字节,2字节和3字节格式,而UTF-8支持更多的字节
  • 基本多文种平面之外的补充字符以代理对(surrogate pairs)的形式表示

使用双字节空字符保证了在已编码字符串中没有嵌入空字节。因为C语言等语言程序中,单字节空字符是用来标志字符串结尾的。当已编码字符串放到这样的语言中处理,一个嵌入的空字符将把字符串一刀两断。

modified UTF-8在没有超过前三个字节表示的时候,和UTF-8编码方式一样,但是超过以后会以代理对(surrogate pairs)的形式表示。至于为什么要以代理对形式表示。这个是因为JVM的默认编码是UTF-16导致的。

一开始的Unicode只有一个可以用16位长完全表示的基本多文种平面,所以Java中的字符(char)为16位长,一个char可以存所有的字符。后来Unicode增加了很多辅助平面,两个字节存不下那些字符,但是为了向后兼容Java不可能更改它的基本语法实现,于是对于超过U+FFFF的字符 (就是所谓的扩展字符)就需要用两个16位长数据来表示,modified UTF-8由UTF-16格式的代理码元来代替原先的Unicode码元作为字符编码表的码元。每个代理码单元由3个字节(就是一个modified UTF-8编码出来的最大字节长)表示。所以在Java内部数据是统一使用modified UTF-8进行编码的,这个编码解码出来的码元是UTF-16编码出来的2字节。JVM把UTF-16编码出来的16位长的数据(2字节,操作系统用8位长的数据,即1字节)作为最小单位进行信息交换。这样的话既不改变原来JVM中的编码规则,又减少了很多扩展字符从UTF-8转码到UTF-16时的运算量,是不是很刺激?

modified UTF-8保证了一个已编码字符串可以一次编为一个UTF-16码,而不是一次一个Unicode码位,使得所有的Unicode字符都能在Java上显示。

不过也不是没有缺点的,使用modified UTF-8进行解码解出来的是UTF-16编码编出来的数据,而UTF-16处理扩展字符需要两个16位长表示。也就是说,要用两个代理码元共同表示一个Unicode码位。原本使用UTF-8编码只需要最多4个字节就能存储一个Unicode码位,使用modified UTF-8编码后却需要6个字节来存储两个代码单元。

总结起来就是,modified UTF-8是对UTF-16的再编码,modified UTF-8和UTF-8是两种完全不同的编码

所以JVM无需解码UTF-16的数据,modified UTF-8代理码元会处理这个映射关系。

文末

个人整理、理解,错漏之处在所难免,还望指教。

参考文献和引用:

  1. Wikipedia: 字符编码
  2. Wikipedia: Unicode
  3. Oracle Javadoc: Interface DataInput
  4. Unicode 5.0.0
  5. The Java® Virtual Machine Specification - Java SE 7 Edition
  6. IDEA Support: byte order mark, BOM problem with utf-8 files