一、为什么需要编码

    在计算机中存储信息的最小单位是1个字节,即8bit,所以能标识的最大字符范围是0~255,而人类自然语言中例如汉语、日语要表示的符号太多,无法单纯用一个字节来完全表示,为了解决这个矛盾必须要有一个新的人类可识别的数据存储结构字符,而从char到byte必须编码。

 
二、常见的编码格式

1 - ASCII码

    总共128个,使用一个字节的低7位表示,0~31是控制字符如换行、回车、删除等,32~126是打印字符,可以通过键盘输入并且能够显示出来

 

2 - ISO-8859-1

    ISO组织在ASCII码的基础上利用了单个字节的所有位定制了一系列标准扩展了ASCII编码,ISO-8859-1仍然是单字节编码,总共可以表示256个字符

 

3 - GB2312

    GB2312全称是《信息技术中文编码字符集》,它采用了双字节编码,总的编码范围是A1~F7,其中A1~A9是符号区,总共包含682个符号;B0~F7是汉字区,包含6763个汉字。

 

4 - GBK

    GBK全称是《汉字内码扩展规范》是国家技术监督局位windows 95制定的新的内码规范,它的出现是为了扩展GB2312,并加入更多的汉字,它的编码范围是8140~FEFE,总共有23940个码位可以表示21003个汉字,它的编码与GB2312兼容,也就是说采用GB2312编码的汉字均可以通过GBK去解码,并且不会有乱码。

5 - UTF-16

    UTF-16定义了Unicode字符在计算机中的存取方法,UTF-16使用两个字节表示Unicode的转化格式,这是一个定长的表示方法,无论什么字符UTF-16都用两个字节表示,2个字节就是16位,所以称之为UTF-16,UTF-16表示字符非常方便,每两个字节表示一个字符,在字符串操作的时候大大简化了操作,这个也是Java以UTF-16作为内存的字符存储格式的一个很重要的原因。

Unitcode使用UTF-16编码规则如下:

    UTF-16编码以16位无符号整数为单位,我们

    

 

6 - UTF-8

    UTF-16统一采用两个字节表示一个字符,虽然在表示上非常简单方便,但是也有其缺点,有很大一部分字符使用一个字节就可以表示的现在需要两个字节表示,存储空间增大了一倍,在现在网络带宽还非常有限的今天,这样做无疑会增大网络传输的流量,而且也没什么必要,而UTF-8采用的是一种变长的技术,每种编码区域都会有不同的字节长度,不同类型的字符可以有1~6个字节组成。

UTF-8有以下编码规则

    1.如果是1个字节,最高为(第8位)为0,则表示这是1个ASCII字符(00~7F)。可见,所有ASCII编码已经是UTF-8了。

    2.如果是1个字节,以11开头,则连续的1的个数暗示这个字符的字节数,例如:110xxxxx代表它是双字节UTF-8字符的首字节。

    3.如果是1个字节,以10开始,表示它不是首字节,则需要向前查找才能得到当前字符的首字节。

 
三、在Java中需要编码的场景

1 - 在IO操作中存在的编码

    我们知道在涉及编码的地方一般都在从字符到字节或者是从字节到字符的转化过程中,而需要这种转化的场景主要是I/O,而这个IO主要包括磁盘IO和网络IO。

    在Java中负责在IO过程中处理字节到字符转换的是InputStreamReader,它继承自Reader,在类创建时关联了一个字节输入流InputStream对象,对具体字节到字符的转换它主要委托给内部的StreamDecode去做,StreamDecoder在解码过程中需要基于指定的Charset获取对应的CharsetDecoder将字节流为字符,如果没有指定将使用操作系统默认的编码方式,在中文环境中通常时GBK。相反在java中关联字符到字节转换的桥梁是OutputStreamWriter,他继承自Writer,在类创建时关联了字节输出流OutputStream对象,对具体字符到字节的转换它主要委托给内部的StreamEncoder,StreamEncoder获取对应Charset的CharsetEncoder编码器将字符编码位字节,如果没有指定字符集Charset通常采用本地操作系统默认的字符集进行编码。

    在我们的应用程序中涉及I/O操作时,只要注意指定统一的编解码Charset字符集,一般不会出现乱码问题。

    对有些应用程序如果不注意指定字符编码,则在中文环境中会使用操作系统默认编码。如果编解码都在中文环境中,通常也没有问题,但还是不推荐使用操作系统的默认编码,因为这样会使你的应用程序的编码格式和运行环境绑定起来,在跨环境时很可能出现乱码问题。

 

2 - 在内存操作中的编码

    在Java开发中除I/O涉及编码外,最常用的应该就是在内存中进行从字符到字节的数据类型转换,在Java中用String表示字符串,所以String类就提供了转换到字节的方法,也支持将字节转换为字符串的构造函数。

    String s = "这是一段中文字符串";
    byte[] b = s.getBytes("UTF-8");  
    String str = new String(b,"UTF-8");

    在程序中这三行代码一共经历了以下过程:

1)UTF-16输入流到Unicode的解码(在JVM中发生)

2)Unicode到UTF-8编码的输出流

3)UTF-8输入流到Unicode的解码

4)Unicode到UTF-16的编码(在JVM中发生)

思考下代码最终执行结果字节数组a与b是否相同,结合Java默认的编码方式以及JVM内部默认采用存储汉字的编码方式,分析这一过程

    byte[] a = new byte[]{(byte) 0xc6, (byte) 0xd0};
    String s = new String(a);
    byte[] b = s.getBytes();

Charset提供encode与decode,分别对应char[]到byte[]的编码和byte[]到char[]的解码。

    String s = "这是一段中文字符串";
    Charset charset = Charset.forName("UTF-8");
    ByteBuffer byteBuffer = charset.encode(s);
    CharBuffer charBuffer = charset.decode(byteBuffer);

 
四、在Java中如何进行编码和解码

    Java中涉及编码的类图如下:

 

下图是String.getBytes(String charsetName)对应的时序图

 

 

    由图可知,String.getBytes(String charsetName)编码基本流程如下:

1)根据charsetName找到Charset类,然后根据这个字符集编码生成CharsetEncoder,这个类是所有字符编码的父类,针对不同的字符编码集在charset中定义了获取对应CharsetEncoder的方法;

2)基于获取到的编码器CharsetEncoder对当前字符串进行编码

 

1 - 按照ISO-8859-1编码方式编码

字符串“I am 君山”用ISO-8859-1编码时,编码结果如图:

 

    可以看出,7个 char 字符经过 ISO-8859-1 编码转变成7个 byte 数组,ISO-8859-1 是单字节编码,中文“君山”被转化成值是 3f 的 byte。3f 也就是“?”字符,所以经常会出现中文变成“?”,很可能就是错误的使用了 ISO-8859-1 这个编码导致的。中文字符经过 ISO-8859-1 编码会丢失信息,通常我们称之为“黑洞”,它会把不认识的字符吸收掉。由于现在大部分基础的 Java 框架或系统默认的字符集编码都是 ISO-8859-1,所以很容易出现乱码问题。

 

2 - 按照GB2312编码

字符串“I am 君山”用GB2312编码时,编码结果如图:

GB2312 对应的 Charset 是 sun.nio.cs.ext.EUC_CN,而对应的 CharsetEncoder是 sun.nio.cs.ext.DoubleByte.Encoder,我们进入该类encodeLoop方法的源码:

            protected CoderResult encodeLoop(CharBuffer var1, ByteBuffer var2) {
                return var1.hasArray() && var2.hasArray() ? this.encodeArrayLoop(var1, var2) : this.encodeBufferLoop(var1, var2);
            }

    这里只是简单的做了基本要素的判空继续进入encodeArrayLoop方法

            protected CoderResult encodeArrayLoop(CharBuffer var1, ByteBuffer var2) {
                char[] var3 = var1.array();
                int var4 = var1.arrayOffset() + var1.position();
                int var5 = var1.arrayOffset() + var1.limit();
                byte[] var6 = var2.array();
                int var7 = var2.arrayOffset() + var2.position();
                int var8 = var2.arrayOffset() + var2.limit();
     
                try {
                    while(true) {
                        if (var4 < var5) {
                            char var15 = var3[var4];
                            int var10 = this.encodeChar(var15);
                            CoderResult var11;
                            if (var10 != 65533) {
                                //若大于255则证明是双字节字符,双字节字符高8位作为第1个字节存储,低8位作为第2个字节存
                                //储,
                                if (var10 > 255) {
                                    if (var8 - var7 < 2) {
                                        var11 = CoderResult.OVERFLOW;
                                        return var11;
                                    }
     
                                    var6[var7++] = (byte)(var10 >> 8);
                                    var6[var7++] = (byte)var10;
                                } else {//否则是单个字节字符,直接编码作为单个字节存储
                                    if (var8 - var7 < 1) {
                                        var11 = CoderResult.OVERFLOW;
                                        return var11;
                                    }
     
                                    var6[var7++] = (byte)var10;
                                }
     
                                ++var4;
                                continue;
                            }
            ......
       }

    这个方法代码挺长,我们略过非核心内容可以看到真正进行字符编码的地方是在图中注释处的代码,我们看到他调用了本类的encoderChar方法继续进入该方法进行后续分析:

            public int encodeChar(char var1) {
                return this.c2b[this.c2bIndex[var1 >> 8] + (var1 & 255)];
            }

    到这里,Java中GB2312的大致编码流程就很清晰了,GB2312 字符集有一个 char 到 byte 的码表,通过这个码表获取每个字符对应的码位值var10,再通过对这个码位值进行判断,如果大于255,则基于高位编址法取它的高8位作为第一个字节存放,低8位作为第2个字节,由此可见GB2312的编解码其实是基于码表进行的。

 

3 - 按照GBK进行编码

字符串“I am 君山”用GBK编码时,编码结果如图:

    你可能已经发现,上图与 GB2312 编码的结果是一样的,没错,GBK 与 GB2312 编码结果是一样的,由此可以得出 GBK 编码是兼容 GB2312 编码的,它们的编码算法也是一样的。不同的是它们的码表长度不一样,GBK 包含的汉字字符更多。所以只要是经过 GB2312 编码的汉字都可以用 GBK 进行解码,反过来则不然。

 

4 - 按照UTF-16编码

字符串“I am 君山”用UTF-16编码时,编码结果如图

    用 UTF-16 编码将 char 数组放大了一倍,单字节范围内的字符,在高位补 0 变成两个字节,中文字符也变成两个字节。从 UTF-16 编码规则来看,仅仅将字符的高位和地位进行拆分变成两个字节。特点是编码效率非常高,规则很简单,由于不同处理器对 2 字节处理方式不同,Big-endian(高位字节在前,低位字节在后)或 Little-endian(低位字节在前,高位字节在后)编码,所以在对一串字符串进行编码是需要指明到底是 Big-endian 还是 Little-endian,所以前面有两个字节用来保存 BYTE_ORDER_MARK 值,UTF-16 是用定长 16 位(2 字节)来表示的 UCS-2 或 Unicode 转换格式,通过代理对来访问 BMP 之外的字符编码。

 

5 - 按照UTF-8进行编码

    字符串“I am 君山”用UTF-8编码时,编码结果如图:

    UTF-16 虽然编码效率很高,但是对单字节范围内字符也放大了一倍,这无形也浪费了存储空间,另外 UTF-16 采用顺序编码,不能对单个字符的编码值进行校验,如果中间的一个字符码值损坏,后面的所有码值都将受影响。而 UTF-8 这些问题都不存在,UTF-8 对单字节范围内字符仍然用一个字节表示,对汉字采用三个字节表示。UTF-8 编码与 GBK 和 GB2312 不同,不用查码表,所以在编码效率上 UTF-8 的效率会更好,所以在存储中文字符时 UTF-8 编码比较理想。

6 - 对几种编码格式的比较

    1)对于中文字符,GB2312与GBK编码规则类似,但是GBK范围更大,它能处理所有汉字字符,所以将GB2312与GBK进行比较,应该选择GBK。
    2)UTF-16与UTF-8都是处理Unicode编码,它们的编码规则不太相同,相对来说,UTF-16的编码效率较高,从字符到到字节的相互转换更简单,进行字符串操作也更好。它适合在本地磁盘和内存之间使用,可以进行字符和字节之间的快速切换,如Java的内存编码就采用UTF-16编码。但是它不合适在网络之间传输,因为网络传输容易损坏字节流,一旦字节流损坏将很难恢复,所以相比较而言UTF-8更适合网络传输。
    3)UTF-8对ASCII字符采用单字节存储,另外单个字符损坏也不会影响后面的其他字符,在编码效率上介于GBK和UTF-16之间,所以UTF-8在编码效率上和编码安全上做了平衡,是理想的中文编码方式。

 
五、在Java Web中涉及的编解码

    前面已经提到了I/O操作会引起编码,而大部分I/O引起的乱码都是网络I/O,因为现在几乎所有的应用程序都涉及网络操作,而数据经过网络传输时是以字节为单位的,所以所有的数据都必须能够被序列化为字节。在Java中数据要被序列化,必须继承Serializable接口。

    用户从浏览器发起一个HTTP请求,存在编码的地方是URL、Cookie、Parameter。服务器端接收到HTTP请求后要解析HTTP,其中URL、Cookie和Post表单参数需要解码,服务器端可能还需要读取数据库中的数据——本地或网络中其他地方的文本文件,这些数据都可能存在编码问题。当Servlet处理完所有请求的数据后,需要将这些数据再编码,通过Socket发送到用户请求的浏览器里,再经过浏览器解码成文本。这个过程如图:

1 - URL的编码解码

    浏览器编码URL是将非ASCII字符按照某种编码格式编码成16进制数字后将每个16进制数字表示的字节前加上%。

    用户提交一个URL,在这个URL中可能存在中文,因此需要编码,如图为用户提交的一个URL:

 

    以Tomcat作为ServletEngine为例,把他们分别对应到配置文件中,Port对应在Tomcat->Server.xml的<Connector port="8080" />中配置,而ContextPath在context.xml的<context path="/examples">中配置,ServletPath在Web应用的web.xml的<url-pattern>中配置,PathInfo是我们请求的具体的Servlet,QueryString是要传递的参数

    注意这里是在浏览器里直接输入URL,所以是通过Get方法请求的,如果通过Post方法请求QueryString将通过表单方式提交到服务器端

    <servlet-mapping>
        <servlet-name>junshangExample</servlet-name>
        <url-pattern>/servlets/servlet/*</url-pattern>
    </servlet-mapping>

    当我们在浏览器直接输入这个URL时,在浏览器端和服务器端会如何编码和解析这个URL呢

    1)浏览器端编码

    我们在fireFox浏览器上测试可以发现浏览器对PathInfo呵呵QueryString采用的编码方式是不一样的,在Chrome中PathInfo是采用UTF-8编码,而QueryString则是GBK,不同浏览器对与PathInfo的编码方式可能还不一样,这就为服务端的解析带来了困难。

    2)服务器端解析

    对于URL的URI部分进行解码的字符集是在connector的<Connector URIEncoding="UTF-8" />中定义的,如果没有定义,那么默认将会采用默认的编码ISO-8859-1进行解析(ISO-8859-1不包含中文)。所以有中文URL的时候最好把URIEncoding设置成UTF-8编码。

    对于QueryString的解析过程:以Get方式HTTP请求的QueryString与以POST方式的HTTP请求的表单参数都是作为Parameters保存的,都是通过request.getParameter获取参数值。对他们的解码是在request.getParameter方法第一次被调用的时进行的。

    QueryString的编码字符集要么是Header中ContentType定义的Charset,要么是默认的ISO-8859-1,要使用ContentType中定义的编码,就要将connector的<Connector URIEncoding="UTF-8" useBodyEncodingForURI="True" />中的useBodyEncodingForURI设置为True。这个配置项容易使人产生混淆,他并不是对整个URI都采用BodyEncoding进行解码,而仅仅是对QueryString使用BodyEncoding解码这一点需要特别注意。

    从上面URL编码和解码过程来看,比较复杂而且编码和解码不是在我们应用程序中能完全控制的,在我们的应用程序中,应该尽量避免在URL中使用非ASCII字符,不然可能会遇到乱码问题。当我们的服务器端最好设置<Connector />中的URIEncoding和useBodyEncodingForURI这两个参数

2 - HTTP Header的编码解码

    当客户端发起一个HTTP请求的时候,除了URL之外还可能会在Header中传递其他的参数,例如Cookie、redirectPath等,这些用户设置的值可能也会存在编码的问题。Tomcat对于他们是怎么解码的呢?

    对于Header中的项进行解码也是在调用request.getHeader时进行的。如果请求的Header项没有解码则调用MessageBytes的toString方法,这个方法对于从byte到char的转化使用的默认编码也是ISO-8859-1而我们也不能设置Header的其他编码格式,所以如果你设置的Header中有非ASCII字符,解码中肯定会有乱码。

    我们在添加Header时,如果一定要传非ASCII字符,可以先将这些字符使用org.apache.catalina.util.URLEncoder编码,再添加到Header中,这样在浏览器到服务器的传递过程中就不会丢失信息了,我们要访问这些项时在按照相应的字符集解码即可

 

3 - POST表单的编解码

    POST表单提交的参数的解码是在第一次调用request.getParameter时发生的,POST表单的参数传递方式与QueryString不同,它是通过HTTP的BODY传递到服务端的。

    当我们在页面上单击提交按钮时,浏览器首先将根据ContentType的Charset编码格式对表单中填入的参数进行编码,然后提交到服务器端,在服务器端同样也是采用ContentType中的字符集进行解码的,这个字符集我们也可以在服务端通过request.setCharacterEncoding(Charset)来进行设置。

    另外针对multipart/for-data类型的参数,也就是上传的文件编码,同样也是使用ContentType定义的字符集编码。注意,上传文件是用字节流的方式传递到服务器的本地临时目录,这个过程并没有涉及字符编码,而真正的编码是将文件内容添加到Parameters中时,如果不能使用这种编码方式则会使用默认编码ISO-8859-1来编码。

 

4 - HTTP Body的编码与解码

    当用户请求的资源已经成功获取后,这些内容将会通过Response返回给客户端浏览器。这个过程要先经过编码,再到浏览器进行解码。编码字符集可以通过response.setCharacterEncoding来设置,它将会覆盖request.setCharacterEncoding的值,并且通过Header的Content-Type返回客户端,浏览器接收到返回的Socket流时将通过Content-Type的charset来解码

    如果返回的HTTP Header中的Content-Type没有设置charset,那么浏览器将根据HTML的<meta HTTP-equiv="Content-Type" content="text/html; charset=GBK" />中指定的charset来解码。如果没有定义,那么浏览器将使用默认的编码来解码。

    访问数据库都是通过客户端JDBC驱动来完成的,使用JDBC来存取数据时要和数据的内置编码保持一致,可以通过设置JDBC URL来指定,如MySQL:url=”jdbc:mysql://localhost:3306/DB?useUnicode=true&characterEncoding=GBK”

 
六、在JS中涉及的编码解码

 

1 - 外部引入JS文件

在一个单独的JS文件中包含中文字符串输入的情况,例如:

    <html>
    <head>
    <script src="static/javascript/script.js" charset="gbk"></script>

如果引入一个script.js脚本,这个脚本中含有如下代码:

docuemnt.write("这是一段中文");

这时如果script没有设置charset,浏览器就会以当前这个页面默认的字符集解析这个JS文件。如果外部的JS文件的编码格式与当前页面的编码格式一致,那么就可以不设置这个charset。但是如果script.js文件与当前页面的编码格式不一致,如script.js是UTF-8编码而页面时GBK编码,上面代码中的中文输入就会变成乱码。

 

2 - JS的URL编码

    通过JS发起异步调用的URL默认的编码也是受浏览器的影响,如果使用原始Ajax的http_request.open('GET',url,true)调用,URL的默认编码在IE是操作系统的默认编码而在Firefox下则是UTF-8编码,另外不同的JS框架可能对于URL的编码处理也不一样。

    处理JS的URL编码问题:

1)encodeURI()与decodeURI()

JS用来对URL编码的函数,他可以将整个URL中的字符(一些特殊字符除外)进行UTF-8编码,在每个码值前加上"%"。

2)encodeURIComponent()和decodeURIComponent()

    encodeURIComponent()这个函数比encodeURI()编码更为彻底。通常用于将一个URL当做一个参数放在另一个URL中

3)Java与JS的编码解码问题。在Java端处理URL编码解码的有两个类,分别是java.net.URLDecoder和java.net.URLEncoder。这两个类可以将所有“%”加UTF-8码值使用UTF-8解码,从而得到原始的字符。Java端的URLEncode和URLDecoder与前端JS对应的是encodeURIComponent和decodeURIComponent。

    注意:前端用encodeURIComponent编码后,到服务端用URLDecoder解码可能会出现乱码,这一定是两个字符编码类型不一致导致的。JS编码默认的是UTF-8编码,而服务器中文解码一段都是GBK或者GB2312,所以用encodeURIComponent编码后是UTF-8,而java用GBK去解码显然不对。

    解决的办法是用encodeURIComponent两次编码,如encodeURIComponent(encodeURIComponent(str))。这样在Java端通过request.getParamter()用GBK解码后取得的就是UTF-8编码的字符串,如果Java端需要使用这个字符串,则再用UTF-8解码一次;如果是将这个结果直接通过JS输出到前端,那么这个UTF-8字符串可以直接在前端正常显示。

 
七、常见问题的分析

    基于前面的了解的Java web编码解码知识之后我们知道出现乱码问题唯一的原因就是在编码解码过程中采用的字符集不一致导致的,因为在一次操作中经常涉及多次编码和解码,因此出现乱码问题的时候也给我们排查带来的难度,下面分析几种常见的情景:

1 - 中文变成看不懂的字符

    例如,字符串“淘!我喜欢!”变成了“Ì Ô £ ¡Î Ò Ï²»¶ £ ¡”编码过程如下图所示

    字符串在解码时所用的字符集与编码字符集不一致导致汉字变成了看不懂的乱码,而且是一个汉字字符变成两个乱码字符。这种情景在开发中经常发生,例如在浏览器中输入一个带有中文字符串参数的URL一些浏览器默认对QueryString采用的是GBK编码方式,但是由于在web中间件例如tomcat没有做相关配置,在服务端读取请求参数时也没有指定编码方式,于是默认使用ISO-8859-1进行解码导致乱码。这种出现乱码且乱码字符串长度是原编码前字符串的两倍的原因可能是采用2字节编码例如GBK、UTF-16等然后使用单字节进行解码例如ISO-8859-1导致的

 

2 - 一个汉字变成一个问号

例如,字符串“淘!我喜欢!”变成了“??????”编码过程如下图所示

 

    将中文和中文符号经过不支持中文的 ISO-8859-1 编码后,所有字符变成了“?”,这是因为用 ISO-8859-1 进行编解码时遇到不在码值范围内的字符时统一用 3f 表示,这也就是通常所说的“黑洞”,所有 ISO-8859-1 不认识的字符都变成了“?”。

 

3 - 一个汉字变成两个问号

例如,字符串“淘!我喜欢!”变成了“????????????”编码过程如下图所示

 

    这种情况比较复杂,中文经过多次编码,但是其中有一次编码或者解码不对仍然会出现中文字符变成“?”现象,出现这种情况要仔细查看中间的编码环节,找出出现编码错误的地方。

 

4 - 一种不正常的正确编码解码

还有一种情况是在我们通过 request.getParameter 获取参数值时,当我们直接调用下面代码会出现乱码

String value = request.getParameter(name);

但是如果用下面的方式解析时取得的 value 会是正确的汉字字符

String value = new String(request.getParameter(name).getBytes("ISO-8859-1"), "GBK");

这种情况是怎么造成的呢?看下图:

    这种情况是这样的,ISO-8859-1 字符集的编码范围是 0000-00FF,正好和一个字节的编码范围相对应。这种特性保证了使用 ISO-8859-1 进行编码和解码可以保持编码数值“不变”。虽然中文字符在经过网络传输时,被错误地“拆”成了两个欧洲字符,但由于输出时也是用 ISO-8859-1,结果被“拆”开的中文字的两半又被合并在一起,从而又刚好组成了一个正确的汉字。虽然最终能取得正确的汉字,但是还是不建议用这种不正常的方式取得参数值,因为这中间增加了一次额外的编码与解码,这种情况出现乱码时因为 Tomcat 的配置文件中 useBodyEncodingForURI 配置项没有设置为”true”,从而造成第一次解析式用 ISO-8859-1 来解析才造成乱码的。

 
八、Java Web编码解码问题总结

    要解决中文编码问题,首先要搞清楚哪些地方会引起字符到字节的编码以及字节到字符的解码,最常见的地方就是存储数据到磁盘或者数据要经过网络传输。其次应针对这些地方搞清楚操作这些数据的框架或系统是如何控制编码的。最后正确设置编码格式,避免使用软件默认的或者操作系统平台默认的编码格式。
————————————————