Loading

小伙子又乱码了吧-Java字符编码原理总结

前提

配合前面阅读的I/O和NIO的资料,现在总结一下关于字符集和乱码问题的原理和解决方案。参考资料:

常用编码分类

ASCII

ASCII,也就是American Standard Code for Information Interchange,即美国标准信息交换代码。它是*于拉丁字母的一套电脑编码系统,主要用于显示现代英语和其他西欧语言。它是现今最通用的【单字节】编码系统,并等同于国际标准ISO/IEC 646。ASCII码是目前计算机中用得最广泛的字符编码。

ASCII码使用指定的7位或8位二进制数组合来表示128或256种可能的字符。标准ASCII码也叫*础ASCII码,使用7位二进制数来表示所有的大写和小写字母,数字0到9、标点符号,以及在美式英语中使用的特殊控制字符。需要特别注意:ASCII码与标准ASCII码的位数上的区分,标准ASCII码是7位二进制表示,它是单字节编码

在计算机的存储单元中,一个ASCII码值占一个字节(8个二进制位),不过字符码一般用十六进制来表示,28=162,所以可以用2个16进制数表示。十六进制以0x开头,所以字符码一般形式是0x****。

ASCII码值二进制位中的第一位(即最高位b7)为0,目的是用作奇偶校验。所谓奇偶校验,是指在代码传送过程中用来检验是否出现错误的一种方法,一般分奇校验和偶校验两种。奇校验规定:正确的代码一个字节中1的个数必须是奇数,若非奇数,则在最高位b7添1;偶校验规定:正确的代码一个字节中1的个数必须是偶数,若非偶数,则在最高位b7添1。

因为标准的ASCII码最多只有128个字符,这里找个完整的标准ASCII码表贴出来:

1.png

简单记一下规律:

  • 1、十进制值48到57代表数字0到9。
  • 2、十进制值65到90代表26个大写字母A到Z。
  • 3、十进制值97到122代表26个小写字母a到z。

另外,应该可以注意到,ASCII码不支持中文。

接着用Java代码验证一下:

    public static void main(String[] args) throws Exception{
        String value = "I have 1 doge";
        byte[] bytes = value.getBytes("ASCII");
        System.out.println(Arrays.toString(bytes).replace(",","").trim());
        System.out.println(new String(bytes,"ASCII"));
    }

输出:

[73 32 104 97 118 101 32 49 32 100 111 103 101]
I have 1 doge

注意上面输出的字节数组的每个字节是十进制表示的,映射关系图:

2.png

上图中的spe代表space,也就是空格。这里说一下,实际上编码的过程是char数组映射为byte数组(字符->字节),而解码的过程就是byte数组映射为char数组(字节->字符),见String的源码

    public byte[] getBytes(String charsetName)
            throws UnsupportedEncodingException {
        if (charsetName == null) throw new NullPointerException();
        //编码
        return StringCoding.encode(charsetName, value, 0, value.length);
    }

    public String(byte bytes[], int offset, int length) {
        checkBounds(bytes, offset, length);
        //解码,这里的value是char数组
        this.value = StringCoding.decode(bytes, offset, length);
    }

没错,因为标准ASCII码的可用字符比较少,所以可以按图说话,整个映射关系甚至可以手写出来,但是对于其他可选字符集比较大的字符编码类型,这个是不可能手动实现的。

ISO-8859-1

ISO-8859-1,也称为Latin1。ISO-8859-1也是单字节编码,128-255之间的字符用于拉丁字母表中特殊语言字符的编码(所以它叫Latin1[拉丁1]),向下兼容ASCII,其编码范围是0x00-0xFF(0-255),0x00-0x7F(0-127)之间完全和ASCII一致,0x80-0x9F(128-159)之间是控制字符,0xA0-0xFF(160-255)之间是文字符号。但是,由于是单字节编码,和计算机最*础的表示单位一致,所以很多时候,仍旧使用ISO-8859-1编码来表示。而且在很多协议上,默认使用ISO-8859-1编码。(参考一些博客和百科,ISO-8859-1支持部分欧洲使用的语言,因为此编码被称为"欧洲人",而ASCII被称为"美国人")。ISO-8859-1能够表示的范围也比较窄,因此也是无法表示中文。但是它是单字节编码的最大集,也就是其他编码一定向下兼容ISO-8859-1。如果要使用ISO-8859-1存储中文,必须把中文(一般是多字节编码,超过1个字节)拆分为多个单字节再使用ISO-8859-1编码存储。先举个例子,以GB2312编码为例,"中文"这两个词的GB2312编码为"d6d0 cec4"两个字符,四个字节,如果想用ISO-8859-1编码表示需要拆分为"d6 d0 ce c4"。

Unicode

Unicode(统一码、万国码、单一码)是国际组织制定的可以容纳世界上所有文字和符号的字符编码方案。Unicode 是为了解决传统的字符编码方案的局限而产生的,它为每种语言中的每个字符设定了统一并且唯一的二进制编码,以满足跨语言、跨平台进行文本转换、处理的要求。1990年开始研发,1994年正式公布。

UCS(Universal Character Set, 通用字符集)是由ISO制定的ISO 10646(或称ISO/IEC 10646)标准所定义的标准字符集。Unicode目前普遍采用的是UCS-2,它用【两个字节】(2byte=16bit)来编码一个字符,两个字节就是16位二进制, 2的16次方等于65536,所以UCS-2最多能编码65536(因为是无符号数表示)个字符。注意字符码一般用十六进制来表示,216=164,所以可以用四个16进制数表示。十六进制以0x开头,所以字符码一般形式是0x****。

Unicode编码从0到127的字符与ASCII编码的字符一样,比如字母"a"的Unicode编码是0x0061,十进制是97,而"a"的ASCII编码是0x61,十进制也是97。对于汉字的编码,事实上Unicode对汉字支持不怎么好,这也是没办法的, 简体和繁体总共有六七万个汉字,而UCS-2最多能表示65536个,总共才六万多个字符,所以Unicode只能排除一些几乎不用的汉字,好在常用的简体汉字也不过七千多个。Unicode字符集中,中、日、韩的三种文字占用了Unicode中0x3000到0x9FFF的部分(共28672个)。为了能表示所有汉字,Unicode也有UCS-4规范,就是用4个字节来编码字符。

Unicode没有规定字符对应的二进制码如何存储。以汉字“汉”为例,它的Unicode码点是0x6c49,对应的二进制数是 110110001001001,二进制数有15位,这也就说明了它至少需要2个字节来表示。可以想象,在Unicode字典中往后的字符可能就需要3个字节或者4个字节,甚至更多字节来表示了。这就导致了一些问题,计算机怎么知道你这个2个字节表示的是一个字符,而不是分别表示两个字符呢?这里我们可能会想到,那就取个最大的,假如Unicode中最大的字符用4字节就可以表示了,那么我们就将所有的字符都用4个字节来表示,不够的就往前面补0。这样确实可以解决编码问题,但是却造成了空间的极大浪费,如果是一个英文文档,那文件大小就大出了3倍,这显然是无法接受的。

于是,为了较好的解决Unicode的编码问题, UTF-8和UTF-16两种当前比较流行的编码方式诞生了。当然还有一个UTF-32的编码方式,也就是上述那种定长编码,字符统一使用4个字节,虽然看似方便,但是却不如另外两种编码方式使用广泛。

Java可以直接使用的字符集就是Unicode,表示的格式是:'\u十六进制表示(前面去掉0x)',例如"汉"这个字符的Unicode十六进制编码是"0x6c49",那么在Java直接使用为"\u6c49"即可,举个例子:

    public static void main(String[] args) throws Exception{
        String value = "\u4f60\u597d";
        byte[] bytes = value.getBytes("Unicode");
        System.out.println(Arrays.toString(bytes).replace(",","").trim());
        System.out.println(new String(bytes,"Unicode"));
    }

输出结果:

[-2 -1 79 96 89 125]
你好

前面说到Unicode编码的中文每个字符应该是是两个字节的,实际上这里的"你好"输出了6个字节(虽然都是十进制表示),那么为什么会这样子的呢?其实前面的两个字节是BOM(byte-order mark)头,主要是用于标识字节编码顺序,相关的内容下下面的"little-endian和big-endian"小节介绍。

UTF

由于对可以用ASCII表示的字符使用UNICODE并不高效,因为UNICODE比ASCII占用大一倍的空间,而对ASCII来说高字节的0对他毫无用处。为了解决这个问题,就出现了一些中间格式的字符集,它们被称为通用转换格式,即UTF(Unicode Transformation Format)。常见的UTF格式有:UTF-7, UTF-7.5, UTF-8,UTF-16, 以及UTF-32。下边只分析一下日常接触最多的UTF-8。

UTF-8(8-bit Unicode Transformation Format)是一种针对Unicode的可变长度字符编码,又称万国码。UTF-8是一个非常惊艳的编码方式,漂亮的实现了对ASCII码的向后兼容,以保证Unicode可以被大众接受。UTF-8是目前互联网上使用最广泛的一种 Unicode 编码方式,它的最大特点就是可变长。它可以使用1 - 4个字节表示一个字符,根据字符的不同变换长度。编码规则如下:

  • 1、对于单个字节的字符,第一位设为0,后面的7位对应这个字符的Unicode码点。因此,对于英文中的0 - 127号字符,与 ASCII码完全相同。这意味着ASCII码那个年代的文档用UTF-8编码打开完全没有问题。
  • 2、对于需要使用N个字节来表示的字符(N > 1),第一个字节的前N位都设为1,第N + 1位设为0,剩余的N - 1个字节的前两位都设位10,剩下的二进制位则使用这个字符的Unicode码点来填充。
UNICODE十六进制码点范围 UTF-8二进制
00000000 - 0000007F 0xxxxxxx
00000080 - 000007FF 110xxxxx 10xxxxxx
00000800 - 0000FFFF 1110xxxx 10xxxxxx 10xxxxxx
00010000 - 0010FFFF 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

我们这里举个例子,用"中"这个汉字做一次UTF-8编码和解码的推算过程。

首先随便找个"站长工具"查到"中"的Unicode码点为"\u4E2D",也就是十六进制为"0x4e2d"(1001110 00101101)。查看上面的表,位于第三行00000800 - 0000FFFF的范围,也就是它的UTF-8二进制格式一定是1110xxxx 10xxxxxx 10xxxxxx。然后从"中"的Unicode码点的十六进制表示的最后一个二进制位开始,依次从后向前填入格式中的x,多出的位补0。下面画个图简单表示一下编码过程:

3.png

最后得到11100100 10111000 10101101,也就是0xE4 0xB8 0xAD(找个UTF-8中文编码表校对一下,发现是正确的)。

解码的过程也十分简单:如果一个字节的第一位是0,则说明这个字节对应一个字符;如果一个字节的第一位1,那么连续有多少个1,就表示该字符占用多少个字节。然后做一次编码的逆向推算即可。

其他的UTF编码的原理不做探究,理解本质其实思路是相似的。

中文编码

【GB2312】是1980年国家制定的汉字内码规范,GB是"国标"的拼音缩写,2312是国标序号。GB2312标准共收录6763个汉字,其中一级汉字3755个,二级汉字3008个。同时,GB2312收录了包括拉丁字母、希腊字母、日文平假名及片假名字母、俄语西里尔字母在内的682个全角字符。GB2312的出现,*本满足了汉字的计算机处理需要,它所收录的汉字已经覆盖中国大陆99.75%的使用频率。

虽然GB2312包含了绝大部分的常用简体汉字,但是由于中文的复杂性,对于人名、古汉语等方面出现的罕用字,GB2312不能处理,如***的"*"字,GB2312中就没有包含,这样导致很多混乱。

正因为GB2312的这些问题,国家标准化委员会又制定了【GB13000】。GB13000制定的原则与GB2312不同,GB13000以国际化为目标,该标准编码参照了Unicode 2.0标准编码。GB13000与GB2312完全不兼容,因早期的计算机中的汉卡采用了GB2312,无法顺利向GB13000过渡,所以GB13000变成了一个纸面上的标准,无法推广。

有了以上经验,国家标准化委员会制定了【GBK】标准。GBK全称《汉字内码扩展规范》,GBK即"国标扩展"汉语拼音的第一个字母,英文名称:Chinese Internal Code Specification,于1995 年制定。GBK兼容GB2312标准,GBK标准中收录了21003个汉字及符号,通常把GB2312中的这6763字称为"常用字",而将包含在GBK而不包含在GB2312字符集内的汉字称为"生僻字"。另外,GBK在GB2312标准的*础上扩展了GB13000包含的字,但编码修改了。该标准一经推出,就被WINDOWS95所采用(另一种说法是微软协助制定了此标准,这也可以印证为什么GBK标准一直没有出现在官方的标准目录中)。因有微软的支持,该标准迅速得到广泛的应用。

2000年,国家标准GB18030-2000《信息交换用汉字编码字符集*本集的补充》发布,并且作为一项国家标准在2001年正式强制执行。【GB18030】是以汉字为主并包含多种我国少数民族文字(如藏、蒙古、傣、彝、朝鲜、维吾尔文等)的超大型中文编码字符集强制性标准,其中收入汉字70000余个。
因为码位不足,GB18030使用了2byte与4byte混合编码方式,这又给软件增加了难题,所以虽然GB18030推出了很多年,仍然没有得到广泛应用。

大五码【Big5】,是通行于台湾、香港地区的一个繁体字编码方案。大五码是由资策会于1984年策划制定,拥有13053个中文字、408个字符以及33个控制字元的字集,是我国早期中文电脑的业界标准,也是中文社群最常用的电子汉字字集标准。而后随着电脑扩充需要,业界各操作系统开发商推出了不同版本的大五码,为统一标准,经济部标准检验局在2003年委托财团法人中国数位化技术推广*金会修改了大五码编码字元表,重整为Big5-2003版本。VimIM在Vim环境中,可以直接键入十进制或十六进制Big5码。既不需要启动输入法,也不需要码表。

由此可知,一般而言,最常用的中文编码就是GBK。

little-endian和big-endian

在介绍Unicode的时候,其主要的规范UCS-2格式可以存储的码点的上限为0xFFFF(65536)。例如汉字"中"的Unicode码点为4E2D,需要用两个字节存储,一个字节是4E,另一个字节为2D。存储的时候,4E在前,2D在后,这就是big-endian(大端字节顺序)方式;2D在前,4E在后,这是little-endian(小端字节顺序)方式。

这两个古怪的名称来自英国作家斯威夫特的《格列佛游记》。在该书中,小人国里爆发了内战,战争起因是人们争论,吃鸡蛋时究竟是从大头(Big-endian)敲开还是从小头(Little-endian)敲开。为了这件事情,前后爆发了六次战争,一个皇帝送了命,另一个皇帝丢了王位。

其实字节顺序和存储字节的内存地址的顺序相关,下面还是用"中"这个汉字为例画个图说明一下:

4.png

简单理解就是:

  • big-endian:高位的字节存放在低位的内存地址。
  • little-endian:低位的字节存放在低位的内存地址。

那么很自然的,就会出现一个问题:计算机怎么知道某一个文件到底采用哪一种字节顺序?

Unicode规范定义,每一个文件的最前面分别加入一个表示编码顺序的字符,这个字符的名字叫做"零宽度非换行空格"(zero width no-break space),用FEFF表示。这正好是两个字节,而且FF比FE大1。如果一个文本文件的头两个字节是FE FF,就表示该文件采用大端字节顺序;如果头两个字节是FF FE,就表示该文件采用小端字节顺序。

这里引用一下阮老师的那篇文章的一个评论:

Unicode编码中表示字节排列顺序的那个文件头,叫做BOM(byte-order mark),FFFE和FEFF就是不同的BOM。
UTF-8文件的BOM是"EF BB BF",但是UTF-8的字节顺序是不变的,因此这个文件头实际上不起作用。有一些编程语言是ISO-8859-1编码,所以如果用UTF-8针对这些语言编程序,就必须去掉BOM,即保存成"UTF-8—without-BOM"的格式才可以,PHP语言就是这样。这里翻出之前提到的例子:

    public static void main(String[] args) throws Exception{
        String value = "\u4f60\u597d";
        byte[] bytes = value.getBytes("Unicode");
        System.out.println(Arrays.toString(bytes).replace(",","").trim());
        System.out.println(new String(bytes,"Unicode"));
    }

输出结果:

[-2 -1 79 96 89 125]
你好

我们知道十六进制转换为十进制:4f->79,60->96,59->89,7d->125,也就是"79 96 89 125"就是"你好"的字节数组的十进制表示,一共是4位。那么前面的2个字节"-2 -1"表示什么呢?下面先贴出一个BOM表示的表格:

9.png

另外,前面提到Java中直接使用Unicode其实就是"UTF-16",这里可以验证一下:

10.png

实际上"Unicode UnicodeBig UTF_16 utf16"最终都会映射到"UTF-16"。

现在可以对比上面的BOM表格,知道UTF-16的big-endian下的BOM是FE FF(十进制是254 255),little-endian下的BOM是FF FE(十进制是255 254)。但是我们知道,Java中byte的范围是[-128,127],超过127就无法表示了,这是因为byte类型是带符号的(signed),要表示范围[0,255]需要转化为"不带符号的(unsigned)byte"。很可惜在Java中不存在无符号的byte类型,这里实际上是需要把[-128,127]的byte类型,转化为[0,255]的int类型。操作的过程如下:

  • 如果byte类型目标值位于[0,127],直接用int强转即可。
  • 如果byte类型目标值位于[-128,-1],保留低位字节,高位字节用0填充,实际上就是相当于该目标值做运算"& 0xff"。

上面的过程伪代码如下:

byte target = "这里是目标值";
int result = -1;
if(target >= 0 && target <= 127){
    result = (int) target;
}
if(-128 <= target &&  target <= -1){
    result = (int)(target & 0xff)
}
//最后得到result

把上面"你好"的那个例子中十进制字节序列中的"-2"代入计算得到254,把"-1"代入计算得到255。刚好,十六进制的254就是"FE",十六进制的255就是"FF",也就是前两个字节是"FE FF",由BOM表查得是"UTF-16(大端序)"。如果比较熟悉NIO的话,ByteBuffer里面的order方法就是用来设置存储byte数组时候的字节顺序,在Java中默认使用big-endian。

乱码的成因和解决

如果文档中只有英文字母和常用的标点(也就是字符集全部包含在ASCII或者ISO-8859-1中),无论你怎么编码和解码都不会出现乱码。一般乱码出现是涉及到了中文字符的编码和解码。这里举一个例子说明乱码的产生过程。先尝试用ISO-8859-1编码一个带有中文字符和英文字符的字符串:

    public static void main(String[] args) throws Exception{
        String value = "我叫doge";
        byte[] bytes = value.getBytes("latin1");
        System.out.println(Arrays.toString(bytes).replace(",","").trim());
        System.out.println(new String(bytes,"latin1"));
    }

输出结果:

[63 63 100 111 103 101]
??doge

查一下上面的ASCII编码表,可知字符串中"doge"的编码和解码过程是完全正确的,出现问题的地方是"我叫"这两个中文字符转换为byte数组的处理过程。通过debug查看了处理encode过程的源码在sun.nio.cs.ISO_8859_1的内部类Encoder的encode方法:

        public int encode(char[] var1, int var2, int var3, byte[] var4) {
            int var5 = 0;
            int var6 = Math.min(var3, var4.length);
            int var7 = var2 + var6;

            while(var2 < var7) {
                //这里是进行一次ecode
                int var8 = encodeISOArray(var1, var2, var4, var5, var6);
                var2 += var8;
                var5 += var8;
                if (var8 != var6) {
                    char var9 = var1[var2++];
                    if (Character.isHighSurrogate(var9) && var2 < var7 && Character.isLowSurrogate(var1[var2])) {
                        if (var3 > var4.length) {
                            ++var7;
                            --var3;
                        }
                        ++var2;
                    }
                    //encode失败,写入repl的值
                    var4[var5++] = this.repl;
                    var6 = Math.min(var7 - var2, var4.length - var5);
                }
            }
            return var5;
        }
    }

当encode失败的时候,就会使用repl值去填充byte数组对应的encode失败的byte的位置,这个repl的值是63,对应ASCII编码表查得就是符号"?"。没错,在Java中使用ISO_8859_1字符集进行encode失败的时候对应的字节会被填充为63,也就是"?"符号。举一反三,其他类似的字符集编码和解码失败出现乱码极有可能就是填充了默认的字节或者错误映射出来的字节,所以有时候会看到奇形怪状的符号。最简单的解决方案就是:统一编码和解码使用的字符集类型,例如编码和解码都使用UTF-8,如果需要支持更多的中文,编码和解码可以使用GBK,如果需要支持繁体中文,那么编码和解码需要选择BIG5。

非字符集相关编码和解码

有时候"编码"和"解码"这两个词语只是做简单的字符映射,并不是字符和字节的映射,例如这里举例说一下BASE64编码和解码。

为什么需要BASE64?

因为有些网络传送渠道并不支持所有的字节,例如传统的邮件只支持可见字符的传送,像ASCII码的控制字符就不能通过邮件传送。这样就受到了很大的限制,比如图片二进制流的每个字节不可能全部是可见字符,所以就传送不了。最好的方法就是在不改变传统协议的情况下,开辟一种新的方案来支持二进制文件的传送。把不可见字符用可见字符来表示。而BASE64就是一种*于64个可见字符来表示二进制数据的表示方法。BASE64只包含26个字母的大小写和0-9十个数字以及符号"+"、"/"、"="。

BASE64码表:

5.png

当然,转换空缺位有可能补"="符号。

BASE64编码和解码规则:

BASE64编码规则:

  • 1、按字符串长度,以每3个8bit的字符为一组(如果只有一个字符或者两个字符的情况见下面的例子)。
  • 2、合并上面的24bit再重新分成4组,每组6bit。
  • 3、新分出来的四组每组高位补0。
  • 4、每组转化为10进制通过BASE64码表转换得到字符再拼接在一起。

BASE64编码注意点:

  • 要求编码字符是8bit,所以必须在ASCII范围内,不是ASCII码的需要转为ASCII码。
  • 编码完成后的字符长度不是4的倍数时,需要在剩余位置补"="符号。
  • 编码完成后的字符串长度一定是4的倍数。

举个例子,对"DOG"进行BASE64编码:

6.png

查询BASE64码表,最后得到"RE9H"。

特殊例子,对"O"单个字符进行BASE64编码:

7.png

查询BASE64码表,最后得到"RA",因为"RA"长度为2,为了满足长度为4的倍数,需要在后面补充"=",也就是最终得到"RA=="。

BASE64解码规则就是编码规则的逆向过程,这里不做详细分析。

BASE64只是用于编码和解码,目的是为了方便数据传输,不要错误理解为加密,因为编码和解码的过程是十分简单的

小结

如果看完这篇文章还不怎么理解编码和解码的话,如果不涉及生僻中文字符或者一些特殊的字符,最好全部使用"UTF-8"就没错了。另外,感谢阮一峰老师的文章,每次看完都有很大的收获。

(本文完)

技术公众号(《Throwable文摘》),不定期推送笔者原创技术文章(绝不抄袭或者转载):

娱乐公众号(《天天沙雕》),甄选奇趣沙雕图文和视频不定期推送,缓解生活工作压力:

posted @ 2018-06-15 17:49  throwable  阅读(6185)  评论(5编辑  收藏  举报