Java一個漢字佔幾個字節(詳解與原理)

目錄

 

1、先說重點:

2、以下是源碼:

3、運行結果

 4、幾種編碼格式的簡單介紹

5、字符編碼的歷史故事

6、一個字符爲什麼佔兩個字節

7、深入分析 Java 中的中文編碼問題(轉載)

Java 中需要編碼的場景

Java 中如何編解碼

Java Web 涉及到的編碼

常見問題分析

編碼方案



 

1、先說重點:

不同的編碼格式佔字節數是不同的,UTF-8編碼下一個中文所佔字節也是不確定的,可能是2個、3個、4個字節;

2、以下是源碼:

複製代碼

 1   @Test
 2     public void test1() throws UnsupportedEncodingException {
 3         String a = "名";
 4         System.out.println("UTF-8編碼長度:"+a.getBytes("UTF-8").length);
 5         System.out.println("GBK編碼長度:"+a.getBytes("GBK").length);
 6         System.out.println("GB2312編碼長度:"+a.getBytes("GB2312").length);
 7         System.out.println("==========================================");
 8 
 9         String c = "0x20001";
10         System.out.println("UTF-8編碼長度:"+c.getBytes("UTF-8").length);
11         System.out.println("GBK編碼長度:"+c.getBytes("GBK").length);
12         System.out.println("GB2312編碼長度:"+c.getBytes("GB2312").length);
13         System.out.println("==========================================");
14 
15         char[] arr = Character.toChars(0x20001);
16         String s = new String(arr);
17         System.out.println("char array length:" + arr.length);
18         System.out.println("content:|  " + s + " |");
19         System.out.println("String length:" + s.length());
20         System.out.println("UTF-8編碼長度:"+s.getBytes("UTF-8").length);
21         System.out.println("GBK編碼長度:"+s.getBytes("GBK").length);
22         System.out.println("GB2312編碼長度:"+s.getBytes("GB2312").length);
23         System.out.println("==========================================");
24     }

複製代碼

3、運行結果

複製代碼

 1 UTF-8編碼長度:3
 2 GBK編碼長度:2
 3 GB2312編碼長度:2
 4 ==========================================
 5 UTF-8編碼長度:4
 6 GBK編碼長度:1
 7 GB2312編碼長度:1
 8 ==========================================
 9 char array length:2
10 content:|  𠀁 |
11 String length:2
12 UTF-8編碼長度:4
13 GBK編碼長度:1
14 GB2312編碼長度:1
15 ==========================================

複製代碼

 4、幾種編碼格式的簡單介紹

幾種編碼格式。

  • ASCII 碼

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

  • ISO-8859-1

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

  • GB2312

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

  • GBK

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

  • GB18030

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

  • UTF-16

說到 UTF 必須要提到 Unicode(Universal Code 統一碼),ISO 試圖想創建一個全新的超語言字典,世界上所有的語言都可以通過這本字典來相互翻譯。可想而知這個字典是多麼的複雜,關於 Unicode 的詳細規範可以參考相應文檔。Unicode 是 Java 和 XML 的基礎,下面詳細介紹 Unicode 在計算機中的存儲形式。

UTF-16 具體定義了 Unicode 字符在計算機中存取方法。UTF-16 用兩個字節來表示 Unicode 轉化格式,這個是定長的表示方法,不論什麼字符都可以用兩個字節表示,兩個字節是 16 個 bit,所以叫 UTF-16。UTF-16 表示字符非常方便,每兩個字節表示一個字符,這個在字符串操作時就大大簡化了操作,這也是 Java 以 UTF-16 作爲內存的字符存儲格式的一個很重要的原因。

  • UTF-8

UTF-16 統一採用兩個字節表示一個字符,雖然在表示上非常簡單方便,但是也有其缺點,有很大一部分字符用一個字節就可以表示的現在要兩個字節表示,存儲空間放大了一倍,在現在的網絡帶寬還非常有限的今天,這樣會增大網絡傳輸的流量,而且也沒必要。而 UTF-8 採用了一種變長技術,每個編碼區域有不同的字碼長度。不同類型的字符可以是由 1~6 個字節組成。

UTF-8 有以下編碼規則:

  1. 如果一個字節,最高位(第 8 位)爲 0,表示這是一個 ASCII 字符(00 - 7F)。可見,所有 ASCII 編碼已經是 UTF-8 了。
  2. 如果一個字節,以 11 開頭,連續的 1 的個數暗示這個字符的字節數,例如:110xxxxx 代表它是雙字節 UTF-8 字符的首字節。
  3. 如果一個字節,以 10 開始,表示它不是首字節,需要向前查找才能得到當前字符的首字節

5、字符編碼的歷史故事

很久很久以前,有一羣人,他們決定用8個可以開合的晶體管來組合成不同的狀態,以表示世界上的萬物。他們認爲8個開關狀態作爲原子單位很好,於是他們把這稱爲"字節"。 

再後來,他們又做了一些可以處理這些字節的機器,機器開動了,可以用字節來組合出更多的狀態,狀態開始變來變去。他們看到這樣是好的,於是它們就這機器稱爲"計算機"。 

開始計算機只在美國用。八位的字節一共可以組合出256(2的8次方)種不同的狀態。 

他們把其中的編號從0開始的32種狀態分別規定了特殊的用途,一但終端設備或者打印機遇上這些約定好的字節時,就要做一些約定的動作。遇上 00x10, 終端就換行,遇上0x07, 終端就向人們嘟嘟叫,例好遇上0x1b, 打印機就打印反白的字,對於終端就用彩色顯示字母。他們看到這樣很好,於是就把這些0x20(十進制32)以下的字節狀態稱爲"控制碼"。 

他們又把所有的空格、標點符號、數字、大小寫字母分別用連續的字節狀態表示,一直編到了第127號,這樣計算機就可以用不同字節來存儲英語的 文字了。大家看到這樣,都感覺很好,於是大家都把這個方案叫做 ANSI 的"Ascii"編碼(American Standard Code for Information Interchange,美國信息互換標準代碼)。當時世界上所有的計算機都用同樣的ASCII方案來保存英文文字。 

後來,就像建造巴比倫塔一樣,世界各地的都開始使用計算機,但是很多國家用的不是英文,他們用到的許多字母在ASCII中根本沒有,爲了也可以在計算機中保存他們的文字,他們決定採用127號之後的空位來表示這些新的字母、符號,還加入了很多畫表格時需要用下到的橫線、豎線、交叉等形狀,一直把序號編到了最後一個狀態255。從128到255這一頁的字符集被稱"擴展字符集"。從此之後,貪婪的人類再沒有新的狀態可以用了,美帝國主義可能沒有想到還有第三世界國家的人們也希望可以用到計算機吧! 

等中國人們得到計算機時,已經沒有可以利用的字節狀態來表示漢字,況且有6000多個常用漢字需要保存呢。但是這難不倒智慧的中國人民,我們不客氣地把那些127號之後的奇異符號們直接取消掉,並且規定:一個小於127的字符的意義與原來相同,但兩個大於127的字符連在一起時,就表示一個漢字,前面的一個字節(他稱之爲高字節)從0xA1用到 0xF7,後面一個字節(低字節)從0xA1到0xFE,這樣我們就可以組合出大約7000多個簡體漢字了。在這些編碼裏,我們還把數學符號、羅馬希臘的字母、日文的假名們都編進去了,連在 ASCII 裏本來就有的數字、標點、字母都統統重新編了兩個字節長的編碼,這就是常說的"全角"字符,而原來在127號以下的那些就叫"半角"字符了。 

中國人民看到這樣很不錯,於是就把這種漢字方案叫做"GB2312"。GB2312 是對 ASCII 的中文擴展。 

但是中國的漢字太多了,我們很快就就發現有許多人的人名沒有辦法在這裏打出來,特別是某些很會麻煩別人的國家領導人(如朱鎔基的“鎔”字)。於是我們不得不繼續把 GB2312 沒有用到的碼位找出來老實不客氣地用上。 

後來還是不夠用,於是乾脆不再要求低字節一定是127號之後的內碼,只要第一個字節是大於127就固定表示這是一個漢字的開始,不管後面跟的是不是擴展字符集裏的內容。結果擴展之後的編碼方案被稱爲 GBK 標準,GBK 包括了 GB2312 的所有內容,同時又增加了近20000個新的漢字(包括繁體字)和符號。 

後來少數民族也要用電腦了,於是我們再擴展,又加了幾千個新的少數民族的字,GBK 擴成了 GB18030。從此之後,中華民族的文化就可以在計算機時代中傳承了。 

中國的程序員們看到這一系列漢字編碼的標準是好的,於是通稱他們叫做 "DBCS"(Double Byte Charecter Set 雙字節字符集)。在DBCS系列標準裏,最大的特點是兩字節長的漢字字符和一字節長的英文字符並存於同一套編碼方案裏,因此他們寫的程序爲了支持中文處理,必須要注意字串裏的每一個字節的值,如果這個值是大於127的,那麼就認爲一個雙字節字符集裏的字符出現了。那時候凡是受過加持,會編程的計算機僧侶們都要每天念下面這個咒語數百遍: 

"一個漢字算兩個英文字符!一個漢字算兩個英文字符……" 

因爲當時各個國家都像中國這樣搞出一套自己的編碼標準,結果互相之間誰也不懂誰的編碼,誰也不支持別人的編碼,連大陸和臺灣這樣只相隔了150海里,使用着同一種語言的兄弟地區,也分別採用了不同的 DBCS 編碼方案——當時的中國人想讓電腦顯示漢字,就必須裝上一個"漢字系統",專門用來處理漢字的顯示、輸入的問題,但是那個臺灣的愚昧封建人士寫的算命程序就必須加裝另一套支持 BIG5 編碼的什麼"倚天漢字系統"纔可以用,裝錯了字符系統,顯示就會亂了套!這怎麼辦?而且世界民族之林中還有那些一時用不上電腦的窮苦人民,他們的文字又怎麼辦? 

真是計算機的巴比倫塔命題啊! 

正在這時,大天使加百列及時出現了——一個叫 ISO (國際標誰化組織)的國際組織決定着手解決這個問題。他們採用的方法很簡單:廢了所有的地區性編碼方案,重新搞一個包括了地球上所有文化、所有字母和符號的編碼!他們打算叫它"Universal Multiple-Octet Coded Character Set",簡稱 UCS, 俗稱 "UNICODE"。 

UNICODE 開始制訂時,計算機的存儲器容量極大地發展了,空間再也不成爲問題了。於是 ISO 就直接規定必須用兩個字節,也就是16位來統一表示所有的字符,對於ascii裏的那些"半角"字符,UNICODE 包持其原編碼不變,只是將其長度由原來的8位擴展爲16位,而其他文化和語言的字符則全部重新統一編碼。由於"半角"英文符號只需要用到低8位,所以其高 8位永遠是0,因此這種大氣的方案在保存英文文本時會多浪費一倍的空間。 

這時候,從舊社會裏走過來的程序員開始發現一個奇怪的現象:他們的strlen函數靠不住了,一個漢字不再是相當於兩個字符了,而是一個!是 的,從 UNICODE 開始,無論是半角的英文字母,還是全角的漢字,它們都是統一的"一個字符"!同時,也都是統一的"兩個字節",請注意"字符"和"字節"兩個術語的不同, "字節"是一個8位的物理存貯單元,而"字符"則是一個文化相關的符號。在UNICODE 中,一個字符就是兩個字節。一個漢字算兩個英文字符的時代已經快過去了。 

從前多種字符集存在時,那些做多語言軟件的公司遇上過很大麻煩,他們爲了在不同的國家銷售同一套軟件,就不得不在區域化軟件時也加持那個雙字節字符集咒語,不僅要處處小心不要搞錯,還要把軟件中的文字在不同的字符集中轉來轉去。UNICODE 對於他們來說是一個很好的一攬子解決方案,於是從 Windows NT 開始,MS 趁機把它們的操作系統改了一遍,把所有的核心代碼都改成了用 UNICODE 方式工作的版本,從這時開始,WINDOWS 系統終於無需要加裝各種本土語言系統,就可以顯示全世界上所有文化的字符了。 

但是,UNICODE 在制訂時沒有考慮與任何一種現有的編碼方案保持兼容,這使得 GBK 與UNICODE 在漢字的內碼編排上完全是不一樣的,沒有一種簡單的算術方法可以把文本內容從UNICODE編碼和另一種編碼進行轉換,這種轉換必須通過查表來進行。 

如前所述,UNICODE 是用兩個字節來表示爲一個字符,他總共可以組合出65535不同的字符,這大概已經可以覆蓋世界上所有文化的符號。如果還不夠也沒有關係,ISO已經準備了UCS-4方案,說簡單了就是四個字節來表示一個字符,這樣我們就可以組合出21億個不同的字符出來(最高位有其他用途),這大概可以用到銀河聯邦成立那一天吧! 

UNICODE 來到時,一起到來的還有計算機網絡的興起,UNICODE 如何在網絡上傳輸也是一個必須考慮的問題,於是面向傳輸的衆多 UTF(UCS Transfer Format)標準出現了,顧名思義,UTF8就是每次8個位傳輸數據,而UTF16就是每次16個位,只不過爲了傳輸時的可靠性,從UNICODE到 UTF時並不是直接的對應,而是要過一些算法和規則來轉換。 

受到過網絡編程加持的計算機僧侶們都知道,在網絡裏傳遞信息時有一個很重要的問題,就是對於數據高低位的解讀方式,一些計算機是採用低位先發送的方法,例如我們PC機採用的 INTEL 架構;而另一些是採用高位先發送的方式。在網絡中交換數據時,爲了覈對雙方對於高低位的認識是否是一致的,採用了一種很簡便的方法,就是在文本流的開始時向對方發送一個標誌符——如果之後的文本是高位在位,那就發送"FEFF",反之,則發送"FFFE"。不信你可以用二進制方式打開一個UTF-X格式的文件,看看開頭兩個字節是不是這兩個字節? 

下面是Unicode和UTF-8轉換的規則 

複製代碼

 1 Unicode 
 2    
 3 UTF-8 
 4    
 5 0000 - 007F 
 6    
 7 0xxxxxxx 
 8    
 9 0080 - 07FF 
10    
11 110xxxxx 10xxxxxx 
12    
13 0800 - FFFF 
14    
15 1110xxxx 10xxxxxx 10xxxxxx 

複製代碼


例如"漢"字的Unicode編碼是6C49。6C49在0800-FFFF之間,所以要用3字節模板:1110xxxx 10xxxxxx 10xxxxxx。將6C49寫成二進制是:0110 1100 0100 1001,將這個比特流按三字節模板的分段方法分爲0110 110001 001001,依次代替模板中的x,得到:1110-0110 10-110001 10-001001,即E6 B1 89,這就是其UTF8的編碼。 

講到這裏,我們再順便說說一個很著名的奇怪現象:當你在 windows 的記事本里新建一個文件,輸入"聯通"兩個字之後,保存,關閉,然後再次打開,你會發現這兩個字已經消失了,代之的是幾個亂碼!呵呵,有人說這就是聯通之所以拼不過移動的原因。 

其實這是因爲GB2312編碼與UTF8編碼產生了編碼衝撞的原因。 

當一個軟件打開一個文本時,它要做的第一件事是決定這個文本究竟是使用哪種字符集的哪種編碼保存的。軟件一般採用三種方式來決定文本的字符集和編碼: 

檢測文件頭標識,提示用戶選擇,根據一定的規則猜測 

最標準的途徑是檢測文本最開頭的幾個字節,開頭字節 Charset/encoding,如下表: 

複製代碼

1 EF BB BF UTF-8 
2    
3 FF FE UTF-16/UCS-2, little endian 
4    
5 FE FF UTF-16/UCS-2, big endian 
6    
7 FF FE 00 00 UTF-32/UCS-4, little endian. 
8    
9 00 00 FE FF UTF-32/UCS-4, big-endian. 

複製代碼


當你新建一個文本文件時,記事本的編碼默認是ANSI(代表系統默認編碼,在中文系統中一般是GB系列編碼), 如果你在ANSI的編碼輸入漢字,那麼他實際就是GB系列的編碼方式,在這種編碼下,"聯通"的內碼是: 

複製代碼

1 c1 1100 0001 
2    
3 aa 1010 1010 
4    
5 cd 1100 1101 
6    
7 a8 1010 1000 

複製代碼

注意到了嗎?第一二個字節、第三四個字節的起始部分的都是"110"和"10",正好與UTF8規則裏的兩字節模板是一致的, 

於是當我們再次打開記事本時,記事本就誤認爲這是一個UTF8編碼的文件,讓我們把第一個字節的110和第二個字節的10去掉,我們就得到了"00001 101010",再把各位對齊,補上前導的0,就得到了"0000 0000 0110 1010",不好意思,這是UNICODE的006A,也就是小寫的字母"j",而之後的兩字節用UTF8解碼之後是0368,這個字符什麼也不是。這就是隻有"聯通"兩個字的文件沒有辦法在記事本里正常顯示的原因。 

而如果你在"聯通"之後多輸入幾個字,其他的字的編碼不見得又恰好是110和10開始的字節,這樣再次打開時,記事本就不會堅持這是一個utf8編碼的文件,而會用ANSI的方式解讀之,這時亂碼又不出現了。

6、一個字符爲什麼佔兩個字節

 

1 public static void main(String[] args) {
2     System.out.printf("The max value of type char is %d.%n",
3             (int)Character.MAX_VALUE);
4     System.out.printf("The min value of type char is %d.%n",
5             (int)Character.MIN_VALUE);
6 }

運行上面的程序,輸出

  The max value of type char is 65535.
  The min value of type char is 0.
說明char的範圍從0到65535,那麼正好是兩個字節所能表示的範圍(65535十六進制就是0xFFFF,一個字節能表示0~0xFF,兩個字節能表示0~0xFFFF),所以說一個char佔兩個字節。

那麼char的值到底是什麼呢?比如當我這樣寫char c = '放';

複製代碼

 1 public static void main(String[] args) throws Exception {
 2     char c = '放';
 3     System.out.printf("The value of char %c is %d.%n", c, (int)c);
 4       
 5     String str = String.valueOf(c);
 6     byte[] bys = str.getBytes("Unicode");
 7     for (int i = 0; i < bys.length; i++) {
 8         System.out.printf("%X ", bys[i]);
 9     }
10     System.out.println();
11       
12     int unicode = (bys[2] & 0xFF) << 8 | (bys[3 & 0xFF]);
13     System.out.printf("The unicode value of %c is %d.%n", c, unicode);
14 }

複製代碼

 運行輸出:
  The value of char 放 is 25918.
  FE FF 65 3E 
  The unicode value of 放 is 25918.
首先你看到,這個char的值是25918,那他是什麼呢?先不管它,接着我把這個char放在一個String裏,並進行Unicode編碼,得到四個字節FE FF 65 3E,前面兩個實際上與內容無關,是BOM,即字節序標識,FE FF表示是Big Endian,也就是高位在前,低位在後,所以按照這個規則,講653E轉換爲10進制int,發現最後輸出25918,也就是這個字符的Unicode值是25918,所以你現在知道一個char到底存儲的是什麼了吧。

至於GBK,UTF-8,UTF-16的關係,我先拋開GBK,因爲它有點特殊。
首先你要知道UTF-8和UTF-16還有UTF-32是爲了方便傳輸和存儲的而產生的對Unicode字符的編碼方式。
先說UTF-8,隨着全球化Unicode流行起來,不管你做什麼,支持Unicode都將是潮流,就算你可能永遠也用不到,但這對西方國家就不太好,因爲以前ASCII字符集,一個字符只需要一個字節,而現在用Unicode一個英文字母也需要兩個字節,如果需要傳輸和存儲,那會浪費一半的空間或流量,所以就想出了一種變長編碼方式,那就是UTF-8,它對ASCII字符集內的字符,只用一個字節編碼,而其他字符按照一定規則進行兩、三、四字節編碼,具體規則是:
Unicode編碼(十六進制)    UTF-8 字節流(二進制)
000000 - 00007F                0xxxxxxx
000080 - 0007FF                110xxxxx 10xxxxxx
000800 - 00FFFF               1110xxxx 10xxxxxx 10xxxxxx
010000 - 10FFFF               11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

但這樣做一些東方國家不幹了,因爲他們的字符基本都是在000800 - 00FFFF這個區間,用UTF-8反倒要多用一個字節,總共需要三個字節才能表示,而且用UTF-8處理他們的字符,不能直接轉換,需要做一些運算,以‘放’爲例,它的Unicode碼是25918,二進制表示是0110010100111110,如果要轉成UTF-8,首先取高四位0110,和1110拼接,組成11100110,然後中間六位010100,與10拼接構成10010100,最後低六位111110,與10拼接構成10111110,所以三個字節是11100110 10010100 10111110,也就是十六進制的E6  94 BE,也就是你上面寫的-26 -108 -66。可以看到這個運算量雖然不大,基本是位操作,但如果你每個字符都要這麼操作實在是有損效率,綜合這幾點考慮,於是又弄了一個UTF-16,不嚴謹地來說它等價於Unicode原生編碼,它統一採用雙字節表示一個字符(其實有四字節區域,但現在一般沒有用到),而由於它用多字節表示,和Unicode一樣需要字節序標識,你上面代碼裏發現它得到-2, -1, 101, 62,轉爲十六進制就是FE FF 65 3E,和我第二個實例程序中相同,說明UTF-16的碼值(如表示‘放’的65 3E)和Unicode原生編碼是相同的。

UTF-32的誕生其實也不奇怪,因爲UTF-16還是一個變長編碼方式,一個字符可能由兩個或四個字節表示,有些有強迫症的人總覺得不好,所以爲了他們就有了UTF-32,它統一使用四字節表示一個字符,因爲用得不多所以不詳細說了。

最後說說GBK是個什麼東西。GBK是國標擴(展)的拼音首字母,是我國在1995年制定的專門針對漢語和一些少數名族語言的編碼方式,和Unicode之間沒有一一對應的關係,也就是說Unicode中有的字符GBK不一定有,GBK有的字符Unicode也不一定有,而且GBK和Unicode中共有字符,他們的編碼值沒有一種簡單的對應關係,也就是無法通過簡單計算得到,只能通過查錶轉換。爲什麼會有GBK這種奇葩呢?其實是當時Unicode還沒制定好,更沒在全球範圍內推廣,而中國人要用電腦總不可能永遠用英語吧?所以我國就自行制定了一個國標,當時是GB2312,(其實臺灣地區針對繁體還有一個Big5,但這裏就不詳述了),GB2312後來增加了很多字符,包括很多少數名族的語言,成爲了一個新的編碼標準,那就是GBK。

7、深入分析 Java 中的中文編碼問題(轉載)

原文鏈接:http://www.ibm.com/developerworks/cn/java/j-lo-chinesecoding/#ibm-pcon

Java 中需要編碼的場景

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

I/O 操作中存在的編碼

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

 

Reader 類是 Java 的 I/O 中讀字符的父類,而 InputStream 類是讀字節的父類,InputStreamReader 類就是關聯字節到字符的橋樑,它負責在 I/O 過程中處理讀取字節到字符的轉換,而具體字節到字符的解碼實現它由 StreamDecoder 去實現,在 StreamDecoder 解碼過程中必須由用戶指定 Charset 編碼格式。值得注意的是如果你沒有指定 Charset,將使用本地環境中的默認字符集,例如在中文環境中將使用 GBK 編碼。

寫的情況也是類似,字符的父類是 Writer,字節的父類是 OutputStream,通過 OutputStreamWriter 轉換字符到字節。如下圖所示:

 

同樣 StreamEncoder 類負責將字符編碼成字節,編碼格式和默認編碼規則與解碼是一致的。

如下面一段代碼,實現了文件的讀寫功能:

清單 1.I/O 涉及的編碼示例

複製代碼

 1 String file = "c:/stream.txt"; 
 2  String charset = "UTF-8"; 
 3  // 寫字符換轉成字節流
 4  FileOutputStream outputStream = new FileOutputStream(file); 
 5  OutputStreamWriter writer = new OutputStreamWriter( 
 6  outputStream, charset); 
 7  try { 
 8     writer.write("這是要保存的中文字符"); 
 9  } finally { 
10     writer.close(); 
11  } 
12  // 讀取字節轉換成字符
13  FileInputStream inputStream = new FileInputStream(file); 
14  InputStreamReader reader = new InputStreamReader( 
15  inputStream, charset); 
16  StringBuffer buffer = new StringBuffer(); 
17  char[] buf = new char[64]; 
18  int count = 0; 
19  try { 
20     while ((count = reader.read(buf)) != -1) { 
21         buffer.append(buffer, 0, count); 
22     } 
23  } finally { 
24     reader.close(); 
25  }

複製代碼

 

在我們的應用程序中涉及到 I/O 操作時只要注意指定統一的編解碼 Charset 字符集,一般不會出現亂碼問題,有些應用程序如果不注意指定字符編碼,中文環境中取操作系統默認編碼,如果編解碼都在中文環境中,通常也沒問題,但是還是強烈的不建議使用操作系統的默認編碼,因爲這樣,你的應用程序的編碼格式就和運行環境綁定起來了,在跨環境下很可能出現亂碼問題。

內存中操作中的編碼

在 Java 開發中除了 I/O 涉及到編碼外,最常用的應該就是在內存中進行字符到字節的數據類型的轉換,Java 中用 String 表示字符串,所以 String 類就提供轉換到字節的方法,也支持將字節轉換爲字符串的構造函數。如下代碼示例:

1  String s = "這是一段中文字符串"; 
2  byte[] b = s.getBytes("UTF-8"); 
3  String n = new String(b,"UTF-8");

 

另外一個是已經被被廢棄的 ByteToCharConverter 和 CharToByteConverter 類,它們分別提供了 convertAll 方法可以實現 byte[] 和 char[] 的互轉。如下代碼所示:

1  ByteToCharConverter charConverter = ByteToCharConverter.getConverter("UTF-8"); 
2  char c[] = charConverter.convertAll(byteArray); 
3  CharToByteConverter byteConverter = CharToByteConverter.getConverter("UTF-8"); 
4  byte[] b = byteConverter.convertAll(c);

 

這兩個類已經被 Charset 類取代,Charset 提供 encode 與 decode 分別對應 char[] 到 byte[] 的編碼和 byte[] 到 char[] 的解碼。如下代碼所示:

1  Charset charset = Charset.forName("UTF-8"); 
2  ByteBuffer byteBuffer = charset.encode(string); 
3  CharBuffer charBuffer = charset.decode(byteBuffer);

 

編碼與解碼都在一個類中完成,通過 forName 設置編解碼字符集,這樣更容易統一編碼格式,比 ByteToCharConverter 和 CharToByteConverter 類更方便。

Java 中還有一個 ByteBuffer 類,它提供一種 char 和 byte 之間的軟轉換,它們之間轉換不需要編碼與解碼,只是把一個 16bit 的 char 格式,拆分成爲 2 個 8bit 的 byte 表示,它們的實際值並沒有被修改,僅僅是數據的類型做了轉換。如下代碼所以:

1  ByteBuffer heapByteBuffer = ByteBuffer.allocate(1024); 
2  ByteBuffer byteBuffer = heapByteBuffer.putChar(c);

 

以上這些提供字符和字節之間的相互轉換隻要我們設置編解碼格式統一一般都不會出現問題。

Java 中如何編解碼

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

清單 2.String 編碼

複製代碼

 1  public static void encode() { 
 2         String name = "I am 君山"; 
 3         toHex(name.toCharArray()); 
 4         try { 
 5             byte[] iso8859 = name.getBytes("ISO-8859-1"); 
 6             toHex(iso8859); 
 7             byte[] gb2312 = name.getBytes("GB2312"); 
 8             toHex(gb2312); 
 9             byte[] gbk = name.getBytes("GBK"); 
10             toHex(gbk); 
11             byte[] utf16 = name.getBytes("UTF-16"); 
12             toHex(utf16); 
13             byte[] utf8 = name.getBytes("UTF-8"); 
14             toHex(utf8); 
15         } catch (UnsupportedEncodingException e) { 
16             e.printStackTrace(); 
17         } 
18  }

複製代碼

 

我們把 name 字符串按照前面說的幾種編碼格式進行編碼轉化成 byte 數組,然後以 16 進制輸出,我們先看一下 Java 是如何進行編碼的。

下面是 Java 中編碼需要用到的類圖

圖 1. Java 編碼類圖

 

首先根據指定的 charsetName 通過 Charset.forName(charsetName) 設置 Charset 類,然後根據 Charset 創建 CharsetEncoder 對象,再調用 CharsetEncoder.encode 對字符串進行編碼,不同的編碼類型都會對應到一個類中,實際的編碼過程是在這些類中完成的。下面是 String. getBytes(charsetName) 編碼過程的時序圖

圖 2.Java 編碼時序圖

 

從上圖可以看出根據 charsetName 找到 Charset 類,然後根據這個字符集編碼生成 CharsetEncoder,這個類是所有字符編碼的父類,針對不同的字符編碼集在其子類中定義瞭如何實現編碼,有了 CharsetEncoder 對象後就可以調用 encode 方法去實現編碼了。這個是 String.getBytes 編碼方法,其它的如 StreamEncoder 中也是類似的方式。下面看看不同的字符集是如何將前面的字符串編碼成 byte 數組的?

如字符串“I am 君山”的 char 數組爲 49 20 61 6d 20 541b 5c71,下面把它按照不同的編碼格式轉化成相應的字節。

按照 ISO-8859-1 編碼

字符串“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,所以很容易出現亂碼問題,後面將會分析不同的亂碼形式是怎麼出現的。

按照 GB2312 編碼

字符串“I am 君山”用 GB2312 編碼,下面是編碼結果:

 

GB2312 對應的 Charset 是 sun.nio.cs.ext. EUC_CN 而對應的 CharsetDecoder 編碼類是 sun.nio.cs.ext. DoubleByte,GB2312 字符集有一個 char 到 byte 的碼錶,不同的字符編碼就是查這個碼錶找到與每個字符的對應的字節,然後拼裝成 byte 數組。查表的規則如下:

 c2b[c2bIndex[char >> 8] + (char & 0xff)]

如果查到的碼位值大於 oxff 則是雙字節,否則是單字節。雙字節高 8 位作爲第一個字節,低 8 位作爲第二個字節,如下代碼所示:

複製代碼

 1  if (bb > 0xff) {    // DoubleByte 
 2     if (dl - dp < 2) 
 3     return CoderResult.OVERFLOW; 
 4     da[dp++] = (byte) (bb >> 8); 
 5     da[dp++] = (byte) bb; 
 6  } else {            // SingleByte 
 7     if (dl - dp < 1) 
 8         return CoderResult.OVERFLOW; 
 9     da[dp++] = (byte) bb; 
10  }

複製代碼

 

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

按照 GBK 編碼

字符串“I am 君山”用 GBK 編碼,下面是編碼結果:

 

你可能已經發現上圖與 GB2312 編碼的結果是一樣的,沒錯 GBK 與 GB2312 編碼結果是一樣的,由此可以得出 GBK 編碼是兼容 GB2312 編碼的,它們的編碼算法也是一樣的。不同的是它們的碼錶長度不一樣,GBK 包含的漢字字符更多。所以只要是經過 GB2312 編碼的漢字都可以用 GBK 進行解碼,反過來則不然。

按照 UTF-16 編碼

字符串“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 之外的字符編碼。

按照 UTF-8 編碼

字符串“I am 君山”用 UTF-8 編碼,下面是編碼結果:

 

UTF-16 雖然編碼效率很高,但是對單字節範圍內字符也放大了一倍,這無形也浪費了存儲空間,另外 UTF-16 採用順序編碼,不能對單個字符的編碼值進行校驗,如果中間的一個字符碼值損壞,後面的所有碼值都將受影響。而 UTF-8 這些問題都不存在,UTF-8 對單字節範圍內字符仍然用一個字節表示,對漢字採用三個字節表示。它的編碼規則如下:

清單 3.UTF-8 編碼代碼片段

複製代碼

 1  private CoderResult encodeArrayLoop(CharBuffer src, ByteBuffer dst){ 
 2     char[] sa = src.array(); 
 3     int sp = src.arrayOffset() + src.position(); 
 4     int sl = src.arrayOffset() + src.limit(); 
 5     byte[] da = dst.array(); 
 6     int dp = dst.arrayOffset() + dst.position(); 
 7     int dl = dst.arrayOffset() + dst.limit(); 
 8     int dlASCII = dp + Math.min(sl - sp, dl - dp); 
 9     // ASCII only loop 
10     while (dp < dlASCII && sa[sp] < '\u0080') 
11         da[dp++] = (byte) sa[sp++]; 
12     while (sp < sl) { 
13         char c = sa[sp]; 
14         if (c < 0x80) { 
15             // Have at most seven bits 
16             if (dp >= dl) 
17                 return overflow(src, sp, dst, dp); 
18             da[dp++] = (byte)c; 
19         } else if (c < 0x800) { 
20             // 2 bytes, 11 bits 
21             if (dl - dp < 2) 
22                 return overflow(src, sp, dst, dp); 
23             da[dp++] = (byte)(0xc0 | (c >> 6)); 
24             da[dp++] = (byte)(0x80 | (c & 0x3f)); 
25         } else if (Character.isSurrogate(c)) { 
26             // Have a surrogate pair 
27             if (sgp == null) 
28                 sgp = new Surrogate.Parser(); 
29             int uc = sgp.parse(c, sa, sp, sl); 
30             if (uc < 0) { 
31                 updatePositions(src, sp, dst, dp); 
32                 return sgp.error(); 
33             } 
34             if (dl - dp < 4) 
35                 return overflow(src, sp, dst, dp); 
36             da[dp++] = (byte)(0xf0 | ((uc >> 18))); 
37             da[dp++] = (byte)(0x80 | ((uc >> 12) & 0x3f)); 
38             da[dp++] = (byte)(0x80 | ((uc >>  6) & 0x3f)); 
39             da[dp++] = (byte)(0x80 | (uc & 0x3f)); 
40             sp++;  // 2 chars 
41         } else { 
42             // 3 bytes, 16 bits 
43             if (dl - dp < 3) 
44                 return overflow(src, sp, dst, dp); 
45             da[dp++] = (byte)(0xe0 | ((c >> 12))); 
46             da[dp++] = (byte)(0x80 | ((c >>  6) & 0x3f)); 
47             da[dp++] = (byte)(0x80 | (c & 0x3f)); 
48         } 
49         sp++; 
50     } 
51     updatePositions(src, sp, dst, dp); 
52     return CoderResult.UNDERFLOW; 
53  }

複製代碼

 

UTF-8 編碼與 GBK 和 GB2312 不同,不用查碼錶,所以在編碼效率上 UTF-8 的效率會更好,所以在存儲中文字符時 UTF-8 編碼比較理想。

幾種編碼格式的比較

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

Java Web 涉及到的編碼

對於使用中文來說,有 I/O 的地方就會涉及到編碼,前面已經提到了 I/O 操作會引起編碼,而大部分 I/O 引起的亂碼都是網絡 I/O,因爲現在幾乎所有的應用程序都涉及到網絡操作,而數據經過網絡傳輸都是以字節爲單位的,所以所有的數據都必須能夠被序列化爲字節。在 Java 中數據被序列化必須繼承 Serializable 接口。

這裏有一個問題,你是否認真考慮過一段文本它的實際大小應該怎麼計算,我曾經碰到過一個問題:就是要想辦法壓縮 Cookie 大小,減少網絡傳輸量,當時有選擇不同的壓縮算法,發現壓縮後字符數是減少了,但是並沒有減少字節數。所謂的壓縮只是將多個單字節字符通過編碼轉變成一個多字節字符。減少的是 String.length(),而並沒有減少最終的字節數。例如將“ab”兩個字符通過某種編碼轉變成一個奇怪的字符,雖然字符數從兩個變成一個,但是如果採用 UTF-8 編碼這個奇怪的字符最後經過編碼可能又會變成三個或更多的字節。同樣的道理比如整型數字 1234567 如果當成字符來存儲,採用 UTF-8 來編碼佔用 7 個 byte,採用 UTF-16 編碼將會佔用 14 個 byte,但是把它當成 int 型數字來存儲只需要 4 個 byte 來存儲。所以看一段文本的大小,看字符本身的長度是沒有意義的,即使是一樣的字符采用不同的編碼最終存儲的大小也會不同,所以從字符到字節一定要看編碼類型。

另外一個問題,你是否考慮過,當我們在電腦中某個文本編輯器裏輸入某個漢字時,它到底是怎麼表示的?我們知道,計算機裏所有的信息都是以 01 表示的,那麼一個漢字,它到底是多少個 0 和 1 呢?我們能夠看到的漢字都是以字符形式出現的,例如在 Java 中“淘寶”兩個字符,它在計算機中的數值 10 進制是 28120 和 23453,16 進制是 6bd8 和 5d9d,也就是這兩個字符是由這兩個數字唯一表示的。Java 中一個 char 是 16 個 bit 相當於兩個字節,所以兩個漢字用 char 表示在內存中佔用相當於四個字節的空間。

這兩個問題搞清楚後,我們看一下 Java Web 中那些地方可能會存在編碼轉換?

用戶從瀏覽器端發起一個 HTTP 請求,需要存在編碼的地方是 URL、Cookie、Parameter。服務器端接受到 HTTP 請求後要解析 HTTP 協議,其中 URI、Cookie 和 POST 表單參數需要解碼,服務器端可能還需要讀取數據庫中的數據,本地或網絡中其它地方的文本文件,這些數據都可能存在編碼問題,當 Servlet 處理完所有請求的數據後,需要將這些數據再編碼通過 Socket 發送到用戶請求的瀏覽器裏,再經過瀏覽器解碼成爲文本。這些過程如下圖所示:

圖 3. 一次 HTTP 請求的編碼示例

 

如上圖所示一次 HTTP 請求設計到很多地方需要編解碼,它們編解碼的規則是什麼?下面將會重點闡述一下:

URL 的編解碼

用戶提交一個 URL,這個 URL 中可能存在中文,因此需要編碼,如何對這個 URL 進行編碼?根據什麼規則來編碼?有如何來解碼?如下圖一個 URL:

圖 4.URL 的幾個組成部分

 

上圖中以 Tomcat 作爲 Servlet Engine 爲例,它們分別對應到下面這些配置文件中:

Port 對應在 Tomcat 的 <Connector port="8080"/> 中配置,而 Context Path 在 <Context path="/examples"/> 中配置,Servlet Path 在 Web 應用的 web.xml 中的

1  <servlet-mapping> 
2         <servlet-name>junshanExample</servlet-name> 
3         <url-pattern>/servlets/servlet/*</url-pattern> 
4  </servlet-mapping>

 

<url-pattern> 中配置,PathInfo 是我們請求的具體的 Servlet,QueryString 是要傳遞的參數,注意這裏是在瀏覽器裏直接輸入 URL 所以是通過 Get 方法請求的,如果是 POST 方法請求的話,QueryString 將通過表單方式提交到服務器端,這個將在後面再介紹。

上圖中 PathInfo 和 QueryString 出現了中文,當我們在瀏覽器中直接輸入這個 URL 時,在瀏覽器端和服務端會如何編碼和解析這個 URL 呢?爲了驗證瀏覽器是怎麼編碼 URL 的我們選擇 FireFox 瀏覽器並通過 HTTPFox 插件觀察我們請求的 URL 的實際的內容,以下是 URL:HTTP://localhost:8080/examples/servlets/servlet/ 君山 ?author= 君山在中文 FireFox3.6.12 的測試結果

圖 5. HTTPFox 的測試結果

君山的編碼結果分別是:e5 90 9b e5 b1 b1,be fd c9 bd,查閱上一屆的編碼可知,PathInfo 是 UTF-8 編碼而 QueryString 是經過 GBK 編碼,至於爲什麼會有“%”?查閱 URL 的編碼規範 RFC3986 可知瀏覽器編碼 URL 是將非 ASCII 字符按照某種編碼格式編碼成 16 進制數字然後將每個 16 進製表示的字節前加上“%”,所以最終的 URL 就成了上圖的格式了。

默認情況下中文 IE 最終的編碼結果也是一樣的,不過 IE 瀏覽器可以修改 URL 的編碼格式在選項 -> 高級 -> 國際裏面的發送 UTF-8 URL 選項可以取消。

從上面測試結果可知瀏覽器對 PathInfo 和 QueryString 的編碼是不一樣的,不同瀏覽器對 PathInfo 也可能不一樣,這就對服務器的解碼造成很大的困難,下面我們以 Tomcat 爲例看一下,Tomcat 接受到這個 URL 是如何解碼的。

解析請求的 URL 是在 org.apache.coyote.HTTP11.InternalInputBuffer 的 parseRequestLine 方法中,這個方法把傳過來的 URL 的 byte[] 設置到 org.apache.coyote.Request 的相應的屬性中。這裏的 URL 仍然是 byte 格式,轉成 char 是在 org.apache.catalina.connector.CoyoteAdapter 的 convertURI 方法中完成的:

複製代碼

 1  protected void convertURI(MessageBytes uri, Request request) 
 2  throws Exception { 
 3         ByteChunk bc = uri.getByteChunk(); 
 4         int length = bc.getLength(); 
 5         CharChunk cc = uri.getCharChunk(); 
 6         cc.allocate(length, -1); 
 7         String enc = connector.getURIEncoding(); 
 8         if (enc != null) { 
 9             B2CConverter conv = request.getURIConverter(); 
10             try { 
11                 if (conv == null) { 
12                     conv = new B2CConverter(enc); 
13                     request.setURIConverter(conv); 
14                 } 
15             } catch (IOException e) {...} 
16             if (conv != null) { 
17                 try { 
18                     conv.convert(bc, cc, cc.getBuffer().length - 
19  cc.getEnd()); 
20                     uri.setChars(cc.getBuffer(), cc.getStart(), 
21  cc.getLength()); 
22                     return; 
23                 } catch (IOException e) {...} 
24             } 
25         } 
26         // Default encoding: fast conversion 
27         byte[] bbuf = bc.getBuffer(); 
28         char[] cbuf = cc.getBuffer(); 
29         int start = bc.getStart(); 
30         for (int i = 0; i < length; i++) { 
31             cbuf[i] = (char) (bbuf[i + start] & 0xff); 
32         } 
33         uri.setChars(cbuf, 0, length); 
34  }

複製代碼

 

從上面的代碼中可以知道對 URL 的 URI 部分進行解碼的字符集是在 connector 的 <Connector URIEncoding=”UTF-8”/> 中定義的,如果沒有定義,那麼將以默認編碼 ISO-8859-1 解析。所以如果有中文 URL 時最好把 URIEncoding 設置成 UTF-8 編碼。

QueryString 又如何解析? GET 方式 HTTP 請求的 QueryString 與 POST 方式 HTTP 請求的表單參數都是作爲 Parameters 保存,都是通過 request.getParameter 獲取參數值。對它們的解碼是在 request.getParameter 方法第一次被調用時進行的。request.getParameter 方法被調用時將會調用 org.apache.catalina.connector.Request 的 parseParameters 方法。這個方法將會對 GET 和 POST 方式傳遞的參數進行解碼,但是它們的解碼字符集有可能不一樣。POST 表單的解碼將在後面介紹,QueryString 的解碼字符集是在哪定義的呢?它本身是通過 HTTP 的 Header 傳到服務端的,並且也在 URL 中,是否和 URI 的解碼字符集一樣呢?從前面瀏覽器對 PathInfo 和 QueryString 的編碼採取不同的編碼格式不同可以猜測到解碼字符集肯定也不會是一致的。的確是這樣 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 Header 的編解碼

當客戶端發起一個 HTTP 請求除了上面的 URL 外還可能會在 Header 中傳遞其它參數如 Cookie、redirectPath 等,這些用戶設置的值很可能也會存在編碼問題,Tomcat 對它們又是怎麼解碼的呢?

對 Header 中的項進行解碼也是在調用 request.getHeader 是進行的,如果請求的 Header 項沒有解碼則調用 MessageBytes 的 toString 方法,這個方法將從 byte 到 char 的轉化使用的默認編碼也是 ISO-8859-1,而我們也不能設置 Header 的其它解碼格式,所以如果你設置 Header 中有非 ASCII 字符解碼肯定會有亂碼。

我們在添加 Header 時也是同樣的道理,不要在 Header 中傳遞非 ASCII 字符,如果一定要傳遞的話,我們可以先將這些字符用 org.apache.catalina.util.URLEncoder 編碼然後再添加到 Header 中,這樣在瀏覽器到服務器的傳遞過程中就不會丟失信息了,如果我們要訪問這些項時再按照相應的字符集解碼就好了。

POST 表單的編解碼

在前面提到了 POST 表單提交的參數的解碼是在第一次調用 request.getParameter 發生的,POST 表單參數傳遞方式與 QueryString 不同,它是通過 HTTP 的 BODY 傳遞到服務端的。當我們在頁面上點擊 submit 按鈕時瀏覽器首先將根據 ContentType 的 Charset 編碼格式對錶單填的參數進行編碼然後提交到服務器端,在服務器端同樣也是用 ContentType 中字符集進行解碼。所以通過 POST 表單提交的參數一般不會出現問題,而且這個字符集編碼是我們自己設置的,可以通過 request.setCharacterEncoding(charset) 來設置。

另外針對 multipart/form-data 類型的參數,也就是上傳的文件編碼同樣也是使用 ContentType 定義的字符集編碼,值得注意的地方是上傳文件是用字節流的方式傳輸到服務器的本地臨時目錄,這個過程並沒有涉及到字符編碼,而真正編碼是在將文件內容添加到 parameters 中,如果用這個編碼不能編碼時將會用默認編碼 ISO-8859-1 來編碼。

HTTP BODY 的編解碼

當用戶請求的資源已經成功獲取後,這些內容將通過 Response 返回給客戶端瀏覽器,這個過程先要經過編碼再到瀏覽器進行解碼。這個過程的編解碼字符集可以通過 response.setCharacterEncoding 來設置,它將會覆蓋 request.getCharacterEncoding 的值,並且通過 Header 的 Content-Type 返回客戶端,瀏覽器接受到返回的 socket 流時將通過 Content-Type 的 charset 來解碼,如果返回的 HTTP Header 中 Content-Type 沒有設置 charset,那麼瀏覽器將根據 Html 的 <meta HTTP-equiv="Content-Type" content="text/html; charset=GBK" /> 中的 charset 來解碼。如果也沒有定義的話,那麼瀏覽器將使用默認的編碼來解碼。

其它需要編碼的地方

除了 URL 和參數編碼問題外,在服務端還有很多地方可能存在編碼,如可能需要讀取 xml、velocity 模版引擎、JSP 或者從數據庫讀取數據等。

xml 文件可以通過設置頭來制定編碼格式

1 <?xml version="1.0" encoding="UTF-8"?>

 

Velocity 模版設置編碼格式:

 services.VelocityService.input.encoding=UTF-8

JSP 設置編碼格式:

1 <%@page contentType="text/html; charset=UTF-8"%>

 

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

 

常見問題分析

在瞭解了 Java Web 中可能需要編碼的地方後,下面看一下,當我們碰到一些亂碼時,應該怎麼處理這些問題?出現亂碼問題唯一的原因都是在 char 到 byte 或 byte 到 char 轉換中編碼和解碼的字符集不一致導致的,由於往往一次操作涉及到多次編解碼,所以出現亂碼時很難查找到底是哪個環節出現了問題,下面就幾種常見的現象進行分析。

中文變成了看不懂的字符

例如,字符串“淘!我喜歡!”變成了“Ì Ô £ ¡Î Ò Ï²»¶ £ ¡”編碼過程如下圖所示

 

字符串在解碼時所用的字符集與編碼字符集不一致導致漢字變成了看不懂的亂碼,而且是一個漢字字符變成兩個亂碼字符。

一個漢字變成一個問號

例如,字符串“淘!我喜歡!”變成了“??????”編碼過程如下圖所示

 

將中文和中文符號經過不支持中文的 ISO-8859-1 編碼後,所有字符變成了“?”,這是因爲用 ISO-8859-1 進行編解碼時遇到不在碼值範圍內的字符時統一用 3f 表示,這也就是通常所說的“黑洞”,所有 ISO-8859-1 不認識的字符都變成了“?”。

一個漢字變成兩個問號

例如,字符串“淘!我喜歡!”變成了“????????????”編碼過程如下圖所示

 

這種情況比較複雜,中文經過多次編碼,但是其中有一次編碼或者解碼不對仍然會出現中文字符變成“?”現象,出現這種情況要仔細查看中間的編碼環節,找出出現編碼錯誤的地方。

一種不正常的正確編碼

還有一種情況是在我們通過 request.getParameter 獲取參數值時,當我們直接調用

1 String value = request.getParameter(name);

 

會出現亂碼,但是如果用下面的方式

1 String value = String(request.getParameter(name).getBytes("ISO-8859-1"), "GBK"); 

 

解析時取得的 value 會是正確的漢字字符,這種情況是怎麼造成的呢?

看下如所示:

 

這種情況是這樣的,ISO-8859-1 字符集的編碼範圍是 0000-00FF,正好和一個字節的編碼範圍相對應。這種特性保證了使用 ISO-8859-1 進行編碼和解碼可以保持編碼數值“不變”。雖然中文字符在經過網絡傳輸時,被錯誤地“拆”成了兩個歐洲字符,但由於輸出時也是用 ISO-8859-1,結果被“拆”開的中文字的兩半又被合併在一起,從而又剛好組成了一個正確的漢字。雖然最終能取得正確的漢字,但是還是不建議用這種不正常的方式取得參數值,因爲這中間增加了一次額外的編碼與解碼,這種情況出現亂碼時因爲 Tomcat 的配置文件中 useBodyEncodingForURI 配置項沒有設置爲”true”,從而造成第一次解析式用 ISO-8859-1 來解析才造成亂碼的。

編碼方案

  1. ASCII
  2. ISO 8859-1或Windows-1252
  3. 中文的GB2312,GBK,GB18030和Big5
  4. Unicode

上面四種方案,其中第三和第四種都可以用來表示漢字,後面分別來看看不同的字符方案佔用的存儲空間大小。

所以,會發現使用不同的編碼方案,就會有不同的佔用空間大小。

參考:https://www.jianshu.com/p/44340006ae0d

參考:http://www.cnblogs.com/lslk89/p/6898526.html

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章