在计算机中存储信息的最小单位是1个字节,即8bit,因此能标识的最大字符范围是0~255,而人类天然语言中例如汉语、日语要表示的符号太多,没法单纯用一个字节来彻底表示,为了解决这个矛盾必需要有一个新的人类可识别的数据存储结构字符,而从char到byte必须编码。javascript
总共128个,使用一个字节的低7位表示,0~31是控制字符如换行、回车、删除等,32~126是打印字符,能够经过键盘输入而且可以显示出来html
ISO组织在ASCII码的基础上利用了单个字节的全部位定制了一系列标准扩展了ASCII编码,ISO-8859-1仍然是单字节编码,总共能够表示256个字符前端
GB2312全称是《信息技术中文编码字符集》,它采用了双字节编码,总的编码范围是A1~F7,其中A1~A9是符号区,总共包含682个符号;B0~F7是汉字区,包含6763个汉字。java
GBK全称是《汉字内码扩展规范》是国家技术监督局位windows 95制定的新的内码规范,它的出现是为了扩展GB2312,并加入更多的汉字,它的编码范围是8140~FEFE,总共有23940个码位能够表示21003个汉字,它的编码与GB2312兼容,也就是说采用GB2312编码的汉字都可以经过GBK去解码,而且不会有乱码。mysql
UTF-16定义了Unicode字符在计算机中的存取方法,UTF-16使用两个字节表示Unicode的转化格式,这是一个定长的表示方法,不管什么字符UTF-16都用两个字节表示,2个字节就是16位,因此称之为UTF-16,UTF-16表示字符很是方便,每两个字节表示一个字符,在字符串操做的时候大大简化了操做,这个也是Java以UTF-16做为内存的字符存储格式的一个很重要的缘由。web
Unitcode使用UTF-16编码规则以下:算法
UTF-16编码以16位无符号整数为单位,咱们sql
UTF-16统一采用两个字节表示一个字符,虽然在表示上很是简单方便,可是也有其缺点,有很大一部分字符使用一个字节就能够表示的如今须要两个字节表示,存储空间增大了一倍,在如今网络带宽还很是有限的今天,这样作无疑会增大网络传输的流量,并且也没什么必要,而UTF-8采用的是一种变长的技术,每种编码区域都会有不一样的字节长度,不一样类型的字符能够有1~6个字节组成。数据库
UTF-8有如下编码规则apache
1.若是是1个字节,最高为(第8位)为0,则表示这是1个ASCII字符(00~7F)。可见,全部ASCII编码已是UTF-8了。
2.若是是1个字节,以11开头,则连续的1的个数暗示这个字符的字节数,例如:110xxxxx表明它是双字节UTF-8字符的首字节。
3.若是是1个字节,以10开始,表示它不是首字节,则须要向前查找才能获得当前字符的首字节。
咱们知道在涉及编码的地方通常都在从字符到字节或者是从字节到字符的转化过程当中,而须要这种转化的场景主要是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字符集,通常不会出现乱码问题。
对有些应用程序若是不注意指定字符编码,则在中文环境中会使用操做系统默认编码。若是编解码都在中文环境中,一般也没有问题,但仍是不推荐使用操做系统的默认编码,由于这样会使你的应用程序的编码格式和运行环境绑定起来,在跨环境时极可能出现乱码问题。
在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中涉及编码的类图以下:
下图是String.getBytes(String charsetName)对应的时序图
由图可知,String.getBytes(String charsetName)编码基本流程以下:
1)根据charsetName找到Charset类,而后根据这个字符集编码生成CharsetEncoder,这个类是全部字符编码的父类,针对不一样的字符编码集在charset中定义了获取对应CharsetEncoder的方法;
2)基于获取到的编码器CharsetEncoder对当前字符串进行编码
字符串“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,因此很容易出现乱码问题。
字符串“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的编解码实际上是基于码表进行的。
字符串“I am 君山”用GBK编码时,编码结果如图:
你可能已经发现,上图与 GB2312 编码的结果是同样的,没错,GBK 与 GB2312 编码结果是同样的,由此能够得出 GBK 编码是兼容 GB2312 编码的,它们的编码算法也是同样的。不一样的是它们的码表长度不同,GBK 包含的汉字字符更多。因此只要是通过 GB2312 编码的汉字均可以用 GBK 进行解码,反过来则否则。
字符串“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 以外的字符编码。
字符串“I am 君山”用UTF-8编码时,编码结果如图:
UTF-16 虽然编码效率很高,可是对单字节范围内字符也放大了一倍,这无形也浪费了存储空间,另外 UTF-16 采用顺序编码,不能对单个字符的编码值进行校验,若是中间的一个字符码值损坏,后面的全部码值都将受影响。而 UTF-8 这些问题都不存在,UTF-8 对单字节范围内字符仍然用一个字节表示,对汉字采用三个字节表示。UTF-8 编码与 GBK 和 GB2312 不一样,不用查码表,因此在编码效率上 UTF-8 的效率会更好,因此在存储中文字符时 UTF-8 编码比较理想。
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在编码效率上和编码安全上作了平衡,是理想的中文编码方式。
前面已经提到了I/O操做会引发编码,而大部分I/O引发的乱码都是网络I/O,由于如今几乎全部的应用程序都涉及网络操做,而数据通过网络传输时是以字节为单位的,因此全部的数据都必须可以被序列化为字节。在Java中数据要被序列化,必须继承Serializable接口。
用户从浏览器发起一个HTTP请求,存在编码的地方是URL、Cookie、Parameter。服务器端接收到HTTP请求后要解析HTTP,其中URL、Cookie和Post表单参数须要解码,服务器端可能还须要读取数据库中的数据——本地或网络中其余地方的文本文件,这些数据均可能存在编码问题。当Servlet处理完全部请求的数据后,须要将这些数据再编码,经过Socket发送到用户请求的浏览器里,再通过浏览器解码成文本。这个过程如图:
浏览器编码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这两个参数
当客户端发起一个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中,这样在浏览器到服务器的传递过程当中就不会丢失信息了,咱们要访问这些项时在按照相应的字符集解码便可
POST表单提交的参数的解码是在第一次调用request.getParameter时发生的,POST表单的参数传递方式与QueryString不一样,它是经过HTTP的BODY传递到服务端的。
当咱们在页面上单击提交按钮时,浏览器首先将根据ContentType的Charset编码格式对表单中填入的参数进行编码,而后提交到服务器端,在服务器端一样也是采用ContentType中的字符集进行解码的,这个字符集咱们也能够在服务端经过request.setCharacterEncoding(Charset)来进行设置。
另外针对multipart/for-data类型的参数,也就是上传的文件编码,一样也是使用ContentType定义的字符集编码。注意,上传文件是用字节流的方式传递到服务器的本地临时目录,这个过程并无涉及字符编码,而真正的编码是将文件内容添加到Parameters中时,若是不能使用这种编码方式则会使用默认编码ISO-8859-1来编码。
当用户请求的资源已经成功获取后,这些内容将会经过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文件中包含中文字符串输入的状况,例如:
<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编码,上面代码中的中文输入就会变成乱码。
经过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编码解码知识以后咱们知道出现乱码问题惟一的缘由就是在编码解码过程当中采用的字符集不一致致使的,由于在一次操做中常常涉及屡次编码和解码,所以出现乱码问题的时候也给咱们排查带来的难度,下面分析几种常见的情景:
例如,字符串“淘!我喜欢!”变成了“Ì Ô £ ¡Î Ò Ï²»¶ £ ¡”编码过程以下图所示
字符串在解码时所用的字符集与编码字符集不一致致使汉字变成了看不懂的乱码,并且是一个汉字字符变成两个乱码字符。这种情景在开发中常常发生,例如在浏览器中输入一个带有中文字符串参数的URL一些浏览器默认对QueryString采用的是GBK编码方式,可是因为在web中间件例如tomcat没有作相关配置,在服务端读取请求参数时也没有指定编码方式,因而默认使用ISO-8859-1进行解码致使乱码。这种出现乱码且乱码字符串长度是原编码前字符串的两倍的缘由多是采用2字节编码例如GBK、UTF-16等而后使用单字节进行解码例如ISO-8859-1致使的
例如,字符串“淘!我喜欢!”变成了“??????”编码过程以下图所示
将中文和中文符号通过不支持中文的 ISO-8859-1 编码后,全部字符变成了“?”,这是由于用 ISO-8859-1 进行编解码时遇到不在码值范围内的字符时统一用 3f 表示,这也就是一般所说的“黑洞”,全部 ISO-8859-1 不认识的字符都变成了“?”。
例如,字符串“淘!我喜欢!”变成了“????????????”编码过程以下图所示
这种状况比较复杂,中文通过屡次编码,可是其中有一次编码或者解码不对仍然会出现中文字符变成“?”现象,出现这种状况要仔细查看中间的编码环节,找出出现编码错误的地方。
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技术内幕》