字符編碼與解碼(附:Java字符流與字節流源碼剖析)

字符編碼與解碼(附:Java字符流與字節流源碼剖析)

1. 從二進制碼到字符

  • 我們先聲明兩點,在計算機中:
    • 任何一個字節都是由二進制碼組成的
    • 任何一個字符都是由二進制碼組成的,一個字符根據解碼的方式不同,通常可以由字節組成(當然,字節同樣也是二進制)
  • 不同的編碼/解碼規範規定了一個字符該由什麼樣的二進制碼錶示,例如:
    • ASCII中,二進制碼’0110 1000’代表了字符’h’
    • GBK中,二進制碼’11111111111111111111111111001010 11111111111111111111111111000000’代表了字符’世’
    • UTF-8中,二進制碼’11111111111111111111111111100100 11111111111111111111111110111000 11111111111111111111111110010110’代表了字符’世’
  • 通過使用編碼/解碼規範(ASCII、GBK、UTF-8等),我們可以實現字符與二進制碼的轉換
    • 編碼:將字符轉爲二進制碼(計算機能夠識別,用於計算機的存儲、網絡傳輸)
    • 解碼:將二進制碼轉爲字符(便於人類識別)
  • 顯然,採用不同的編碼、解碼規範很可能會導致數據的編碼或解碼錯誤,例如:
    • 將一個字符’世’,先用GBK規範編碼,再用UTF-8規範解碼(對照前面字符’世’的不同二進制碼可知)

2. 舉例:UTF-8中的編碼/解碼

  • 首先,我們要知道UTF-8規範是按8bit(1byte)一次的方式轉換二進制碼的,並且它是變長多字節編碼:
    • 一個英文字母用1byte表示
    • 一個漢字用3byte表示(少部分漢字佔4byte)

2.1 UTF-8 編碼

  • 那麼,我們用Java寫一份代碼來看一下吧
    String content = "hello 世界";
    byte[] bytes = content.getBytes("UTF-8"); // 使用UTF-8編碼,轉爲字節數組(即二進制碼)
    for (int i = 0; i < bytes.length; i++) {
        byte current = bytes[i];
        System.out.println(
                i + " -> " +
                "十進制: " + current +
                ", 十六進制: " + Integer.toHexString(current) +
                ", 二進制: " + Integer.toBinaryString(current)
        );
    }
    
  • 輸出結果如下
    0 -> 十進制: 104, 十六進制: 68, 二進制: 1101000
    1 -> 十進制: 101, 十六進制: 65, 二進制: 1100101
    2 -> 十進制: 108, 十六進制: 6c, 二進制: 1101100
    3 -> 十進制: 108, 十六進制: 6c, 二進制: 1101100
    4 -> 十進制: 111, 十六進制: 6f, 二進制: 1101111
    5 -> 十進制: 32, 十六進制: 20, 二進制: 100000
    6 -> 十進制: -28, 十六進制: ffffffe4, 二進制: 11111111111111111111111111100100
    7 -> 十進制: -72, 十六進制: ffffffb8, 二進制: 11111111111111111111111110111000
    8 -> 十進制: -106, 十六進制: ffffff96, 二進制: 11111111111111111111111110010110
    9 -> 十進制: -25, 十六進制: ffffffe7, 二進制: 11111111111111111111111111100111
    10 -> 十進制: -107, 十六進制: ffffff95, 二進制: 11111111111111111111111110010101
    11 -> 十進制: -116, 十六進制: ffffff8c, 二進制: 11111111111111111111111110001100
    
  • 解釋一下
    • 前面6個byte是英文字符與一般符號,同ASCII的方式編碼,每個字符編碼爲1byte,例如
      • ‘h’ -> ‘1101000’
      • ‘e’ -> ‘1100101’
    • 後面6個byte是中文漢字,每個字符編碼爲3byte,例如
      • ‘世’ -> ‘11111111111111111111111111100100’和’11111111111111111111111110111000’和’11111111111111111111111110010110’

2.2 UTF-8 解碼

  • 我們再用二進制碼構造兩個漢字試試(UTF-8方式解碼)
    byte shi1 = 0b11111111111111111111111111100100;
    byte shi2 = 0b11111111111111111111111110111000;
    byte shi3 = 0b11111111111111111111111110010110;
    byte[] shi = {shi1, shi2, shi3};
    System.out.println(new String(shi, "UTF-8"));
    
    byte jie1 = 0b11111111111111111111111111100111;
    byte jie2 = 0b11111111111111111111111110010101;
    byte jie3 = 0b11111111111111111111111110001100;
    byte[] jie = {jie1, jie2, jie3};
    System.out.println(new String(jie, "UTF-8"));
    
  • 輸出如下
    世
    界
    

2.3 錯誤的編碼與解碼

  • 如果我們先用GBK規範編碼,再用UTF-8規範解碼,會發生什麼事情?
    String content = "hello 世界";
    
    byte[] gbkBytes = content.getBytes("GBK");
    String utf8Str = new String(gbkBytes, "UTF-8");
    System.out.println(utf8Str);
    System.out.println(content);
    
  • 輸出如下
    hello ����
    hello 世界
    
  • 顯然,因爲GBK和UTF-8對於英文字符的解析方式相同,所以’hello '部分沒有出錯,但是因爲對於漢字的編碼/解碼規定不同,導致了亂碼
  • 另外,如果一個文本文件是GBK編碼的,使用ISO-8859-1解碼,再使用ISO-8859-1編碼,最後存儲
    • 使用ISO-8859-1解碼,如果打印字符,顯然是亂碼的
    • 存儲的結果則是沒有問題的,因爲ISO-8859-1是單字節編碼,並且使用了單字節內的所有空間。因此將任何字節流按ISO-8859-1解碼,再編碼,都不會有丟失的問題。(MySQL默認的編碼Latin1就是如此)
  • GBK與ISO-8859-1的示例代碼如下
    import java.io.FileInputStream;
    import java.io.FileOutputStream;
    
    public class Demo02 {
    
        public static void main(String[] args) throws Exception {
            String content = "hello 世界";
            String path = "./test.txt";
    
            // 對字符串進行GBK編碼,並存儲
            byte[] gbkBytes = content.getBytes("GBK");
            saveByteArray(path, gbkBytes, 0, gbkBytes.length);
    
            // 讀取該文件字節碼
            byte[] bytes = new byte[1024];
            int len = readByteArray(path, bytes);
            System.out.println("len = " + len);
    
            // 使用GBK解碼,並打印
            String gbkStr = new String(bytes, 0, len, "GBK");
            System.out.println("gbkStr = " + gbkStr);
    
            // 使用iso-8859-1解碼,並打印
            String isoStr = new String(bytes, 0, len, "iso-8859-1");
            System.out.println("isoStr = " + isoStr);
    
            // 使用iso-8859-1對該字符串進行編碼,然後存儲
            byte[] isoBytes = isoStr.getBytes("iso-8859-1");
            saveByteArray(path, isoBytes, 0, isoBytes.length);
        }
    
        public static void saveByteArray(String path, byte[] bytes, int off, int len) throws Exception {
            FileOutputStream fos = new FileOutputStream(path);
            fos.write(bytes);
            fos.close();
        }
    
        public static int readByteArray(String path, byte[] bytes) throws Exception {
            FileInputStream fis = new FileInputStream(path);
            int len = fis.read(bytes);
            fis.close();
            return len;
        }
        
    }
    

3. 字符編碼的記錄

  • ASCII

    • 美國信息交換標準代碼
    • 7 bit 表示一個字符
    • 共128個字符
  • ISO-8859-1

    • 對於ASCII的擴展
    • 8 bit 表示一個字符,會使用整個byte
    • 共256個字符
  • GB2312

    • 國標,漢字的編碼集
    • 2 byte 表示一個字符
    • 共6763個漢字
  • GBK

    • 對於GB2312的擴展,能表示更多的字符
    • 2 byte 表示一個字符
    • 共21003個漢字
  • GB18030

    • 對於GBK的擴展,最完整的漢字編碼集
    • 變長多字節編碼,1個、2個或4個byte表示一個字符
    • 共70000餘個漢字
  • BIG5

    • 由臺灣制定,主要用於繁體漢字編碼
    • 2 byte 表示一個字符
    • 共13060個漢字
  • Unicode

    • 由國際標準化組織制定,整合全世界的字符
    • 2 byte 表示一個字符
    • 表示全世界所有的字符
    • 如果只使用英文字符,較浪費空間
  • UTF(Unicode Translation Format)

    • 通用轉換格式,是Unicode的實現,解決了Unicode空間浪費的問題
    • UTF-8, UTF-16, UTF-16LE(little endian), UTF-16BE(big endian), UTF-32
  • UTF-8

    • 變長多字節編碼,1~4字節表示一個字符
      • 1 byte 表示一個US-ASCIl字符
      • 2 byte 表示一個拉丁文字符(拉丁文、希臘文、西裏爾字母、亞美尼亞語、希伯來文、阿拉伯文、敘利亞文等)
      • 3 byte 表示一個漢字(中日韓文字、東南亞文字、中東文字等)
      • 4 byte 表示其他極少使用的語言
  • UTF-8-BOM(Byte Order Mark)

    • Unicode規定使用BOM來標識字節順序,UTF-8-BOM的文件會以EF BB BF開頭
    • UTF-16和UTF-32需要決定是按2Byte讀還是按4byte讀,需要BOM來決定順序
    • UTF-8是按1byte讀的,沒有字節序問題,是不需要BOM來標識字節序的
    • 建議:使用UTF-8時,最好使用不帶BOM的UTF-8

4. Java中字符流與字節流的關係(源碼剖析)

  • 我們從字符流入手,先看java.io.FileReader,它繼承於java.io.InputStreamReader,其構造器如下
    public FileReader(String fileName) throws FileNotFoundException {
            super(new FileInputStream(fileName));
    }
    
    public FileReader(File file) throws FileNotFoundException {
            super(new FileInputStream(file));
    }
    
    public FileReader(FileDescriptor fd) {
        super(new FileInputStream(fd));
    }
    
  • 其構造器都是會new FileInputStream(),而FileInputStream繼承於InputStream
  • FileInputStream會通過native方法open0獲取到輸入字節流,其代碼如下
    public FileInputStream(File file) throws FileNotFoundException {
        String name = (file != null ? file.getPath() : null);
        SecurityManager security = System.getSecurityManager();
        if (security != null) {
            security.checkRead(name);
        }
        if (name == null) {
            throw new NullPointerException();
        }
        if (file.isInvalid()) {
            throw new FileNotFoundException("Invalid file path");
        }
        fd = new FileDescriptor();
        fd.attach(this);
        path = name;
        open(name);
    }
    
    private void open(String name) throws FileNotFoundException {
        open0(name);
    }
    
    private native void open0(String name) throws FileNotFoundException;
    
  • 接着FileReader的構造器,調用了其父類InputStreamReader的構造器(super方法),將FileInputStream傳了進去
  • InputStreamReader實際是InputStream的包裝類,對其進行功能增強(提供字符解碼能力)。在構造器中,利用StreamDecoder對InputStream進行解碼
  • InputStreamReader構造器代碼如下
    public InputStreamReader(InputStream in) {
        super(in);
        try {
            sd = StreamDecoder.forInputStreamReader(in, this, (String)null); // ## check lock object
        } catch (UnsupportedEncodingException e) {
            // The default encoding should always be available
            throw new Error(e);
        }
    }
    
  • StreamDecoder中的解碼方法會先看是否指定了Charset(例如UTF-8),如果沒指定會使用默認的Charset,最後如果系統支持該Charset,那麼會返回StreamDecoder,代碼如下
    public static StreamDecoder forInputStreamReader(InputStream var0, Object var1, String var2) throws UnsupportedEncodingException {
        String var3 = var2;
        if (var2 == null) {
            var3 = Charset.defaultCharset().name();
        }
    
        try {
            if (Charset.isSupported(var3)) {
                return new StreamDecoder(var0, var1, Charset.forName(var3));
            }
        } catch (IllegalCharsetNameException var5) {
        }
    
        throw new UnsupportedEncodingException(var3);
    }
    
  • 而在InputStreamReader中,會將StreamDecoder賦值給全局變量sd,後續的讀取相關方法,皆是調用sd的read方法,代碼如下
    public String getEncoding() {
        return sd.getEncoding();
    }
    
    public int read() throws IOException {
        return sd.read();
    }
    
    public int read(char cbuf[], int offset, int length) throws IOException {
        return sd.read(cbuf, offset, length);
    }
    
    public boolean ready() throws IOException {
        return sd.ready();
    }
    
    public void close() throws IOException {
        sd.close();
    }
    
  • 顯然,我們可以知道Java中字符流與字節流的關係,其實就是字符流是對字節流功能的增強(編碼/解碼),本質上字符流用到的還是字節流
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章