字符集与编码(六)——getBytes 方法及乱码初步

2019/05/21 0 作者 Marco

在前一篇里我们谈了 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):

image_thumb26

咦?居然测试失败了,红条现身了。怎么回事?Windows 系统缺省不是 GBK 吗?而且它所那个实际值 11,则极大地暗示了使用了 UTF-8 作为缺省,我们知道 BMP 中,一个汉字是三字节,所以 1×5+3×2=5+6=11。让我们用测试来证实一下:

image_thumb28

果不其然,绿条显示测试通过,是 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”,在弹出的窗口中,我们终于发现了猫腻:

image_thumb30

可以看到在 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 了,所以测试失败了:

image_thumb32

这里我使用了绿色背景,所以看上去跟传统的黑色背景有些差别。

让我们也加上 -Dfile.encoding=UTF-8 再跑一下,果然,最后一行的“OK”表示测试通过了:

image_thumb36

图上还用红框圈出两个乱码字符,这点在下面再分析。

那么现在一切已经很清楚了:

string.getBytes 在没有指定参数的时候,它使用了 JVM 的缺省编码:

  • 如果启动 JVM 时没有明确设置编码,那么 JVM 就会使用所在操作系统的缺省编码;
  • 但如果启动时明确地设置了编码,那么这一设置将成为 JVM 中的缺省编码!

所以呢,这里的坑还是有些多的,而且坑里的水又是比较深的。如果你走路时是那种喜欢仰望星空的哲学家式的人,你一定要会游泳才行呀!

至于其它的平台,具体是怎么样的,是否与 java 一样存在不少的“坑”,这个无法一概而论,读者可根据所在平台的具体情况作具体分析。

乱码的初步分析

在前面的最后一张截图中可以看到,出现了两个乱码的字符“饾劄”,既来之,我们干脆就见招拆招,分析分析之。我们初步猜测是,当我们设置了 -Dfile.encoding=UTF-8 这一参数后,打印流也变成了 UTF-8 来编码,而命令行窗口依然按照 GBK 来解码传递过来的字节流,所以就出现乱码了。

问题回顾

让我们综合来看一下,首先,输出的问号及乱码是前面有一个方法里有打印语句导致的,在那里打印了一个错误的代理对及一个正确的代理对,在前面篇章也曾提及,图如下:

image_thumb15

当然了,JUnit 是不赞成你使用打印语句的,JUnit 强调自动化测试,所以一切判断应该由 assert 之类的语句去完成,而不应该打印出来,然后靠人眼去看去判断。在下图中,我们能看到,JUnit 在测试成功一个方法后,会输出一个点(.),而在失败时则会输出一个 E,而我们的打印流夹杂在其中,打乱了它的输出。

我们再来对比一下两次执行的细节:

image

首先无论是 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 编码看看:

image

可以看到,测试是通过的,我们还打印了 GBK 的字节输出,发现是 F0 9D 84 9E,你是否觉得有点眼熟呢?再次看看前面发过的图:

image

其实从测试通过我们就知道,这两个字节数组必然是相等的。那么现在我们也大概能明白这个乱码是怎么一回事了,在此之前我们再说说另一个概念——代码页。

代码页(Code Page)

其实这也是处理字符集编码问题时经常遇到的一个概念了,虽然前面一直没怎么提到它,不过这里也不打算多么详细地去讲它:

不那么严格地去看,代码页可以看作是字符集编码的同义词,比如 Code Page 936 就相当于 GBK,而 Code Page 65001 则相当于 UTF-8。

可以通过在命令行窗口中输入“chcp”来查看当前代码页:

chcp=change code page(改变代码页)

  1. 要是不带参数就是输出当前的代码页。
  2. 带参数则另起一个 console,并把此新开的 console 的代码页设置为指定的值。(注:这一功能在我的电脑上执行时貌似有点问题,有时会开一个新的窗口,但窗口与字体都变得很小;有时又没开新窗口

以下是查看当前活动代码页的一个截图:

image

还可以在标题栏–右键–属性–选项中查看,如下,可以看到 936 就是 GBK:

image

Code Page 936 就是命令行窗口的缺省值,也即它缺省将使用 GBK 来解码它收到的字节流

乱码的机理

现在是时候仔细分析一下这次乱码的产生机理了:

  1. 我们在代码中打印了一个代理对,即 U+1D11E 这个码点所代表的一个音乐符,在 JVM 的内存中就是以 UTF-16 的代理对编码形式存在的,可以想像在堆内存中有这么一个字节数组,它的值是(D8 34 DD 1E)。
  2. 我们在启动 JVM 时加入了“-Dfile.encoding=UTF-8”参数,所以缺省编码就成了 UTF-8。
  3. 当打印发生时,会以缺省编码形式得到向外输出的字节流(字节数组),也即内部某处实质调用了 string.getBytes(“UTF-8”),这样就得到了一个临时的字节数组(F0 9D 84 9E),其实就是 UTF-8 对 U+1D11E 的编码,JVM 向命令行窗口输出这样一个字节数组,自然是希望在命令行中打印出一个音乐符来。
  4. 可是,命令行只是得到这么一串字节流(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,更详细地分析可参考乱码探源系列中的以下篇章:

文本在内存中的编码(1)(2)(3)

让解码与编码一致

既然前面说到,由于命令行窗口采用了 GBK 来解码 UTF-8 的字节流,从而导致了乱码,自然,我们就想,如果把命令行窗口也设置成 UTF-8 编码,事情不就 OK 了吗?让我们来试试。

在 CMD 下验证

前面说了,代码页 65001 就是 UTF-8,那么就输入“chcp 65001”,回车,结果如下:

image

为了少截一些图片,图中同时把标题属性窗口也开了。

可以看到“Active Code Page: 65001”的字样,同时标题属性窗口也证实了目前是 UTF-8 编码。

再次执行前面的命令:

image

可是情况并不如我们想像那样,可以看到出来四个问号,按理应该只出来一个字符(哪怕不能显示)。

更糟糕的是,如果我们换种字体,输出应该不会受此影响,但事实证明不是如此!下图中把字体从原来的“Consolas”换成了“点阵字体”

image

换完后的输出结果,变成了几个奇怪的字符!

image

结果完全无法理喻,可能是有 bug,看来在 windows 的命令行窗口下是无法验证这点了。

由此也可看出,乱码真是挺麻烦的一件事,有时问题还不是出自于你,在这里不打算继承深挖下去了,怕没完没了。

让我们转战其它地方试试。

在 git bash 上验证

首先想到 git bash,让我们看看:(注:这里要对 classpath 里面的内容用双引号括住,因为里面有分号对 git bash 有影响

image

老问题,看来它也是用了 GBK,转成 UTF-8 看看:

image

悲剧,不支持这个命令。又不清楚如何调整它的编码,囧,只好作罢。可机子上还装有 cygwin,再一次转战。

有句话是怎么说来着?不要吊死在一棵树上,多找几棵树试试~

在 cygwin 上验证

输出 $LANG 时可看到,它缺省已经是 UTF-8(窃喜,正愁不知如何调整呢~),直接上命令(注:这里同样要把 classpath 里面的内容用双引号括住,因为它也是模拟 Linux 的 console,所以不括住也会受里面分号的影响

image

这次终于算是正常了,可看到只有一个字符,不过由于字库不支持增补字符的原因而无法显示,调整字体试试?

image

虽然这里列出了不少字体,至少比命令行窗口下要多得多了,但还是没有我后来下载的支持增补字符的字体,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,对吧?再跑一下:

image

尼玛!!又见红了!咋猜啥啥不是呢?贝利的乌鸦嘴也没这么衰!仔细看看,它说实际是 16,哪里又多出两个字节来?这里也没有什么增补平面的字符呀!没辙了,要么打印出来,要么直接断点查看,我们就简单打印它好了:

image

元凶终于现身了,就在最头部的地方,楞是多出了两字节“FEFF“,这是啥呢?我想有人看到这里已经明白了,这就是 BOM,在下一篇我们再谈论这个话题。