深入分析 Java 中的中文編碼問題http://www.ibm.com/developerworks/cn/java/j-lo-chinesecoding/

編碼問題一直困擾著開發(fā)人員,尤其在 Java 中更加明顯,因為 Java 是跨平臺語言,不同平臺之間編碼之間的切換較多。本文將向你詳細介紹 Java 中編碼問題出現(xiàn)的根本原因,你將了解到:Java 中經(jīng)常遇到的幾種編碼格式的區(qū)別;Java 中經(jīng)常需要編碼的場景;出現(xiàn)中文問題的原因分析;在開發(fā) Java web 程序時可能會存在編碼的幾個地方,一個 HTTP 請求怎么控制編碼格式?如何避免出現(xiàn)中文問題?

幾種常見的編碼格式

為什么要編碼

不知道大家有沒有想過一個問題,那就是為什么要編碼?我們能不能不編碼?要回答這個問題必須要回到計算機是如何表示我們?nèi)祟惸軌蚶斫獾姆柕模@些符號也就是我們?nèi)祟愂褂玫恼Z言。由于人類的語言有太多,因而表示這些語言的符號太多,無法用計算機中一個基本的存儲單元—— byte 來表示,因而必須要經(jīng)過拆分或一些翻譯工作,才能讓計算機能理解。我們可以把計算機能夠理解的語言假定為英語,其它語言要能夠在計算機中使用必須經(jīng)過一次翻譯,把它翻譯成英語。這個翻譯的過程就是編碼。所以可以想象只要不是說英語的國家要能夠使用計算機就必須要經(jīng)過編碼。這看起來有些霸道,但是這就是現(xiàn)狀,這也和我們國家現(xiàn)在在大力推廣漢語一樣,希望其它國家都會說漢語,以后其它的語言都翻譯成漢語,我們可以把計算機中存儲信息的最小單位改成漢字,這樣我們就不存在編碼問題了。
所以總的來說,編碼的原因可以總結(jié)為:
計算機中存儲信息的最小單元是一個字節(jié)即 8 個 bit,所以能表示的字符范圍是 0~255 個
人類要表示的符號太多,無法用一個字節(jié)來完全表示
要解決這個矛盾必須需要一個新的數(shù)據(jù)結(jié)構(gòu) char,從 char 到 byte 必須編碼

如何“翻譯”

明白了各種語言需要交流,經(jīng)過翻譯是必要的,那又如何來翻譯呢?計算中提拱了多種翻譯方式,常見的有 ASCII、ISO-8859-1、GB2312、GBK、UTF-8、UTF-16 等。它們都可以被看作為字典,它們規(guī)定了轉(zhuǎn)化的規(guī)則,按照這個規(guī)則就可以讓計算機正確的表示我們的字符。目前的編碼格式很多,例如 GB2312、GBK、UTF-8、UTF-16 這幾種格式都可以表示一個漢字,那我們到底選擇哪種編碼格式來存儲漢字呢?這就要考慮到其它因素了,是存儲空間重要還是編碼的效率重要。根據(jù)這些因素來正確選擇編碼格式,下面簡要介紹一下這幾種編碼格式。

  • ASCII 碼

學過計算機的人都知道 ASCII 碼,總共有 128 個,用一個字節(jié)的低 7 位表示,0~31 是控制字符如換行回車刪除等;32~126 是打印字符,可以通過鍵盤輸入并且能夠顯示出來。

  • ISO-8859-1

128 個字符顯然是不夠用的,于是 ISO 組織在 ASCII 碼基礎(chǔ)上又制定了一些列標準用來擴展 ASCII 編碼,它們是 ISO-8859-1~ISO-8859-15,其中 ISO-8859-1 涵蓋了大多數(shù)西歐語言字符,所有應用的最廣泛。ISO-8859-1 仍然是單字節(jié)編碼,它總共能表示 256 個字符。

  • GB2312

它的全稱是《信息交換用漢字編碼字符集 基本集》,它是雙字節(jié)編碼,總的編碼范圍是 A1-F7,其中從 A1-A9 是符號區(qū),總共包含 682 個符號,從 B0-F7 是漢字區(qū),包含 6763 個漢字。

  • GBK

全稱叫《漢字內(nèi)碼擴展規(guī)范》,是國家技術(shù)監(jiān)督局為 windows95 所制定的新的漢字內(nèi)碼規(guī)范,它的出現(xiàn)是為了擴展 GB2312,加入更多的漢字,它的編碼范圍是 8140~FEFE(去掉 XX7F)總共有 23940 個碼位,它能表示 21003 個漢字,它的編碼是和 GB2312 兼容的,也就是說用 GB2312 編碼的漢字可以用 GBK 來解碼,并且不會有亂碼。

  • GB18030

全稱是《信息交換用漢字編碼字符集》,是我國的強制標準,它可能是單字節(jié)、雙字節(jié)或者四字節(jié)編碼,它的編碼與 GB2312 編碼兼容,這個雖然是國家標準,但是實際應用系統(tǒng)中使用的并不廣泛。

  • UTF-16

說到 UTF 必須要提到 Unicode(Universal Code 統(tǒng)一碼),ISO 試圖想創(chuàng)建一個全新的超語言字典,世界上所有的語言都可以通過這本字典來相互翻譯。可想而知這個字典是多么的復雜,關(guān)于 Unicode 的詳細規(guī)范可以參考相應文檔。Unicode 是 Java 和 XML 的基礎(chǔ),下面詳細介紹 Unicode 在計算機中的存儲形式。
UTF-16 具體定義了 Unicode 字符在計算機中存取方法。UTF-16 用兩個字節(jié)來表示 Unicode 轉(zhuǎn)化格式,這個是定長的表示方法,不論什么字符都可以用兩個字節(jié)表示,兩個字節(jié)是 16 個 bit,所以叫 UTF-16。UTF-16 表示字符非常方便,每兩個字節(jié)表示一個字符,這個在字符串操作時就大大簡化了操作,這也是 Java 以 UTF-16 作為內(nèi)存的字符存儲格式的一個很重要的原因。

  • UTF-8

UTF-16 統(tǒng)一采用兩個字節(jié)表示一個字符,雖然在表示上非常簡單方便,但是也有其缺點,有很大一部分字符用一個字節(jié)就可以表示的現(xiàn)在要兩個字節(jié)表示,存儲空間放大了一倍,在現(xiàn)在的網(wǎng)絡帶寬還非常有限的今天,這樣會增大網(wǎng)絡傳輸?shù)牧髁浚乙矝]必要。而 UTF-8 采用了一種變長技術(shù),每個編碼區(qū)域有不同的字碼長度。不同類型的字符可以是由 1~6 個字節(jié)組成。
UTF-8 有以下編碼規(guī)則:
如果一個字節(jié),最高位(第 8 位)為 0,表示這是一個 ASCII 字符(00 - 7F)。可見,所有 ASCII 編碼已經(jīng)是 UTF-8 了。
如果一個字節(jié),以 11 開頭,連續(xù)的 1 的個數(shù)暗示這個字符的字節(jié)數(shù),例如:110xxxxx 代表它是雙字節(jié) UTF-8 字符的首字節(jié)。
如果一個字節(jié),以 10 開始,表示它不是首字節(jié),需要向前查找才能得到當前字符的首字節(jié)

回頁首

Java 中需要編碼的場景

前面描述了常見的幾種編碼格式,下面將介紹 Java 中如何處理對編碼的支持,什么場合中需要編碼。

I/O 操作中存在的編碼

我們知道涉及到編碼的地方一般都在字符到字節(jié)或者字節(jié)到字符的轉(zhuǎn)換上,而需要這種轉(zhuǎn)換的場景主要是在 I/O 的時候,這個 I/O 包括磁盤 I/O 和網(wǎng)絡 I/O,關(guān)于網(wǎng)絡 I/O 部分在后面將主要以 Web 應用為例介紹。下圖是 Java 中處理 I/O 問題的接口:


Figure xxx. Requires a heading

Reader 類是 Java 的 I/O 中讀字符的父類,而 InputStream 類是讀字節(jié)的父類,InputStreamReader 類就是關(guān)聯(lián)字節(jié)到字符的橋梁,它負責在 I/O 過程中處理讀取字節(jié)到字符的轉(zhuǎn)換,而具體字節(jié)到字符的解碼實現(xiàn)它由 StreamDecoder 去實現(xiàn),在 StreamDecoder 解碼過程中必須由用戶指定 Charset 編碼格式。值得注意的是如果你沒有指定 Charset,將使用本地環(huán)境中的默認字符集,例如在中文環(huán)境中將使用 GBK 編碼。
寫的情況也是類似,字符的父類是 Writer,字節(jié)的父類是 OutputStream,通過 OutputStreamWriter 轉(zhuǎn)換字符到字節(jié)。如下圖所示:

Figure xxx. Requires a heading

同樣 StreamEncoder 類負責將字符編碼成字節(jié),編碼格式和默認編碼規(guī)則與解碼是一致的。
如下面一段代碼,實現(xiàn)了文件的讀寫功能:

清單 1.I/O 涉及的編碼示例
String file = "c:/stream.txt"; String charset = "UTF-8"; // 寫字符換轉(zhuǎn)成字節(jié)流 FileOutputStream outputStream = new FileOutputStream(file); OutputStreamWriter writer = new OutputStreamWriter( outputStream, charset); try { writer.write("這是要保存的中文字符"); } finally { writer.close(); } // 讀取字節(jié)轉(zhuǎn)換成字符 FileInputStream inputStream = new FileInputStream(file); InputStreamReader reader = new InputStreamReader( inputStream, charset); StringBuffer buffer = new StringBuffer(); char[] buf = new char[64]; int count = 0; try { while ((count = reader.read(buf)) != -1) { buffer.append(buffer, 0, count); } } finally { reader.close(); }

在我們的應用程序中涉及到 I/O 操作時只要注意指定統(tǒng)一的編解碼 Charset 字符集,一般不會出現(xiàn)亂碼問題,有些應用程序如果不注意指定字符編碼,中文環(huán)境中取操作系統(tǒng)默認編碼,如果編解碼都在中文環(huán)境中,通常也沒問題,但是還是強烈的不建議使用操作系統(tǒng)的默認編碼,因為這樣,你的應用程序的編碼格式就和運行環(huán)境綁定起來了,在跨環(huán)境下很可能出現(xiàn)亂碼問題。
內(nèi)存中操作中的編碼
在 Java 開發(fā)中除了 I/O 涉及到編碼外,最常用的應該就是在內(nèi)存中進行字符到字節(jié)的數(shù)據(jù)類型的轉(zhuǎn)換,Java 中用 String 表示字符串,所以 String 類就提供轉(zhuǎn)換到字節(jié)的方法,也支持將字節(jié)轉(zhuǎn)換為字符串的構(gòu)造函數(shù)。如下代碼示例:
String s = "這是一段中文字符串"; byte[] b = s.getBytes("UTF-8"); String n = new String(b,"UTF-8");

另外一個是已經(jīng)被被廢棄的 ByteToCharConverter 和 CharToByteConverter 類,它們分別提供了 convertAll 方法可以實現(xiàn) byte[] 和 char[] 的互轉(zhuǎn)。如下代碼所示:
ByteToCharConverter charConverter = ByteToCharConverter.getConverter("UTF-8"); char c[] = charConverter.convertAll(byteArray); CharToByteConverter byteConverter = CharToByteConverter.getConverter("UTF-8"); byte[] b = byteConverter.convertAll(c);

這兩個類已經(jīng)被 Charset 類取代,Charset 提供 encode 與 decode 分別對應 char[] 到 byte[] 的編碼和 byte[] 到 char[] 的解碼。如下代碼所示:
Charset charset = Charset.forName("UTF-8"); ByteBuffer byteBuffer = charset.encode(string); CharBuffer charBuffer = charset.decode(byteBuffer);

編碼與解碼都在一個類中完成,通過 forName 設(shè)置編解碼字符集,這樣更容易統(tǒng)一編碼格式,比 ByteToCharConverter 和 CharToByteConverter 類更方便。
Java 中還有一個 ByteBuffer 類,它提供一種 char 和 byte 之間的軟轉(zhuǎn)換,它們之間轉(zhuǎn)換不需要編碼與解碼,只是把一個 16bit 的 char 格式,拆分成為 2 個 8bit 的 byte 表示,它們的實際值并沒有被修改,僅僅是數(shù)據(jù)的類型做了轉(zhuǎn)換。如下代碼所以:
ByteBuffer heapByteBuffer = ByteBuffer.allocate(1024); ByteBuffer byteBuffer = heapByteBuffer.putChar(c);

以上這些提供字符和字節(jié)之間的相互轉(zhuǎn)換只要我們設(shè)置編解碼格式統(tǒng)一一般都不會出現(xiàn)問題。

回頁首

Java 中如何編解碼

前面介紹了幾種常見的編碼格式,這里將以實際例子介紹 Java 中如何實現(xiàn)編碼及解碼,下面我們以“I am 君山”這個字符串為例介紹 Java 中如何把它以 ISO-8859-1、GB2312、GBK、UTF-16、UTF-8 編碼格式進行編碼的。
清單 2.String 編碼

String name = "I am 君山"; 
toHex(name.toCharArray()); 
try { byte[] iso8859 = name.getBytes("ISO-8859-1"); 
toHex(iso8859); byte[] gb2312 = name.getBytes("GB2312"); toHex(gb2312); byte[] gbk = name.getBytes("GBK"); 
toHex(gbk);
 byte[] utf16 = name.getBytes("UTF-16"); 
toHex(utf16); 
byte[] utf8 = name.getBytes("UTF-8"); 
toHex(utf8);
 } 
catch (UnsupportedEncodingException e)
 { e.printStackTrace(); } }```

我們把 name 字符串按照前面說的幾種編碼格式進行編碼轉(zhuǎn)化成 byte 數(shù)組,然后以 16 進制輸出,我們先看一下 Java 是如何進行編碼的。
下面是 Java 中編碼需要用到的類圖
圖 1. Java 編碼類圖
![圖 1. Java 編碼類圖](http://upload-images.jianshu.io/upload_images/1353074-54e5a4dd71448213.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)首先根據(jù)指定的 charsetName 通過 Charset.forName(charsetName) 設(shè)置 Charset 類,然后根據(jù) Charset 創(chuàng)建 CharsetEncoder 對象,再調(diào)用 CharsetEncoder.encode 對字符串進行編碼,不同的編碼類型都會對應到一個類中,實際的編碼過程是在這些類中完成的。下面是 String. getBytes(charsetName) 編碼過程的時序圖
圖 2.Java 編碼時序圖
![圖 2.Java 編碼時序圖](http://upload-images.jianshu.io/upload_images/1353074-6d3ab72f384f076d.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)從上圖可以看出根據(jù) charsetName 找到 Charset 類,然后根據(jù)這個字符集編碼生成 CharsetEncoder,這個類是所有字符編碼的父類,針對不同的字符編碼集在其子類中定義了如何實現(xiàn)編碼,有了 CharsetEncoder 對象后就可以調(diào)用 encode 方法去實現(xiàn)編碼了。這個是 String.getBytes 編碼方法,其它的如 StreamEncoder 中也是類似的方式。下面看看不同的字符集是如何將前面的字符串編碼成 byte 數(shù)組的?
如字符串“I am 君山”的 char 數(shù)組為 49 20 61 6d 20 541b 5c71,下面把它按照不同的編碼格式轉(zhuǎn)化成相應的字節(jié)。
按照 ISO-8859-1 編碼
字符串“I am 君山”用 ISO-8859-1 編碼,下面是編碼結(jié)果:
![Figure xxx. Requires a heading](http://upload-images.jianshu.io/upload_images/1353074-7a0132326aaeac15.gif?imageMogr2/auto-orient/strip)從上圖看出 7 個 char 字符經(jīng)過 ISO-8859-1 編碼轉(zhuǎn)變成 7 個 byte 數(shù)組,ISO-8859-1 是單字節(jié)編碼,中文“君山”被轉(zhuǎn)化成值是 3f 的 byte。3f 也就是“?”字符,所以經(jīng)常會出現(xiàn)中文變成“?”很可能就是錯誤的使用了 ISO-8859-1 這個編碼導致的。中文字符經(jīng)過 ISO-8859-1 編碼會丟失信息,通常我們稱之為“黑洞”,它會把不認識的字符吸收掉。由于現(xiàn)在大部分基礎(chǔ)的 Java 框架或系統(tǒng)默認的字符集編碼都是 ISO-8859-1,所以很容易出現(xiàn)亂碼問題,后面將會分析不同的亂碼形式是怎么出現(xiàn)的。

按照 GB2312 編碼
字符串“I am 君山”用 GB2312 編碼,下面是編碼結(jié)果:
![Figure xxx. Requires a heading](http://upload-images.jianshu.io/upload_images/1353074-bcda8d9da8857731.gif?imageMogr2/auto-orient/strip)GB2312 對應的 Charset 是 sun.nio.cs.ext. EUC_CN 而對應的 CharsetDecoder 編碼類是 sun.nio.cs.ext. DoubleByte,GB2312 字符集有一個 char 到 byte 的碼表,不同的字符編碼就是查這個碼表找到與每個字符的對應的字節(jié),然后拼裝成 byte 數(shù)組。查表的規(guī)則如下:
c2b[c2bIndex[char >> 8] + (char & 0xff)]

如果查到的碼位值大于 oxff 則是雙字節(jié),否則是單字節(jié)。雙字節(jié)高 8 位作為第一個字節(jié),低 8 位作為第二個字節(jié),如下代碼所示:
if (bb > 0xff) { // DoubleByte if (dl - dp < 2) return CoderResult.OVERFLOW; da[dp++] = (byte) (bb >> 8); da[dp++] = (byte) bb; } else { // SingleByte if (dl - dp < 1) return CoderResult.OVERFLOW; da[dp++] = (byte) bb; }

從上圖可以看出前 5 個字符經(jīng)過編碼后仍然是 5 個字節(jié),而漢字被編碼成雙字節(jié),在第一節(jié)中介紹到 GB2312 只支持 6763 個漢字,所以并不是所有漢字都能夠用 GB2312 編碼。

按照 GBK 編碼
字符串“I am 君山”用 GBK 編碼,下面是編碼結(jié)果:
![Figure xxx. Requires a heading](http://upload-images.jianshu.io/upload_images/1353074-c96197f80c8a4f0e.gif?imageMogr2/auto-orient/strip)你可能已經(jīng)發(fā)現(xiàn)上圖與 GB2312 編碼的結(jié)果是一樣的,沒錯 GBK 與 GB2312 編碼結(jié)果是一樣的,由此可以得出 GBK 編碼是兼容 GB2312 編碼的,它們的編碼算法也是一樣的。不同的是它們的碼表長度不一樣,GBK 包含的漢字字符更多。所以只要是經(jīng)過 GB2312 編碼的漢字都可以用 GBK 進行解碼,反過來則不然。

按照 UTF-16 編碼
字符串“I am 君山”用 UTF-16 編碼,下面是編碼結(jié)果:
![Figure xxx. Requires a heading](http://upload-images.jianshu.io/upload_images/1353074-abcaac4c2357ba8a.gif?imageMogr2/auto-orient/strip)用 UTF-16 編碼將 char 數(shù)組放大了一倍,單字節(jié)范圍內(nèi)的字符,在高位補 0 變成兩個字節(jié),中文字符也變成兩個字節(jié)。從 UTF-16 編碼規(guī)則來看,僅僅將字符的高位和地位進行拆分變成兩個字節(jié)。特點是編碼效率非常高,規(guī)則很簡單,由于不同處理器對 2 字節(jié)處理方式不同,Big-endian(高位字節(jié)在前,低位字節(jié)在后)或 Little-endian(低位字節(jié)在前,高位字節(jié)在后)編碼,所以在對一串字符串進行編碼是需要指明到底是 Big-endian 還是 Little-endian,所以前面有兩個字節(jié)用來保存 BYTE_ORDER_MARK 值,UTF-16 是用定長 16 位(2 字節(jié))來表示的 UCS-2 或 Unicode 轉(zhuǎn)換格式,通過代理對來訪問 BMP 之外的字符編碼。

按照 UTF-8 編碼
字符串“I am 君山”用 UTF-8 編碼,下面是編碼結(jié)果:
![Figure xxx. Requires a heading](http://upload-images.jianshu.io/upload_images/1353074-e9b58f5eb5a11183.gif?imageMogr2/auto-orient/strip)UTF-16 雖然編碼效率很高,但是對單字節(jié)范圍內(nèi)字符也放大了一倍,這無形也浪費了存儲空間,另外 UTF-16 采用順序編碼,不能對單個字符的編碼值進行校驗,如果中間的一個字符碼值損壞,后面的所有碼值都將受影響。而 UTF-8 這些問題都不存在,UTF-8 對單字節(jié)范圍內(nèi)字符仍然用一個字節(jié)表示,對漢字采用三個字節(jié)表示。它的編碼規(guī)則如下:

清單 3.UTF-8 編碼代碼片段
private CoderResult encodeArrayLoop(CharBuffer src, ByteBuffer dst){ char[] sa = src.array(); int sp = src.arrayOffset() + src.position(); int sl = src.arrayOffset() + src.limit(); byte[] da = dst.array(); int dp = dst.arrayOffset() + dst.position(); int dl = dst.arrayOffset() + dst.limit(); int dlASCII = dp + Math.min(sl - sp, dl - dp); // ASCII only loop while (dp < dlASCII && sa[sp] < '\u0080') da[dp++] = (byte) sa[sp++]; while (sp < sl) { char c = sa[sp]; if (c < 0x80) { // Have at most seven bits if (dp >= dl) return overflow(src, sp, dst, dp); da[dp++] = (byte)c; } else if (c < 0x800) { // 2 bytes, 11 bits if (dl - dp < 2) return overflow(src, sp, dst, dp); da[dp++] = (byte)(0xc0 | (c >> 6)); da[dp++] = (byte)(0x80 | (c & 0x3f)); } else if (Character.isSurrogate(c)) { // Have a surrogate pair if (sgp == null) sgp = new Surrogate.Parser(); int uc = sgp.parse(c, sa, sp, sl); if (uc < 0) { updatePositions(src, sp, dst, dp); return sgp.error(); } if (dl - dp < 4) return overflow(src, sp, dst, dp); da[dp++] = (byte)(0xf0 | ((uc >> 18))); da[dp++] = (byte)(0x80 | ((uc >> 12) & 0x3f)); da[dp++] = (byte)(0x80 | ((uc >> 6) & 0x3f)); da[dp++] = (byte)(0x80 | (uc & 0x3f)); sp++; // 2 chars } else { // 3 bytes, 16 bits if (dl - dp < 3) return overflow(src, sp, dst, dp); da[dp++] = (byte)(0xe0 | ((c >> 12))); da[dp++] = (byte)(0x80 | ((c >> 6) & 0x3f)); da[dp++] = (byte)(0x80 | (c & 0x3f)); } sp++; } updatePositions(src, sp, dst, dp); return CoderResult.UNDERFLOW; }

UTF-8 編碼與 GBK 和 GB2312 不同,不用查碼表,所以在編碼效率上 UTF-8 的效率會更好,所以在存儲中文字符時 UTF-8 編碼比較理想。
幾種編碼格式的比較
對中文字符后面四種編碼格式都能處理,GB2312 與 GBK 編碼規(guī)則類似,但是 GBK 范圍更大,它能處理所有漢字字符,所以 GB2312 與 GBK 比較應該選擇 GBK。UTF-16 與 UTF-8 都是處理 Unicode 編碼,它們的編碼規(guī)則不太相同,相對來說 UTF-16 編碼效率最高,字符到字節(jié)相互轉(zhuǎn)換更簡單,進行字符串操作也更好。它適合在本地磁盤和內(nèi)存之間使用,可以進行字符和字節(jié)之間快速切換,如 Java 的內(nèi)存編碼就是采用 UTF-16 編碼。但是它不適合在網(wǎng)絡之間傳輸,因為網(wǎng)絡傳輸容易損壞字節(jié)流,一旦字節(jié)流損壞將很難恢復,想比較而言 UTF-8 更適合網(wǎng)絡傳輸,對 ASCII 字符采用單字節(jié)存儲,另外單個字符損壞也不會影響后面其它字符,在編碼效率上介于 GBK 和 UTF-16 之間,所以 UTF-8 在編碼效率上和編碼安全性上做了平衡,是理想的中文編碼方式。

[回頁首](http://www.ibm.com/developerworks/cn/java/j-lo-chinesecoding/#ibm-pcon)
Java Web 涉及到的編碼
對于使用中文來說,有 I/O 的地方就會涉及到編碼,前面已經(jīng)提到了 I/O 操作會引起編碼,而大部分 I/O 引起的亂碼都是網(wǎng)絡 I/O,因為現(xiàn)在幾乎所有的應用程序都涉及到網(wǎng)絡操作,而數(shù)據(jù)經(jīng)過網(wǎng)絡傳輸都是以字節(jié)為單位的,所以所有的數(shù)據(jù)都必須能夠被序列化為字節(jié)。在 Java 中數(shù)據(jù)被序列化必須繼承 Serializable 接口。
這里有一個問題,你是否認真考慮過一段文本它的實際大小應該怎么計算,我曾經(jīng)碰到過一個問題:就是要想辦法壓縮 Cookie 大小,減少網(wǎng)絡傳輸量,當時有選擇不同的壓縮算法,發(fā)現(xiàn)壓縮后字符數(shù)是減少了,但是并沒有減少字節(jié)數(shù)。所謂的壓縮只是將多個單字節(jié)字符通過編碼轉(zhuǎn)變成一個多字節(jié)字符。減少的是 String.length(),而并沒有減少最終的字節(jié)數(shù)。例如將“ab”兩個字符通過某種編碼轉(zhuǎn)變成一個奇怪的字符,雖然字符數(shù)從兩個變成一個,但是如果采用 UTF-8 編碼這個奇怪的字符最后經(jīng)過編碼可能又會變成三個或更多的字節(jié)。同樣的道理比如整型數(shù)字 1234567 如果當成字符來存儲,采用 UTF-8 來編碼占用 7 個 byte,采用 UTF-16 編碼將會占用 14 個 byte,但是把它當成 int 型數(shù)字來存儲只需要 4 個 byte 來存儲。所以看一段文本的大小,看字符本身的長度是沒有意義的,即使是一樣的字符采用不同的編碼最終存儲的大小也會不同,所以從字符到字節(jié)一定要看編碼類型。
另外一個問題,你是否考慮過,當我們在電腦中某個文本編輯器里輸入某個漢字時,它到底是怎么表示的?我們知道,計算機里所有的信息都是以 01 表示的,那么一個漢字,它到底是多少個 0 和 1 呢?我們能夠看到的漢字都是以字符形式出現(xiàn)的,例如在 Java 中“淘寶”兩個字符,它在計算機中的數(shù)值 10 進制是 28120 和 23453,16 進制是 6bd8 和 5d9d,也就是這兩個字符是由這兩個數(shù)字唯一表示的。Java 中一個 char 是 16 個 bit 相當于兩個字節(jié),所以兩個漢字用 char 表示在內(nèi)存中占用相當于四個字節(jié)的空間。
這兩個問題搞清楚后,我們看一下 Java Web 中那些地方可能會存在編碼轉(zhuǎn)換?
用戶從瀏覽器端發(fā)起一個 HTTP 請求,需要存在編碼的地方是 URL、Cookie、Parameter。服務器端接受到 HTTP 請求后要解析 HTTP 協(xié)議,其中 URI、Cookie 和 POST 表單參數(shù)需要解碼,服務器端可能還需要讀取數(shù)據(jù)庫中的數(shù)據(jù),本地或網(wǎng)絡中其它地方的文本文件,這些數(shù)據(jù)都可能存在編碼問題,當 Servlet 處理完所有請求的數(shù)據(jù)后,需要將這些數(shù)據(jù)再編碼通過 Socket 發(fā)送到用戶請求的瀏覽器里,再經(jīng)過瀏覽器解碼成為文本。這些過程如下圖所示:
圖 3. 一次 HTTP 請求的編碼示例([查看大圖](http://www.ibm.com/developerworks/cn/java/j-lo-chinesecoding/image021-lg.png))
![圖 3. 一次 HTTP 請求的編碼示例](http://upload-images.jianshu.io/upload_images/1353074-5cc89d7aabacf5e5.gif?imageMogr2/auto-orient/strip)如上圖所示一次 HTTP 請求設(shè)計到很多地方需要編解碼,它們編解碼的規(guī)則是什么?下面將會重點闡述一下:
URL 的編解碼
用戶提交一個 URL,這個 URL 中可能存在中文,因此需要編碼,如何對這個 URL 進行編碼?根據(jù)什么規(guī)則來編碼?有如何來解碼?如下圖一個 URL:
圖 4.URL 的幾個組成部分
![圖 4.URL 的幾個組成部分](http://upload-images.jianshu.io/upload_images/1353074-ba5727338a198b9c.gif?imageMogr2/auto-orient/strip)上圖中以 Tomcat 作為 Servlet Engine 為例,它們分別對應到下面這些配置文件中:
Port 對應在 Tomcat 的 <Connector port="8080"/> 中配置,而 Context Path 在 <Context path="/examples"/> 中配置,Servlet Path 在 Web 應用的 web.xml 中的
<servlet-mapping> <servlet-name>junshanExample</servlet-name> <url-pattern>/servlets/servlet/*</url-pattern> </servlet-mapping>

<url-pattern> 中配置,PathInfo 是我們請求的具體的 Servlet,QueryString 是要傳遞的參數(shù),注意這里是在瀏覽器里直接輸入 URL 所以是通過 Get 方法請求的,如果是 POST 方法請求的話,QueryString 將通過表單方式提交到服務器端,這個將在后面再介紹。
上圖中 PathInfo 和 QueryString 出現(xiàn)了中文,當我們在瀏覽器中直接輸入這個 URL 時,在瀏覽器端和服務端會如何編碼和解析這個 URL 呢?為了驗證瀏覽器是怎么編碼 URL 的我們選擇 FireFox 瀏覽器并通過 HTTPFox 插件觀察我們請求的 URL 的實際的內(nèi)容,以下是 URL:HTTP://localhost:8080/examples/servlets/servlet/ 君山 ?author= 君山在中文 FireFox3.6.12 的測試結(jié)果
圖 5. HTTPFox 的測試結(jié)果
![圖 5. HTTPFox 的測試結(jié)果](http://upload-images.jianshu.io/upload_images/1353074-ec1ec9a1afcb20ad.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)君山的編碼結(jié)果分別是:e5 90 9b e5 b1 b1,be fd c9 bd,查閱上一屆的編碼可知,PathInfo 是 UTF-8 編碼而 QueryString 是經(jīng)過 GBK 編碼,至于為什么會有“%”?查閱 URL 的編碼規(guī)范 RFC3986 可知瀏覽器編碼 URL 是將非 ASCII 字符按照某種編碼格式編碼成 16 進制數(shù)字然后將每個 16 進制表示的字節(jié)前加上“%”,所以最終的 URL 就成了上圖的格式了。
默認情況下中文 IE 最終的編碼結(jié)果也是一樣的,不過 IE 瀏覽器可以修改 URL 的編碼格式在選項 -> 高級 -> 國際里面的發(fā)送 UTF-8 URL 選項可以取消。
從上面測試結(jié)果可知瀏覽器對 PathInfo 和 QueryString 的編碼是不一樣的,不同瀏覽器對 PathInfo 也可能不一樣,這就對服務器的解碼造成很大的困難,下面我們以 Tomcat 為例看一下,Tomcat 接受到這個 URL 是如何解碼的。
解析請求的 URL 是在 org.apache.coyote.HTTP11.InternalInputBuffer 的 parseRequestLine 方法中,這個方法把傳過來的 URL 的 byte[] 設(shè)置到 org.apache.coyote.Request 的相應的屬性中。這里的 URL 仍然是 byte 格式,轉(zhuǎn)成 char 是在 org.apache.catalina.connector.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 的 <Connector URIEncoding=”UTF-8”/> 中定義的,如果沒有定義,那么將以默認編碼 ISO-8859-1 解析。所以如果有中文 URL 時最好把 URIEncoding 設(shè)置成 UTF-8 編碼。
QueryString 又如何解析? GET 方式 HTTP 請求的 QueryString 與 POST 方式 HTTP 請求的表單參數(shù)都是作為 Parameters 保存,都是通過 request.getParameter 獲取參數(shù)值。對它們的解碼是在 request.getParameter 方法第一次被調(diào)用時進行的。request.getParameter 方法被調(diào)用時將會調(diào)用 org.apache.catalina.connector.Request 的 parseParameters 方法。這個方法將會對 GET 和 POST 方式傳遞的參數(shù)進行解碼,但是它們的解碼字符集有可能不一樣。POST 表單的解碼將在后面介紹,QueryString 的解碼字符集是在哪定義的呢?它本身是通過 HTTP 的 Header 傳到服務端的,并且也在 URL 中,是否和 URI 的解碼字符集一樣呢?從前面瀏覽器對 PathInfo 和 QueryString 的編碼采取不同的編碼格式不同可以猜測到解碼字符集肯定也不會是一致的。的確是這樣 QueryString 的解碼字符集要么是 Header 中 ContentType 中定義的 Charset 要么就是默認的 ISO-8859-1,要使用 ContentType 中定義的編碼就要設(shè)置 connector 的 <Connector URIEncoding=”UTF-8” useBodyEncodingForURI=”true”/> 中的 useBodyEncodingForURI 設(shè)置為 true。這個配置項的名字有點讓人產(chǎn)生混淆,它并不是對整個 URI 都采用 BodyEncoding 進行解碼而僅僅是對 QueryString 使用 BodyEncoding 解碼,這一點還要特別注意。
從上面的 URL 編碼和解碼過程來看,比較復雜,而且編碼和解碼并不是我們在應用程序中能完全控制的,所以在我們的應用程序中應該盡量避免在 URL 中使用非 ASCII 字符,不然很可能會碰到亂碼問題,當然在我們的服務器端最好設(shè)置 <Connector/> 中的 URIEncoding 和 useBodyEncodingForURI 兩個參數(shù)。
HTTP Header 的編解碼
當客戶端發(fā)起一個 HTTP 請求除了上面的 URL 外還可能會在 Header 中傳遞其它參數(shù)如 Cookie、redirectPath 等,這些用戶設(shè)置的值很可能也會存在編碼問題,Tomcat 對它們又是怎么解碼的呢?
對 Header 中的項進行解碼也是在調(diào)用 request.getHeader 是進行的,如果請求的 Header 項沒有解碼則調(diào)用 MessageBytes 的 toString 方法,這個方法將從 byte 到 char 的轉(zhuǎn)化使用的默認編碼也是 ISO-8859-1,而我們也不能設(shè)置 Header 的其它解碼格式,所以如果你設(shè)置 Header 中有非 ASCII 字符解碼肯定會有亂碼。
我們在添加 Header 時也是同樣的道理,不要在 Header 中傳遞非 ASCII 字符,如果一定要傳遞的話,我們可以先將這些字符用 org.apache.catalina.util.URLEncoder 編碼然后再添加到 Header 中,這樣在瀏覽器到服務器的傳遞過程中就不會丟失信息了,如果我們要訪問這些項時再按照相應的字符集解碼就好了。
POST 表單的編解碼
在前面提到了 POST 表單提交的參數(shù)的解碼是在第一次調(diào)用 request.getParameter 發(fā)生的,POST 表單參數(shù)傳遞方式與 QueryString 不同,它是通過 HTTP 的 BODY 傳遞到服務端的。當我們在頁面上點擊 submit 按鈕時瀏覽器首先將根據(jù) ContentType 的 Charset 編碼格式對表單填的參數(shù)進行編碼然后提交到服務器端,在服務器端同樣也是用 ContentType 中字符集進行解碼。所以通過 POST 表單提交的參數(shù)一般不會出現(xiàn)問題,而且這個字符集編碼是我們自己設(shè)置的,可以通過 request.setCharacterEncoding(charset) 來設(shè)置。
另外針對 multipart/form-data 類型的參數(shù),也就是上傳的文件編碼同樣也是使用 ContentType 定義的字符集編碼,值得注意的地方是上傳文件是用字節(jié)流的方式傳輸?shù)椒掌鞯谋镜嘏R時目錄,這個過程并沒有涉及到字符編碼,而真正編碼是在將文件內(nèi)容添加到 parameters 中,如果用這個編碼不能編碼時將會用默認編碼 ISO-8859-1 來編碼。
HTTP BODY 的編解碼
當用戶請求的資源已經(jīng)成功獲取后,這些內(nèi)容將通過 Response 返回給客戶端瀏覽器,這個過程先要經(jīng)過編碼再到瀏覽器進行解碼。這個過程的編解碼字符集可以通過 response.setCharacterEncoding 來設(shè)置,它將會覆蓋 request.getCharacterEncoding 的值,并且通過 Header 的 Content-Type 返回客戶端,瀏覽器接受到返回的 socket 流時將通過 Content-Type 的 charset 來解碼,如果返回的 HTTP Header 中 Content-Type 沒有設(shè)置 charset,那么瀏覽器將根據(jù) Html 的 <meta HTTP-equiv="Content-Type" content="text/html; charset=GBK" /> 中的 charset 來解碼。如果也沒有定義的話,那么瀏覽器將使用默認的編碼來解碼。
其它需要編碼的地方
除了 URL 和參數(shù)編碼問題外,在服務端還有很多地方可能存在編碼,如可能需要讀取 xml、velocity 模版引擎、JSP 或者從數(shù)據(jù)庫讀取數(shù)據(jù)等。
xml 文件可以通過設(shè)置頭來制定編碼格式
<?xml version="1.0" encoding="UTF-8"?>

Velocity 模版設(shè)置編碼格式:
services.VelocityService.input.encoding=UTF-8

JSP 設(shè)置編碼格式:
<%@page contentType="text/html; charset=UTF-8"%>

訪問數(shù)據(jù)庫都是通過客戶端 JDBC 驅(qū)動來完成,用 JDBC 來存取數(shù)據(jù)要和數(shù)據(jù)的內(nèi)置編碼保持一致,可以通過設(shè)置 JDBC URL 來制定如 MySQL:url="jdbc:mysql://localhost:3306/DB?useUnicode=true&characterEncoding=GBK"。 

[回頁首](http://www.ibm.com/developerworks/cn/java/j-lo-chinesecoding/#ibm-pcon)
常見問題分析
在了解了 Java Web 中可能需要編碼的地方后,下面看一下,當我們碰到一些亂碼時,應該怎么處理這些問題?出現(xiàn)亂碼問題唯一的原因都是在 char 到 byte 或 byte 到 char 轉(zhuǎn)換中編碼和解碼的字符集不一致導致的,由于往往一次操作涉及到多次編解碼,所以出現(xiàn)亂碼時很難查找到底是哪個環(huán)節(jié)出現(xiàn)了問題,下面就幾種常見的現(xiàn)象進行分析。
中文變成了看不懂的字符
例如,字符串“淘!我喜歡!”變成了“ì ? £ ?? ò ?2?? £ ?”編碼過程如下圖所示
![Figure xxx. Requires a heading](http://upload-images.jianshu.io/upload_images/1353074-094a1b82207deb56.gif?imageMogr2/auto-orient/strip)字符串在解碼時所用的字符集與編碼字符集不一致導致漢字變成了看不懂的亂碼,而且是一個漢字字符變成兩個亂碼字符。

一個漢字變成一個問號
例如,字符串“淘!我喜歡!”變成了“??????”編碼過程如下圖所示
![Figure xxx. Requires a heading](http://upload-images.jianshu.io/upload_images/1353074-c99544f8b243936b.gif?imageMogr2/auto-orient/strip)將中文和中文符號經(jīng)過不支持中文的 ISO-8859-1 編碼后,所有字符變成了“?”,這是因為用 ISO-8859-1 進行編解碼時遇到不在碼值范圍內(nèi)的字符時統(tǒng)一用 3f 表示,這也就是通常所說的“黑洞”,所有 ISO-8859-1 不認識的字符都變成了“?”。

一個漢字變成兩個問號
例如,字符串“淘!我喜歡!”變成了“????????????”編碼過程如下圖所示
![Figure xxx. Requires a heading](http://upload-images.jianshu.io/upload_images/1353074-fe773d27b9119422.gif?imageMogr2/auto-orient/strip)這種情況比較復雜,中文經(jīng)過多次編碼,但是其中有一次編碼或者解碼不對仍然會出現(xiàn)中文字符變成“?”現(xiàn)象,出現(xiàn)這種情況要仔細查看中間的編碼環(huán)節(jié),找出出現(xiàn)編碼錯誤的地方。

一種不正常的正確編碼
還有一種情況是在我們通過 request.getParameter 獲取參數(shù)值時,當我們直接調(diào)用
String value = request.getParameter(name);

會出現(xiàn)亂碼,但是如果用下面的方式
String value = String(request.getParameter(name).getBytes(" ISO-8859-1"), "GBK"); 

解析時取得的 value 會是正確的漢字字符,這種情況是怎么造成的呢?
看下如所示:
![Figure xxx. Requires a heading](http://upload-images.jianshu.io/upload_images/1353074-8d1cfc3d23d9a990.gif?imageMogr2/auto-orient/strip)這種情況是這樣的,ISO-8859-1 字符集的編碼范圍是 0000-00FF,正好和一個字節(jié)的編碼范圍相對應。這種特性保證了使用 ISO-8859-1 進行編碼和解碼可以保持編碼數(shù)值“不變”。雖然中文字符在經(jīng)過網(wǎng)絡傳輸時,被錯誤地“拆”成了兩個歐洲字符,但由于輸出時也是用 ISO-8859-1,結(jié)果被“拆”開的中文字的兩半又被合并在一起,從而又剛好組成了一個正確的漢字。雖然最終能取得正確的漢字,但是還是不建議用這種不正常的方式取得參數(shù)值,因為這中間增加了一次額外的編碼與解碼,這種情況出現(xiàn)亂碼時因為 Tomcat 的配置文件中 useBodyEncodingForURI 配置項沒有設(shè)置為”true”,從而造成第一次解析式用 ISO-8859-1 來解析才造成亂碼的。

[回頁首](http://www.ibm.com/developerworks/cn/java/j-lo-chinesecoding/#ibm-pcon)

總結(jié)
本文首先總結(jié)了幾種常見編碼格式的區(qū)別,然后介紹了支持中文的幾種編碼格式,并比較了它們的使用場景。接著介紹了 Java 那些地方會涉及到編碼問題,已經(jīng) Java 中如何對編碼的支持。并以網(wǎng)絡 I/O 為例重點介紹了 HTTP 請求中的存在編碼的地方,以及 Tomcat 對 HTTP 協(xié)議的解析,最后分析了我們平常遇到的亂碼問題出現(xiàn)的原因。
綜上所述,要解決中文問題,首先要搞清楚哪些地方會引起字符到字節(jié)的編碼以及字節(jié)到字符的解碼,最常見的地方就是讀取會存儲數(shù)據(jù)到磁盤,或者數(shù)據(jù)要經(jīng)過網(wǎng)絡傳輸。然后針對這些地方搞清楚操作這些數(shù)據(jù)的框架的或系統(tǒng)是如何控制編碼的,正確設(shè)置編碼格式,避免使用軟件默認的或者是操作系統(tǒng)平臺默認的編碼格式。
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,250評論 6 530
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 97,923評論 3 413
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,041評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,475評論 1 308
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,253評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 54,801評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,882評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,023評論 0 285
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,530評論 1 331
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 40,494評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,639評論 1 366
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,177評論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 43,890評論 3 345
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,289評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,552評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,242評論 3 389
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,626評論 2 370

推薦閱讀更多精彩內(nèi)容