在前一篇里我们谈了 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 的 gitee.com 上, 具体代码见 http://gitee.com/goldenshaw/java_code_complete/blob/master/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 时明确地设置了编码, 那么这一设置将成为 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, 对吧?
@Test
public void testGetBytesUTF16() throws UnsupportedEncodingException {
String str = "hello你好";
assertThat(str.getBytes("UTF-16").length).isEqualTo(14);
}
再跑一下:
尼玛!! 又见红了! 咋猜啥啥不是呢? 贝利的乌鸦嘴也没这么衰! 仔细看看, 它说实际是 16, 哪里又多出两个字节来? 这里也没有什么增补平面的字符呀!没辙了, 要么打印出来, 要么直接断点查看, 我们就简单打印它好了:
@Test
public void testGetBytesUTF16() throws UnsupportedEncodingException {
String str = "hello你好";
assertThat(str.getBytes("UTF-16").length).isEqualTo(16);
System.out.println(DatatypeConverter.printHexBinary(str.getBytes("UTF-16")));
}
元凶终于现身了, 就在最头部的地方, 楞是多出了两字节 FEFF, 这是啥呢? 我想有人看到这里已经明白了, 这就是 BOM, 在下一篇我们再谈论这个话题.