【Java基礎】讓編碼問題不再困惑你
- 2019 年 10 月 11 日
- 筆記
目錄
如果你是一個生活在2003年的程式設計師,卻不了解字元、字符集、編碼和Unicode這些基礎知識。那你可要小心了,要是被我抓到你,我會讓你在潛水艇里剝六個月洋蔥來懲罰你。 –來源網路
1. ASCII編碼
上個世紀60年代,美國制定了一套字元編碼,對英語字元與二進位位之間的關係,做了統一規定。這被稱為ASCII碼,一直沿用至今。ASCII碼一共規定了128個字元的編碼,比如空格"SPACE"是32(二進位00100000),大寫的字母A是65(二進位01000001)。這128個符號(包括32個不能列印出來的控制符號),只佔用了一個位元組的後面7位,最前面的1位統一規定為0。0~31 是控制字元如換行回車刪除等,32~126 是列印字元,可以通過鍵盤輸入並且能夠顯示出來。
英語用128個符號編碼就夠了,但是用來表示其他語言,128個符號是不夠的。比如,在法語中,字母上方有注音符號,它就無法用ASCII碼錶示。於是,一些歐洲國家就決定,利用位元組中閑置的最高位編入新的符號。比如,法語中的é的編碼為130(二進位10000010)。這樣一來,這些歐洲國家使用的編碼體系,可以表示最多256個符號。
但是,這裡又出現了新的問題。不同的國家有不同的字母,因此,哪怕它們都使用256個符號的編碼方式,代表的字母卻不一樣。比如,130在法語編碼中代表了é,在希伯來語編碼中卻代表了字母Gimel (ג),在俄語編碼中又會代表另一個符號。但是不管怎樣,所有這些編碼方式中,0—127表示的符號是一樣的,不一樣的只是128—255的這一段。
至於亞洲國家的文字,使用的符號就更多了,漢字就多達10萬左右。一個位元組只能表示256種符號,肯定是不夠的,就必須使用多個位元組表達一個符號。比如,簡體中文常見的編碼方式是GB2312,使用兩個位元組表示一個漢字,所以理論上最多可以表示65536個符號。
2. Unicode編碼
可以想像,如果有一種編碼,將世界上所有的符號都納入其中。每一個符號都給予一個獨一無二的編碼,那麼就不會出現上面的問題。Unicode編碼就是這樣一種編碼。
Unicode是一個很大的字符集合,現在的規模可以容納100多萬個符號。每個符號的編碼都不一樣,比如,U+0639表示阿拉伯字母Ain,U+0041表示英語的大寫字母A,U+4E25表示漢字「嚴」。
需要注意的是,Unicode只是一個符號集,它只規定了符號的二進位程式碼,卻沒有規定這個二進位程式碼應該如何存儲。這就造成了兩個問題:
- 第一個問題是,如何才能區別unicode和ascii?電腦怎麼知道三個位元組表示一個符號,而不是分別表示三個符號呢?
- 第二個問題是,我們已經知道,英文字母只用一個位元組表示就夠了,如果unicode統一規定,每個符號用三個或四個位元組表示,那麼每個英文字母前都必然有二到三個位元組是0,這對於存儲來說是極大的浪費,文本文件的大小會因此大出二三倍,這是無法接受的。
記住,Unicode只是一個用來映射字元和數字的標準。它對支援字元的數量沒有限制,也不要求字元必須佔兩個、三個或者其它任意數量的位元組。Unicode字元是怎樣被編碼成記憶體中的位元組這是另外的話題,它是被UTF(Unicode Transformation Formats)定義的。
3. UTF-8編碼
互聯網的普及,強烈要求出現一種統一的編碼方式。UTF-8就是在互聯網上使用最廣的一種unicode的實現方式。其他實現方式還包括UTF-16和UTF-32,不過在互聯網上基本不用。重複一遍,這裡的關係是,UTF-8是Unicode的實現方式之一。
UTF-8(8-bit Unicode Transformation Format)是一種針對Unicode的可變長度字元編碼,又稱萬國碼。由Ken Thompson於1992年創建。現在已經標準化為RFC 3629。UTF-8用1到4個位元組編碼Unicode字元。用在網頁上可以統一頁面顯示中文簡體繁體及其它語言(如英文,日文,韓文)。
UTF-8最大的一個特點,就是它是一種變長的編碼方式。它可以使用1~4個位元組表示一個符號,根據不同的符號而變化位元組長度(UTF-8編碼可以容納2^21個字元,總共200多萬個字元)。
UTF-8的編碼規則很簡單,只有二條:
-
對於單位元組的符號,位元組的第一位設為0,後面7位為這個符號的unicode碼。因此對於英語字母,UTF-8編碼和ASCII碼是相同的。
-
對於n位元組的符號(n>1),第一個位元組的前n位都設為1,第n+1位設為0,後面位元組的前兩位一律設為10。剩下的沒有提及的二進位位,全部為這個符號的unicode碼。
下表總結了編碼規則,字母x表示可用編碼的位。
Unicode符號範圍 | UTF-8編碼方式
UTF位元組數 (十六進位) | (二進位)
——————–+———————————————一個位元組 0000 0000-0000 007F | 0xxxxxxx
兩個位元組 0000 0080-0000 07FF | 110xxxxx 10xxxxxx
三個位元組 0000 0800-0000 FFFF | 1110xxxx 10xxxxxx 10xxxxxx
四個位元組 0001 0000-0010 FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
下面, 還是以漢字「嚴」為例,演示如何實現UTF-8編碼。
已知「嚴」的unicode是4E25(100111000100101),根據上表,可以發現4E25處在第三行的範圍內(0000 0800-0000 FFFF),因此「嚴」的UTF-8編碼需要三個位元組,即格式是「1110xxxx 10xxxxxx 10xxxxxx」。然後,從「嚴」的最後一個二進位位開始,依次從後向前填入格式中的x,多出的位補0。這樣就得到了,「嚴」的UTF-8編碼是「11100100 10111000 10100101」,轉換成十六進位就是E4B8A5。
4. UTF8、UTF16和UTF32之間的區別
首先我們要確定一個概念就是Unicode是一個字符集,這個字符集世界上所有的字元定義了一個唯一編碼。其僅僅規定了每個符號的二進位程式碼,沒有制定細化的存儲規則。UTF-8、UTF-16、UTF-32才是Unicode的存儲格式定義。(拿一個通訊中的列子做個對比,一個訊號(類比成Unicode編碼指),通過不同的編碼方式,會被編碼成不同的高低訊號)
4.1 UCS-2和UCS-4
Unicode是為整合全世界的所有語言文字而誕生的。任何文字在Unicode中都對應一個值, 這個值稱為程式碼點(code point)。程式碼點的值通常寫成 U+ABCD 的格式。而文字和程式碼點之間的對應關係就是UCS-2(Universal Character Set coded in 2 octets)。顧名思義,UCS-2是用兩個位元組來表示程式碼點,其取值範圍為 U+0000~U+FFFF。
為了能表示更多的文字,人們又提出了UCS-4,即用四個位元組表示程式碼點。它的範圍為 U+00000000~U+7FFFFFFF,其中 U+00000000~U+0000FFFF和UCS-2是一樣的。
要注意,UCS-2和UCS-4隻規定了程式碼點和文字之間的對應關係,並沒有規定程式碼點在電腦中如何存儲。規定存儲方式的稱為UTF(Unicode Transformation Format),其中應用較多的就是UTF-16和UTF-8了。
4.2 UTF-16
UTF-16由RFC2781規定,它使用兩個位元組來表示一個程式碼點。不難猜到,UTF-16是完全對應於UCS-2的,即把UCS-2規定的程式碼點通過Big Endian或Little Endian方式直接保存下來。UTF-16包括三種:UTF-16,UTF-16BE(Big Endian),UTF-16LE(Little Endian)。UTF-16BE和UTF-16LE不難理解,而UTF-16就需要通過在文件開頭以名為BOM(Byte Order Mark)的字元來表明文件是Big Endian還是Little Endian。BOM為U+FEFF這個字元。其實BOM是個小聰明的想法。由於UCS-2沒有定義U+FEFF,因此只要出現 FF FE 或者 FE FF 這樣的位元組序列,就可以認為它是U+FEFF,並且可以判斷出是Big Endian還是Little Endian。
BOM(Byte Order Mark)用來放在文檔的開頭告訴閱讀器該文檔的位元組序。UTF-8不需要BOM來表明位元組順序,但可以用BOM來表明編碼方式。字元"ZERO WIDTH NO-BREAK SPACE"的UTF-8編碼是EF BB BF。所以如果接收者收到以EF BB BF開頭的位元組流,就知道這是UTF-8編碼了。UTF-16才需要加bom。因為它是按unicode順序編碼,在BMP範圍內是二位元組,需要識別是大或小位元組序。
低位元組序(Little Endian)和高位元組序(Big Endian)
低位元組序和高位元組序只是一個關於在記憶體中存儲和讀取一段位元組(被稱作words)的約定。這意味著當你讓電腦用UTF-16把字母A(佔兩個位元組)存在記憶體中時,使用哪種位元組序方案決定了你把第一個位元組放在第二個位元組的前面還是後面。這麼說有點不太容易懂,讓我們來看一個例子:當你使用UTF-16存下某段內容時,在不同的系統中它的後半部分可能是這樣的:
00 68 00 65 00 6C 00 6C 00 6F(高位元組序,高位位元組被存在前面)
68 00 65 00 6C 00 6C 00 6F 00(低位元組序,低位位元組被存在前面)位元組序方案只是一個微處理器架構設計者的偏好問題,例如,Intel使用低位元組序,Motorola使用高位元組序。
舉個例子。「ABC」這三個字元用各種方式編碼後的結果如下:
編碼類型 | 碼值 |
---|---|
UTF-16BE | 00 41 00 42 00 43 |
UTF-16LE | 41 00 42 00 43 00 |
UTF-16(Big Endian) | FE FF 00 41 00 42 00 43 |
UTF-16(Little Endian) | FF FE 41 00 42 00 43 00 |
UTF-16(不帶BOM) | 00 41 00 42 00 43 |
4.3 UTF-32
UTF-32用四個位元組表示程式碼點,這樣就可以完全表示UCS-4的所有程式碼點,而無需像UTF-16那樣使用複雜的演算法。 與UTF-16類似,UTF-32也包括UTF-32、UTF-32BE、UTF-32LE三種編碼,UTF-32也同樣需要BOM字元。
4.4 文本編輯器怎麼知道文本的編碼
當一個軟體打開一個文本時,它要做的第一件事是決定這個文本究竟是使用哪種字符集的哪種編碼保存的。軟體一般採用三種方式來決定文本的字符集和編碼:
-
檢測文件頭標識(BOM)
EF BB BF UTF-8
FE FF UTF-16/UCS-2, big endian
FF FE UTF-16/UCS-2, little endian
FF FE 00 00 UTF-32/UCS-4, little endian.
00 00 FE FF UTF-32/UCS-4, big-endian. -
軟體自己根據編碼規則猜測當前文件的編碼
-
提示用戶自己輸入當前文件的編碼
5. GBK、GB2312和GB18030之間的區別
GB2312是對ASCll碼的擴展,佔用兩個位元組。一個小於127的字元的意義與原來相同,但兩個大於127的字元連在一起時,就表示一個漢字,前面的一個位元組(他稱之為高位元組)從0xA1用到0xF7,後面一個位元組(低位元組)從0xA1到0xFE,這樣我們就可以組合出大約7000多個簡體漢字了。在這些編碼里,我們還把數學符號、羅馬希臘的字母、日文的假名們都編進去了,連在 ASCII 里本來就有的數字、標點、字母都統統重新編了兩個位元組長的編碼,這就是常說的"全形"字元,而原來在127號以下的那些就叫"半形"字元了。
GB2312能表示的字元還是不夠用,於是GBK出現了。GBK是對GB1212的擴展,也是佔用2個位元組,GBK不再要求低位元組一定是127號之後的內碼,只要第一個位元組是大於127就固定表示這是一個漢字的開始,不管後面跟的是不是擴展字符集里的內容。結果擴展之後的編碼方案被稱為 GBK 標準,GBK 包括了 GB2312 的所有內容,同時又增加了近20000個新的漢字(包括繁體字)和符號。
GB18030採用變長編碼,可以是1個位元組、2個位元組和4個位元組。是對GB2312和GBK的擴展,完全兼容兩者。
經過上面介紹,我們可以看出Unicode是一個世界標準,針對世界上所有語言符號制定編碼表,而GBK、GB2312等則主要是針對中國的字元進行編碼。
6. Java中的編碼問題
我們知道涉及到編碼的地方一般都在字元到位元組或者位元組到字元的轉換上,而需要這種轉換的場景主要是在 I/O 的時候,這個 I/O 包括磁碟 I/O 和網路 I/O。而大部分 I/O 引起的亂碼都是網路 I/O。
用戶從瀏覽器端發起一個 HTTP 請求,需要存在編碼的地方是 URL、Cookie、Parameter。伺服器端接受到 HTTP 請求後要解析 HTTP 協議,其中 URI、Cookie 和 POST 表單參數需要解碼,伺服器端可能還需要讀取資料庫中的數據,本地或網路中其它地方的文本文件,這些數據都可能存在編碼問題,當 Servlet 處理完所有請求的數據後,需要將這些數據再編碼通過 Socket 發送到用戶請求的瀏覽器里,再經過瀏覽器解碼成為文本。這些過程如下圖所示:
如上圖所示一次 HTTP 請求設計到很多地方需要編解碼,它們編解碼的規則是什麼?下面將會重點闡述一下:
URL 的編解碼
用戶提交一個 URL,這個 URL 中可能存在中文,因此需要編碼,如何對這個 URL 進行編碼?根據什麼規則來編碼?有如何來解碼?如下圖一個 URL:
Port 對應在 Tomcat 的
<servlet-mapping> <servlet-name>junshanExample</servlet-name> <url-pattern>/servlets/servlet/*</url-pattern> </servlet-mapping>
上圖中 PathInfo 和 QueryString 出現了中文,當我們在瀏覽器中直接輸入這個 URL 時,在瀏覽器端和服務端會如何編碼和解析這個 URL 呢?為了驗證瀏覽器是怎麼編碼 URL 的我們選擇 FireFox 瀏覽器並通過 HTTPFox 插件觀察我們請求的 URL 的實際的內容,以下是 URL:HTTP://localhost:8080/examples/servlets/servlet/君山?author= 君山 在中文 FireFox3.6.12 的測試結果:
君山的編碼結果分別是:e5 90 9b e5 b1 b1,be fd c9 bd,查閱上一屆的編碼可知,PathInfo 是 UTF-8 編碼而 QueryString 是經過 GBK 編碼,至於為什麼會有「%」?查閱 URL 的編碼規範 RFC3986 可知瀏覽器編碼 URL 是將非 ASCII 字元按照某種編碼格式編碼成 16 進位數字然後將每個 16 進位表示的位元組前加上「%」,所以最終的 URL 就成了上圖的格式了。
從上面測試結果可知瀏覽器對 PathInfo 和 QueryString 的編碼是不一樣的,不同瀏覽器對 PathInfo 也可能不一樣,這就對伺服器的解碼造成很大的困難,下面我們以 Tomcat 為例看一下,Tomcat 接受到這個 URL 是如何解碼的。
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的
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 的
從上面的 URL 編碼和解碼過程來看,比較複雜,而且編碼和解碼並不是我們在應用程式中能完全控制的,所以在我們的應用程式中應該盡量避免在 URL 中使用非 ASCII 字元,不然很可能會碰到亂碼問題,當然在我們的伺服器端最好設置
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 的 中的 charset 來解碼。如果也沒有定義的話,那麼瀏覽器將使用默認的編碼來解碼。
其他需要注意編碼的地方
除了 URL 和參數編碼問題外,在服務端還有很多地方可能存在編碼,如可能需要讀取 xml、velocity 模版引擎、JSP 或者從資料庫讀取數據等。
xml 文件可以通過設置頭來制定編碼格式
<?xml version="1.0" encoding="UTF-8"?>
Velocity 模版設置編碼格式:
services.VelocityService.input.encoding=UTF-8
JSP 設置編碼格式:
<%@page contentType="text/html; charset=UTF-8"%>
訪問資料庫都是通過客戶端 JDBC 驅動來完成,用 JDBC 來存取數據要和數據的內置編碼保持一致,可以通過設置 JDBC URL 來制定如 MySQL:
url="jdbc:mysql://localhost:3306/DB?useUnicode=true&characterEncoding=GBK"
8. 亂碼問題分析
下面看一下,當我們碰到一些亂碼時,應該怎麼處理這些問題?出現亂碼問題唯一的原因都是在 char 到 byte 或 byte 到 char 轉換中編碼和解碼的字符集不一致導致的,由於往往一次操作涉及到多次編解碼,所以出現亂碼時很難查找到底是哪個環節出現了問題。根據自己的經驗,往往從最源頭開始一步步查原因是最快的。
9. 參考
為什麼Java最多只能標識65535個字元
Unicode本身只是一個標準,不是具體實現,並沒有限定位元組數。目前用於實用的 Unicode 版本對應於 UCS-2,使用16位的編碼空間,因此最大能表示65535個字元。Unicode是發展的,6萬個確實不夠,事實上現在的Unicode已經支援超過10萬個字元(第10萬個於2005年被採納,為馬來亞拉姆語。當前的Unicode版本為6.3,2013年9月30日制定。Java中使用的仍是UCS-2。