正片开始
Java的编解码具体实现
这里将以实际例子介绍 Java 中如何实现编码及解码,下面我们以“I am 君山”这个字符串为例介绍 Java 中如何把它以 ISO-8859-1、GB2312、GBK、UTF-16、UTF-8 编码格式进行编码的。(书的作者笔名是“君山”)
String name = "I am 君山";
try {
byte[] iso8859 = name.getBytes("ISO-8859-1");
byte[] gb2312 = name.getBytes("GB2312");
byte[] gbk = name.getBytes("GBK");
byte[] utf16 = name.getBytes("UTF-16");
byte[] utf8 = name.getBytes("UTF-8");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
我们先来看一下Java是如何编码的,下面是 Java 中编码需要用到的类图:(3.1中有第一层的代码)老千层饼了

那么其中的过程就是:
更详细一点,String.getBytes(charsetName)编码过程的时序图为:
下面看看不同的字符集是如何将前面的字符串编码成 byte 数组的?
如字符串“I am 君山”的 char 数组为[ 49 20 61 6d 20 541b 5c71],下面把它按照不同的编码格式转化成相应的字节。(其实看我加粗的部分就行了)
按照 ISO-8859-1 编码
字符串“I am 君山”用 ISO-8859-1 编码,下面是编码结果: ISO-8859-1 是单字节编码,中文“君山”被转化成值是 3f 的 byte。3f 也就是“?”字符,所以经常会出现中文变成“?”很可能就是错误的使用了 ISO-8859-1 这个编码导致的(这是不可逆的!)。中文字符经过 ISO-8859-1 编码会丢失信息,通常我们称之为“黑洞”,它会把不认识的字符吸收掉。由于现在大部分基础的 Java 框架或系统默认的字符集编码都是 ISO-8859-1,所以很容易出现乱码问题,后面将会分析不同的乱码形式是怎么出现的。
按照 GB2312 编码
5 个字符经过编码后仍然是 5 个字节,而汉字被编码成双字节,在第一节中介绍到 GB2312 只支持 6763 个汉字,所以并不是所有汉字都能够用 GB2312 编码。
GB2312 字符集有一个 char 到 byte 的码表,不同的字符编码就是查这个码表找到与每个字符的对应的字节,然后拼装成 byte 数组。查表的公式(不用关心):
c2b[c2bIndex[char >> 8] + (char & 0xff)]按照 GBK 编码
你可能已经发现上图与 GB2312 编码的结果是一样的,没错 GBK 与 GB2312 编码结果是一样的,由此可以得出 GBK 编码是兼容 GB2312 编码的,它们的编码算法也是一样的。不同的是它们的码表长度不一样,GBK 包含的汉字字符更多。所以只要是经过 GB2312 编码的汉字都可以用 GBK 进行解码,反过来则不然。
按照 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 之外的字符编码。
UTF-16 虽然编码效率很高,但是对单字节范围内字符也放大了一倍,这无形也浪费了存储空间,另外 UTF-16 采用顺序编码,不能对单个字符的编码值进行校验,如果中间的一个字符码值损坏,后面的所有码值都将受影响。
按照 UTF-8 编码
而 UTF-8 这些问题都不存在,UTF-8 对单字节范围内字符仍然用一个字节表示,对汉字采用三个字节表示。(编码规则详见3.1)
UTF-8 编码与 GBK 和 GB2312 不同,不用查码表,所以在编码效率上 UTF-8 的效率会更好,所以在存储中文字符时 UTF-8 编码比较理想。
几种方式的比较
- 后面四种编码格式都能处理中文字符
- GB2312 与 GBK 编码规则类似,但是 GBK 范围更大,它能处理所有汉字字符
- UTF-16 与 UTF-8 都是处理 Unicode 编码,UTF-16 适合在本地磁盘和内存之间使用,可以进行字符和字节之间快速切换,如 Java 的内存编码就是采用 UTF-16 编码。
- UTF-8 更适合网络传输,对 ASCII 字符采用单字节存储,另外单个字符损坏也不会影响后面其它字符,在编码效率上介于 GBK 和 UTF-16 之间,所以 UTF-8 在编码效率上和编码安全性上做了平衡,是理想的中文编码方式。(网络传输容易损坏字节流,一旦字节流损坏将很难恢复,所以不能用UTF-16)
Java Web中的编解码
有 I/O 的地方就会涉及到编码,而大部分 I/O 引起的乱码都是网络 I/O,而数据经过网络传输都是以字节为单位的,所以所有的数据都必须能够被序列化为字节(在 Java 中数据被序列化必须继承 Serializable 接口。2.1有讲)
两个问题
1.一段文本的”实际大小“怎么计算?
什么叫“实际大小”?举个例子,有个数字1234567(一百二十三万四千五百六十七),如果用UTF-8,占用 7 个 byte,采用 UTF-16 编码将会占用 14 个 byte,但是把它当成 int 型数字来存储只需要 4 个 byte 来存储。(那么你说它的实际大小是多少呢?)
所以看一段文本的大小,看字符本身的长度是没有意义的,即使是一样的字符采用不同的编码最终存储的大小也会不同,从字符到字节一定要看编码类型。
再提一下压缩:
- 为了减少网络传输量,大家就要想办法压缩Cookie大小,但是即使使用不同的压缩算法,只会减少字符数,不会减少字节数。
- 所谓压缩就是将n个多字节的字符压成一个多字节字符,但也仅仅是减少了String.length()。
- 压缩可以理解为一种另类的“编码”
2.输入汉字的时候,计算机是怎么表示的
这里的表示可不是编码格式,我们知道计算机都是用0和1来表示的,像“淘宝”二字,十进制——[28120 23453],十六进制——[6bd8 5d9d]。而在Java中一个char是16bit=2字节,所以一个汉字=2字节的空间。
需要编码的地方
用户从浏览器端发起一个 HTTP 请求,需要存在编码的地方是 URL、Cookie、Parameter。
服务器端接受到 HTTP 请求后要解析 HTTP 协议,其中 URI、Cookie 和 (POST )表单参数需要解码,服务器端可能还需要读取数据库中的数据,本地或网络中其它地方的文本文件,这些数据都可能存在编码问题,当 Servlet 处理完所有请求的数据后,需要将这些数据再编码通过 Socket 发送到用户请求的浏览器里,再经过浏览器解码成为文本。
这些过程如下图所示:

URL
用户提交一个URL,它可能存在中文,因此不能回避编码。
先复习一下URL的组成:
(具体的在Tomcat配置文件里如何写,就不在过多介绍了,请自行在传智、尚硅谷、黑马等网站学习)
上图中PathInfo和QueryString部分都出现了中文,那么服务器又如何解析这个URL的后半部分呢(Google 浏览器 Network):

没错,这个“亚索”就被分解成了——“%E4%BA%9A%E7%B4%A2”(因为是XJB写的,所以是404)
这里PathInfo和QueryString是UTF-8(而Firefox中QueryString默认是GBK),至于为什么会有“%”?查阅 URL 的编码规范 RFC3986 可知,浏览器编码 URL 是:将非 ASCII 字符按照某种编码格式,编码成 16 进制数字,然后将每个 16 进制表示的字节前加上“%”,所以最终的 URL 就成了上图了。
解码(Tomcat为栗子= =)
解码操作是在InternalInputBuffer(org.apache.coyote.HTTP11.InternalInputBuffer )类的,parseRequestLine 方法中。
这个方法把传过来的 URL 的 byte[] 设置到 Request(org.apache.coyote.Request) 类的相应的属性中。
这里的 URL 仍然是 byte 格式,转成 char 是在CoyoteAdapter (org.apache.catalina.connector.CoyoteAdapter ) 类的 convertURI 方法中完成的。
看波图,理解理解:
看一波复杂的CoyoteAdapter.convertURI的源代码(爱过就过):
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); request.setURIConverter(conv); } } catch (IOException e) {...} if (conv != null) { try { conv.convert(bc, cc, cc.getBuffer().length - cc.getEnd()); uri.setChars(cc.getBuffer(), cc.getStart(), cc.getLength()); return; } catch (IOException e) {...} } } // Default encoding: fast conversion 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中定义的( ,这个是配置哦),所以有中文最好设置了。
QueryString的解码过程
QueryString (GET方法)与表单参数(POST方法)都是通过request.getParameter方法获取参数值的。对它们的解码,在整个请求过程中,第一次调用request.getParameter方法时进行(等有Filter的时候,你就容易犯错)。request.getParameter 方法被调用时将会调用 Request(org.apache.catalina.connector.Request)类的 parseParameters 方法。
其中,Request.parseParameters()会对GET和POST传过来的参数进行解码(Decode),但是两个字符集可能不一样哦= = 。
QueryString的解码字符集在要么是 Header 中 ContentType 中定义的 Charset 要么就是默认的 ISO-8859-1。(如果你非要改的话,要在connector的 中的 useBodyEncodingForURI 设置为 true。)
小结:在应用程序中,尽量避免在URL中使用非ASCII字符;当然,如果中文无法避免,那就在服务器端设置好URIEncoding和useBodyEncodingForURI两个参数吧。(不然就乱码咯)
HTTP Header
对Header 中的项(Cookie、redirectPath等)进行解码,是在调用 request.getHeader 中进行的,如果请求的 Header 项没有解码则调用 **MessageBytes.toString()**方法。(这个方法将从 byte 到 char ,使用的默认编码是 ISO-8859-1)
对于上面,我们也不能设置 Header 的其它解码格式,莫得办法,所以如果 Header 中有非 ASCII 字符,解码肯定会有乱码。
但是!!!如果一定要传非 ASCII 字符,可以偷奸耍滑一波——我们可以先将这些字符用 URLEncoder 类(org.apache.catalina.util.URLEncoder)编码,然后再添加到 Header 中,这样在浏览器到服务器的传递过程中就不会丢失信息了,如果我们要访问这些项时再按照相应的字符集解码就好了。
(POST)表单
前面URL部分有提到过,POST表单的参数解码是在第一次调用 request.getParameter 发生的。
和QueryString 不同的是,它是通过 HTTP BODY(马上来讲)传递到服务端的。
当我们在页面上点击 submit 按钮时,浏览器首先将根据 ContentType 的 Charset 编码格式对表单填的参数进行编码然后提交到服务器端,在服务器端同样也是用 ContentType 中Charset进行解码(但是,浏览器在默认情况下,提交的content-type不含charset信息,所以Tomcat会使用系统默认的方式去解析)。所以通过 POST 表单提交的参数一般不会出现问题,而且这个字符集编码是我们自己设置的,可以调用request.setCharacterEncoding(charset) 来设置。
注意:一定要在第一次调用 request.getParameter之前就设置request.setCharacterEncoding(charset) ,否则POST表单很可能直接乱码。(也就是说如果有Filter,你就要设置好,要是编解码不可逆,哭都没地方哭)
HTTP BODY
请求讲完了,来讲响应。
当用户请求的资源已经成功获取后,这些内容将通过 Response 返回给客户端浏览器,这个过程先要经过编码
再到浏览器进行解码。(过程类似Request,看看就过)
这个过程的编解码字符集可以通过 response.setCharacterEncoding 来设置,它将会覆盖 request.getCharacterEncoding 的值,并且通过 Header 的 Content-Type 返回客户端
浏览器接受到,将通过发过来的 Content-Type 的 charset 来解码,如果没有,那么浏览器将根据 HTML的 中的 charset 来解码。
常见问题分析(中文乱码)
由于往往一次操作涉及到多次编解码,所以出现乱码时很难查找到底是哪个环节出现了问题。下面来分析(欣赏)不同的乱码场景:(你得知道你GG是怎么G的,才好改正)
中文变成了奇怪的字符
例如,字符串“淘!我喜欢!”变成了“ì ? £ ?? ò ?2?? £ ?”编码过程如下图所示
字符串在解码时所用的字符集与编码字符集不一致导致汉字变成了看不懂的乱码,而且是一个汉字字符变成两个乱码字符。(解码的时候翻车了)
一个汉字变成了一个“?”
例如,字符串“淘!我喜欢!”变成了“??????”编码过程如下图所示
将中文和中文符号经过不支持中文的 ISO-8859-1 编码后,所有字符变成了“?”,这是因为用 ISO-8859-1 进行编解码时遇到不在码值范围内的字符时统一用 3f 表示,这也就是通常所说的“黑洞”,所有 ISO-8859-1 不认识的字符都变成了“?”(3.2前面有讲)。(编码的时候就翻车了)
一个汉字变成了两个“?”
例如,字符串“淘!我喜欢!”变成了“????????????”编码过程如下图所示
这种情况比较复杂,中文经过多次编码,但是其中有一次编码或者解码不对仍然会出现中文字符变成“?”现象,出现这种情况要仔细查看中间的编码环节,找出出现编码错误的地方。(多处地点翻车)
正确但不正常的编码
还有一种情况是在我们通过 request.getParameter 获取参数值时,当我们直接调用
String value = request.getParameter(name);
会出现乱码,但是如果用下面这样,有中间商调和:
//变byte[],再变char[]
String value = String(request.getParameter(name).getBytes(" ISO-8859-1"), "GBK");
解析时取得的 value 会是正确的汉字字符,这种情况是怎么造成的呢?
这种情况是这样的:
首先ISO-8859-1 字符集的编码范围是 0000-00FF,正好和一个字节的编码范围相对应。
虽然中文字符在经过网络传输时,被错误地“拆”成了两个欧洲字符,但由于输出时也是用 ISO-8859-1,结果被“拆”开的中文字的两半又被合并在一起,从而又刚好组成了一个正确的汉字。
虽然最终能取得正确的汉字,但是还是不建议用这种不正常的方式取得参数值,因为这中间增加了一次额外的编码与解码。
这种情况出现乱码时,因为 Tomcat 的配置文件中 useBodyEncodingForURI 配置项没有设置为”true”,从
而造成第一次解析式用 ISO-8859-1 来解析才造成乱码的。
总结
几种常见编码格式的区别,然后介绍了支持中文的几种编码格式,并比较了它们的使用场景。
Java 那些地方会涉及到编码问题,已经 Java 中如何对编码的支持。
以网络 I/O 为例重点介绍了 HTTP 请求中的存在编码的地方
Tomcat 对 HTTP 协议的解析
中文乱码问题&出现的原因。
学会了吗?点波赞再走!!