Teaser Image

mindwind

十日画一水,五日画一石




「多少年来,乱码问题一直是程序员心中的痛。 多少程序员,在乱码中迷乱却不见字符集的偷笑。」

今天一同事突然跑来找我,说我们之间的线上系统接口突然返回消息出现中文乱码了。 在此之前接口一直正常,但是代码在这期间我们都承认并没有作任何改动。 既然都说程序代码没有改动,那么我首先推测的必然是环境发生了改变。 咨询对方怎么处理接口返回的消息的,他们说进行了字符串字符集编码转换,于是让其粘贴代码来看看。

new String(msg.getBytes(), Charset.forName("GBK"));

于是看到了上面这段似曾相识的代码片段,这段代码是什么含义呢? 有人可能会说:“就是把 msg 字符串从其默认编码转换成 GBK 编码”。 这样想已经不是理解偏差,而是南辕北辙,大相径庭了。 上面那行代码用到了 String 类的两个 API:

public byte[] getBytes()
public String(byte bytes[], Charset charset)

String 类说是 java 中使用最频繁,最常用的类,应该毫无争议吧。 但可能很多人并没仔细看过这个最熟悉类的java doc。 getBytes()的 java doc 如下描述:

Encodes this String into a sequence of bytes using the platform’s default charset, storing the result into a new byte array.

意思是将该字符串使用平台默认字符集编码为一个字节序列并存储于返回的字节数组中。 而另一个构造函数 API 的 java doc 则说:

Constructs a new String by decoding the specified array of bytes using the specified charset.

意思是使用指定的字符集解码字节数组来构造一个新的字符串。

通过 java doc 的说明,我们明白了这个构造函数指定的字符集是用于解码第一个传入参数字节数组的,而不是表达用这个字符集构造一个新的字符串(噢哦,不知道还有多少人被这个构造函数的签名欺骗误导了)。 那么上面的那行代码其实在表达什么意思呢,这里就呼之欲出了。 它将 msg 字符串采用平台默认编码为字节数组,再基于此字节数组通过 GBK 进行解码来构造一个新的字符串。 那么这个过程中发生了什么事情,字符串编码如我们所想从平台默认变为 GBK?如果这样想就真是很傻很天真了。 其实上面代码的执行过程,如果平台默认编码就是 GBK,那么这个代码就是创建了一个新字符串和原来 msg 相等,没副作用,干点无用功。 前文提及的接口乱码的问题正是因为,原先对方平台编码为 GBK,昨天突然改变为 UTF-8 后导致的乱码。

而程序原作者期待的转换编码过程其实完全没有必要,因为 java 中字符串的编码统一采用了 unicode 字符集。 而 String 对象的构造过程,就是将指定字符集编码的字节数组转换为 unicode 编码的字符串。 在 java 中说什么 GBK 编码的字符串,ISO-8859-1 编码的字符串都是无稽之谈。 那么我们经常在 web 开发中使用的一种转码方式,也是一段熟悉的代码可能又会使某些人陷入困惑了,如下:

new String(request.getParameter("xxxx").getBytes("iso-8859-1"),"utf-8")

上面这段代码在基于 tomcat 开发的一些 web 应用中进行转码有效,这又是为什么? 其实原因是从浏览器传入的请求参数一般默认使用的 utf-8 编码,而 tomcat 接收到请求后将参数采用默认的 iso-8859-1 解码生成了一个错误的字符串参数。 要恢复就得先把字符串恢复为原始字节数组,再通过 utf-8 来解码生成正确得字符串,所以才有如上写法。 而如上写法,对于不明白浑浑噩噩的程序员,就误认为将 iso-8859-1 的字符串转换为了 utf-8 格式的字符串。(这也没什么好羞耻的,我也这样以为了好些年:)

但字符集编码转换可不是负负得正那么简单,我们之所以能正确的转换回来,是因为 iso-8859-1 的字符集是单字节字符集编码的。 下面举个简单例子来说明下字符集编码转换的问题。

// 单字节编码字符集
Charset iso88591 = Charset.forName("iso-8859-1");
// 双字节编码字符集
Charset big5 = Charset.forName("big5");
// 可变长度编码字符集
Charset utf8 = Charset.forName("utf-8");

// 原始字符串
String src = "测试";
// utf8 编码的字节数组
byte[] utf8Bytes = src.getBytes(utf8);
// 使用 iso-8859-1 错误解码的字符串(乱码)
String wrongStr = new String(utf8Bytes, iso88591);
// 使用 big5 错误解码的字符串(还是乱码)
String wrongStr2 = new String(utf8Bytes, big5);

System.out.println("wrongStr-iso88591-decoding = " + wrongStr + "    len=" + wrongStr.length());
System.out.println("wrongStr-big5-decoding     = " + wrongStr2 + "   len=" + wrongStr2.length());
System.out.println("orignal-utf8-bytes         = " + Arrays.toString(utf8Bytes));


// 把 iso-8859-1 错误解码的字符串恢复utf8编码的字节数组 - 可逆
byte[] resumeBytes = wrongStr.getBytes(iso88591);
String rightStr = new String(resumeBytes, utf8);
// 把 big5 错误解码的字符串恢复utf8编码的字节数组 - 不可逆
byte[] resumeBytes2 = wrongStr2.getBytes(big5);
String rightStr2 = new String(resumeBytes2, utf8);

System.out.println("resume-iso88591-utf8-bytes = " + Arrays.toString(resumeBytes));
System.out.println("resume-big5-utf8-bytes     = " + Arrays.toString(resumeBytes2));
System.out.println(rightStr);
System.out.println(rightStr2);

原始字符串 src,首先编码为 utf-8 的字节数组。 用 iso-8859-1 单字节字符集解码得到一个错误的字符串 wrongStr。 用 big5 双字节字符集解码得到一个错误的字符串 wrongStr2。 对两个错误的(乱码)字符串调用 getBytes(charset) 尝试还原为原始字节数组,再使用正确的编码 utf-8 解码生成正确的字符串。 可以看到如下输出结果:

wrongStr-iso88591-decoding = æµè¯   len=6
wrongStr-big5-decoding     = 瘚??   len=3
orignal-utf8-bytes         = [-26, -75, -117, -24, -81, -107]
resume-iso88591-utf8-bytes = [-26, -75, -117, -24, -81, -107]
resume-big5-utf8-bytes     = [-26, -75, 63, 63]
测试
???

中文 “测试” 两个字,采用 utf-8 编码,这两个字正好每个编码为 3 个字节,一共6个字节。 iso-8859-1 属于单字节字符集,解码生成的字符串字符和字节一一对应,因此解码后的乱码字符串 wrongStr 长度为 6。 而 big5 属于双字节字符集,解码生成的字符串每 2 个字节对应一个字符,因此解码后的乱码字符串 wrongStr2 长度为 3。 如上,[-26, -75] 字节组合解码为 big5 字符[瘚],而字节组合 [-117, -24] 和 [-81, -107] 在 big5 中没有对应的字符,因此被解码为[??]。 当从一个大字符集字节序列解码为一个小字符集字符串时,可能会产生解码丢失,并且不可逆转,因此如上随后我们用 big5 在编码回来时已经不是原来的字节序列了。

最后,我们在处理 java 编码问题时要区分清楚几个概念:

  1. java 字符串,内部统一采用 unicode 字符集。
  2. java 字符串之间不存在字符集转换,只有字节序列之间才存在字符集转换。

补充说明

unicode 是一种符号集,规定了符号的二进制代码,却没规定如何存储,现在的规模容纳了100多万种符号。 unicode 的不同存储方式,代表了 unicode 的多种实现方式。 utf-8 只是 unicode 的一种实现方式,类似的还有 utf-16, utf-32。 java 的字符串 和 jvm 运行时都是采用 utf-16 格式。