為了徹底理解亂碼問題,一怒之下我把字符集歷史扒了個底朝天

雙子孤狼發表於2021-04-19

前言

在日常開發中,亂碼問題可以說曾經都困擾過我們,那麼為什麼會有亂碼發生呢?為什麼全世界不統一使用一套編碼呢?本文將會從字符集的發展歷史來解答這兩個問題,看完本篇,相信大家對亂碼現象會有本質上的認識。

一個故事來理解為什麼要編碼

現在有兩個人,張三和李四,張三隻會中文,李四隻會英文,那麼這時候他們怎麼溝通?解決辦法是他們可以找個翻譯,這個翻譯的過程就可以理解為編碼,也就是說從中文到英文或者從英文到中文這就是一個編碼的過程,編碼的本質就是為了讓對方能讀懂自己的語言。

人類的各種官方語言和方言數不勝數,所以在應用到在計算機時總不能兩兩互相編碼吧?而且最重要的是人類的語言並不適合計算機使用,所以就需要發明一種適合計算機的語言,這就是二進位制。二進位制就是當今世界計算機的語言,當然,曾經前蘇聯也發明過三進位制計算機,但是沒有普及,這個感興趣的可以自己去了解下。

有了二進位制這種計算機能讀懂的語言就好辦了,當我們想和計算機溝通的時候,先轉成二進位制(編碼),計算機處理完成之後,再轉換回人類語言(解碼),這就是需要編碼的原因。

為什麼會亂碼

但是為什麼會亂碼呢?還是用上面的故事中張三李四來舉例,假如有一次張三說了一個生僻詞,然後翻譯從來沒見過這個詞,這時候翻譯就不知道怎麼翻譯了,沒有辦法,就直接翻譯成了 ??,也就是亂碼了。

在計算機的世界也是同理,比如我們想從一個程式 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-1ISO-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-8UTF-16UTF-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~DBFFDC00~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,而63ASCII 中就恰好就對應了 ? 號。

所以一般我們看到編碼出現 ? 基本就說明當前是採用 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,以一個位元組為單位,就可以拆分成:0101001111001100,其中第一部分就稱之為高位位元組,第二部分就稱之為低位位元組,將這兩部分順序互換儲存就產生了大端模式小端模式

  • 大端模式(Big-endian):顧名思義就是以高位位元組結尾,低位在前(左),高位在後(右)。如 字就會儲存為:11001100 01010011
  • 小端模式(Little-endian):顧名思義就是以低位位元組結尾,高位在前(左),低位在後(右)。如 字就會儲存為:01010011 11001100(和我們平常計算二進位制的邏輯一致,從右到左依次從 20 次方開始)。

注:在 Java 中預設採用的是大端模式,雖然底層的處理器可能會採用不同的模式儲存位元組,但是因為有 JVM 的存在,這些細節已經被遮蔽,所以平常大家可能也沒有很關注這些。

BOM

既然底層儲存分為了大端和小端兩種模式,那麼假如我們現在有一個檔案,計算機又是怎麼知道當前是採用的大端模式還是小端模式呢?

BOMbyte order mark(位元組順序標記),出現在文字檔案頭部。BOM 就是用來標記當前檔案採用的是大端模式還是小端模式儲存。我想這個大家應該都見過,平常在使用記事本儲存文件的時候,需要選擇採用的是大端還是小端:

UCS 編碼中有一個叫做 Zero Width No-Break Space(零寬無間斷間隔)的字元,對應的編碼是 FEFFFEFF 是不存在的字元,正常來說不應該出現在實際資料傳輸中。

但是為了區分大端模式和小端模式,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,所以目前存在了帶 BOMUTF-8 檔案和不帶 BOMUTF-8 檔案,這兩種格式在某些場景可能會出現不相容的問題,所以在平常使用中也可以稍微留意這個問題。

總結

本文主要從編碼的歷史開始,講述了編碼的儲存規則並且分析了產生亂碼的本質原因,同時也分析了位元組的兩種儲存模型以及 BOM 相關問題,通過本文相信對於專案中出現的亂碼問題,大家會有一個清晰的思路來分析問題。

相關文章