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

摘要: 本文主要讲述 string.getBytes() 方法, 分析了系统缺省编码的各种陷阱, 并针对测试中出现的乱码作了初步的分析, 对代码页的概念也进行了介绍.

目录

在前一篇里我们谈了 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, 让我们简单实验一下:

缺省编码 getBytes 测试

以下测试如无特殊说明均在 Windows 平台下完成, 我的操作系统是 64 位 win7

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

Charset.defaultCharset 和 System.getProperty("file.encoding")

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

eclipse debug property

可以看到在 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

就两个参数而已:

  1. 第一个参数 JUnitCore 类就是有 main 方法的要执行的类;
  2. 第二个参数就是我们的测试类 EncodingTest 了, 作为参数传递给 JUnitCore.

这里是作为 string 参数传递进去的, 我们可以推测, JUnit 里面的实现自然会用到 反射(reflection) 之类的技术, 另外, 测试类中使用了 注解(annotation), 没有继承任何类, 所以可以肯定这一点.

如果你对 JUnit 不太熟悉, 甚至用久了 IDE, 对命令行已经很陌生, 也可自行写个简单的带 main 方法的类来测试. 总之, 达到一切传入参数由我们掌控的目的即可.

另: 如果在命令行下用 junit 来测试, 我们无法像在 eclipse 中那样特别指定只测试其中的一个方法, 这里对 EncodingTest 类中的所有的方法都进行了测试.

下面是执行的结果, 可以看到这下缺省确实是 GBK 了, 所以测试失败了:

cmd 下的 encoding test

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

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

cmd 下的 encoding test 加上 -Dfile.encoding=UTF-8 参数

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

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

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

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

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

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

乱码的初步分析

在前面的最后一张截图中可以看到, 出现了两个乱码的字符 饾劄, 既来之, 我们干脆就见招拆招, 分析分析之.

我们初步猜测是, 当我们设置了 -Dfile.encoding=UTF-8 这一参数后, 打印流也变成了 UTF-8 来编码, 而命令行窗口依然按照 GBK 来解码传递过来的字节流, 所以就出现乱码了.

问题回顾

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

错误顺序的代理对测试

当然了, JUnit 是不赞成你使用打印语句的, JUnit 强调自动化测试, 所以一切判断应该由 assert 之类的语句去完成, 而不应该打印出来, 然后靠人眼去看去判断.

在下图中, 我们能看到, JUnit 在测试成功一个方法后, 会输出一个点(.), 而在失败时则会输出一个 E, 而我们的打印流夹杂在其中, 打乱了它的输出.

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

cmd 缺省编码与指定编码的测试对比

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

代理对编码对比: utf-8 与 gbk

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

增补字符 音乐符在三种 unicode 编码的字节数组

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

代码页(Code Page)

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

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

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

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

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

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

cmd chcp 活动代码页

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

cmd 当前代码页 936 GBK

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, 更详细地分析可参考乱码探源系列中的以下篇章:

让解码与编码一致

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

在 CMD 下验证

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

cmd 当前代码页 65001 utf-8

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

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

再次执行前面的命令:

active code page 65001 下执行 cmd 命令

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

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

cmd 更换字体

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

cmd 无法显示增补字符

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

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

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

在 git bash 上验证

首先想到 git bash, 让我们看看:

git bash 下编码测试

注: 这里要对 -classpath 后面的内容用双引号括住, 因为里面有分号对 git bash 有影响

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

git bash 不支持 chcp

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

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

在 cygwin 上验证

输出 $LANG 时可看到, 它缺省已经是 UTF-8(窃喜, 正愁不知如何调整呢~), 直接上命令

cygwin 下测试增补字符的显示

注: 这里同样要把 -classpath 后面的内容用双引号括住, 因为它也是模拟 Linux 的 console, 所以不括住也会受里面分号的影响

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

cygwin 调整字体

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

再跑一下:

getBytes utf-16 时的长度异常问题

尼玛!! 又见红了! 咋猜啥啥不是呢? 贝利的乌鸦嘴也没这么衰! 仔细看看, 它说实际是 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")));
    }

getBytes utf-16 bom

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