早期,互聯網尚未發展起來,計算機僅用於處理一些本地的資料,因此不少國家和地區針對本土的語言設計了編碼方案,這種與區域相關的編碼統稱爲ANSI編碼(由於都是對ANSI-ASCII碼的擴展)。可是他們沒有事先商量好怎麼相互兼容,而是本身搞本身的,這樣就埋下了編碼衝突的禍根,好比大陸使用的GB2312編碼與臺灣使用的Big5編碼就有衝突,一樣的兩個字節,在兩種編碼方案裏表示的是不一樣的字符,隨着互聯網的興起,一個文檔裏常常會包含多種語言,計算機在顯示的時候就遇到麻煩了,由於它不知道這兩個字節到底屬於哪一種編碼。java
這樣的問題在世界上廣泛存在,所以從新定義一個通用的字符集,爲世界上全部字符進行統一編號的呼聲不斷高漲。程序員
由此Unicode碼應運而生,它爲世界上全部字符進行了統一編號,因爲它能夠惟一標識一個字符,因此字體也只須要針對Unicode碼進行設計就好了。但Unicode標準定義的是一個字符集,而沒有規定編碼方案,也就是說它僅僅定義了一個個抽象的數字與其對應的字符,而沒有規定具體怎麼存儲一串Unicode數字,真正規定怎麼存儲的是UTF-八、UTF-1六、UTF-32等方案,因此帶有UTF開頭的編碼,都是能夠直接經過計算和Unicode數值(Code Point,代碼點)進行轉換的。顧名思義,UTF-8就是8位長度爲基本單位編碼,它是變長編碼,用1~6個字節來編碼一個字符(由於受Unicode範圍的約束,因此實際最大隻有4字節);UTF-16是16位爲基本單位編碼,也是變長編碼,要麼2個字節要麼4個字節;UTF-32則是定長的,固定4字節存儲一個Unicode數。編程
其實我之前一直對Unicode有點誤解,在個人印象中Unicode碼最大隻能到0xFFFF,也就是最多隻能表示 2^16 個字符,在仔細看了維基百科以後才明白,早期的UCS-2編碼方案確實是這樣,UCS-2固定使用兩個字節來編碼一個字符,所以它只能編碼BMP(基本多語言平面,即0×0000-0xFFFF,包含了世界上最經常使用的字符)範圍內的字符。爲了要編碼Unicode大於0xFFFF的字符,人們對UCS-2編碼進行了拓展,創造了UTF-16編碼,它是變長的,在BMP範圍內,UTF-16與UCS-2徹底一致,而BMP以外UTF-16則使用4個字節來存儲。框架
爲了方便下面的描述,先交代一下代碼單元(Code Unit)的概念,某種編碼的基本組成單位就叫代碼單元,好比UTF-8的代碼單元爲1個字節,UTF-16的代碼單元爲2個字節,很差解釋,可是很好理解。編程語言
爲了兼容各類語言以及更好的跨平臺,Java String保存的就是字符的Unicode碼。它之前使用的是UCS-2編碼方案來存儲Unicode,後來發現BMP範圍內的字符不夠用了,可是出於內存消耗和兼容性的考慮,並無升到UCS-4(即UTF-32,固定4字節編碼),而是採用了上面所說的UTF-16,char類型可看做其代碼單元。這個作法致使了一些麻煩,若是全部字符都在BMP範圍內還沒事,如有BMP外的字符,就再也不是一個代碼單元對應一個字符了,length方法返回的是代碼單元的個數,而不是字符的個數,charAt方法返回的天然也是一個代碼單元而不是一個字符,遍歷起來也變得麻煩,雖然提供了一些新的操做方法,總歸仍是不方便,並且還不能隨機訪問。測試
此外,我發現Java在編譯的時候還不會處理大於0xFFFF的Unicode字面量,因此若是你敲不出某個非BMP字符來,可是你知道它的Unicode碼,得用一個比較笨的方法來讓String存儲它:手動計算出該字符的UTF-16編碼(四字節),把前兩個字節和後兩個字節各做爲一個Unicode數,而後賦值給String,示例代碼以下所示。字體
public static void main(String[] args) { //String str = ""; //咱們想賦值這樣一個字符,假設我輸入法打不出來 //但我知道它的Unicode是0x1D11E //String str = "\u1D11E"; //這樣寫不會識別 //因而經過計算獲得其UTF-16編碼 D834 DD1E String str = "\uD834\uDD1E"; //而後這麼寫 System.out.println(str); //成功輸出了"" }
Windows系統自帶的記事本能夠另存爲Unicode編碼,實際上指的是UTF-16編碼。上面說了,主要使用的字符編碼都在BMP範圍內,而在BMP範圍內,每一個字符的UTF-16編碼值與對應的Unicode數值是相等的,這大概就是微軟把它稱爲Unicode的緣由吧。舉個例子,我在記事本中輸入了」好a「兩個字符,而後另存爲Unicode big endian(高位優先)編碼,用WinHex打開文件,內容以下圖,文件開頭兩個字節被稱爲Byte Order Mark(字節順序標記),(FE FF)標識字節序爲高位優先,而後(59 7D)正是」好「的Unicode碼,(00 61)正是」a「的Unicode碼。編碼
有了Unicode碼,也還不能當即解決問題,由於首先世界上已經存在了大量的非Unicode標準的編碼數據,咱們不可能丟棄它們,其次Unicode的編碼每每比ANSI編碼更佔空間,因此從節約資源的角度來講,ANSI編碼仍是有存在的必要的。因此須要創建一個轉換機制,使得ANSI編碼能夠轉換到Unicode進行統一處理,也能夠把Unicode轉換到ANSI編碼以適應平臺的要求。spa
轉換方法提及來比較容易,對於UTF系列或者是ISO-8859-1這種被兼容的編碼,能夠經過計算和Unicode數值直接進行轉換(實際可能也是查表),而對於系統遺留下來的ANSI編碼,則只能經過查表的方式進行,微軟把這種映射表稱爲Code Page(代碼頁),並按編碼進行分類編號,好比咱們常見的cp936就是GBK的代碼頁,cp65001就是UTF-8的代碼頁。下圖是微軟官網查到的GBK->Unicode映射表(目測不全),同理還應有反向的Unicode->GBK映射表。
有了代碼頁,就能夠很方便的進行各類編碼轉換了,好比從GBK轉換到UTF-8,只須要先按照GBK的編碼規則對數據按字符劃分,用每一個字符的編碼數據去查GBK代碼頁,獲得其Unicode數值,再用該Unicode去查UTF-8的代碼頁(或直接計算),就能夠獲得對應的UTF-8編碼。反過來同理。注意:UTF-8是Unicode的標準實現,它的代碼頁中包含了全部的Unicode取值,因此任意編碼轉換到UTF-8,再轉換回去都不會有任何丟失。至此,咱們能夠得出一個結論就是,要完成編碼轉換工做,最重要的是第一步要成功的轉換到Unicode,因此正確選擇字符集(代碼頁)是關鍵。
理解了轉碼丟失問題的本質後,我才忽然明白JSP的框架爲何要以ISO-8859-1去解碼HTTP請求參數,致使咱們獲取中文參數的時候不得不寫這樣的語句:
String param = new String(s.getBytes("iso-8859-1"), "UTF-8");
由於JSP框架接收到的是參數編碼的二進制字節流,它不知道這到底是什麼編碼(或者不關心),也就不知道該查哪一個代碼頁去轉換到Unicode。而後它就選擇了一種絕對不會產生丟失的方案,它假設這是ISO-8859-1編碼的數據,而後查ISO-8859-1的代碼頁,獲得Unicode序列,由於ISO-8859-1是按字節編碼的,並且不一樣於ASCII的是,它對0 ~ 255空間的每一位都進行了編碼,因此任意一個字節都能在它的代碼頁中找到對應的Unicode,若再從Unicode轉回原始字節流的話也就不會有任何丟失。它這樣作,對於不考慮其餘語言的歐美程序員來講,能夠直接用JSP框架解碼好的String,而要兼容其餘語言的話也只須要轉回原始字節流,再以實際的代碼頁去解碼一下就好。
我對Unicode以及字符編碼的相關概念闡述完畢,接下來用Java實例來感覺一下。
String的構造方法就是把各類編碼數據轉換到Unicode序列(以UTF-16編碼存儲),下面這段測試代碼,用來展現Java String構造方法的應用,實例中都不涉及非BMP字符,因此就不用codePointAt那些方法了。
public class Test { public static void main(String[] args) throws IOException { //"你好"的GBK編碼數據 byte[] gbkData = {(byte)0xc4, (byte)0xe3, (byte)0xba, (byte)0xc3}; //"你好"的BIG5編碼數據 byte[] big5Data = {(byte)0xa7, (byte)0x41, (byte)0xa6, (byte)0x6e}; //構造String,解碼爲Unicode String strFromGBK = new String(gbkData, "GBK"); String strFromBig5 = new String(big5Data, "BIG5"); //分別輸出Unicode序列 showUnicode(strFromGBK); showUnicode(strFromBig5); } public static void showUnicode(String str) { for (int i = 0; i < str.length(); i++) { System.out.printf("\\u%x", (int)str.charAt(i)); } System.out.println(); } }
運行結果以下圖
從結果能夠發現,只要指定了正確的字符集(代碼頁),String就能夠解碼出正確的Unicode,最後能夠試試println(「\u4f60\u597d」),輸出的就是「你好」。
String擁有了Unicode序列,想要轉換到其它編碼就易如反掌了,根據你參數指定的字符集,去相應的代碼頁查找就能夠轉換過去了,固然若是該字符集不支持某字符(也就是沒有這條Unicode記錄),那就會致使編碼丟失,不再能還原到原來的Unicode序列了。
這裏,咱們和第1節的作法相反,咱們把Unicode序列轉換到其它各類編碼,以下所示。
public class Test { public static void main(String[] args) throws IOException { //字符串"你好" String str = "\u4f60\u597d"; //轉換到各類編碼 showBytes(str, "GBK"); showBytes(str, "BIG5"); showBytes(str, "UTF-8"); } public static void showBytes(String str, String charset) throws IOException { for (byte b : str.getBytes(charset)) System.out.printf("0x%x ", b); System.out.println(); } }
運行結果以下圖
能夠發現,因爲String掌握了Unicode碼,要轉換到其它編碼so easy!
有了上面兩部分的基礎,要實現編碼互轉就很簡單了,只須要把他們聯合使用就能夠了。先new String把原編碼數據轉換爲Unicode序列,再調用getBytes轉到指定的編碼就OK。
好比一個很簡單的GBK到Big5的轉換代碼以下
public static void main(String[] args) throws UnsupportedEncodingException { //假設這是以字節流方式從文件中讀取到的數據(GBK編碼) byte[] gbkData = {(byte) 0xc4, (byte) 0xe3, (byte) 0xba, (byte) 0xc3}; //轉換到Unicode String tmp = new String(gbkData, "GBK"); //從Unicode轉換到Big5編碼 byte[] big5Data = tmp.getBytes("Big5"); //後續操做…… }
上面已經解釋了,JSP框架採用ISO-8859-1字符集來解碼的緣由。先用一個例子來模擬這個還原過程,代碼以下
public class Test { public static void main(String[] args) throws UnsupportedEncodingException { //JSP框架收到6個字節的數據 byte[] data = {(byte) 0xe4, (byte) 0xbd, (byte) 0xa0, (byte) 0xe5, (byte) 0xa5, (byte) 0xbd}; //打印原始數據 showBytes(data); //JSP框架假設它是ISO-8859-1的編碼,生成一個String對象 String tmp = new String(data, "ISO-8859-1"); //**************JSP框架部分結束******************** //開發者拿到後打印它發現是6個歐洲字符,而不是預期的"你好" System.out.println(" ISO解碼的結果:" + tmp); //所以首先要獲得原始的6個字節的數據(反查ISO-8859-1的代碼頁) byte[] utfData = tmp.getBytes("ISO-8859-1"); //打印還原的數據 showBytes(utfData); //開發者知道它是UTF-8編碼的,所以用UTF-8的代碼頁,從新構造String對象 String result = new String(utfData, "UTF-8"); //再打印,正確了! System.out.println(" UTF-8解碼的結果:" + result); } public static void showBytes(byte[] data) { for (byte b : data) System.out.printf("0x%x ", b); System.out.println(); } }
運行結果以下,第一次輸出是不正確的,由於解碼規則不對,也查錯了代碼頁,獲得的是錯誤的Unicode。而後發現經過錯誤的Unicode反查ISO-8859-1代碼頁還能完美的還原數據。
而後咱們嘗試把ISO-8859-1替換爲ASCII,結果就會變成這樣子
這是由於,ASCII雖然也是每字節對應一個字符,可是它只對0~127這個空間進行了編碼,也就是說每一個字節的最大值只能爲0x7F,而上面的6個字節所有都大於這個數值,所以在ASCII的代碼頁中是找不到這6個字節的,因而Java就搞了一個缺省值。我用以下的代碼測試發現,當經過編碼數據在代碼頁中查不到對應的Unicode時,就返回缺省值\ufffd(對應圖中第一種問號),反過來,當經過Unicode在代碼頁中查不到對應的編碼數據時,就返回缺省值0x3f(ASCII,對應圖中第二種問號)。由此,這個輸出結果也就能夠解釋清楚了。
public static void main(String[] args) throws IOException { //輸出結果全爲\ufffd byte[] data = {(byte) 0x80}; showUnicode(new String(data, "UTF-8")); showUnicode(new String(data, "GBK")); showUnicode(new String(data, "Big5")); //輸出結果全爲0x3f String str = "\uccdd"; showBytes(str, "GBK"); showBytes(str, "BIG5"); showBytes(str, "ISO-8859-1"); }
這就是開頭所提到的那個問題,把問題描述一下先。就以下這麼一小段代碼,源文件使用UTF-8編碼保存。(注意別用Windows的記事本,由於它會在UTF-8文件最前面加入一個3字節的BOM頭,而不少程序都不兼容這一點)
public class Test { public static void main(String[] args) { System.out.println("中"); } }
而後在Windows中使用默認參數編譯該文件(系統區域設置爲簡體中文,即默認使用GBK字符集解碼),而後會獲得以下錯誤
這不是重點,重點若是把「中」換成「中國」,編譯就會成功,運行結果以下圖。另外進一步可發現,中文字符個數爲奇數時編譯失敗,偶數時經過。這是爲何呢?下面詳細分析一下。
由於Java String內部使用的是Unicode,因此在編譯的時候,編譯器就會對咱們的字符串字面量進行轉碼,從源文件的編碼轉換到Unicode(維基百科說用的是與UTF-8稍微有點不一樣的編碼)。編譯的時候咱們沒有指定encoding參數,因此編譯器會默認以GBK方式去解碼,對UTF-8和GBK有點了解的應該會知道,通常一箇中文字符使用UTF-8編碼須要3個字節,而GBK只須要2個字節,這就能解釋爲何字符數的奇偶性會影響結果,由於若是2個字符,UTF-8編碼佔6個字節,以GBK方式來解碼剛好能解碼爲3個字符,而若是是1個字符,就會多出一個沒法映射的字節,就是圖中問號的地方。
再具體一點的話,源文件中「中國」二字的UTF-8編碼是 e4 b8 ad e5 9b bd,編譯器以GBK方式解碼,3個字節對分別查cp936獲得3個Unicode值,分別是6d93 e15e 6d57,對應結果圖中的三個奇怪字符。以下圖所示,編譯後這3個Unicode在.class文件中實際以類UTF-8編碼存儲,運行的時候,JVM中存儲的就是Unicode,然而最終輸出時,仍是會編碼以後傳遞給終端,此次約定的編碼就是系統區域設置的編碼,因此若是終端編碼設置改了,仍是會亂碼。咱們這裏的e15e在Unicode標準中並無定義相應的字符,因此在不一樣平臺不一樣字體下顯示會有所不一樣。
能夠想象,若是反過來,源文件以GBK編碼存儲,而後騙編譯器說是UTF-8,那基本上是不管輸入多少箇中文字符都沒法編譯經過了,由於UTF-8的編碼頗有規律性,隨意組合的字節是不會符合UTF-8編碼規則的。
固然,要使編譯器能正確的把編碼轉換到Unicode,最直接的方法仍是老老實實告訴編譯器源文件的編碼是什麼。
通過此次收集整理和實驗,瞭解了不少與編碼相關的概念,也熟悉了編碼轉換的具體過程,這些思想能夠推廣到各類編程語言去,實現原理都相似,因此我想之後再遇到這類問題,應該不會再不知因此然了。