尊重个人劳动成果,转载请声明:http://blog.csdn.net/softmanfly/article/details/43611985
乱码是软件开发中的常见问题,程序员如果对码不清楚的话经常会被各种码搞得晕头转向,我在开发一个JavaWeb项目时也遇到了一些乱码的问题,百思不得其解,最后通过阅读源码和一定的猜测,对编码和乱码问题有了一定的心得体会,故记录下来(如果只想深入了解Java中的编码相关内容的话可以直接看红字下面的部分):
问题来由:在http get方法中url后面添加query string,使用中文作为参数,提交到服务器导致乱码,比如一个请求:
http://localhost:8080/register?userName=小波波,最后到达服务器时调用request.getParameter("userName")就变成了乱码。
问题分析:
网上查阅了一大堆的方法,有设置charsetEncoding的,有设置URIEncoding的,有new String(params.getBytes("ISO-8859-1"), "utf-8")转化一下的;
然后我就开始各种尝试,发现有时候能扭转乱码,有时候变成其他的乱码,虽然能够解决一时的问题,但是还是不明白本质的原因,所以我决定先从源码入手,看看getParameter函数是如何取得我们需要的参数的值得,以下是getParameter函数的源码:
public String getParameter(String name) {
parseParameters();
Object value = parameters.get(name);
if (value == null)
return (null);
else if (value instanceof String[])
return (((String[]) value)[0]);
else if (value instanceof String)
return ((String) value);
else
return (value.toString());
}
发现是在parseParamters里面进行的处理:
<span style="font-size:18px;">if (parsedParams) {
return;//如果已经解码过,就直接返回
}
parameters = new HashMap<>();
parameters = copyMap(getRequest().getParameterMap());
mergeParameters();</span><pre name="code" class="java"><span style="font-family: Arial, Helvetica, sans-serif;"><span style="font-size:14px;">parsedParams = true;</span></span>
发现应该是继续在mergeParamters里进行处理:
private void mergeParameters() {
if ((queryParamString == null) || (queryParamString.length() < 1))
return;
HashMap<String, String[]> queryParameters = new HashMap<>();
String encoding = getCharacterEncoding();
if (encoding == null)
encoding = "ISO-8859-1";
RequestUtil.parseParameters(queryParameters, queryParamString,
encoding);//问题出在这
Iterator<String> keys = parameters.keySet().iterator();
while (keys.hasNext()) {
String key = keys.next();
Object value = queryParameters.get(key);
if (value == null) {
queryParameters.put(key, parameters.get(key));
continue;
}
queryParameters.put
(key, mergeValues(value, parameters.get(key)));
}
parameters = queryParameters;
}
大致理解以下这个函数,应该是在将通过get方法中?后面的query string携带的参数和通过addParameter方法添加的参数进行合并(merge),所以乱码问题应该来自对queryParameters的处理,也就是RequestUtil.parseParameters函数
打开这个函数:
public static void parseParameters(Map<String,String[]> map, String data,
String encoding) {
if ((data != null) && (data.length() > 0)) {
// use the specified encoding to extract bytes out of the
// given string so that the encoding is not lost.
byte[] bytes = null;
try {
bytes = data.getBytes(B2CConverter.getCharset(encoding));
parseParameters(map, bytes, encoding);
} catch (UnsupportedEncodingException uee) {
if (log.isDebugEnabled()) {
log.debug(sm.getString("requestUtil.parseParameters.uee",
encoding), uee);
}
}
}
}
发现首先将data转化为对应编码的bytes数组(data就是query string也就是?后面的userName=小波波),然后再调用
parseParameters(map, bytes, encoding);
对byte数组进行处理,那么再次进入这个parseParameters函数:
public static void parseParameters(Map<String,String[]> map, byte[] data,
String encoding) throws UnsupportedEncodingException {
Charset charset = B2CConverter.getCharset(encoding);
if (data != null && data.length > 0) {
int ix = 0;
int ox = 0;
String key = null;
String value = null;
while (ix < data.length) {
byte c = data[ix++];
switch ((char) c) {
case '&':
value = new String(data, 0, ox, charset);
if (key != null) {
putMapEntry(map, key, value);
key = null;
}
ox = 0;
break;
case '=':
if (key == null) {
key = new String(data, 0, ox, charset);
ox = 0;
} else {
data[ox++] = c;
}
break;
case '+':
data[ox++] = (byte)' ';
break;
case '%':
data[ox++] = (byte)((convertHexDigit(data[ix++]) << 4)
+ convertHexDigit(data[ix++]));
break;
default:
data[ox++] = c;
}
}
//The last value does not end in '&'. So save it now.
if (key != null) {
value = new String(data, 0, ox, charset);
putMapEntry(map, key, value);
}
}
大家可以清楚的看到这里就是在进行实际的解析,分别得到参数的key(userName)和value(小波波),然后将其放入到一个Map<Key,Value>中,我们注意到这一行:
value = new String(data, 0, ox, charset);
这里就是真正将data数组中关于小波波的部分按照charset转化成String value,所以乱码问题的出现应该是这个charset的问题!从上面的源码中我们可以知道charset值是通过这样来设置的:
String encoding = getCharacterEncoding();
if (encoding == null)
encoding = "ISO-8859-1";
那么把charset设置成什么编码才不会导致乱码呢?要想搞清楚这个问题还真不容易,你必须得对编码有一个比较清晰的了解,
以下内容才是本文的精华,能够让你对Java中的编码有一个很好的认识,以及为什么上面源码中会多次出现ISO8859-1这个编码种类,它有什么特点能够让他在众多的编码方式中脱颖而出成为Java源码中多次用来当做默认的编码:
首先我们来看看Java中的String类,String类理解了,玩转Java中的编码就不是难事了。
String类中有2个比较常用的操作:
一个是str.getBytes(String charsetName)
一个是new String(s.getBytes("GBK"), "UTF-8");
搜索了很多网上的资料,关于这两个操作的深入分析的文章还是很少的,反正我是没找到,所以还是得靠源码来说话,一开始我想既然String和byte数组联系如此紧密,那么String里面一定有2个成员变量把,一个是byte[] bytes负责存放0和1序列,一个是 Charset charset代表bytes的编码种类,假如一个String s="哈";那么在这个String里应该存放着
byte [] bytes = 11011011101...(这里纯属假设,目的是把原理阐述清楚就行) 然后还有Charset charset = Charset.getFromName("UTF-8");这就告诉系统这个String里的byte应该对应UTF-8的编码表进行解码,当系统要显示这个字给用户时,系统就会去UTF-8对应的编码表查找这个特定顺序的01序列,然后找到了“哈”这个字并将它显示出来,一开始认为这么想挺合理的,根据Charset种类的不同,解读bytes的方式也不同,所以当charset不对时,自然就解读不出正确的bytes所表达的内容,造成乱码。 而当调用s.getBytes("GBK")时,这个函数就会把bytes从UTF-8编码像GBK编码转化(我的想象是有一种编码转化的参照表)这样我们就得到了一个GBK编码方式的bytes数组,此时假如我们执行一个这样的操作:s =new String(s.getBytes("GBK"), "GBK"),然后再打印s会同样得到一个“哈”字,只不过此哈非彼哈,这是一个GBK编码的“哈”,他的bytes数组存放的01顺序是按照GBK编码的方式来的,结果我写了一个这样的demo:
package test;
import java.io.UnsupportedEncodingException;
public class TestString {
public static void main(String[] args) throws UnsupportedEncodingException {
String s = new String("哈".getBytes("UTF-8"), "GBK");
System.out.println(s);
}
}
期盼他输出哈字,一运行发现乱码了,原本以为我成功的将UTF-8编码的“哈”成功转化成了GBK编码的哈,然后输出也会得到一个GBK编码的哈字呢!可是居然得到的是这货:鍝?
这时候抱着迷惑的心情打开了String的源码,才彻底的拨云见雾,看到庐山真面了,我首先打开了
new String("哈".getBytes("UTF-8"), "GBK");
看看这个构造函数到底在做些什么事情:
public String(byte bytes[], int offset, int length, String charsetName)
throws UnsupportedEncodingException
{
if (charsetName == null)
throw new NullPointerException("charsetName");
checkBounds(bytes, offset, length);
char[] v = StringCoding.decode(charsetName, bytes, offset, length);
this.offset = 0;
this.count = v.length;
this.value = v;
}
这才发现String里的根本没有什么byte数组,而是有一个char数组,那么这个char数组是通过
StringCoding.decode(charsetName, bytes, offset, length);
得来的,于是再打开decode函数:
static char[] decode(String charsetName, byte[] ba, int off, int len)
throws UnsupportedEncodingException
{
StringDecoder sd = (StringDecoder)deref(decoder);
String csn = (charsetName == null) ? "ISO-8859-1" : charsetName;
if ((sd == null) || !(csn.equals(sd.requestedCharsetName())
|| csn.equals(sd.charsetName()))) {
sd = null;
try {
Charset cs = lookupCharset(csn);
if (cs != null)
sd = new StringDecoder(cs, csn);
} catch (IllegalCharsetNameException x) {}
if (sd == null)
throw new UnsupportedEncodingException(csn);
set(decoder, sd);
}
return sd.decode(ba, off, len);
}
这里有2点值得关注:
1:sd = new StringDecoder(cs, csn);
2:<span style="font-family: Arial, Helvetica, sans-serif;">sd.decode(ba, off, len);</span>
打开StringDecoder的构造函数:
private StringDecoder(Charset cs, String rcn) {
this.requestedCharsetName = rcn;
this.cs = cs;
this.cd = cs.newDecoder()
.onMalformedInput(CodingErrorAction.REPLACE)
.onUnmappableCharacter(CodingErrorAction.REPLACE);
}
发现StringDecoder的一个参数cd(CharsetDecoder)是根据cs(Charset)的不同而new出来的Decoder种类也不同,调查后发现这里相当于是一个接口,然后又不同的CharsetDecoder的具体实现,比如UTF8Decoder,GBKDecoder等,所以sd.decode方法应该是具有多态效应的,也就是说要根据不同种类的Decoder实现不同的解码效果,打开decode函数看源码:
static char[] decode(String charsetName, byte[] ba, int off, int len)
throws UnsupportedEncodingException
{
StringDecoder sd = (StringDecoder)deref(decoder);
String csn = (charsetName == null) ? "ISO-8859-1" : charsetName;
if ((sd == null) || !(csn.equals(sd.requestedCharsetName())
|| csn.equals(sd.charsetName()))) {
sd = null;
try {
Charset cs = lookupCharset(csn);
if (cs != null)
sd = new StringDecoder(cs, csn);
} catch (IllegalCharsetNameException x) {}
if (sd == null)
throw new UnsupportedEncodingException(csn);
set(decoder, sd);
}
return sd.decode(ba, off, len);
}
可以看到在上面根据cs(Charset)获得了相应的cd(CharsetDecoder)然后调用相应cd的decode函数把一个bb(ByteBuffer)解码为一个cb(CharBuffer)(这里一定要搞清楚byte和char的区别:byte无编码的说法,char有编码);那么我们就会想,为什么要把一个byte数组转成一个char数组呢,这要从java中的char类型说起,java中的char占用2个字节,遵循的是Unicode编码规范(注意规范2个字,这并不是一种具体的编码方式,而是一套规范,告诉你什么样的01序列对应什么符号)那么通过上面的源码解读,我不禁猜想java中的String中的char数组其实存放的是依照Unicode编码规范的01序列,然后系统是根据Unicode中01序列和具体符号的对应关系去显示String中的符号序列的。
那么我们在构造String的时候,其实就是在把按照其他编码方式编码的字符转化成Unicode编码方式的字符,然后用一个Char数组存放在String中,也就是说其他编码方式都可以通过一定的计算手段转化为Unicode编码的01序列,网上一搜资料,果不其然,UTF-8转Unicode只要简单的进行线性替换(不懂的自行搜索学习),而GBK转Unicode也只要进行一定的加减运算就可以得到,所以Unicode可以转化成各种其他的编码,同时各种其他编码也可以向Unicode转化,这样一来就实现了一个统一,这也是Java采用Unicode的原因吧,比如我们在执行如下操作时:
String s = new String(“哈”);其实是在将本机默认编码方式下的“哈”字(假如此时本机的本地默认编码是GBK),从GBK编码通过一定的计算转化成Unicode下“哈”对应的那2个字节,然后放到一个Char数组中,Java对String进行显示时是不用关心你原来的编码方式,因为他们都被统一成了Unicode规范,这样Java就能够根据Char数组去显示“哈”字了,又比如如下操作:String s = new String("哈".getBytes("GBK"), "UTF-8")产生乱码的原因在于,首先我们获得了GBK编码下的“哈”字的byte数组,里面存放的是GBK编码下“哈”字对应的01序列,然后我们又根据“UTF-8”转化到Unicode的规则对一个本应该依照GBK到Unicode转化规则的byte数组进行了编码转化,使得获得的Char数组失去了本来的意思,而变成了乱码,比如哈字在GBK下编码是110,本来按照GBK到Unicode的转化规则,会转化成011,然后存放到Char数组中,而011在Unicode中正好对应哈这个字,然后此时却按照UTF-8到Unicode的转化规则,错误的转化成了101,这个时候就产生了乱码,更可怕的是有可能110这个序列根本不符合UTF-8的编码规则,这时候就会转化成?号这一类的符号,造成一种黑洞现象,编码被吞了。
所以String中其实存放的实质是:由在其他编码方式下的byte数组按照一定的转化规则转化成Unicode规范后的char数组,而String本身其实不具有Charset属性,也就是说“这个String是什么方式编码”这种说法是错误的,而应该说byte数组遵照什么样的编码方式。
回到最初提到的问题:
为什么Java对ISO8859-1这个编码方式如此情有独钟,动不动就拿来当默认的编解码方式。查询一下ISO8859-1编码相关知识不难知道,这个编码是一个单字节的编码,而且编码范围正好是从0x00-0xFF,涵盖了8个位的所有排列组合的情形,没有一个多余的位(与之相反的一个列子是UTF-8编码,其中有一些0和1的排列组合情况是没有对应任何符号的,比如当UTF-8用2个字节表示一个字符时,开头必须是110,而111这种情况就没有被纳入编码表中),ISO8859-1的一个好处就是,他跟Unicode的互相转换规则非常简单,下面是一个Unicode和ISO8859-1互转的例子:
- String-ISO-8859-1〉ByteArray:/u0061/u4E2D/u6587(a中文)-〉0x61 0x3F 0x3F
- ByteArray-ISO-8859-1〉String:0x61 0x3F 0x3F-〉/u0061/u003F/u003F(a??)
当从ISO转化回来Unicode时,规则就更简单了,只需要在高位添加0,就得到了对应的Unicode Char数组,只可惜的是由于0x3F转化为Unicode是0x003F 也就是?符号,这样就造成了编码转化过程中的编码丢失(类似黑洞吸走了一般),正是基于ISO和Unicode互转非常简单的原理,ISO可以充当任何一种编码和Unicode之间转化的中间态编码,比如下面这个程序:
package test;
import java.io.UnsupportedEncodingException;
import javax.sound.sampled.AudioFormat.Encoding;
public class TestString {
public static void main(String[] args) throws UnsupportedEncodingException {
byte [] bytes = "人".getBytes("UTF-8");
String s1 = new String(bytes, "ISO-8859-1");
String s2 = new String(s1.getBytes("ISO-8859-1"), "UTF-8");
System.out.println(s2);
}
}
1:首先根据UTF-8对“人”这个String里放的Char数组进行编码得到UTF-8编码下的“人”字的byte数组,此时byte中存放的是以下内容:
E4,BA,BA 占用了3个字节的空间
2:然后构造一个s1,利用ISO编码充当中间态,将上面的byte数组按照ISO转Unicode的规则转化成Char数组,这时候s1中存放的char数组内容如下(ISO转Unicode的规则是高位填0):
00E4;00BA;00BA;字节体积扩大了1倍
3:最后将s1中的char数组再由Unicode编码规范转化回ISO编码(规则是高位如果为0,直接去掉高位的那个字节,如果不为0,则超出ISO表达范围,统一转成0x3F),这时候得到的bytes数组存放的是如下内容:
E4,BA, BA 正好是UTF-8编码下的“人”字,此时如果构造一个s2,将这个bytes数组再按照UTF-8转Unicode的规则转成Char数组的话,这个String中的char数组存放的字节序列就是“你”字所对应的Unicode码,最后打印s2得到的就是一个“人”字。
以上就是ISO编码的神奇之处,他能够充当编码互换中的中间态。这也是为什么上面的JDK源码中大量使用ISO编码进行互转的原因。
所以当我们在get方法后面的query string中携带中文时,中文从浏览器被提交到服务器使用的基本上是UTF-8编码,到达服务器后,服务器再使用ISO编码对UTF-8编码转化来的String Char数组进行一下中间处理,如果你要获得正确的中文参数,就需要使用new String(req.getParameter("userName").getBytes("ISO8859-1"),"UTF-8")再转化回遵照UTF-8编码转Unicode规则得到的String,就可以获得带有正确中文含义的参数的值了。
再举些例子帮助理解:
假设本地默认编码是GBK
byte [] bytes = "哈".getBytes();
那么bytes里的01序列就是GBK编码下对应的哈的编码序列
String s = new String(bytes);//没有指明后一个参数charset代表采用本地默认编码进行byte到char的转化
然后bytes又按照GBK到Unicode的转化规则,转化为Unicode下的哈字的01序列存放到Char数组中。
而如果采用如下任何一种方式都会造成乱码(本机默认编码为GBK):
1:byte [] bytes = “哈”.getBytes("UTF-8"); String s = new String(bytes); //乱码原因是bytes是按照UTF-8编码的01序列,而构造String是遵照的是GBK到Unicode的转化方式。
2:byte[] bytes = "哈".getBytes(); String s = new String(bytes, "UTF-8");//打印s输出的是?乱码原因是bytes是按照GBK编码的01序列,比如是0101,而构造String是遵照的UTF-8到Unicode的变化规则,并且0101这个编码不在UTF-8的编码范围内,所以只能统一把这些无法解码的01序列转化成Unicode的?字符,如果要是哈在GBK编码下不是以0开头 而是以110开头,并且在UTF-8的编码范围之内,那么就可以完成UTF-8像Unicode的转化,只不过byte已经丢失了它原本的符号意义,变成了一堆其他字符。
byte [] bytes = "哈哈".getBytes("UTF-8"); //说明bytes遵照UTF-8编码,01序列的排列方式符号UTF-8编码的要求,如果假设有一个utf8Decoder(byte[] bytes)函数对其进行解码的话,能够得到“哈哈”
String s = new String(bytes, "UTF-8");//将遵照UTF-8编码的bytes数组按照UTF-8到Unicode的转化规则,转化为Unicode的char数组,此时s为“哈哈”;
byte[] bytes = "a中文".getBytes("ISO-8859-1");//由Unicode转ISO,对应下图第一步,由于中文二字在ISO中没有对应编码,所以统一转化成?符号
String s = new String(bytes, "ISO-8859-1");//由ISO转Unicode,遵照的规律就是高位的那一字节补0,低位8字节不变,导致变回来以后就成了a??而不是a中文,因为在第一步时,中文二字被黑洞现象吞掉了。
- String-ISO-8859-1〉ByteArray:/u0061/u4E2D/u6587(a中文)-〉0x61 0x3F 0x3F
- ByteArray-ISO-8859-1〉String:0x61 0x3F 0x3F-〉/u0061/u003F/u003F(a??)