Unicode字符集与UTF-8编码

我们在讲字节流和字符流的时候提到过Unicode字符集与编码的问题。很多同学可能还是有点懵的。这一节课,针对这个问题,我们好好讲一下。如果你在网上进行搜索,会发现很多文章根本就没有分清字符集和编码的问题,然后瞎讲一通,把UTF-8和Unicode做为一个概念。这种文章流传广泛,影响深远,可谓是流毒甚深。

在Java web 程序中,我们经常会遇到乱码的问题,如果不搞清这个问题,还是会遇到很多令人头痛的问题。

字符集

字符集是一张码表,它规定了文字与数字的一一对应关系。与计算机的内部表示没有必然的联系。

比如ASCII字符集,它规定了0~127这128个数字与哪些字符的对应关系。这就是一个字典表,我去查 0x41是大写字母 'A', 0x61是小写字母'a'。这些都是在ascii码表里规定了的。

ASCII表示英文字符是够了,那为了表示汉字又该怎么办呢?这里,我们先不谈GB2312,GBK等等字符集,因为这些方案既包括了字符集,又包括了编码方案,这也是很多搞不清楚字符集和编码的原因之一吧。

我们今天只关注一种字符集,那就是Unicode字符集。

Unicode编码系统为表达任意语言的任意字符而设计。它使用4字节的数字来表达每个字母、符号,或者表意文字(ideograph)。每个数字代表唯一的至少在某种语言中使用的符号。(并不是所有的数字都用上了,但是总数已经超过了65535,所以2个字节的数字是不够用的。)被几种语言共用的字符通常使用相同的数字来编码。每个字符对应一个数字,每个数字对应一个字符。

在计算机科学领域中,Unicode(统一码、万国码、单一码、标准万国码)是业界的一种标准,它可以使电脑同时显示世界上数十种文字。Unicode 是基于通用字符集(Universal Character Set)的标准来发展,并且同时也以文本的形式对外发布。Unicode 还不断在扩增, 每个新版本插入更多新的字符。直至目前为止的第六版,Unicode 就已经包含了超过十万个字符(在2005年,Unicode 的第十万个字符被采纳且认可成为标准之一)。Unicode 组织(The Unicode Consortium)是由一个非营利性的机构所运作,并主导 Unicode 的后续发展,其目标在于:将既有的字符编码方案以Unicode 编码方案来加以取代,特别是既有的方案在多语环境下,皆仅有有限的空间以及不兼容的问题。

举个例子,在这篇文章里:知乎专栏。我们提到“海”这个汉字的unicode码是28023,说的就是在Unicode码表里,28023这个数字对应的是汉字“海”

编码方案

好了。我们现在已经有一个把数字与汉字对应起来的方案了。那么,这些数字在计算机里是怎么存储的呢?这就讨论到编码方案的问题了。

直观地想,现在unicode字符集已经超过了双字节的表示范围了(65536)。那最简单,就是使用int来表示一个字符,一个 int 是4字节的,这就解决问题了。但是,英文字母的话,本来一个字母只有一个byte就够了,现在转成unicode以后,却要无端地扩大了4倍,这是一种浪费。为了节约,所以人们又设计出来变长的编码方法来进行编码。今天我们就主要介绍其中最重要的一种:UTF-8

UTF-8的编码方法比较简单,大致可以这么描述:

1. 0~127,直接使用原码。比如0x61,在UTF-8里,就使用一个字节表示。其值就是0x61。

2. 两字节的UTF-8,都编码成这个样子:110XXXXX 10XXXXXX。这就是说,如果超过了127,就不能再使用一个字节进行编码了,要扩展成两字节编码。而两字节编码的情况呢,其中5位是固定的。剩下的11位可以用来编码。

第一字节的前三位是110,第二字节的前两位是10,比如128,它的二进制是1000 0000,那么它的UTF-8就必须是两字节的。把后面的6个0,编码到第二字节,把前面的10,编码到第一字节,那么结果就是:

110 00010,10 000000

3. 三字节的UTF-8的编码格式是这样的:1110 XXXX, 10 XXXXXX, 10 XXXXXX,可以编码16位数字,涵盖了大部分的常用汉字。我们按照上面所说的编码过程来编码一个汉字试一下。比如,海是28023,转成二进制是:110 1101 0111 0111。我们把这个数字的后6位放到第三字节,中间6位放到第二字节,头上4位放到第一字节。就得到这样的编码:

1110 0110, 10 110101, 10 110111

然后我们把他们转成byte的十进制形式。别忘了,最高位是符号位,1代表这个数是负号。我们按照之前补码的表示形式,把这三个字节转过来,就得到了:-26, -75,-73。好了,到此为止,我们就讲明白了,如何把一个汉字编码成UTF-8格式了。

还有4字节和5字节的编码格式,请大家自行查阅相关资料。我这里就不讲了。

我们还知道,直接使用字节流从控制台读入UTF-8编码的汉字时,读入的就是上面的三个字节,也就是原始的UTF-8编码。但如果使用字符流去读的话,得到的就是unicode码。这中间一定发现了什么事情。接下来,我们就看一下字节流向字符流转换的时候,究意发生了什么。

源码解析

与UTF-8编解码相关的代码位于jdk/src/share/classes/sun/nio/cs/UTF_8.java

我们这里只列举编码(encode)的程序,解码(decode)的程序大家自己看吧。

        public int decode(byte[] sa, int sp, int len, char[] da) {
            final int sl = sp + len;
            int dp = 0;
            int dlASCII = Math.min(len, da.length);
            ByteBuffer bb = null;  // only necessary if malformed

            // ASCII only optimized loop
            while (dp < dlASCII && sa[sp] >= 0)
                da[dp++] = (char) sa[sp++];

            while (sp < sl) {
                int b1 = sa[sp++];
                if (b1 >= 0) {
                    // 1 byte, 7 bits: 0xxxxxxx
                    da[dp++] = (char) b1;
                } else if ((b1 >> 5) == -2 && (b1 & 0x1e) != 0) {
                    // 2 bytes, 11 bits: 110xxxxx 10xxxxxx
                    if (sp < sl) {
                        int b2 = sa[sp++];
                        ....// 这里非法检查,略去。
                            da[dp++] = (char) (((b1 << 6) ^ b2)^
                                           (((byte) 0xC0 << 6) ^
                                            ((byte) 0x80 << 0)));
                        }
                        continue;
                    }
                    if (malformedInputAction() != CodingErrorAction.REPLACE)
                        return -1;
                    da[dp++] = replacement().charAt(0);
                    return dp;
                } else if ((b1 >> 4) == -2) {
                    // 3 bytes, 16 bits: 1110xxxx 10xxxxxx 10xxxxxx
                    if (sp + 1 < sl) {
                        int b2 = sa[sp++];
                        int b3 = sa[sp++];
                        if (isMalformed3(b1, b2, b3)) {
                           .....                            
                        } else {
                            char c = (char)((b1 << 12) ^
                                              (b2 <<  6) ^
                                              (b3 ^
                                              (((byte) 0xE0 << 12) ^
                                              ((byte) 0x80 <<  6) ^
                                              ((byte) 0x80 <<  0))));
                             .......               
                        }
                        continue;
                    }
                    if (malformedInputAction() != CodingErrorAction.REPLACE)
                        return -1;
                    if (sp  < sl && isMalformed3_2(b1, sa[sp])) {
                        da[dp++] = replacement().charAt(0);
                        continue;
                    }
                    da[dp++] = replacement().charAt(0);
                    return dp;
                } else if ((b1 >> 3) == -2) {
                    // 4 bytes, 21 bits: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
                    if (sp + 2 < sl) {
                        int b2 = sa[sp++];
                        int b3 = sa[sp++];
                        int b4 = sa[sp++];
                        int uc = ((b1 << 18) ^
                                  (b2 << 12) ^
                                  (b3 <<  6) ^
                                  (b4 ^
                                   (((byte) 0xF0 << 18) ^
                                   ((byte) 0x80 << 12) ^
                                   ((byte) 0x80 <<  6) ^
                                   ((byte) 0x80 <<  0))));
                        if (isMalformed4(b2, b3, b4) ||
                            // shortest form check
                            !Character.isSupplementaryCodePoint(uc)) {
                            .......
                        } else {
                            da[dp++] = Character.highSurrogate(uc);
                            da[dp++] = Character.lowSurrogate(uc);
                        }
                        continue;
                    }
                    if (malformedInputAction() != CodingErrorAction.REPLACE)
                        return -1;
                    b1 &= 0xff;
                    if (b1 > 0xf4 ||
                        sp  < sl && isMalformed4_2(b1, sa[sp] & 0xff)) {
                        da[dp++] = replacement().charAt(0);
                        continue;
                    }
                    sp++;
                    if (sp  < sl && isMalformed4_3(sa[sp])) {
                        da[dp++] = replacement().charAt(0);
                        continue;
                    }
                    da[dp++] = replacement().charAt(0);
                    return dp;
                } else {
                    if (malformedInputAction() != CodingErrorAction.REPLACE)
                        return -1;
                    da[dp++] = replacement().charAt(0);
                }
            }
            return dp;
        }
    }

代码中有大量的非法检查,我们把这些非法检查都去掉了。那么程序的主体就是进行移位拼装。大家可以试着把-26, -75,-73这三个byte,传到这个函数中来,看一看最后数组da中所得到的是不是汉字“海”的unicode码28023。测试的程序如下所示:

import java.nio.ByteBuffer;
import java.nio.CharBuffer;

import static java.nio.charset.StandardCharsets.UTF_8;

public class Main {
    public static void main(String args[]) {
        byte[] b = {-26, -75, -73};
        ByteBuffer bb = ByteBuffer.allocate(3);
        bb.put(b, 0, 3);
        bb.flip();
        CharBuffer cb = UTF_8.decode(bb);
        char c = cb.charAt(0);
        System.out.println(c);
    }
}

最后打印出来的结果就是一个汉字 “海”

好了。今天的作业,自己动手试一下自己的名字的unicode码。再试着把这个unicode编码成UTF-8。

再教大家一个小技巧:在QQ对话框的输入框里,按住ALT,然后用数字小键盘输入unicode码就可以得到汉字。例如,按住alt,用数字键盘输入28023,就可以得到海字。输入97,就得到小写字母 a,等等。也许可以用这个方式向你的女神表白哦~

2. 自己去翻翻JDK里的源代码,看看除了UTF-8之外,还有什么编码方式。查阅资料,翻看代码,试试能不能看懂。


上一节课:红黑树(三):TreeMap源码解析

下一节课:哈希表


目录:课程目录

编辑于 2017-03-01

文章被以下专栏收录