您的位置:首页 > 编程语言 > Java开发

编程基础知识——Java相关的“编码”问题

2014-06-09 22:06 295 查看
全面分析Java相关的“编码”问题

本文说明

Java程序员遇到的一个很头痛的问题就是编码问题。一旦开发与系统平台及网络相关的程序而需要传输中文字符时,若不能正确掌握编码的知识,将频频出现乱码问题而扰乱开发计划。目前已经存在大量的关于编码问题的讲解。个人觉得这些文档主要是教“怎样解决目前问题”而非“为什么有这个问题”。希望本文能够帮助读者从根本上了解编码原理,以及软件开发过程中出现乱码的原因,而非仅仅是解决某个具体问题。

字符集UNICODE

从概念上讲,字符集并不完全等同与编码标准,这两个概念的区别很模糊。(未经过文献证实,只是以掌握的知识这么认为)。大家都清楚Java使用的是UNICODE字符集。请注意这里使用的是“字符集”关键字。下面以UNICODE为例浅谈字符集与编码标准。

UNICODE是一套字符集而非编码标准。UNICODE字符集并非一直不变,目前流行使用的版本是使用2个字节来存储理论数量为256x256=65535(个)的字符集。两个字节能表示的范围也就是这个数字,以16进制表示就是0x0000-0xFFFF。这65535(个)已经足够收录目前世界上主要语言的大多数字符了,保证日常通信是没问题的。最新的UNICODE标准已经可以支持几百万个字符了,当然随之而来的则是一个字符占用的字节数将更大。

在目前使用的JRE版本中,一个中文字符是使用两个字节的内存空间来存储的。在Java中测试UNICODE的相关功能是非常容易的:

首先是查看一个中文字符的UNICODE编码值(特别注意,这里指的是字符集中一个字符所代表的数字,它是存放在内存中的)。运行如下代码将可以得到:

charch1 = '汉' ;

intnch1 = (int)ch1 ;

System.out.println("汉:" + Integer.toHexString(nch1));

运行结果为:汉:6c49。

也就是说“汉”这个字符在内存中实际上是占用了两个字节的空间,一个字节存放的是“6C”而另一个字节存放的是“49”。这个十六进制数“0x6c49”所代表的数字则是在UNICODE字符集标准中“汉”这个字对应的数字值。

Java中提供了对“转码”相当好的支持,使用的时候也非常方便。请运行下面的代码:

Stringstr = “\u6c49” ;

System.out.println("6C49--> " + str);

运行结果:6C49--> 汉。

“\U”是将十六进制数转化成字符的转义字符。它只对它后面紧跟上的四个字符起作用。比如说:(‘a’的UNICODE数字编码是61)

Stringstr = “\u61” ; //这句代码是通不过编译的,”\u后面至少要4位十六进制的数字字符”

Stringstr = “\ugf61” ; //代码无法通过编译,提示不正确的UNICODE编码。

Stringstr = “\u0061”; //代码能够运行,打印str的结果为”a” 。

在JRE的bin目录下有一个native2ascii.exe程序,它可以提供对中文字符的转码,转化成上述的\uXXXX的格式。因为这种格式的字符串可以在完全不支持中文的环境中存储和传输,并且也可以被Java程序轻松的还原(String str =“\u6c49” ;这样就还原了,够简单了)。实际上手工编写一个native2ascii.exe的功能也是很简单的,前面已经介绍了其中的原理了。

Java中UNICODE字符集是表示在内存中的字符的编码,是和String类紧密相关的。一旦一个字符串建立,这些字符在内存中所存放的十六进制数就是根据UNICODE而来。在不需要写入文件和网络传输的情况下它的编码都是统一的。换句话说String类在处理字符的时候会将对象自身存储的“字节数组”以UNICODE来分析,两两字节来读取,将两个字节作为一组当成一个字符来处理。(注意这里)

这种不需要转换编码的字符串处理过程是程序员非常希望见到的。但实际上Java系统的运行其实到处都存在转码的处理。因为不可能数据只存在于内存中吧,只在内存中处理就没任何意义了,用户需要对数据进行保存,读取,打印,输入等等操作,每一样操作都是存在“转码”处理的,只是一般我们不会去关心,JRE已经帮你做好了很多事。

下面进入下一个重点知识,JRE与操作系统字符编码转换。

Windows操作系统默认支持的是GBK,它是一种编码标准。GBK我没深入研究过,它肯定是一种编码标准,因为它的编码可以写入文件。但是不是字符集笔者也不曾深入研究。我们不讨论它的编码原理,只需要知道它是和UNICODE不一样的,也就是说同一个字符在它们之间存放的十六进制数是不一样的。

JRE运行在WINDOWS中。我们从键盘输入的字符是以windows默认的编码标准接收的,也就是你输入的任何东西先是进入到操作系统又才会进入JRE。操作系统读取数据的时候内存中存放的是操作系统的默认支持的编码标准。这种编码格式不是JRE默认支持的。所以数据再进入JRE的时候就需要一次转码。

java.nio.charset.Charset.defaultCharset().name()方法可以得到当前操作系统默认使用的编码标准。JRE与操作系统之间进行数据通信时,在未特别指定的情况下都以这个编码进行转化,实际上就是UNICODE和这个方法返回的结果的那个编码进行转换。

通常情况下,当JRE读取控制台输入的字符时不需要进行编码指定,因为控制台接收数据后所存放的编码不会被用户更改,所以JRE只要用系统默认的编码进行转化就可以正确处理。(这个过程是:操作系统中存在一个字符串,它是以GBK编码的。而JRE需要将它读到JRE的内存里头,就需要通过JRE里面已经编写好了的GBK->UNICODE算法进行转化。String 类中提供了转化的方法,详细请看java.lang.String类的多种构造方法)。

当处理的是文件的时候就复杂一点了,因为文件中字符串的编码并不肯定是系统默认的编码。这个时候用户在读取的时候可以指定某一种编码来读取,使用InputStreamReader类,它有设置编码的构造方法。

由于JRE封装好了UNICODE与系统平台之间的编码转换,所以对于不经常使用编码的人员来说,编码问题就不重要了。

UTF-8编码

Java中经常使用的编码标准莫过于“UTF-8”了。它是编码标准而非单单是字符集,它也不是UNICODE。UNICODE中是以固定长存放字符的,而UTF-8是变长的。这其中又有什么关系呢?其实UTF-8和GBK、GB2312等等属于同一类,只是一种编码标准而已。它的功能是将内存中的字符保存到硬盘或者发送到网络传输线的时候使用的编码。这里需要注意理解一点,UNICODE字符集所用来表示字符的编码是“不能”直接写入硬盘和发送当网络传输线上去的。也就是说“6c49”这个4个十六进制字符所代表的字符是“汉”,但如果将“汉”这个字写入到文件,无论以哪种编码格式写入,在文件里保存的二进制编码中绝对不是6c49所表示的二进制编码。(以上为个人理解和测试的结果)。

内存中的“汉”这个字,所占用的两个字节里面存放的是“6c”和“49”。如果要以UTF-8格式写入到文件中,它的字节数组将是什么呢?下面通过测试得到这个字节数组的值。(具体这些编码标准什么编码的不是本文讨论的问题,因为它怎么编其实并不直接影响乱码的出现,而是编码之间的转化才导致了乱码)。

Stringstr = "汉" ;

byte []str_utf8_bytes = str.getBytes("UTF-8");

得到的str_utf8_bytes则是“汉”这个字用UTF-8编码时的字节数组。它的值经过测试得到结果是{-26,-79,-119}。也就是说,当“汉”这个字以UTF-8编码标准写入文件时,它占用了三个byte的大小,分别是-26,-79,-119。通过这三个数字组成一个字节数组,是可以还原得到一个汉字“汉”的。代码如下:

byte[]bytes_utf8 = new byte[]{-26,-79,-119}; //创建字节数组

Stringstr = new String(bytes_utf8,"UTF-8"); //使用字节数组创建字符串,并且告诉系统使用“UTF-8”编码标准类解析这个字节数组。

System.out.println("字节数组{-26,-79,-119}表示汉字:" +str);

输出结果: 字节数组{-26,-79,-119}表示汉字:汉。

其中这三个数字都是以十进制表示的整数,如果想以十六进制查看只需要用Integer.toHexString就可以转化成十六进制了,可以测试,它们与“6C”和“49”这两个“汉”的UNICODE码差了很远。

使用同样方法,可以分别得到“汉”这个字使用GBK编码时所得到的字节数组

Stringstr = "汉" ;

byte []str_gbk_bytes = str.getBytes("GBK");

得到的字节数组为{-70-70},结果告诉我们“汉”这个字以GBK编码的时候产生了两个字节,这两个字节分别存放的是-70和-70。同样可以使用上面的方法还原,读者可以自己测试。

注意:\uXXXX格式是Java常用的。它这种格式转化和这里的编码并不是一回事。因为不可能将\uXXXX直接写入文件。写入文件之后可能Java系统可以识别,但其他平台就未必了。而使用UTF-8和GBK则可以和其他平台进行通信。

特别强调:同一个字使用不同的编码得到的字节数组的值可能不一样(这样说是因为有些编码之间有兼容关系,可能是一样的),虚拟机中的字符串并不是以某种可写入文件的编码格式存储的,而是以UNICODE值存放的。要写入文件或者发送到网络传输线上面都是需要进行转码的,转码的方法就是String.getBytes()方法,给一个编码名称做参数就得到了该编码下这个字符串的字节数组了。还原的时候是使用String的构造方法,但是你必须知道这个字节数组是以哪种格式编码的,不然系统不能正确读取。刚才已经得知{-70,-70}这个字节数组是“汉”这个字以GBK编码时得到的字节数组。如果你使用String
str = new String(new byte[]{-70,-70},”utf-8”)来还原,肯定是会出乱码的。

以上就介绍完了JRE之中关于编码的知识,也简单介绍了编码与编码之间的转化。UNICODE可以作为一个中间标准,因为JRE已经完成了UNICODE与任意编码之间的转换功能,这样就可以完成不同编码之间的转化功能。

WEB系统中编码问题的出现

JSP页面中设置编码

在WEB系统的通信模式中,出现编码转换的地方很多。浏览器向服务器发送一次请求的数据包中,就有三个地方涉及到编码问题。

它们分别是:URL、URL后面的参数以及表单。

通常情况下URL和URL后面的参数都不会出现中文字符,在没有中文字符的时候是不存在乱码问题的,因为所有编码标准中对于英文字符的码值都是一样的。而当URL和URL中参数出现中文的时候,编码问题就相当棘手。

首先是URL路径中如果出现中文,形如 http://www.abc.com/pro/查询.jsp 的URL字符串,浏览器访问的是一个中文文件名的JSP文件“查询.jsp”。浏览器在发送请求的时候会将路径中的中文经过转码。首先假设浏览器以UTF-8转码。“查询”这两个汉字得到的字节数组为{-26,-97,-91,-24,-81,-94}。这里-26是个负数,并不能直接转化成十六进制。这其中涉及到数字编码的问题(原码、反码和补码的知识是计算机组成原理中涉及的)。只需要知道,浏览器将这六个数字转化成了六组十六进制字符串,且一个字符串只有两个字符,并且最后以“%”来连接。转化的结果是“%E6%9F%A5%E8%AF%A2”。也就是说,转码之后出现的每一个字节,都以%XX的形式表示,XX表示这个字节所代表的十六进制数。注意一个问题,26和-26转化成的结果肯定是不一样的。至于这里到底怎么转的,需要详细了解原码、反码和补码的知识。以范围来说0x00-0xFF表示的范围刚好是一个字节表示的范围,而一个字节表示的整数范围是-128到127。[-128,127]与[0x00,0xFF]是有一个对应关系的,并且是一对一的关系。不必去深究这里是怎么转化的,在Java中提供了现成的方法完成这个转化过程,java.net.URLEncoder.encode方法可以完成转化。(查看附录)

关键的问题是这样的:浏览器发送一个请求的时候,一次请求是一个数据包。这个数据包中同时包含了访问的URL路径(中文经过转码),还有URL后面以“?”连接的参数(如果参数有中文也会转码),还加上页面中的表单。比如如下的表单就会出现这个问题

<formaction=”用户登陆.jsp?username =张三” >

<input … />

<input … />

<input type=”submit” …/>

</form>

当用户输入了中文数据点了提交。这一次请求中三个存放数据的区域都出现了中文。为什么会频频造成乱码呢?下面将阐述:

第一部分,URL路径部分的中文以何种编码标准进行转码,并不是程序中能够设置的。而是浏览器默认的。注意!注意!这是一个严重的问题,也就是说浏览器无论访问哪个网址,出现中文的时候都是以自己“愿意”的编码标准进行转码。可以通过设置来修改浏览器在转化URL路径时使用的编码标准。如IE的设置方式是:打开internet选项。在高级里面,有一个“国际”分类,里面有一个复选框“发送UTF-8 URL”。如果勾上,浏览器将“始终”以UTF-8进行转码。如果未勾上,则会同第二部分的编码方式一样。不同的浏览器处理的方式还不一样,值得注意!

第二部分,URL路径后面跟随的参数部分使用的编码方式又不一样了,这个编码方式是根据当前的HTML页面决定的。通常访问一个新的URL都是以表单发送或者超级链接,这样前一个HTML页面中指定的编码方式将决定这次请求时参数的编码方式。如果是首次访问人工输入的URL,则浏览器以默认编码方式进行转码。

第三部分,表单部分浏览器以何种编码方式发送数据,下文将有介绍。

总结来说,URL路径部分以何种格式进行编码是浏览器自己决定的,而参数部分和表单部分同时是根据当前页面的语言环境决定的。那么是页面的哪个设置决定了的呢?见如下JSP代码:

<%@ page language="java"contentType="text/html; charset=utf-8"

pageEncoding="gbk"%>

<html>

<head>

<metahttp-equiv="Content-Type" content="text/html; charset=utf-8">

<title>Inserttitle here</title>

</head>

<body>

</body>

</html>

其中有三个设置编码的地方,按顺序分别取名A,B和C。其中A处和B是在<%@page ...>中的两个属性,第一个是contentType="text/html; charset=utf-8",第二个是pageEncoding="gbk"。这两个分别什么含义呢?这两个到底哪个决定了上述的浏览器的编码方式呢。答案是第一个。

第一个charset=utf-8的设置,告诉浏览器当前页面使用utf-8对URL后面的参数部分进行转码,并且同时以UTF-8发送表单。

第二个pageEncoding="gbk"它的作用范围并不涉及到浏览器。它的作用效果是决定当前JSP文件保存到操作系统中以何种编码方式编码;并且告诉WEB服务器,在读取到这个JSP页面的时候该也何种编码读取。如果当pageEncoding未设置的时候,系统会参考第一个contentType="text/html; charset=utf-8"的编码。如果都未设置,当页面又有中文的时候,JSP页面根本无法保存,不信你就试试看。

第三个是HTML部分设置的编码方式。它是告诉浏览器取得从服务器返回回来的字节数组时,解析字节数组该使用的编码方式。就是浏览器将使用这个编码方式解析从网络上取得的字节数组而转化成HTML字符串。查看HTML文档,其中对charset的解释为“设置或获取用于解码对象的字符集”。Servlet测试代码如下:

response.setCharacterEncoding("UTF-8");
PrintWriter out =response.getWriter();
out.append("<html>");
out.append("<head>");
out.append("<metahttp-equiv=\"Content-Type\" content=\"text/html; charset=gbk\">");
out.append("<title>Inserttitle here</title>");
out.append("</head>");
out.append("<body>");
out.append("<div>写个汉字看看</div>");
out.append("</body>");
out.append("</html>");
out.flush();
out.close();

当response中指定的编码方式和HTML设置的编码方式不一样时,浏览器无法正确显示数据。也就是说WEB服务器向浏览器发送的数据是以UTF-8编码的,而浏览器读取的时候是以GBK读取,肯定无法正确显示。这里又说明一个问题,在out.append("<metahttp-equiv=\"Content-Type\" content=\"text/html; charset=gbk\">");之前的代码中不应该出现中文,浏览器要读到这里的时候才会修改自己读取数据的方式。实际开发中,<title>标签要放在<meta>标签之后。

但是这个地方的配置只在纯.html文件中起作用,或者在Servlet中未设置Response.setContentType的情况下,而在JSP中会被<%@page contentType="text/html;charset=utf-8">覆盖,后面有详细介绍。

在JSP页面中的HTML部分指定的编码方式是无效的,因为<%@page language="java" contentType="text/html; charset=utf-8"%>已经指定了。也就是说浏览器会根据这一条命令指定的编码来读取服务器传回来的数据,同时也指定了Servlet在发送数据的时候使用的编码。如果笔者有兴趣,可以做查相关资料。服务器返回给浏览器的数据中除了HTML之外还包含很多数据的header。Response.setContentType设置的数据就是存放在这些header里头的。这一条指令在jsp转化成java文件之后,变成了代码“response.setContentType("text/html;charset=utf-8");”。如果这里未设置,那么浏览器会根据HTML里面<meta>中指定的编码方式来解析。

比如说,在一个.html页面中,肯定没得response.setContentType。

<html>
<head>
<metahttp-equiv="Content-Type" content="text/html;charset=utf-8">
<title>Insert titlehere</title>
</head>
<body>
</body>
</html>

此时浏览器会使用<meta>里面配置的编码方式来发送当前表单和对URL进行转码。如果是JSP页面生成的HTML中的<metahttp-equiv="Content-Type" content="text/html;charset=utf-8">通常是无效的。因为浏览器会以response.setContentType的值为准,这个值是放在返回时数据包中的header中的,查看网页源代码是看不到的。

那为什么还要HTML中可以指定一个编码格式呢。这是因为HTML还有另一个用途,它可以直接保存到操作系统中由浏览器直接读取,而非从网络上获取。浏览器访问WEB服务器的时候,服务器返回数据之前就告诉了浏览器该用何种编码格式解析我发给你的数据。浏览器直接从操作系统中读取一个HTML文件的时候就不同了,没人告诉我。那么我就要从HTML中的<meta http-equiv=\"Content-Type\"content=\"text/html; charset=gbk\">这段代码去判断。

FileOutputStream writer = new FileOutputStream("test.html");
PrintWriter out = newPrintWriter(new OutputStreamWriter(writer,"utf-8"));

out.append("<html>");
out.append("<head>");
out.append("<metahttp-equiv=\"Content-Type\" content=\"text/html;charset=gbk\">");
out.append("<title>Inserttitle here</title>");
out.append("</head>");
out.append("<body>");
out.append("<div>写个汉字看看</div>");
out.append("</body>");
out.append("</html>");

out.flush();
out.close();

通过上面代码测试,写入一个HTML文件到硬盘,并且指定以utf-8编码格式写入文件。然后用浏览器打开这个HTML文件,使用的是GBK进行解析,发现是乱码。如果这两个编码是一致的,就不是乱码了。也就是说HTML中的编码在这里起了作用。

服务器取得参数

URL的解析的时候使用的编码方式是在配置文件中设置的。以TOMCAT为例,conf/server.xml中设置服务器解析URL的编码方式。

<Connector port="8080"protocol="HTTP/1.1"

maxThreads="150" connectionTimeout="20000"

redirectPort="8443"

URIEncoding="utf-8"/>

最后一项就是告诉服务器该如何来解析浏览器访问的URL,无论是路径部分还是参数部分都一样。这样就出现了一个问题值得注意,浏览器对路径部分和参数部分的编码方式不一定是一样的。而浏览器解析的时候又会以一种方式处理。很多中文网站都会将所有地方的编码设置成GBK,在所有设置编码格式的地方都设置GBK的情况下,有一个问题必须避免,那就是URL路径部分不能有中文出现,否则出错。因为浏览器还是会以UTF-8来编码路径部分,以GBK编码参数部分和页面中的表单。而如果服务器server.xml中配置的编码是GBK的话,服务器会用GBK来解析URL。殊不知这里是URL其实同时包含了UTF-8编码的路径部分和GBK编码的参数部分。

服务器获取数据时用得最多的方法莫过于request.getParameter()了。request.setCharacterEncoding()这个方法可以设置读取数据使用的编码。而这个方法设置的编码只能影响表单部分,并且只对POST方式起作用。因为GET方式的参数是放在URL中的。URL的解析方式是配置文件决定的。所以如果你的参数带有中文,并且服务器配置了正确的编码来解析URL,那么这里直接request.getParameter()的结果就是你需要的字符串,也不需要转码了。

大家都知道,表单部分发送过来的参数是需要转一次码才能正确显示的。通常都是用的这个方法request.getParameter().getBytes("ISO-8859-1");然后在将取得的字节数组作为参数,使用String的构造方法进行转换。比如

byte [] buffer = request.getParameter(“name”).getBytes("ISO-8859-1");

String name = new String(buffer,”utf-8”);

这样得到了用户输入在表单中的name数据。

为什么这里有个ISO-8859-1呢,为什么这里需要这样转换呢?原因在于,web服务器在读取浏览器发送来的表单的时候,一律使用ISO-8859-1读取。这又回到了开始讲到的不同编码之间的转换问题了。浏览器本来发送的数据是以UTF-8编码的,而浏览器错误的将它们以ISO-8859-1读取了,所以就要用上面的办法还原回去。request.getParameter取得的结果是已经经过服务器转码之后的结果,并且是以ISO-8859-1这种不完全合适的方式进行的转码。要取得正确的结果,就要原路返回,先以ISO-8859-1编码方式取得字节数组,这个字节数组就是浏览器发送过来的原始数据。这个原始数据是以UTF-8编码的。所以后面参数里面给的UTF-8。

总结

在WEB系统的开发中,设置编码最关键的是response.setContentType方法中指定的编码方式。它直接影响URL路径部分(IE未强制设置以UTF-8发送URL时),URL参数部分以及表单部分的中文数据编码格式。

避免乱码最好的方式还是避免在URL中出现中文,参数尽量以POST方式发送,配置统一的编码方式。
附录,负数编码(补码)

负数编码(正数就是直接将数字转化成二进制,很简单)

上文中说道“-26”经过转码变成了“E6”

byte b = -26;

根据反码编码规律,一个字节有八位,如[0,0000000],其中第一位是符号位,1为负,0为正。

26转化成二进制是11010

11010(2)=1*2^4 + 1*2^3 + 0*2^2 + 1*2^1 + 0*2^0 其中2^3表示二的三次方

=16 + 8 + 0 + 2 + 0

=26

根据格式[0,0000000],其中数字部分有7位,则差的两位补0。得到26=0011010(2)。

将0011010按位取反,并且最后在最后一位加1得到:1100110

最后得到的-26的反码表示法是[1,1100110];

最后把11100110这个完整的当成一个无符号数,取得整数值是230

然后将230转化成十六进制则是“E6”。

关键提示:这里实际上是一个数据两种解释得到的结果。11100110这是-26这个数在一个字节中具体的二进制码存放形式,它是以补码表示的。补码的规则是首位是1为负,0为正。如果不把它当成补码,而当成无符号数,取得的值是230。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: