在《再谈java乱码:GBK和UTF-8互转尾部乱码问题分析》我们分析了,如果从一个UTF-8 的字节序列,经过 new String(b,"GBK") 的操作,"可能"(与总字节数有关)会破坏数据。结果可能是,损失最后一个"字"。
反过来呢?可能会很惨,大范围溃散。。。
同时,可参考:一段java代码带你认识锟斤拷
GBK字节码用UTF-8解码来看一段代码:
代码语言:javascript复制public static void main(String[] args) throws IOException, ParseException { String str="中国人"; System.out.println(str); byte[] b=str.getBytes("GBK"); System.out.println("GBK-8 字节码长度:"+b.length); printHex(b); System.out.println("******"); str=new String(b,"UTF-8"); b=str.getBytes("UTF-8"); printHex(b); System.out.println("按照通常的经验,三个汉字的UTF-8长度,应该是9,然而不是。"); System.out.println("UTF-8 字节码长度:"+b.length); System.out.println("******"); System.out.println("why?"); b="中国人".getBytes("UTF-8"); System.out.println("三个汉字的UTF-8字节码应该是:"+b.length); printHex(b);}private static void printHex(byte[] b) { StringBuilder sb=new StringBuilder(); for(byte t:b) {sb.append(Integer.toHexString((t & 0xF0)>>4).toUpperCase());sb.append(Integer.toHexString(t & 0xF).toUpperCase()).append(" "); } System.out.println(sb.toString());}输出结果:
代码语言:javascript复制中国人GBK-8 字节码长度:6D6 D0 B9 FA C8 CB******EF BF BD D0 B9 EF BF BD EF BF BD EF BF BD按照通常的经验,三个汉字的UTF-8长度,应该是9,然而不是。UTF-8 字节码长度:14******why?三个汉字的UTF-8字节码应该是:9E4 B8 AD E5 9B BD E4 BA BA原因在于,str=new String(b,"UTF-8"); 这行代码破坏了数据,而在此之前的数据是正常的。
UTF-8 的编码规则我们通常说,UTF-8字符集的汉字,每一个字占3个字节。我们并没有说过 UTF-8 字符集的一个字符都是3个字节。
UTF-8是一种变长字节编码方式,它的长度从1~6个字节都是合法的编码范围。
对于某一个字符的UTF-8编码,如果只有一个字节则其最高二进制位为0;
如果是多字节,其第一个字节从最高位开始,二进制位中连续的1的个数决定了其编码的位数,其余各字节均以10开头。
UTF-8最多可用到6个字节。
具体可以参看下表:
utf-8的字节数(byte)
有效数据位(bit)
1
0xxxxxxx
2
110xxxxx 10xxxxxx
3
1110xxxx 10xxxxxx 10xxxxxx
4
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
5
111110xx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx
6
1111110x 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx
我们来数一下x的数量,也就是每一种编码规则包含的有效数据位:
utf-8的字节数(byte)
有效数据位(bit)
1
7
2
5+6=11
3
4+6*2=16
4
3+6*3=21
5
2+6*4=26
6
1+6*5=31
那么,如果需要编码的bit数大于可以编码的bit数,则该编码方案无效。
假设需要编码的数据位为6 bits,那么这个六种方案都可以编码;如果需要编码的数据位为27 bits,那么只有6字节方案可以编码。
但事与愿违,抛开浪费空间不说,如果我们把3字节汉字的数据位前面强行置0,让它以4字节编码,数据转换过程还是会破坏,这里留一个疑问。
那么,4字节字符到底是什么?emoji,所谓Emoji就是一种在Unicode位于 \u1F601-\u1F64F 区段的字符。这个显然超过了目前常用的UTF-8字符集的编码范围 \u0000-\uFFFF 。
如 "{(byte)0xF0,(byte)0x9F,(byte)0x98,(byte)0x81}" 表示一个笑脸。
言归正传,实际上我们关注的是Unicode和UTF-8之间的关系:
Unicode符号范围
UTF-8编码方式
0000 0000-0000 007F
0xxxxxxx
0000 0080-0000 07FF
110xxxxx 10xxxxxx
0000 0800-0000 FFFF
1110xxxx 10xxxxxx 10xxxxxx
0001 0000-0010 FFFF
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
转码实例根据编码规则,我们手动来把一个汉字进行一个转码,来实际体验一下:
代码语言:javascript复制public static void main(String[] args) throws Exception { System.out.println("UTF-8:"); printBin("中".getBytes("UTF-8")); System.out.println("unicode:"); printOctet("中".getBytes("UTF-16BE")); //上面打印的unicode码是:01001110 00101101 //要转为UTF-8 ,我们要知道它占用了几个数据位 //数一数,去掉高位前面的0,是15个数据位 //查上面的表可以知道,可以使用3字节及以上的编码方案 //完整的unicode码被分段为:0100 111000 101101,分别拼接上头,如下: byte[] tmpb= {(byte)Integer.parseInt("1110"+"0100",2) //第一个字节是1110xxxx ,(byte)Integer.parseInt("10"+"111000",2) ,(byte)Integer.parseInt("10"+"101101",2) }; //打印看看,应该没问题 System.out.println(new String(tmpb,"UTF-8"));}解决问题Java8测试开头提出了问题,现在就解决问题。
例子中的三个汉字,用UTF-8 转一次为什么不是意料中的9字节,而是14个字节呢?
我们把代码改一下,打印一下二进制。
代码语言:javascript复制public static void main(String[] args) throws Exception { String str = "中国人"; byte[] b = str.getBytes("GBK"); System.out.println(b.length); printHex(b); printOctet(b);//就加了这一行 str = new String(b, "UTF-8"); b = str.getBytes("UTF-8"); System.out.println(b.length); printHex(b);}private static void printHex(byte[] b) { StringBuilder sb = new StringBuilder(); for (byte t : b) {sb.append(Integer.toHexString((t & 0xF0) >> 4).toUpperCase());sb.append(Integer.toHexString(t & 0xF).toUpperCase()).append(" "); } System.out.println(sb.toString());}private static void printOctet(byte[] b) { StringBuilder sb = new StringBuilder(); for (byte t : b) {sb.append(String.format("%08d", Integer.parseInt(Integer.toBinaryString(t & 0xFF)))).append(" "); } System.out.println(sb.toString());}输出结果:
代码语言:javascript复制6D6 D0 B9 FA C8 CB11010110 11010000 10111001 11111010 11001000 1100101114EF BF BD D0 B9 EF BF BD EF BF BD EF BF BD来看一下 str = new String(b, "UTF-8"); 这一行到底干了什么事情?
代码语言:javascript复制原始的byte[]为:11010110 11010000 10111001 11111010 11001000 11001011首先读取第一个字节,11010110,根据UTF-8 编码规则,因为110开头,编码器认为这是一个双字节的字,它会去取第二个字节,而且要求第二个字节必须是10开头。这时它发现错了,因为,他会用 "EF BF BD" 三个字节替换第一个字节,转成二进制,就是第二段字节流的:“11101111 10111111 10111101”。"EF BF BD" 是什么?前文已经说过,就是一个标准占位符。那么,第二个字节它已经拿出来了,根据规则,因为110开头,编码器还是当做一个双字节字处理,再取第三个字节,是10开头,符合规则,当做双字节处理,正常。因此,直接把 D0 B9 拼接到新的字节流里,现在新的字节流变成了:[EF BF BD] [D0 B9]第四个字节,11111010 以111110 开头,编码器认为这是一个5字节编码的UTF-8字,后面至少需要4个后续字节,明显不够了。因此,再拼接一个 "EF BF BD" ,新的字节流变成了:[EF BF BD] [D0 B9] [EF BF BD]依次处理第五、第六个字节,同样再次拼接了两个"EF BF BD" ,最终的字节流是:[EF BF BD] [D0 B9] [EF BF BD] [EF BF BD] [EF BF BD]14个字节。jdk 1.6/1.7如果使用 jdk 1.6 和 1.7 来运行用例,结论不同了,最终是8个字节:
代码语言:javascript复制中国人GBK-8 字节码长度:6D6 D0 B9 FA C8 CB******EF BF BD D0 B9 EF BF BD按照通常的经验,三个汉字的UTF-8长度,应该是9,然而不是。UTF-8 字节码长度:8从打印的日志来看,原字节码,前三个字节的分析没有问题。问题在于后面的三个字节,遇到错误的字节时,编码器直接用三位的占位符替换了错误的三个字节。
jdk 版本的影响编码器的源码暂时没找到,先从表面上来看一下他们不同的编码规则的不同。
先看一个例子:
代码语言:javascript复制String str="中国86";System.out.println(str);byte[] b=str.getBytes("GBK");str=new String(b,"UTF-8");System.out.println(str);输出结果:
代码语言:javascript复制比如用 “中国86" 来测试,java8,打印是这样的:�й�86而 java6、7打印是这样的:�й�如此看来,jdk6、7太暴力,发现一个异常字节,直接忽略后续2个字节,当做一个占位符,哪怕你后面两个字节 0x38 0x36 是可识别的ascii码。
因此jdk6、7的破坏性更强,java8的规则是优化了的结果,尽可能保留了有效数据,这也是unicode中占位符的初衷。
参见:https://en.wikipedia.org/wiki/Specials_(Unicode_block)#Replacement_character(https://en.wikipedia.org/wiki/Specials_(Unicode_block%29#Replacement_character)
Since the replacement is the same for all errors this makes it impossible to recover the original character.
小结先回顾一下前文的结论:
对于任意字节流,使用ISO-8859-1 转为字符串再转回来,是安全的;使用GBK和UTF-8可能会破坏数据。
现在扩展一下,使用GBK可能会破坏数据,损失最后一个字;如果使用UTF-8 可能损失大部分的字。
但这绝不是说UTF-8 是不好的,而是在这个乱码问题出现的时候,UTF-8是最惨烈的。实际上,UTF-8 尤其是动态长度的编码方案,无疑是最经济的。而且,4字节字符的出现,双字节编码方案,完全无法解决,唯UTF-8才是较好的选择(utf-8mb4)。
参考汉字unicode编码表:http://www.chi2ko.com/tool/CJK.htm emoji编码表: https://apps.timwhitlock.info/emoji/tables/unicode