文章目录

  • 1. 常见字符集
  • 1.1 概念
  • 1.2 常见字符集
  • 1.3 优缺点
  • 2. Java IO 与编码
  • 2.1 Java内码
  • 2.2 I/O 操作中存在的编码
  • 2.3 内存中的编码
  • 2.4 Java 中如何编解码
  • 3. Java Web 与编码
  • 3.1 浏览器的编码
  • 3.1.1 直接访问
  • 3.1.2 通过页面跳转
  • 3.2 Tomcat的解码
  • 3.2.1 URL的解码
  • 3.2.2 QueryString 的解码
  • 3.3.3 HTTP Header 的编解码
  • 3.3.4 POST 表单的编解码
  • 3.3.5 响应 HTTP BODY 的编解码
  • 4. 常见乱码分析
  • 附录:
  • 1. UTF-8编码举例
  • 2. 低字节序(Little Endian)和高字节序(Big Endian)
  • 参考文献


1. 常见字符集

1.1 概念

  • 字符集合(Character set):是一组形状的集合,例如所有汉字的集合,它体现了字符的“形状”,它与计算机、编码等无关。
  • 编码字符集(Coded character set):为每一个「字符」分配一个唯一的 ID(学名为码位 / 码点 / Code Point),是一组字符对应的编码(即数字),为字符集合中的每一个字符给予一个数字。
  • 字符编码方案(Character-encoding schema):将「码位」转换为字节序列的规则(编码/解码 可以理解为 加密/解密 的过程)。例如,UTF-8是编码字符集 Unicode 的一种编码方式。
  • 字符集(Charset):编码字符集和字符编码方案合起来被称之为字符集(Charset),例如,GBK是一个字符集,同时包含编码字符集和字符编码方案。

1.2 常见字符集

  • ASCII:总共有 128 个,用一个字节的低 7 位表示,0~31 是控制字符如换行回车删除等;32~126 是打印字符,可以通过键盘输入并且能够显示出来。
  • ISO-8859-1:128 个字符显然是不够用的,于是 ISO 组织在 ASCII 码基础上又制定了一系列标准用来扩展 ASCII 编码,它们是 ISO-8859-1~ISO-8859-15,其中 ISO-8859-1 涵盖了大多数西欧语言字符,所有应用的最广泛。ISO-8859-1 仍然是单字节编码,它总共能表示 256 个字符。Java的一些框架会使用ISO-8859-1。
  • GB2312:**双字节编码汉字,单字节兼容ASCII。**即,一个小于127的字节表示意义与ASCII相同,但两个大于127的字节为一个字符,表示汉字。这就是常说的“全角”字符,而原来在127号以下的那些就叫“半角”字符了。
  • GBK:扩展了GB2312,增加了近20000个新的汉字(包括繁体字)和符号。编码规则,不再要求两个大于127的字节为汉字,只需要第一个字节大于127即可。
  • Unicode:Unicode(Universal Code 统一码),ISO 试图想创建一个全新的超语言字典,世界上所有的语言都可以通过这本字典来相互翻译。一开始UCS(Universal Character Set)用2个字节表示,叫做UCS-2,后来2个字节不够用,于是就用4个字节,叫做UCS-4。Unicode常见的字符编码方案有UTF-8、UTF-16和UTF-32:
  • UTF-8:是一套以 8 位为一个编码单位的可变长编码,会将一个码位编码为 1 到 4 个字节,实现了对ASCII码的向后兼容。UTF-8的编码规则很简单,只有二条:1)对于单字节的符号,字节的第一位设为0,后面7位为这个符号的Unicode码。因此对于英语字母,UTF-8编码和ASCII码是相同的,只需要一个字节。2)对于n字节的符号(n>1),第一个字节的前n位都设为1,第n+1位设为0,后面字节的前两位一律设为10。剩下的没有提及的二进制位,全部为这个符号的Unicode码,剩余高位用0补充。UTF-8编码汉字一般需要使用3个字节。
  • UTF-16(UCS-2):UCS-2简单的使用固定的2个字节来表示Unicode的基本多文种平面(英文为 Basic Multilingual Plane,简写 BMP。它又简称为“零号平面”, plane 0)中的字符,其它平面(Unicode共有17个平面,每个平面65,536个码点)中的字符无法表示。UTF-16扩展了UCS-2,使用相同的2个字节编码零号平面,同时,使用4个字节通过代理表示其它平面字符。即,UTF-16使用2个或者4个字节来存储字符。UTF-16BE也是Java的内码,字符串等在JVM内存中以该格式编码。
  • UTF-32(UCS-4):两者一致,使用固定的4字节编码Unicode所有的字符。

1.3 优缺点

  • GB2312和GBK:两者编码方式类似,占用空间一致,但GBK的字符数大于GB2312,所以应选择GBK。
  • UTF-8和UTF-16:UTF-16编码简单,字符->字节的转换方便,但其存在字节序问题。UTF-8具有前缀码,单个字符损坏不会影响后面字符,更适合网络传输(UTF-16、GBK等顺序编码,局部的字节错误,特别是丢失或增加可能导致所有后续字符全部错乱);对ASCII兼容性强。在编码效率上,UTF-16一般使用2字节/字符,UTF-8单字节英文,双字节中文,孰强孰弱还看场景。实际来看,UTF-16常用作程序内码,UTF-8则在网络传输应用很广。
  • UTF-8和GBK:在含有大量中文的场景,GBK要比UTF-8编码效率高。

2. Java IO 与编码

有I/O的地方常涉及编码,编码与解码发生在字节与字符之间的相互转换上,一旦使用了错误的编码方式解码字节,往往无法复原。下图展示了典型的编码解码流程。

java中code是什么意思中文 java code first_Java

  • 解码,从字节到字符、字符串,解码时一定要明确指定编码方式,否则会使用默认的字符集解码,容易造成乱码,也不利于跨平台。Java默认字符集可用Charset.defaultCharset方法获取。可以在两个地方设定,一是执行java程序时使用-Dfile.encoding参数指定;二是在程序执行时使用Properties进行指定,如下:
private static void setEncoding(String charset) {
    Properties properties = System.getProperties();
    properties.put("file.encoding",charset);
    System.out.println(properties.get("file.encoding"));
}
  • 编码,从字符到字节,编码的目的是为了方便存储、传输。同一个字符,使用不同的编码,得到的字节也不相同。

2.1 Java内码

内码,即程序内部使用的字符编码。Java使用UTF-16BE作为内码,也就是说,String等类型都是以UTF-16BE的编码方式储存在内存中的,一般每个字符占用2个字节。上一节图中所说的解码,就是根据指定的字符集转换输入流的编码为UTF-16。如下图所示,汉字“中”在内存中被保存为0x4E2D,高字节序UTF-16编码,两个字节。

java中code是什么意思中文 java code first_java中code是什么意思中文_02

需要注意的是,Java中char只占用两个字节,但是UTF-16的编码字节有2B和4B了两种,那么char如何保存扩展字符(基本多语言平面以外的字符,UTF-16对其使用4字节编码)呢?答案是不能保存,char或者Character准确的说是使用UCS-2编码的,只能表示BMP字符。所以,严格来说,“Java不总是使用UTF-16作为内码”?如下图所示,对char或者Character赋值扩展字符时,编译器报错:

java中code是什么意思中文 java code first_java中code是什么意思中文_03

那么,String能保存扩展字符吗?可以的。JDK1.5之后,String使用两个char共四字节来表示扩展一个字符。

java中code是什么意思中文 java code first_Java_04

2.2 I/O 操作中存在的编码

我们知道涉及到编码的地方一般都在字符到字节或者字节到字符的转换上,而需要这种转换的场景主要是在 I/O 的时候,这个 I/O 包括磁盘 I/O 和网络 I/O,关于网络 I/O 部分在后面将主要以 Web 应用为例介绍。下图是 Java 中处理 I/O 问题的接口:

java中code是什么意思中文 java code first_Java_05

InputStreamReader继承了抽象类Reader,并且包含StreanDecode类型成员变量。Reader 类是 Java 的 I/O 中读字符的父类,而 InputStream 类是读字节的父类,InputStreamReader 类就是关联字节到字符的桥梁,它负责在 I/O 过程中处理读取字节到字符的转换,而具体字节到字符的解码实现它由 StreamDecoder 去实现,在 StreamDecoder 解码过程中必须由用户指定 Charset 编码格式。值得注意的是如果你没有指定 Charset,将使用本地环境中的默认字符集,例如在中文环境中将使用 GBK 编码。

写的情况也是类似,字符的父类是 Writer,字节的父类是 OutputStream,通过 OutputStreamWriter 转换字符到字节。如下图所示:

java中code是什么意思中文 java code first_字符串_06

同样 StreamEncoder 类负责将字符编码成字节,编码格式和默认编码规则与解码是一致的。I/O 涉及的编码示例:

public static void decodeAndEncode() {
        String file = "c:/stream.txt"; 
        String charset = "UTF-8"; 
        
        // 写字符换转成字节流
        try (FileOutputStream outputStream = new FileOutputStream(file);
                OutputStreamWriter writer = new OutputStreamWriter(outputStream, charset);){
            writer.write("这是要保存的中文字符");
        } catch (IOException e) {
            e.printStackTrace();
        }
        
        // 读取字节转换成字符
        StringBuffer buffer = new StringBuffer();
        char[] buf = new char[64];
        int count = 0;
        try (FileInputStream inputStream = new FileInputStream(file);
                InputStreamReader reader = new InputStreamReader(inputStream, charset);){
            while ((count = reader.read(buf)) != -1) {
                buffer.append(buf, 0, count);
            }
            System.out.println(buffer);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

在我们的应用程序中涉及到 I/O 操作时只要注意指定统一的编解码 Charset 字符集,一般不会出现乱码问题,有些应用程序如果不注意指定字符编码,中文环境中取操作系统默认编码,如果编解码都在中文环境中,通常也没问题,但是还是强烈的不建议使用操作系统的默认编码,因为这样,你的应用程序的编码格式就和运行环境绑定起来了,在跨环境下很可能出现乱码问题。在我们的应用程序中涉及到 I/O 操作时只要注意指定统一的编解码 Charset 字符集,一般不会出现乱码问题,有些应用程序如果不注意指定字符编码,中文环境中取操作系统默认编码,如果编解码都在中文环境中,通常也没问题,但是还是强烈的不建议使用操作系统的默认编码,因为这样,你的应用程序的编码格式就和运行环境绑定起来了,在跨环境下很可能出现乱码问题。

2.3 内存中的编码

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

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

Charset 提供 encode 与 decode 分别对应 char[] 到 byte[] 的编码和 byte[] 到 char[] 的解码。如下代码所示:

Charset charset = Charset.forName("UTF-8"); 
ByteBuffer byteBuffer = charset.encode(string); 
CharBuffer charBuffer = charset.decode(byteBuffer);

编码与解码都在一个类中完成,通过 forName 设置编解码字符集,这样更容易统一编码格式,比 ByteToCharConverter 和 CharToByteConverter 类更方便。
Java 中还有一个 ByteBuffer 类,它提供一种 char 和 byte 之间的软转换,它们之间转换不需要编码与解码,只是把一个 16bit 的 char 格式,拆分成为 2 个 8bit 的 byte 表示,它们的实际值并没有被修改,仅仅是数据的类型做了转换。
以上这些提供字符和字节之间的相互转换只要我们设置编解码格式统一一般都不会出现问题。

2.4 Java 中如何编解码

下面是 Java 中编码需要用到的类图

java中code是什么意思中文 java code first_字符串_07

首先根据指定的 charsetName 通过 Charset.forName(charsetName) 设置 Charset 类,然后根据 Charset 创建 CharsetEncoder 对象,再调用 CharsetEncoder.encode 对字符串进行编码,不同的编码类型都会对应到一个类中,实际的编码过程是在这些类中完成的。下面是 String. getBytes(charsetName) 编码过程的时序图

java中code是什么意思中文 java code first_字符串_08

从上图可以看出根据 charsetName 找到 Charset 类,然后根据这个字符集编码生成 CharsetEncoder,这个类是所有字符编码的父类,针对不同的字符编码集在其子类中定义了如何实现编码,有了 CharsetEncoder 对象后就可以调用 encode 方法去实现编码了。这个是 String.getBytes 编码方法,其它的如 StreamEncoder 中也是类似的方式。下面看看不同的字符集是如何将前面的字符串编码成 byte 数组的?
如字符串“I am 君山”的 char 数组为 49 20 61 6d 20 541b 5c71,下面把它按照不同的编码格式转化成相应的字节。

  • 按照 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,所以很容易出现乱码问题,后面将会分析不同的乱码形式是怎么出现的。

  • 按照 GB2312 编码
    字符串“I am 君山”用 GB2312 编码,下面是编码结果:
  • java中code是什么意思中文 java code first_编解码_09

  • GB2312 对应的 Charset 是 sun.nio.cs.ext. EUC_CN 而对应的 CharsetDecoder 编码类是 sun.nio.cs.ext. DoubleByte,GB2312 字符集有一个 char 到 byte 的码表,不同的字符编码就是查这个码表找到与每个字符的对应的字节,然后拼装成 byte 数组。
    前 5 个英文字符经过编码后仍然是 5 个字节,而汉字被编码成双字节,在第一节中介绍到 GB2312 只支持 6763 个汉字,所以并不是所有汉字都能够用 GB2312 编码。
  • 按照 GBK 编码
    字符串“I am 君山”用 GBK 编码,下面是编码结果:
  • java中code是什么意思中文 java code first_字符串_10

  • 你可能已经发现上图与 GB2312 编码的结果是一样的,没错 GBK 与 GB2312 编码结果是一样的,由此可以得出 GBK 编码是兼容 GB2312 编码的,它们的编码算法也是一样的。不同的是它们的码表长度不一样,GBK 包含的汉字字符更多。所以只要是经过 GB2312 编码的汉字都可以用 GBK 进行解码,反过来则不然。
  • 按照 UTF-16 编码
    字符串“I am 君山”用 UTF-16 编码,下面是编码结果:
  • java中code是什么意思中文 java code first_字符串_11

  • 从 UTF-16 编码规则来看,仅仅将字符的高位和地位进行拆分变成两个字节。特点是编码效率非常高,规则很简单,由于不同处理器对 2 字节处理方式不同,Big-endian(高位字节在前,低位字节在后)或 Little-endian(低位字节在前,高位字节在后)编码,所以在对一串字符串进行编码是需要指明到底是 Big-endian 还是 Little-endian,所以前面有两个字节用来保存 BYTE_ORDER_MARK 值,UTF-16 是用定长 16 位(2 字节)来表示的 UCS-2,通过代理对来访问 BMP 之外的字符编码。
  • 按照 UTF-8 编码
    字符串“I am 君山”用 UTF-8 编码,下面是编码结果:
  • java中code是什么意思中文 java code first_java中code是什么意思中文_12

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

3. Java Web 与编码

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

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

java中code是什么意思中文 java code first_Java_13

如上图所示一次 HTTP 请求设计到很多地方需要编解码,它们编解码的规则是什么?下面将会重点阐述一下:

3.1 浏览器的编码

用户提交一个 URL,这个 URL 中可能存在中文,因此需要编码,如何对这个 URL 进行编码?根据什么规则来编码?又如何来解码?如下图一个 URL:

java中code是什么意思中文 java code first_java中code是什么意思中文_14


上图中以 Tomcat 作为 Servlet Engine 为例,它们分别对应到下面这些配置文件中:

Port 对应在 Tomcat 的 中配置,而 Context Path 在 中配置,Servlet Path 在 Web 应用的 web.xml 中的

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

中配置,PathInfo 是我们请求的具体的 Servlet,QueryString 是要传递的参数,注意这里是在浏览器里直接输入 URL 所以是通过 Get 方法请求的,如果是 POST 方法请求的话,QueryString 将通过表单方式提交到服务器端。
下面测试URL:HTTP://localhost:8080/examples/servlets/servlet/君山?author=君山, 在不同浏览器和情况下的编码。

3.1.1 直接访问

通过在浏览器地址栏中输入URL来访问指定站点,观察浏览器如何编码URL,使用Fiddler抓包。

  • IE 11
GET /examples/servlets/servlet/%E5%90%9B%E5%B1%B1?author=��ɽ HTTP/1.1

其中,PathInfo对中文字符使用了UTF-8编码后,再使用URL编码(百分号编码)。 URL编码是将非 ASCII 字符按照某种编码格式编码后(这里是UTF-8),以16进制表示,然后将每个16进制表示的字节前加上“%”。由于RFC 1738并没有具体规定编码方式,所以这部分由浏览器决定。IE 浏览器可以修改 URL 的编码格式在选项 -> 高级 -> 国际 -> 以UTF-8形式发送URL路径,取消该选项后,PathInfo将使用GBK编码 + 百分号编码。
而QueryString字符串却是乱码,其十六进制为:

BE FD C9 BD

为“君山”的GBK编码,但并未对其进行进一步的百分号编码。

  • Chrome 59
http://localhost:8080/examples/servlets/servlet/%E5%90%9B%E5%B1%B1?author=%E5%90%9B%E5%B1%B1

Chrome对PathInfo和QueryString均使用 UTF-8 + 百分号 编码。

从上面测试结果可知,直接访问时,浏览器对 PathInfo 和 QueryString 的编码可能是不一样的,不同浏览器对 PathInfo 和 QueryString 也可能不一样。

3.1.2 通过页面跳转

通过页面的表单发起GET请求,jsp文件如下,第一行charset决定了响应的contentType,此处为GBK,测试通过该页面提交查询请求:

<%@ page language="java" contentType="text/html; charset=GBK"
    pageEncoding="UTF-8"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <title>Spring Boot Demo - JSP</title>
</head>
<body>
        <form action="http://localhost:8080/examples/servlets/servlet2/君山" method="get" enctype="multipart/form-data">
            <table>
                <tr>
                    <td>姓名</td>
                    <td><input name="name" type="text"></td>
                    <td>年龄</td>
                    <td><input name="age" type="text"></td>
                </tr>
                <tr><input type="submit" value="提交"></tr>
            </table>
        </form>
</body>
</html>
  • Chrome 59
http://localhost:8080/examples/servlets/servlet2/%E5%90%9B%E5%B1%B1?name=%BE%FD%C9%BD&age=88

其中,PathInfo的编码依然是UTF-8+百分号编码,不随响应的编码改变;而queryString的编码和页面响应头Content-Type指定的编码相同,此处为GBK。

  • IE 11
GET /examples/servlets/servlet2/%E5%90%9B%E5%B1%B1?name=%BE%FD%C9%BD&age=12 HTTP/1.1

与Chrome相同,PathInfo的编码是UTF-8,queryString的编码同页面响应头指定的编码一致,此处为GBK,并且使用了百分号编码,没有之前的乱码问题。

  • 表单使用POST时
    使用POST时,IE与Chrome相同,PathInfo依然使用UTF-8+百分号编码,表单的数据位于BODY内,其编码方式根据页面的Content-Type。
  • 总结
  • 在使用表单以GET方式发起请求时,IE 11 与 Chrome 59 对URL的编码方式相同:
    PathInfo:使用UTF-8+百分号编码
    QueryString:使用页面响应头指定的编码+百分号编码
    因为URL不能传输非ASCII字符,所以GET方式多了URL编码。编码后、含有百分号的字符串,将以ASCII(ISO-8859-1?)编码成字节流传递。
  • 使用表单以POST发起请求时
    PathInfo:使用UTF-8+百分号编码
    表单数据:使用页面响应头指定的编码方式编码成字节流后传递,没有百分号编码。

3.2 Tomcat的解码

3.2.1 URL的解码

以Tomcat 7.0.85为例,解析请求的 URL 是在 org.apache.coyote.HTTP11.InternalInputBuffer 的 parseRequestLine 方法中,这个方法把传过来的 URL 的 byte[] 设置到 org.apache.coyote.Request 的相应的属性中。这里的 URL 仍然是 byte 格式,转成 char 是在 org.apache.catalina.connector.CoyoteAdapter 的 convertURI 方法中完成的(此时,URI已完成百分号解码):

/**
     * Character conversion of the URI.
     */
    protected void convertURI(MessageBytes uri, Request request)
        throws Exception {
        
        ByteChunk bc = uri.getByteChunk();
        int length = bc.getLength();
        CharChunk cc = uri.getCharChunk();
        cc.allocate(length, -1);

        String enc = connector.getURIEncoding();
        if (enc != null) {
            B2CConverter conv = request.getURIConverter();
            try {
                if (conv == null) {
                    conv = new B2CConverter(enc, true);
                    request.setURIConverter(conv);
                } else {
                    conv.recycle();
                }
            } catch (IOException e) {
                log.error("Invalid URI encoding; using HTTP default");
                connector.setURIEncoding(null);
            }
            if (conv != null) {
                try {
                    conv.convert(bc, cc, true);
                    uri.setChars(cc.getBuffer(), cc.getStart(), cc.getLength());
                    return;
                } catch (IOException ioe) {
                    // Should never happen as B2CConverter should replace
                    // problematic characters
                    request.getResponse().sendError(
                            HttpServletResponse.SC_BAD_REQUEST);
                }
            }
        }

        // Default encoding: fast conversion for ISO-8859-1
        byte[] bbuf = bc.getBuffer();
        char[] cbuf = cc.getBuffer();
        int start = bc.getStart();
        for (int i = 0; i < length; i++) {
            cbuf[i] = (char) (bbuf[i + start] & 0xff);
        }
        uri.setChars(cbuf, 0, length);
    }

}

从上面的代码中可以知道对 URL 的 URI 部分进行解码的字符集是在 connector 的 中定义的,如果没有定义,那么将以默认编码 ISO-8859-1 解析。所以如果有中文 URL 时最好把 URIEncoding 设置成 UTF-8 编码。
从Tomcat 8开始,默认的URIEncoding以修改为UTF-8。——未验证

3.2.2 QueryString 的解码

QueryString 又如何解析? GET 方式 HTTP 请求的 QueryString 与 POST 方式 HTTP 请求的表单参数都是作为 Parameters 保存,都是通过 request.getParameter 获取参数值。对它们的解码是在 request.getParameter 方法第一次被调用时进行的。request.getParameter 方法被调用时将会调用 org.apache.catalina.connector.Request 的 parseParameters 方法。这个方法将会对 GET 和 POST 方式传递的参数进行解码,但是它们的解码字符集有可能不一样。POST 表单的解码将在后面介绍。
QueryString 的解码字符集是在哪定义的呢?它本身是通过 HTTP 的 Header 传到服务端的,并且也在 URL 中,是否和 URI 的解码字符集一样呢?从前面浏览器对 PathInfo 和 QueryString 的编码采取不同的编码格式不同可以猜测到解码字符集肯定也不会是一致的。的确是这样 **QueryString 的解码字符集要么是 Header 中 ContentType 中定义的 Charset 要么就是默认的 ISO-8859-1,要使用 ContentType 中定义的编码就要设置 connector 的 中的 useBodyEncodingForURI 设置为 true。**这个配置项的名字有点让人产生混淆,它并不是对整个 URI 都采用 BodyEncoding 进行解码而仅仅是对 QueryString 使用 BodyEncoding 解码,这一点还要特别注意。
从上面的 URL 编码和解码过程来看,比较复杂,而且编码和解码并不是我们在应用程序中能完全控制的,所以在我们的应用程序中应该尽量避免在 URL 中使用非 ASCII 字符,不然很可能会碰到乱码问题,当然在我们的服务器端最好设置 中的 URIEncoding 和 useBodyEncodingForURI 两个参数。

3.3.3 HTTP Header 的编解码

当客户端发起一个 HTTP 请求除了上面的 URL 外还可能会在 Header 中传递其它参数如 Cookie、redirectPath 等,这些用户设置的值很可能也会存在编码问题,Tomcat 对它们又是怎么解码的呢?
对 Header 中的项进行解码也是在调用 request.getHeader 是进行的,如果请求的 Header 项没有解码则调用 MessageBytes 的 toString 方法,这个方法将从 byte 到 char 的转化使用的默认编码也是 ISO-8859-1,而我们也不能设置 Header 的其它解码格式,所以如果你设置 Header 中有非 ASCII 字符解码肯定会有乱码。
我们在添加 Header 时也是同样的道理,不要在 Header 中传递非 ASCII 字符,如果一定要传递的话,我们可以先将这些字符用 org.apache.catalina.util.URLEncoder 编码然后再添加到 Header 中,这样在浏览器到服务器的传递过程中就不会丢失信息了,如果我们要访问这些项时再按照相应的字符集解码就好了。

3.3.4 POST 表单的编解码

在前面提到了 POST 表单提交的参数的解码是在第一次调用 request.getParameter 发生的,POST 表单参数传递方式与 QueryString 不同,它是通过 HTTP 的 BODY 传递到服务端的。**当我们在页面上点击 submit 按钮时浏览器首先将根据 ContentType 的 Charset 编码格式对表单填的参数进行编码然后提交到服务器端,在服务器端同样也是用 ContentType 中字符集进行解码。**所以通过 POST 表单提交的参数一般不会出现问题,而且这个字符集编码是我们自己设置的,可以通过 request.setCharacterEncoding(charset) 来设置。
另外针对 multipart/form-data 类型的参数,也就是上传的文件编码同样也是使用 ContentType 定义的字符集编码,值得注意的地方是上传文件是用字节流的方式传输到服务器的本地临时目录,这个过程并没有涉及到字符编码,而真正编码是在将文件内容添加到 parameters 中,如果用这个编码不能编码时将会用默认编码 ISO-8859-1 来编码。

3.3.5 响应 HTTP BODY 的编解码

当用户请求的资源已经成功获取后,这些内容将通过 Response 返回给客户端浏览器,这个过程先要经过编码再到浏览器进行解码。这个过程的编解码字符集可以通过 response.setCharacterEncoding 来设置,它将会覆盖 request.getCharacterEncoding 的值,并且通过 Header 的 Content-Type 返回客户端,浏览器接受到返回的 socket 流时将通过 Content-Type 的 charset 来解码,如果返回的 HTTP Header 中 Content-Type 没有设置 charset,那么浏览器将根据 Html 的 中的 charset 来解码。如果也没有定义的话,那么浏览器将使用默认的编码来解码。

4. 常见乱码分析

黑洞

附录:

1. UTF-8编码举例

举一个例子:It’s 知乎日报你看到的unicode字符集是这样的编码表:

I 0049
t 0074
' 0027
s 0073
  0020
知 77e5
乎 4e4e
日 65e5
报 62a5

每一个字符对应一个十六进制数字。计算机只懂二进制,因此,严格按照unicode的方式(UCS-2),应该这样存储:

I 00000000 01001001
t 00000000 01110100
' 00000000 00100111
s 00000000 01110011
  00000000 00100000
知 01110111 11100101
乎 01001110 01001110
日 01100101 11100101
报 01100010 10100101

这个字符串总共占用了18个字节,但是对比中英文的二进制码,可以发现,英文前9位都是0!浪费啊,浪费硬盘,浪费流量。怎么办?UTF。UTF-8是这样做的:1. 单字节的字符,字节的第一位设为0,对于英语文本,UTF-8码只占用一个字节,和ASCII码完全相同;2. n个字节的字符(n>1),第一个字节的前n位设为1,第n+1位设为0,后面字节的前两位都设为10,这n个字节的其余空位填充该字符unicode码,高位用0补足。这样就形成了如下的UTF-8标记位:0xxxxxxx110xxxxx 10xxxxxx1110xxxx 10xxxxxx 10xxxxxx11110xxx 10xxxxxx 10xxxxxx 10xxxxxx111110xx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx1111110x 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx… …于是,”It’s 知乎日报“就变成了:

I 01001001
t 01110100
' 00100111
s 01110011
  00100000
知 11100111 10011111 10100101
乎 11100100 10111001 10001110
日 11100110 10010111 10100101
报 11100110 10001010 10100101

和上边的方案对比一下,英文短了,每个中文字符却多用了一个字节。但是整个字符串只用了17个字节,比上边的18个短了一点点。下边是课后作业:请将”It’s 知乎日报“的GB2312和GBK码(自行google)转成二进制。不考虑历史因素,从技术角度解释为什么在unicode和UTF-8大行其道的同时,GB2312和GBK仍在广泛使用。剧透:一切都是为了节省你的硬盘和流量。

2. 低字节序(Little Endian)和高字节序(Big Endian)

Endian读作End-ian或者Indian。这个术语的起源可以追溯到格列佛游记。(小说中,小人国为水煮蛋应该从大的一端(Big-End)剥开还是小的一端(Little-End)剥开而争论,争论的双方分别被称为“大端派”和“小端派”。)
低字节序和高字节序只是一个关于在内存中存储和读取一段字节(被称作words)的约定。这意味着当你让计算机用UTF-16把字母A(占两个字节)存在内存中时,使用哪种字节序方案决定了你把第一个字节放在第二个字节的前面还是后面。这么说有点不太容易懂,让我们来一个例子:当你使用UTF-16存下来自你朋友的附件时,在不同的系统中它的后半部分可能是这样的:
00 68 00 65 00 6C 00 6C 00 6F(高字节序,高位字节被存在前面)
68 00 65 00 6C 00 6C 00 6F 00(低字节序,低位字节被存在前面)
字节序方案只是一个微处理器架构设计者的偏好问题,例如,Intel使用低字节序,Motorola使用高字节序。但是由于Java是平台无关的,所以Java内码被设计为Big Endian的。但是当Java中的字符进行编码时,就要注意其字节序了。例如UTF-16字符编码方案就分为UTF-16BE和UTF-16LE。
字节顺序标记(BOM)
如果你经常要在高低字节序的系统间转换文档,并且希望区分字节序,还有一种奇怪的约定,被称作BOM。BOM是一个设计得很巧妙的字符,用来放在文档的开头告诉阅读器该文档的字节序。在UTF-16中,它是通过在第一个字节放置FE FF来实现的。在不同字节序的文档中,它会被显示成FF FE或者FE FF,清楚的把这篇文档的字节序告诉了解释器。
BOM尽管很有用,但并不是很简洁,因为还有一个类似的概念,称作「魔术字」(Magic Byte),很多年来一直被用来表明文件的格式。BOM和魔术字间的关系一直没有被清楚的定义过,因此有的解释器会搞混它们。