再再谈java乱码:GBK和UTF-8互转尾部乱码问题分析(续)

    • GBK字节码用UTF-8解码
    • UTF-8 的编码规则
    • 转码实例
    • 解决问题
      • jdk 18 测试
      • jdk 1617
      • jdk 版本的影响
    • 小结
    • 参考

在《再谈java乱码:GBK和UTF-8互转尾部乱码问题分析》我们分析了,如果从一个UTF-8 的字节序列,经过 new String(b,"GBK") 的操作,”可能”(与总字节数有关)会破坏数据。结果可能是,损失最后一个”字”。

反过来呢?可能会很惨,大范围溃散。。。

同时,可参考:一段java代码带你认识锟斤拷

GBK字节码用UTF-8解码

来看一段代码:

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());
}

输出结果:

中国人
GBK-8 字节码长度:6
D6 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字节码应该是:9
E4 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

转码实例

根据编码规则,我们手动来把一个汉字进行一个转码,来实际体验一下:

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"));
}

解决问题

jdk 1.8 测试

开头提出了问题,现在就解决问题。

例子中的三个汉字,用UTF-8 转一次为什么不是意料中的9字节,而是14个字节呢?

我们把代码改一下,打印一下二进制。

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());
}

输出结果:

6
D6 D0 B9 FA C8 CB
11010110 11010000 10111001 11111010 11001000 11001011
14
EF BF BD D0 B9 EF BF BD EF BF BD EF BF BD

来看一下 str = new String(b, "UTF-8"); 这一行到底干了什么事情?

原始的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个字节:

中国人
GBK-8 字节码长度:6
D6 D0 B9 FA C8 CB
******
EF BF BD D0 B9 EF BF BD
按照通常的经验,三个汉字的UTF-8长度,应该是9,然而不是。
UTF-8 字节码长度:8

从打印的日志来看,原字节码,前三个字节的分析没有问题。问题在于后面的三个字节,遇到错误的字节时,编码器直接用三位的占位符替换了错误的三个字节。

jdk 版本的影响

编码器的源码暂时没找到,先从表面上来看一下他们不同的编码规则的不同。

先看一个例子:

String str="中国86";
System.out.println(str);

byte[] b=str.getBytes("GBK");

str=new String(b,"UTF-8");
System.out.println(str);

输出结果:

比如用 “中国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

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

时间: 2024-10-03 17:05:20

再再谈java乱码:GBK和UTF-8互转尾部乱码问题分析(续)的相关文章

再谈java乱码:GBK和UTF-8互转尾部乱码问题分析

一直以为java中任意unicode字符串可以使用任意字符集转为byte[]再转回来只要不抛出异常就不会丢失数据事实证明这是错的. 经过这个实例也明白了为什么 getBytes()需要捕获异常虽然有时候它也没有捕获到异常. 言归正传先看一个实例. 用ISO-8859-1中转UTF-8数据 设想一个场景 用户A有一个UTF-8编码的字节流通过一个接口传递给用户B 用户B并不知道是什么字符集他用ISO-8859-1来接收保存 在一定的处理流程处理后把这个字节流交给用户C或者交还给用户A他们都知道这是

浅谈Java设计模式关于原型模式(Prototype)思索建议

java教程:关于Java设计模式关于原型模式(Prototype) IT信息技术http://www.52ij.com/jishu/ 首先需要弄清楚什么叫原型模式,或者说为什么要有原型模式,运用它会给我们带来什么或能解决什么问题?原型模式(Prototype)同抽象工厂模式同属于创建型模式,它主要关注于大量相同或相似对象的创建问题,应用原型模式就是先需要一个原对象,然后通过对原对象进行复制(克隆),来产生一个与原对象相同或相似的新对象.注意这里所说的对象相同不是指复制出来的副本对象与原对象是同

浅谈Java 对于继承的初级理解_java

概念:继承,是指一个类的定义可以基于另外一个已存在的类,即子类继承父类,从而实现父类的代码的重用.两个类的关系:父类一般具有各个子类共性的特征,而子类可以增加一些更具个性的方法.类的继承具有传递性,即子类还可以继续派生子类,位于上层的类概念更加抽象,位于下层的类的概念更加具体. 1.定义子类: 语法格式 [修饰符] class 子类名 extends 父类名{ 子类体 } 修饰符:public private protected default 子类体是子类在继承父类的内容基础上添加的新的特有内

从追MM谈Java的23种设计模式

Normal 0 7.8 磅 0 2 false false false EN-US ZH-CN X-NONE MicrosoftInternetExplorer4 从追MM 谈Java 的23 种设计模式   1 .FACTORY- 追MM 少不了请吃饭了,麦当劳的鸡翅和肯德基的鸡翅都是MM 爱吃的东西,虽然口味有所不同,但不管你带MM 去麦当劳或肯 德基,只管向服务员说" 来四个鸡翅" 就行了.麦当劳和肯德基就是生产鸡翅的Factory.   工厂模式:客户类和工厂类分开.消费者任

浅谈java异常处理之空指针异常_java

听老师说,在以后的学习中大部分的异常都是空指针异常.所以抽点打游戏的时间来查询一下什么是空指针异常 一:空指针异常产生的主要原因如下: (1)当一个对象不存在时又调用其方法会产生异常obj.method() // obj对象不存在 (2)当访问或修改一个对象不存在的字段时会产生异常obj.method() // method方法不存在 (3)字符串变量未初始化: (4)接口类型的对象没有用具体的类初始化,比如: List lt:会报错 List lt = new ArrayList():则不会报

浅谈Java反射与代理_java

Java反射机制与动态代理,使得Java更加强大,Spring核心概念IoC.AOP就是通过反射机制与动态代理实现的. 1 Java反射 示例: User user = new User(); user.setTime5Flag("test"); Class<?> cls = Class.forName("com.test.User"); //接口必须public,无论是否在本类内部使用!或者使用cls.getDeclaredMethod(),或者遍历修

浅谈java中BigDecimal的equals与compareTo的区别_java

这两天在处理支付金额校验的时候出现了点问题,有个金额比较我用了BigDecimal的equals方法来比较两个金额是否相等,结果导致金额比较出现错误(比如3.0与3.00的比较等). [注:以下所讲都是以sun jdk 1.4.2版本为例,其他版本实现未必一致,请忽略] 首先看一下BigDecimal的equals方法: public boolean equals(Object x){ if (!(x instanceof BigDecimal)) return false; BigDecima

浅谈java+内存分配及变量存储位置的区别_java

Java内存分配与管理是Java的核心技术之一,之前我们曾介绍过Java的内存管理与内存泄露以及Java垃圾回收方面的知识,今天我们再次深入Java核心,详细介绍一下Java在内存分配方面的知识.一般Java在内存分配时会涉及到以下区域: ◆寄存器:我们在程序中无法控制 ◆栈:存放基本类型的数据和对象的引用,但对象本身不存放在栈中,而是存放在堆中(new 出来的对象) ◆堆:存放用new产生的数据 ◆静态域:存放在对象中用static定义的静态成员 ◆常量池:存放常量 ◆非RAM存储:硬盘等永久

浅谈Java之Map 按值排序 (Map sort by value)_java

Map是键值对的集合,又叫作字典或关联数组等,是最常见的数据结构之一.在java如何让一个map按value排序呢? 看似简单,但却不容易! 比如,Map中key是String类型,表示一个单词,而value是int型,表示该单词出现的次数,现在我们想要按照单词出现的次数来排序: Map map = new TreeMap(); map.put("me", 1000); map.put("and", 4000); map.put("you", 3