Java 中文字符串编码和转换原理(GBK 转 UTF-8)

发表于2016-12-03   10119次阅读

在Java的中,中文的编码转换经常会困扰很多人,网上很多文章都讲的是方法,却没有介绍原理,每次遇到同样问题是都要在网上翻一遍,再一个个去实验,浪费了很多开发时间。其实万变不离其宗,文本从原理出发,分析在Java在编码转换中的底层逻辑,从此让你原理这个困扰。

Photo credit: ljw via Visual hunt / CC BY-NC-SA

Char 和 Unicode

众所周知,Java 中的字符串使用Unicode作为编码的,不管是英文、中文还是其他字符都是以 char 类型存储的,占用两个字节(准确的说,应该是绝大多数Unicode字符都是2个字节,也有一些扩展字符是超过2个字节的,后面我们会举个例子)。要理解Java的编码原理,稍显要理解Unicode,口说无凭,举例为证:

        ////代码源文件是按照 UTF-8 编码
        String s1 = "你好";
        String s2 = "ab";

        System.out.println("s1.length():"+s1.length());
        System.out.println("s2.length():"+s2.length());

        System.out.println("s1.getBytes().length:"+s1.getBytes().length);
        System.out.println("s2.getBytes().length:"+s2.getBytes().length);

        System.out.println("s1.getBytes().length:"+s1.toCharArray().length);
        System.out.println("s2.getBytes().length:"+s2.toCharArray().length);

        System.out.println("charsToHex(s1.toCharArray()):"+charsToHex(s1.toCharArray()));
        System.out.println("charsToHex(s2.toCharArray()):"+charsToHex(s2.toCharArray()));

        System.out.println("\u4F60\u597D");
        System.out.println("\u0061\u0062");

输出结果为:

其中 s1.length()s2.length() 都返回 2,那么为什么都返回 2 呢、我们看下 String.length() 方法的源代码:

String.length()方法源码

value[] 变量声明源码)

可见,String.length() 方法仅仅是简单返回 value[] 数组的长度,value[]才真正用来保存字符串的内容,而且 value[] 变量是按照 char 类型存储的,可见,在Java中,每一个字符串的字符都是char类型,都占用2个字节。

s1.toCharArray().lengths2.toCharArray().length 同样返回 2,其原理和 length() 类似,我们再看下,toCharArray()方法的源码:

可见 toCharArray() 同样是利用 value[] 变量,仅仅是开辟了新的内存空间,重新复制了一下,长度和 value[] 一样,value[] 长度为 2,所以 toCharArray().length 返回的也是 2。

我们再将 s1 和 s2 的对应的 char 类型字节码按照16进制打印出来,验证Java 中是否是使用 Unicode 进行编码的,我们用 charsToHex() 方法将char 类型的数组转成16进制的字符串,代码如下:

    public static String charsToHex(char[] chars){
        StringBuffer sb = new StringBuffer();

        for(char each : chars){
            sb.append(Integer.toHexString((int) each));
        }

        return sb.toString().toUpperCase();
    }

上例中,charsToHex(s1.toCharArray()) 输出 4F60597DcharsToHex(s2.toCharArray()) 输出 6162(实际上应该是 00610062,这里省略了前导0),和 Unicode 中文字符集表 对照:

"你"字编码为 4F60:

"好"字的编码为 597D:

("a","b" 的编码和 ASCII一致)

和我们输出16进制结果是一致的,而且我们通过 Unicode 形式的字符串反向("\u4F60\u597D""\u0061\u0062")输出,得到的结果同样是“你好”和“ab”。

看到这里,我们至少强调了一个事实,Java 中 String 类型中真实的内容是按照 char 来存储的,不管是中文还是英文字符串中每一个字符占用一个 char 空间,而且都是用 Unicode 进行编码的。

那么可能有人会有疑问,Java 中使用 char 来保存字符,仅占用2个字节,最多 65536 个字符,那么世界上那么多种语言、文字,Unicode 保存得了吗?

别急,其实没有想象中那么严重,这个世界上字符最多语言非中文莫属,每一个文字都是一个字符。但是常用的汉字也就几千个,广泛使用的GBK中文编码收录的21,886个(参见维基百科《汉字内码扩展规范》)。在 Unicode 中,常用的中文编码范围 0x4E00-0x9FA5,包含20902个汉字,常见汉字都包含在内了。而且据维基百科Unicdoe介绍,“在Unicode 5.0的99089个字符中,有71226个字符与汉字有关”。

再者,Unicode 最新规范中也有很多扩展字符(比如很多生僻汉字),并不是按照一个字符占用两个字节来设计的,而是占用更多字节,可参考Unicode 字符集大全中的 CJK Extension 章节。口说无凭,再举个栗子(例子中的字符来自 CJK Extension B 字符集 够生僻吧,你会读吗?)

该字符对应的 Unicode 编码为:

从结果中可见,该字占用了两个 char 空间,代码中算两个字符。

编码

上面理解了 Unicode 之后,下面就要进入正题了。可能你会从上面的例子中看出来 s1.getBytes().lengths2.getBytes().length 一个返回 6 一个返回 2,而 s1.length()s2.length() 返回的却都是 2,这就涉及到真正的编码问题了。我们再针对编码问题扩充一下上面的例子:

        //代码源文件是按照 UTF-8 编码
        String s1 = "你好";
        String s2 = "ab";

        System.out.println("Charset.defaultCharset().name():"+Charset.defaultCharset().name());

        System.out.println("s1.length():"+s1.length());
        System.out.println("s2.length():"+s2.length());

        System.out.println("s1.getBytes().length:"+s1.getBytes().length);
        System.out.println("s1.getBytes(\"UTF-8\").length:"+s1.getBytes("UTF-8").length);
        System.out.println("s1.getBytes(\"GBK\").length:"+s1.getBytes("GBK").length);

        System.out.println("s2.getBytes().length:"+s2.getBytes().length);
        System.out.println("s2.getBytes(\"UTF-8\").length:"+s2.getBytes("UTF-8").length);
        System.out.println("s2.getBytes(\"GBK\").length:"+s2.getBytes("GBK").length);

        System.out.println("bytesToHex(s1.getBytes()):"+bytesToHex(s1.getBytes()));
        System.out.println("bytesToHex(s1.getBytes(\"UTF-8\")):"+bytesToHex(s1.getBytes("UTF-8")));
        System.out.println("bytesToHex(s1.getBytes(\"GBK\")):"+bytesToHex(s1.getBytes("GBK")));

        System.out.println("bytesToHex(s2.getBytes()):"+bytesToHex(s2.getBytes()));
        System.out.println("bytesToHex(s2.getBytes(\"UTF-8\")):"+bytesToHex(s2.getBytes("UTF-8")));
        System.out.println("bytesToHex(s2.getBytes(\"GBK\")):"+bytesToHex(s2.getBytes("GBK")));

输出结果为:

其中 bytesToHex() 是讲字节码转成16进制字符串,代码如下:

    final protected static char[] hexArray = "0123456789ABCDEF".toCharArray();
    public static String bytesToHex(byte[] bytes) {
        char[] hexChars = new char[bytes.length * 2];
        for ( int j = 0; j < bytes.length; j++ ) {
            int v = bytes[j] & 0xFF;
            hexChars[j * 2] = hexArray[v >>> 4];
            hexChars[j * 2 + 1] = hexArray[v & 0x0F];
        }
        return new String(hexChars);
    }

从输出结果中看,同样是“你好”,按照默认方式、UTF-8编码和GBK编码,得到的字节数组长度和编码并不一样。这说明,getBytes(),已经用了内置的编码转换了。过程中是如何做的编码转换的呢?我们深入代码看下。

都用到了 StringCoding.encode() 进行encode(编码)操作,StringCoding 是非共有类,外部不能直接使用。

(StringEncoder 构造函数)

在 StringEncoder 中利用 Charset.newEncoder() 来创建 CharsetEncoder 实例,CharsetEncoder 的实现类才是真正进行编码转换的类,不同的编码器有不同的实现,UTF-8 对应的 sun.nio.cs.UTF_8$Encoder,GBK 编码对应的实现类是 sun.nio.cs.ext.DoubleByte$Encoder,更多的 Encoder实现可以下下 sun.nio.cs 包下的内容,如图:

StringEncoder.encode 对应的源码如下图:

StringEncoder.encode 最终调用 ce(CharsetEncoder 的实现类,sun.nio.cs.UTF_8$Encoder 或者 sun.nio.cs.ext.DoubleByte$Encoder,针对不同的编码会使用不同的实现类)中的 encode 方法将原始字符内容(String 中的 char value[])转换成编码所对应的字节。

其核心原理可以用下面几段代码来表示:

        //根据编码创建对应的 Encoder 实例,这里是sun.nio.cs.UTF_8$Encoder
        CharsetEncoder ceUTF8 = Charset.forName("UTF-8").newEncoder(); 
        //这里仅仅是举例,实际上buffer长度通过计算获取
        ByteBuffer sbUTF8 = ByteBuffer.allocate(6);
        //真正的编码转换,修改字节编码,输出到sbUTF8中。
        ceUTF8.encode(CharBuffer.wrap(s.toCharArray()), sbUTF8, false); 

完整代码及输出结果如下:

        String s = "你好";

        System.out.println("-------------------------------");
        System.out.println("s.getBytes(\"UTF-8\").length:"+s.getBytes("UTF-8").length);
        System.out.println("s.getBytes(\"GBK\").length:"+s.getBytes("GBK").length);
        System.out.println("bytesToHex(s.getBytes(\"UTF-8\")):"+bytesToHex(s.getBytes("UTF-8")));
        System.out.println("bytesToHex(s.getBytes(\"GBK\")):"+bytesToHex(s.getBytes("GBK")));

        //根据编码创建对应的 Encoder 实例,这里是sun.nio.cs.UTF_8$Encoder
        CharsetEncoder ceUTF8 = Charset.forName("UTF-8").newEncoder(); 
        //这里仅仅是举例,实际上buffer长度通过计算获取
        ByteBuffer sbUTF8 = ByteBuffer.allocate(6);
        //真正的编码转换,修改字节编码,输出到sbUTF8中。
        ceUTF8.encode(CharBuffer.wrap(s.toCharArray()), sbUTF8, false); 

        System.out.println("-------------------------------");
        System.out.println(sbUTF8.array().length);
        System.out.println(bytesToHex(sbUTF8.array()));
        System.out.println(new String(sbUTF8.array(), "UTF-8"));

        System.out.println("-------------------------------");
        CharsetEncoder ceGBK = Charset.forName("GBK").newEncoder(); //实现类为 sun.nio.cs.ext.DoubleByte$Encoder
        ByteBuffer sbGBK = ceGBK.encode(CharBuffer.wrap(s.toCharArray()));
        System.out.println(sbGBK.array().length);
        System.out.println(bytesToHex(sbGBK.array()));

所以,Java 字符串的编码转换的核心是:

  • 所有的转换都是使用 String 对象中的 char[] value 进行转换,及 即从 Unicode 向其他编码转换
  • String.getBytes(编码),会自动获取对应编码的字节数组,底层已经通过各种编码对应具体 Encoder(如sun.nio.cs.UTF_8$Encoder) 类将字符串对应的字节数组进行转换。

真正的编码转换方法

说道这里,你可能会想,我进行编码转换是不是这样就可以:

String s = "你好";//UTF-8
//第二个参数也要写,否则会受当前 Charset.defaultCharset().name() 影响
String gbk = new String(s.getBytes("GBK"),"GBK");

其实,这样做并没有什么意义,在 String 内部依然会按照 Unicode 进行编码、即使 s.getBytes("GBK") 已经获得了按照GBK编码的字节数组。我们可以看下 String 类的构造函数以及底层代码:

可见,通过字节数组创建字符串只是 s.getBytes 的逆过程,其核心是调用 CharsetDecoder 的 decode 方法,和 CharsetEncoder 正好想法,原理一样。

所以这也是在Java中并不强调编码转换的原因,因为内部都是用的 Unicode 编码,真正有意义的转换仅仅发生在 s.getBytes('UTF-8') 这一步,如果再通过 new String(bytes,charset) 方式创建字符串,依然会转回 Unicode 编码。

结论

  • Java 中字符串保存在String类型中的 char[] 数组中,其编码永远为 Unicode。
  • 通过string.getBytes('UTF-8') 即可完成编码转换,所有的转换都是从unicode 转成对应编码的byte数组,如果再转回 String 类型,其内部依然是 Unicode 编码,不受外部编码影响。

参考阅读: