前言
在日常開發中,亂碼問題可以說曾經都困擾過我們,那么為什么會有亂碼發生呢?為什么全世界不統一使用一套編碼呢?本文將會從字符集的發展歷史來解答這兩個問題,看完本篇,相信大家對亂碼現象會有本質上的認識。
一個故事來理解為什么要編碼
現在有兩個人,張三和李四,張三只會中文,李四只會英文,那么這時候他們怎么溝通?解決辦法是他們可以找個翻譯,這個翻譯的過程就可以理解為編碼,也就是說從中文到英文或者從英文到中文這就是一個編碼的過程,編碼的本質就是為了讓對方能讀懂自己的語言。
人類的各種官方語言和方言數不勝數,所以在應用到在計算機時總不能兩兩互相編碼吧?而且最重要的是人類的語言並不適合計算機使用,所以就需要發明一種適合計算機的語言,這就是二進制。二進制就是當今世界計算機的語言,當然,曾經前蘇聯也發明過三進制計算機,但是沒有普及,這個感興趣的可以自己去了解下。
有了二進制這種計算機能讀懂的語言就好辦了,當我們想和計算機溝通的時候,先轉成二進制(編碼),計算機處理完成之后,再轉換回人類語言(解碼),這就是需要編碼的原因。
為什么會亂碼
但是為什么會亂碼呢?還是用上面的故事中張三李四來舉例,假如有一次張三說了一個生僻詞,然后翻譯從來沒見過這個詞,這時候翻譯就不知道怎么翻譯了,沒有辦法,就直接翻譯成了 ??
,也就是亂碼了。
在計算機的世界也是同理,比如我們想從一個程序 A
發送 雙子孤狼
四個字到另一個程序 B
,這時候計算機數據傳輸的時候會轉成二進制,傳輸過去之后,因為二進制不適合人類閱讀,所以 B
就需要進行解碼,可是現在 B
並不知道 A
用的是什么語言進行的編碼,所以就胡亂用英文進行解碼,解碼出來的字符英文肯定是不存在的,也就是在英文字符集里面找不到 雙子孤狼
這個單詞,這時候就會發生亂碼。
所以亂碼的本質其實就是當前編碼無法解析接收到的二進制數據。
字符集的歷史
知道了為什么要編碼以及亂碼的原因之后,不禁又有另一個疑問了,如果說全世界都統一用一種編碼,那在正常情況下也就沒有亂碼問題了,可是現實情況卻是各種編碼猶如八仙過海各顯神通,整的我們程序員頭暈腦脹,一不留神亂碼就出來了。不過要回答這個問題那么就需要了解一下字符集的發展歷史了。
ASCII 編碼的誕生
計算機最開始誕生於美國,而且計算機只能識別二進制,所以我們就需要把常用語言和二進制關聯起來。美國人把英文里面常用的字符以及一些控制字符轉換成了二進制數據,比如我們耳熟能詳的小寫字母 a
,對應的十進制是 97
,二進制就是 01100001
。而一個字節有 8
位,即最大能表示 255
個字符,但是英語的常用字符比較少,常用的字母以及一些常用符號列出來就是 128
個,所以美國人就占用了這 0-127
的位置,形成了一個編碼對應關系表,這就是 ASCII
(American Standard Code for Information Interchange,美國標准信息交換碼) 編碼,ASCII
編碼表的對應關系如果大家想知道的可以自己去查一下,這里就不列舉了。
IOS-8859 編碼家族誕生
隨着計算機的普及,計算機傳到了歐洲,這時候發現歐洲的常用字符也需要進行編碼,於是國際標准化組織(ISO)及國際電工委員會(IEC)決定聯合制定另一套字符集標准。於是 ISO-8859-1
字符集就誕生了。
因為 ASCII
只用到了 0-127
個位置,另外 128-255
的位置並沒有被占用(也就是一個字節的最高位並沒有被使用),於是歐洲人就把第 8
位利用了起來,從此 這128-255
就被西歐常用字符占用了,ISO-8859-1
字符也叫做 Latin1
編碼。
慢慢的,隨着時間的推移,歐洲越來越多國家的字符需要編碼,所以就衍生了一系列的字符集,從 ISO-8859-1
到 ISO-8859-16
經過了一系列的微調,但是這些都屬於 ISO-8859
標准。
需要注意的是,ISO-8859
標准是向下兼容 ASCII
字符集的,所以平常我們見到的許多場景下默認都是用的 ISO-8859-1
編碼比較多,而不會直接使用 ASCII
編碼。
GB2312 和 GBK 等雙字節編碼誕生
慢慢的,隨着時間的推移,計算機傳到了亞洲,傳到了中國以及其他國家,這時候許多國家都針對自己國家的常用文字制定了自己國家的編碼,中國也不例外。
但是這個時候卻發現,一個字節的 8
位已經全部被占用了,於是只能再擴展一個字節,也就是用 2
個字節來存儲。但是兩個字節來存儲又有一個問題,那就是比如我讀取了兩個字節出來,這兩個字節到底是表示兩個單字節字符還是表示的是雙字節的中文呢?
於是我們偉大的中國人民就決定制定一套中文編碼,用來兼容 ASCII
,因為 ASCII
編碼中的單字節字符一定是小於 128
的,所以最后我們就決定,中文的雙字節字符都從 128
之后開始,也就是當發現字符連續兩位都大於 128
時,就說明這是一個中文,指定了之后我們就把這種編碼方式稱之為 GB2312
編碼。
需要注意的是 GB2312
並不兼容 ISO-8859-n
編碼集,但是兼容 ASCII
編碼。
GB2312
編碼收錄了常用的漢字 6763
個和非漢字圖形字符 682
(包括拉丁字母、希臘字母、日文平假名及片假名字母、俄語西里爾字母在內的全角字符)個。
隨着計算機的更進一步普及,GB2312
也暴露出了問題,那就是 GB2312
中收錄的中文漢字都是簡體字和常用字,對於一些生僻字以及繁體字沒有收錄,於是乎 GBK
出現了。
GB2312
編碼因為兩個字節采用的都是高位,就算全部對應上,最大也只能存儲 16384
個漢字,而我國漢字如果加上繁體字和生僻字是遠遠不夠的,於是 GBK
的做法就是只要求第一位是大於 128
,第二位可以小於 128
,這就是說只要發現一個字節大於 128
,那么緊隨其后的一個字節就是和其作為一個整體作為中文字符,這樣最多就能存儲 32640
個漢字了。當然,GBK
並沒有全部用完,GBK
共收入 21886
個漢字和圖形符號,其中漢字(包括部首和構件)21003
個,圖形符號 883
個。
后面隨着計算機的再進一步普及,我們也慢慢擴展了其他的中文字符集,比如 GB18030
等,但是這些都屬於雙字節字符。
到這里希望大家明白,為什么英文是一個字符,中文是兩個甚至更多字符了。一個原因就是低位被用了,另一個就是常用中文字符太多了,一個字節是遠遠存不完的。
Unicode 字符誕生
其實計算機在發展過程中,不單單是美國,歐洲和中國,其他許多國家都有自己的字符,比如日本,韓國等都有自己的字符集,可以說很混亂,於是有關部門看不下去了,決定結束這種世界大戰的混亂局面,重新制定另一套字符標准,這就是 Unicode
。
從一出生開始,Unicode
就覺得除了自己,其他各位都是渣渣。所以它壓根就沒准備兼容其他編碼,直接另起爐灶來了一套標准。Unicode
字符最開始采用的是 UCS-2
標准,UCS-2
標准規定一個字符至少使用 2
個字節來表示。當然,2
個字節即使全被利用也只能存儲 65536
個字符,這肯定容納不了世界上所有的語言和符號以及控制字符,所以后面又有了 UCS-4
標准,可以用 4
個字節來存儲一個字符,四個字節來存儲全世界所有語言文字和控制字符是基本沒有問題了。
需要注意的是:Unicode
編碼只是定義了字符集,對於字符集具體應該如何存儲並沒有做要求。站在我們開發的角度,相當於 Unicode
只定義了接口,但是沒有具體的實現。
UTF 編碼家族誕生
UTF
系列編碼就是對 Unicode
字符集的實現,只不過實現的方式有所區別,其中主要有:UTF-8
,UTF-16
,UTF-32
等類型。
UTF-32 編碼
UTF-32
編碼基本按照 Unicode
字符集標准來實現,任何一個符號都占用 4
個字節。可以想象,這會浪費多大空間,對英文而言,空間擴大了四倍,中文也擴大了兩倍,所以這種編碼方式也導致了 Unicode
在最初並沒有被大家廣泛的接受。
UTF-16 編碼
UTF-16
編碼相比較 UTF-32
做了一點改進,其采用 2
個字節或者 4
個字節來存儲。大部分情況下 UTF-16
編碼都是采用 2
個字節來存儲,而當 2
個字節存儲時,UTF-16
編碼會將 Unicode
字符直接轉成二進制進行存儲,對於另外一些生僻字或者使用較少的符號,UTF-16
編碼會采用 4
個字節來存儲,但是采用四個字節存儲時需要做一次編碼轉換。
下表就是 UTF-16
編碼的存儲格式:
Unicode 編碼范圍(16 進制) | UTF-16 編碼的二進制存儲格式 |
---|---|
0x0000 0000 - 0x0000 FFFF | xxxxxxxx xxxxxxxx |
0x0001 0000 - 0x0010 FFFF | 110110xx xxxxxxxx 110111xx xxxxxxxx |
這個表先不解釋,后面解釋 UTF-8
編碼時會一起說明。
UTF-8 編碼
UTF-8
是一種變長的編碼,兼容了 ASCII
編碼,為了實現變長這個特性,那么就必須要有一個規范來規定存儲格式,這樣當程序讀了 2
個或者多個字節時能解析出這到底是表示多個單字節字符還是一個多字節字符。
UTF-8
編碼的存儲規范如下表所示:
Unicode 編碼范圍(16 進制) | UTF-8 編碼的二進制存儲格式 |
---|---|
0x0000 0000 - 0x0000 007F | 0xxxxxxx |
0x0000 0080 - 0x0000 07FF | 110xxxxx 10xxxxxx |
0x0000 0800 - 0x0000 FFFF | 1110xxxx 10xxxxxx 10xxxxxx |
0x0001 0000 - 0x0010 FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
接下來我們以 雙
字為例來進行說明:
雙:對應的 Unicode
編碼為 \u53cc
,轉成二進制就是:101001111001100
,這時候表格中的第一行只有 7
位存不下去,第二列也只有 11
位,也不夠存儲,所以只能存儲到第三列,第三列有 16
位,從后往前依次填補 x
的位置,填完之后還有一位空余,直接補 0
,最終得到:11100101 10001111 10001100,所以雙
字就占用了 3
個字節,當然,有些生僻字會占用到四個字節。
所以上面的 UTF-16
編碼也是同理,如果當前字符采用的是兩字節存儲,那么直接轉成二進制存儲即可,位數不足直接補 0
即可,而當采用 4
個字節存儲時,則需要和 UTF-8
一樣進行一次轉換,也就是說只能將其填充到 x
的位置,x
之外的是固定格式。
需要注意的是:在 UTF-16
編碼中,2
個字節也可能出現 4
字節中 110110xx
或者 110111xx
開頭的格式,這兩部分對應的區間分別是:D800~DBFF
和 DC00~DFFF
,所以為了避免這種歧義的發生,這兩部分區間是是專門空出來的,沒有進行編碼。
為什么有時候亂碼都是 ? 號
在 Java
開發中,經常會碰到亂碼顯示為 ?
號,比如下面這個例子:
String name = "雙子孤狼";
byte[] bytes = name.getBytes(StandardCharsets.ISO_8859_1);
System.out.println(new String(bytes));//輸出:????
這個輸出結果的原因是中文無法用 ISO_8859_1
編碼進行存儲,而示例中卻強制用 ISO_8859_1
編碼進行解碼。
在 Java
中提供了一個 ISO_8859_1
類用來解碼,解碼時當發現當前字符轉成十進制之后大於 255
時就會直接不進行解碼,轉而直接賦一個默認值 63
,所以上面的示例中的 byte
數組結果就是 63 63 63 63
,而63
在 ASCII
中就恰好就對應了 ?
號。
所以一般我們看到編碼出現 ?
基本就說明當前是采用 ISO_8859_1
進行的解碼,而當前的字符又大於 255
。
拓展知識
了解了編碼發展歷史之后,接下來就讓我們一起了解下其他和編碼相關的題外話。
代碼點和代碼單元
在 Java
中的字符串是由 char
序列組成,而 char
又是采用 UTF-16
表示的 Unicode
代碼點的代碼單元。這句話里面涉及到了代碼點和代碼單元,初次接觸的朋友可能會有點迷惑,但是了解了 Unicode
字符集標准和 UTF-16
的編碼方式之后就比較好理解。
- 代碼點:一個代碼點等同於一個
Unicode
字符。 - 代碼單元:在
UTF-16
中,兩個字節表示一個代碼單元,代碼單元是最小的不可拆分的部分,所以如果在UTF-8
中,一個代碼單元就是一個字節,因為UTF-8
中可以用一個字節表示一個字符。
平常我們調用字符串的 length()
方法,返回的就是代碼單元數量,而不是代碼點數量,所有如果碰到一些需要用 4
個字節來表示的繁體字,那么代碼單元數就會小於代碼點數,而想要獲取代碼點數量,可以通過其他方法獲取,獲取方式如下:
String name = "𤭢";//\uD852\uDF62
System.out.println(name.length());//代碼單元數,輸出2
System.out.println(name.codePointCount(0, name.length()));//代碼點數,輸出1
大端模式和小端模式
在計算機中,數據的存儲是以字節為單位的,那么當一個字符需要使用多個字節來表示的時候,就會產生一個問題,那就是多字節字符應該從前往后組合還是從后往前組合。
還是以 雙
字為例,轉成二進制為:0101001111001100
,以一個字節為單位,就可以拆分成:01010011
和 11001100
,其中第一部分就稱之為高位字節,第二部分就稱之為低位字節,將這兩部分順序互換存儲就產生了大端模式和小端模式。
- 大端模式(Big-endian):顧名思義就是以高位字節結尾,低位在前(左),高位在后(右)。如
雙
字就會存儲為:11001100 01010011
。 - 小端模式(Little-endian):顧名思義就是以低位字節結尾,高位在前(左),低位在后(右)。如
雙
字就會存儲為:01010011 11001100
(和我們平常計算二進制的邏輯一致,從右到左依次從2
的0
次方開始)。
注:在 Java
中默認采用的是大端模式,雖然底層的處理器可能會采用不同的模式存儲字節,但是因為有 JVM
的存在,這些細節已經被屏蔽,所以平常大家可能也沒有很關注這些。
BOM
既然底層存儲分為了大端和小端兩種模式,那么假如我們現在有一個文件,計算機又是怎么知道當前是采用的大端模式還是小端模式呢?
BOM
即 byte order mark
(字節順序標記),出現在文本文件頭部。BOM
就是用來標記當前文件采用的是大端模式還是小端模式存儲。我想這個大家應該都見過,平常在使用記事本保存文檔的時候,需要選擇采用的是大端還是小端:
在 UCS
編碼中有一個叫做 Zero Width No-Break Space(零寬無間斷間隔)的字符,對應的編碼是 FEFF
。FEFF
是不存在的字符,正常來說不應該出現在實際數據傳輸中。
但是為了區分大端模式和小端模式,UCS
規范建議在傳輸字節流前,先傳輸字符 Zero Width No-Break Space
。而且根據這個字符的順序來區分大端模式和小端模式。
下表就是不同編碼的 BOM
:
編碼 | 16 進制 BOM |
---|---|
UTF-8 | EF BB BF |
UTF-16(大端模式) | FE FF |
UTF-16(小端模式) | FF FE |
UTF-32(大端模式) | 00 00 FE FF |
UTF-32(小端模式) | FF FE 00 00 |
有了這個規范,解析文件的時候就可以知道當前編碼以及其存儲模式了。注意這里 UTF-8
編碼比較特殊,因為本身 UTF-8
編碼有特殊的順序格式規定,所以 UTF-8
本身並沒有什么大端模式和小端模式的區別.
根據 UTF-8
本身的特殊編碼格式,在沒有 BOM
的情況下也能被推斷出來,但是因為微軟是建議都加上 BOM
,所以目前存在了帶 BOM
的 UTF-8
文件和不帶 BOM
的 UTF-8
文件,這兩種格式在某些場景可能會出現不兼容的問題,所以在平常使用中也可以稍微留意這個問題。
總結
本文主要從編碼的歷史開始,講述了編碼的存儲規則並且分析了產生亂碼的本質原因,同時也分析了字節的兩種存儲模型以及 BOM
相關問題,通過本文相信對於項目中出現的亂碼問題,大家會有一個清晰的思路來分析問題。