在前一篇里我们谈了Unicode的代码单元及string.length,现在接着前面的讨论继续谈string.getBytes()方法并对乱码的产生作初步分析。
string.getBytes方法
首先声明一下,以下讨论如无特别说明,均是在Java语言环境下。如果你用的不是java,我只能说声抱歉。但另一方面,我相信无论是何种语言或平台,也必然有类似的方法及类似的处理,而其中的原理也必将是相通的,当然了,具体到细节上则可能会有些差异。
带参数的调用
首先,string.getBytes它可以带参数去调用,这是最简单的情形,如下
@Test
public void testGetBytesGbk() throws UnsupportedEncodingException {
String str = "hello你好";
assertThat(str.getBytes("GBK").length).isEqualTo(9);
}
因为GBK是变长编码,对ASCII字符采用一字节,汉字则是两字节,所以总的长度是1×5+2×2=5+4=9,所以测试是通过的。
注:本文代码均已经上传到开源中国oschina的git.oschina.net上,具体代码见http://git.oschina.net/goldenshaw/java_code_complete/blob/master/jcc-modules/jcc-core/src/test/java/org/jcc/core/encode/GetBytesTest.java(注:有些代码后来又做了修改,与下面截图中的一些可能有差异)
无参数的调用
此外,string.getBytes它又可以不带参数去调用,这是最容易引发误解的,也是乱码的一大根源。如下面的代码所示,那么这表示什么呢?
@Test
public void testDefaultGetByte() {
String str = "hello你好";
assertThat(str.getBytes().length).isEqualTo(9);
}
有人可能会想,既然String在内存中是以UTF-16编码,是不是指它用UTF-16编码时所用的字节呢?答案是否定的。可能有人已经知道这个问题怎么回事,他们会说,没有参数时就使用系统的缺省编码。可是等等,这里所谓“系统”究竟指什么?操作系统?如果你就是这么认为的话,你可能又错了。
所谓的缺省编码
缺省的编码究竟是哪种?有句话说得好:
是骡子是马,拉出来溜溜就知道了。
Eclipse下的缺省编码
“hello你好”这一串字符,前面说了,按GBK编码长度为9,让我们简单实验一下:(以下测试如无特殊说明均在Windows平台下完成,我的操作系统是64位win7):
咦?居然测试失败了,红条现身了。怎么回事?Windows系统缺省不是GBK吗?而且它所那个实际值11,则极大地暗示了使用了UTF-8作为缺省,我们知道BMP中,一个汉字是三字节,所以1×5+3×2=5+6=11。让我们用测试来证实一下:
果不其然,绿条显示测试通过,是UTF-8,怎么回事呢?还是要再次声明一下:
作者一直在windows下使用Live Writer写着博客,我也有虚拟机,上面也装了linux的Ubuntu,可是并没有开启,更没在上面作测试,一切测试都是在windows下做的。我向毛主席发誓!!
上图中的代码如下
@Test
public void testDefaultEncoding() {
assertThat(Charset.defaultCharset().toString()).isEqualTo("UTF-8");
assertThat(System.getProperty("file.encoding")).isEqualTo("UTF-8");
}
让我们用调试模式再跑一下此方法,以此获取eclipse运行此方法时的一些细节,在此不用设置断点。
在eclipse中,可以按下“Ctrl+Shift+向上(下)箭头”快速跳到方法名中,然后按下“Alt+Shift+D T”快速地以debug方式跑一下,“Alt+Shift+D T”表示按下“Alt+Shift+D”后紧接着再按“T”,是一种更加复杂的快捷键组合方式。当然了,你也可以鼠标选中方法名--右键—Debug As—JUnit Test。
然后在Debug视图中,选中运行的实例--右键--选择“properties”,在弹出的窗口中,我们终于发现了猫腻:
可以看到在Command Line中,eclipse传入了一个额外的参数“-Dfile.encoding=UTF-8”,我们可以大胆猜测一下正是这一参数改变了string.getBytes的缺省值!
注:其它平台下是什么情况,我不敢断言。实际上,eclipse之前的一些版本是否也是如此,我也说不准,我目前用的是win7下的64位eclipse kepler SR1。
注:这一值实际来自于当前工程所用编码。
命令行中的缺省编码
让我们跳过eclipse,直接在命令行中验证一下,上图中eclipse正好为我们列出了正常运行所需要的一系列classpath,我们直接拷贝来用。
要是没有eclipse生成这一堆classpath,我才不想去命令行下演示呢,敲这些玩意简直让人抓狂。我们还可以看到eclipse中并没有使用“java”这个命令来启动JVM,它直接就用了javaw.exe,所以也许你是否设置了JAVA_HOME对eclipse并没有什么影响。
执行的命令如下:
java
-classpath
D:\develop\oschina\java_code_complete\jcc-modules\jcc-core\target\test-classes;D:\develop\oschina\java_code_complete\jcc-modules\jcc-core\target\classes;D:\m2\repository\junit\junit\4.11\junit-4.11.jar;D:\m2\repository\org\hamcrest\hamcrest-core\1.3\hamcrest-core-1.3.jar;D:\m2\repository\org\assertj\assertj-core\1.5.0\assertj-core-1.5.0.jar;
org.junit.runner.JUnitCore
org.jcc.core.encode.EncodingTest
注:以上列出的classpath仅对本机适用,熟悉maven的同学可能已经看出classpath里的第一项就是源码文件夹(source folder)“test”下的类编译后缺省放置的位置,第二项则是源码文件夹“src”下的类编译后放置的位置,其实对这个例子而言这里并不需要这个,因为这是个纯粹为测试而写的测试类,并没有引用src下的任何类。其它的则是用到的jar了。还有我把maven的缺省库设置在了D:\m2\repository下。大家如有兴趣在本地亲自实验,则可按照上图方式拿到eclipse正常运行的Command Line中的classpath(它还包含了跟eclipse运行有关的一些jar,在命令行运行时可把那些去掉)。
另:git上的项目使用maven来构建的,如果对此不熟悉,请自行搜索了解。
以上的命令看上去有些乱,把classpath去掉的话,就简单一些了:
java org.junit.runner.JUnitCore org.jcc.core.encode.EncodingTest
进一步去掉包名则是
java JUnitCore EncodingTest
就两个参数而已,第一个参数JUnitCore类就是有main方法的要执行的类;第二个参数就是我们的测试类EncodingTest了,作为参数传递给JUnitCore。
这里是作为string参数传递进去的,我们可以推测,JUnit里面的实现自然会用到反射(reflection)之类的技术,另外,测试类中使用了注解(annotation),没有继承任何类,所以可以肯定这一点。
如果你对JUnit不太熟悉,甚至用久了IDE,对命令行已经很陌生,也可自行写个简单的带main方法的类来测试。总之,达到一切传入参数由我们掌控的目的即可。
另:如果在命令行下用junit来测试,我们无法像在eclipse中那样特别指定只测试其中的一个方法,这里对EncodingTest类中的所有的方法都进行了测试。
下面是执行的结果,可以看到这下缺省确实是GBK了,所以测试失败了:
这里我使用了绿色背景,所以看上去跟传统的黑色背景有些差别
让我们也加上-Dfile.encoding=UTF-8再跑一下,果然,最后一行的“OK”表示测试通过了:
图上还用红框圈出两个乱码字符,这点在下面再分析
那么现在一切已经很清楚了:
string.getBytes在没有指定参数的时候,它使用了JVM的缺省编码,如果启动JVM时没有明确设置编码,那么JVM就会使用所在操作系统的缺省编码;但如果启动时明确地设置了编码,那么这一设置将成为JVM中的缺省编码!
所以呢,这里的坑还是有些多的,而且坑里的水又是比较深的。如果你走路时是那种喜欢仰望星空的哲学家式的人,你一定要会游泳才行呀!
至于其它的平台,具体是怎么样的,是否与java一样存在不少的“坑”,这个无法一概而论,读者可根据所在平台的具体情况作具体分析。
乱码的初步分析
在前面的最后一张截图中可以看到,出现了两个乱码的字符“饾劄”,既来之,我们干脆就见招拆招,分析分析之。我们初步猜测是,当我们设置了-Dfile.encoding=UTF-8这一参数后,打印流也变成了UTF-8来编码,而命令行窗口依然按照GBK来解码传递过来的字节流,所以就出现乱码了。
问题回顾
让我们综合来看一下,首先,输出的问号及乱码是前面有一个方法里有打印语句导致的,在那里打印了一个错误的代理对及一个正确的代理对,在前面篇章也曾提及,图如下
当然了,JUnit是不赞成你使用打印语句的,JUnit强调自动化测试,所以一切判断应该由assert之类的语句去完成,而不应该打印出来,然后靠人眼去看去判断。在下图中,我们能看到,JUnit在测试成功一个方法后,会输出一个点(.),而在失败时则会输出一个E,而我们的打印流夹杂在其中,打乱了它的输出。
我们再来对比一下两次执行的细节:
首先无论是GBK还是UTF-8,前面那个错误代理对的打印都输出了两个??,表明都没有找到相应的字符。(图中蓝色部分)
但我们感兴趣的是第二个打印(图中左边红色部分),它以代理对方式实际打印的是那个U+1D11E的音乐符,可以看到,在第一个窗口中,还是只有一个问号,可是在第二次我们加入“-Dfile.encoding=UTF-8”后,输出了两个奇怪的字符“饾劄”,我们自然要问,为什么乱码了?更进一步的,为什么是这两个字呢?
在业余的时间,我喜欢看一些记录片,《重返危机现场》(Seconds from Disaster)是我喜欢的一个系列,由国家地理频道(National Geographic Channel)拍摄,片中对各类事故,如空难,列车出轨,航天飞机爆炸等的发生原因作了精彩而深刻的调查与分析,片头经常出现的一句名言就是:“Disasters don’t just happen。”(灾难不会凭空发生),与此类似,乱码也不是无缘无故的,当然了,我们的问题与那些比起来就是小巫见大巫了。
另:如果对空难有特别的兴趣,《空中浩劫》也是相当精彩的一个系列。
猜想与验证
让我们干脆做个深度历险,把这两个怪怪的字“饾劄”拷贝到程序中去,如下:
@Test
public void testGarbledCase() throws UnsupportedEncodingException {
String str = "饾劄";
String str2 = "\uD834\uDD1E";
assertThat(str.getBytes("GBK")).isEqualTo(str2.getBytes("UTF-8"));
System.out.println(DatatypeConverter.printHexBinary(str.getBytes("GBK")));
}
我敢说没几个人知道如何念这两个字,你们的语文水平也就这样了。你问我会不会念呀?这个。。。怎么说呢,今天天气还不错!其实我也不会啦~
我们猜测它是命令行窗口错误地以GBK编码方式去解码一段UTF-8的字节流导致的,让我们用测试来验证一下,并获取它的GBK编码看看:
可以看到,测试是通过的,我们还打印了GBK的字节输出,发现是F0 9D 84 9E,你是否觉得有点眼熟呢?再次看看前面发过的图:
其实从测试通过我们就知道,这两个字节数组必然是相等的。那么现在我们也大概能明白这个乱码是怎么一回事了,在此之前我们再说说另一个概念——代码页。
代码页(Code Page)
其实这也是处理字符集编码问题时经常遇到的一个概念了,虽然前面一直没怎么提到它,不过这里也不打算多么详细地去讲它:
不那么严格地去看,代码页可以看作是字符集编码的同义词,比如Code Page 936就相当于GBK,而Code Page 65001则相当于UTF-8。
可以通过在命令行窗口中输入“chcp”来查看当前代码页
chcp=change code page(改变代码页)
要是不带参数就是输出当前的代码页。
带参数则另起一个console,并把此新开的console的代码页设置为指定的值。(注:这一功能在我的电脑上执行时貌似有点问题,有时会开一个新的窗口,但窗口与字体都变得很小;有时又没开新窗口)
以下是查看当前活动代码页的一个截图:
还可以在标题栏--右键--属性--选项中查看,如下,可以看到936就是GBK
Code Page 936就是命令行窗口的缺省值,也即它缺省将使用GBK来解码它收到的字节流。
乱码的机理
现在是时候仔细分析一下这次乱码的产生机理了:
我们在代码中打印了一个代理对,即U+1D11E这个码点所代表的一个音乐符,在JVM的内存中就是以UTF-16的代理对编码形式存在的,可以想像在堆内存中有这么一个字节数组,它的值是(D8 34 DD 1E)。
我们在启动JVM时加入了“-Dfile.encoding=UTF-8”参数,所以缺省编码就成了UTF-8。
当打印发生时,会以缺省编码形式得到向外输出的字节流(字节数组),也即内部某处实质调用了string.getBytes(“UTF-8”),这样就得到了一个临时的字节数组(F0 9D 84 9E),其实就是UTF-8对U+1D11E的编码,JVM向命令行窗口输出这样一个字节数组,自然是希望在命令行中打印出一个音乐符来。
可是,命令行只是得到这么一串字节流(F0 9D 84 9E),这里不包含任何的编码信息,所以它还是愣头愣脑按着自己的缺省GBK来解码,它先拿到第一个字节F0(11110000),一看最高位是1,所以它认为这是一个汉字编码的第一个字节,于是它继续地读入第二个字节9D,并把(F0 9D)合一起去查GBK的码表,这一查还真查到一个字,就是“饾”了(我们觉得这像是一个乱码,可计算机知道什么呢?),所以它很高兴地向外输出了这么一个字符。至于后面的(84 9E)呢,道理是一样的,所以又输出了另一个字符“劄”。
其实通过前面的测试我们就知道了,“饾劄”用GBK编码后的字节数组恰恰与U+1D11E这个码点对应的音乐符以UTF-8编码后的字节数组相同,所以这就是故事的全部。
尽管我们以后要对付的乱码问题千差万别,但很多的问题其背后的基本原理与以上的例子没有本质区别。
string.getBytes的本质
另外在此也正好先说说string.getBytes的本质:
string.getBytes不过是把一种编码的字节数组转换成另一种编码的字节数组。
这里的一种编码在Java中就是UTF-16,这个已经定了,你不用操心,你也改不了!
这里的另一种编码则由你来指定,不指定就用缺省,反正得要有,没有还转个球!
所以呢,string.getBytes其实就是bytes.getBytes,不过是一堆的bytes变来变去。
在Java中呢,前面的bytes其实是限死了的,就是bytesInUTF16.getBytes(XXX)(怎么说呢,严格地讲,应该是codeUnitsInUTF16.getBytes(XXX),但另一方面,code unit底层也就是两个bytes),所以你只要指定后面一个参数,即你要把一串已经是UTF-16编码的bytes变成哪种编码的bytes。
那么转换的依据又是什么呢?自然就是bytes背后都要表示的是相同的抽象字符了。
比如有一串字节数组表示的是“hello你好”这7个字符,转换成另一种编码的字节数组后,在那种编码中,它所表示的也必须是“hello你好”这7个字符。具体的转换细节,我们在以后的篇章中再详细分析。
getBytes最好与new String一起结合来分析,一个是String到bytes,一个是bytes到String,更详细地分析可参考乱码探源系列中的以下篇章:
让解码与编码一致
既然前面说到,由于命令行窗口采用了GBK来解码UTF-8的字节流,从而导致了乱码,自然,我们就想,如果把命令行窗口也设置成UTF-8编码,事情不就OK了吗?让我们来试试。
在CMD下验证
前面说了,代码页65001就是UTF-8,那么就输入“chcp 65001”,回车,结果如下:
为了少截一些图片,图中同时把标题属性窗口也开了
可以看到“Active Code Page: 65001”的字样,同时标题属性窗口也证实了目前是UTF-8编码。
再次执行前面的命令:
可是情况并不如我们想像那样,可以看到出来四个问号,按理应该只出来一个字符(哪怕不能显示)。
更糟糕的是,如果我们换种字体,输出应该不会受此影响,但事实证明不是如此!下图中把字体从原来的“Consolas”换成了“点阵字体”
换完后的输出结果,变成了几个奇怪的字符!
结果完全无法理喻,可能是有bug,看来在windows的命令行窗口下是无法验证这点了。
由此也可看出,乱码真是挺麻烦的一件事,有时问题还不是出自于你,在这里不打算继承深挖下去了,怕没完没了。
让我们转战其它地方试试。
在git bash上验证
首先想到git bash,让我们看看:(注:这里要对classpath里面的内容用双引号括住,因为里面有分号对git bash有影响)
老问题,看来它也是用了GBK,转成UTF-8看看:
悲剧,不支持这个命令。又不清楚如何调整它的编码,囧,只好作罢。可机子上还装有cygwin,再一次转战。
有句话是怎么说来着?不要吊死在一棵树上,多找几棵树试试~
在cygwin上验证
输出$LANG时可看到,它缺省已经是UTF-8(窃喜,正愁不知如何调整呢~),直接上命令(注:这里同样要把classpath里面的内容用双引号括住,因为它也是模拟linux的console,所以不括住也会受里面分号的影响)
这次终于算是正常了,可看到只有一个字符,不过由于字库不支持增补字符的原因而无法显示,调整字体试试?
虽然这里列出了不少字体,至少比命令行窗口下要多得多了,但还是没有我后来下载的支持增补字符的字体,Word等软件里能列出的很多字体这里也没有,看来对console下能用什么字体还是有一些限制的,所以在console下显示增补字符这个希望也只能落空了。
非Windows平台,linux,mac…
我在这里就不演示了(其实我也不会~),你要是不知道如何去整?
丫的既然已经玩上了高大上的如Linux,怎么还会搞不掂这些简单的问题呢!你要是说恰巧知识有些盲点,那么俺也不懂,自己问度娘,谷哥,搜叔,必姨,36娌,雅夫等去,这些亲戚都很愿意回答你的任何问题,你要是还搞不掂,连俺都要鄙视你了:“就这水平还敢玩Linux,不装逼装Windows会死吗?”哥也在用Windows,哥表示不丢人,用得还挺舒畅。
不知道在开源社区说这些会不会招来怨恨?不过经常我们用的Windows倒是挺符合开源里的免费精神,哈哈,你们都懂的~我倒是听说开源里的那个free,更加强调是“自由”而不是免费,呵呵
记得Linus Torvalds好像说过,软件就像那个啥,sex?,然后呢,free更好。(Software is like sex: it's better when it's free.)也不知道大湿口里的free究竟是自由还是免费抑或是两者兼有之……
UTF-16编码的问题
经上所述,虽然八戒会爬树,缺省还是靠不住(八戒毕竟还是公的嘛)。很多的乱码问题,很可能就是这种多变的缺省所害的。所以不能依赖于这些缺省。前面已经做过明确指定GBK编码的测试,这次我们使用UTF-16再试下,可以先简单计算一下,“hello你好”7个字符都在BMP中都是两字节,所以7×2=14,对吧?再跑一下:
尼玛!!又见红了!咋猜啥啥不是呢?贝利的乌鸦嘴也没这么衰!仔细看看,它说实际是16,哪里又多出两个字节来?这里也没有什么增补平面的字符呀!没辙了,要么打印出来,要么直接断点查看,我们就简单打印它好了:
元凶终于现身了,就在最头部的地方,楞是多出了两字节“FEFF“,这是啥呢?我想有人看到这里已经明白了,这就是BOM,在下一篇我们再谈论这个话题。(至于原因嘛,这篇实在已经是太长了!)
来源:oschina
链接:https://my.oschina.net/u/1772009/blog/313077