Java的位元組流,字元流和緩衝流對比探究

黃鈺朝發表於2020-05-29

一、前言

所謂IO,也就是Input/Output。Java程式跟外部進行的資料交換就叫做Java的IO操作。程式中資料的輸入輸出,被抽象為流, 按照相對於程式的流向,可分為輸出流和輸入流。 按照資料流的格式,可分為位元組流和字元流。Java IO流的體系很龐大,功能豐富。

本文主要探討了Java中位元組操作和字元操作的區別。

二、位元組操作和字元操作

下圖可以表示Java 的IO體系:

 :

類似於C語言中二進位制檔案和文字檔案的區別,字元其實只是一種特殊的二進位制位元組,是按照一定的編碼方式處理之後,按照一定規則來儲存資訊的資料,字元在計算機中也是由二進位制組成的,只不過這種二進位制可以按照一種規則解碼後,成為人類可以直接閱讀的自然語言,而普通的二進位制檔案只有計算機能直接“閱讀”。位元組操作和字元操作的區別就在於資料的格式。

在Java中,位元組輸入輸出流有兩個抽象基類:

  • 位元組輸入流:InputStream
  • 位元組輸出流:OutputStream

字元輸入輸出流也有兩個抽象基類:

  • 字元輸入流:Reader

  • 字元輸出流:Writer

此外, Java提供了從位元組流到字元流的轉換流,分別是InputStreamReader和OutputStreamWriter,但沒有從字元流到位元組流的轉換流。實際上:

字元流=位元組流+編碼表

一次讀取一個位元組陣列的效率明顯比一次讀取一個位元組的效率高,因此Java提供了帶緩衝區的位元組類,稱為位元組緩衝區類:BufferedInputStream和BufferedOutputStream,同理還有字元緩衝區類BufferedReader和BufferedWriter。

在使用場景上,無法直接獲取文字資訊的二進位制檔案,比如圖片,mp3,視訊檔案等,只能使用位元組流。而對於文字資訊,則更適合使用字元流。

三、兩種方式的效率測試

下面通過編寫測試程式來比較兩種方式的效率區別:

3.1 測試程式碼

筆者編寫了8個方法來分別測試位元組方式/字元方式的輸入輸出流,帶緩衝區的輸入輸出流。

package com.verygood.island;

import org.junit.BeforeClass;
import org.junit.Test;
import org.junit.platform.commons.annotation.Testable;

import java.io.*;

/**
 * @author <a href="mailto:kobe524348@gmail.com">黃鈺朝</a>
 * @description
 * @date 2020-05-27 08:50
 */
@Testable
public class UnitTest {

    public static final String PATH = "C:\\Users\\Misterchaos\\Documents\\Java Develop Workplaces\\" +
            "Github repository\\island\\src\\test\\java\\com\\verygood\\island\\";

    /**
     * 用於輸出的物件
     */
    public static byte[] outputbytes = null;

    public static char[] outputchars = null;

    int count = 1;

    /**
     * 用於輸入的物件
     */
    public static final File inputFile = new File("C:\\Users\\Misterchaos\\Downloads\\安裝包\\TEST.zip");


    @BeforeClass
    public static void before() {
        StringBuilder stringBuilder = new StringBuilder("");
        for (int i = 0; i < 1000000; i++) {
            stringBuilder.append("stringstringstringstringstringstring");
        }
        outputbytes = stringBuilder.toString().getBytes();
        outputchars = stringBuilder.toString().toCharArray();
    }


    @Test
    public void test0() {
        System.out.println("--------------------------------------------------------");
        System.out.println("                      測試輸出流                          ");
        System.out.println("--------------------------------------------------------");
    }


    // 位元組流
    @Test
    public void test1() {
        try {
            System.out.println("********方式一:位元組流輸出**********");
            // 新建檔案命名
            String name = PATH + "位元組流輸出檔案.txt";
            File file = new File(name);
            // 建立輸入輸出流物件
            FileOutputStream fos = new FileOutputStream(file);
            // 讀寫資料
            long s1 = System.currentTimeMillis();// 測試開始,計時
            writeBytes(fos);
            long s2 = System.currentTimeMillis();// 測試結束,計時
            fos.close();
            System.out.println("輸出檔案耗時:" + (s2 - s1) + "ms");
            System.out.println("檔案大小:" + file.length() / 1024 + "KB");
            file.delete();
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }


    // 位元組流
    @Test
    public void test2() {
        try {
            System.out.println("********方式二:字元流輸出**********");
            // 新建檔案命名
            String name = PATH + "字元流輸出檔案.txt";
            File file = new File(name);
            // 建立輸入輸出流物件
            FileWriter fileWriter = new FileWriter(file);
            // 讀寫資料
            long s1 = System.currentTimeMillis();// 測試開始,計時
            writeChars(fileWriter);
            long s2 = System.currentTimeMillis();// 測試結束,計時
            fileWriter.close();
            System.out.println("輸出檔案耗時:" + (s2 - s1) + "ms");
            System.out.println("檔案大小:" + file.length() / 1024 + "KB");
            file.delete();

        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }


    // 位元組緩衝流
    @Test
    public void test3() {
        try {
            System.out.println("********方式三:位元組緩衝流輸出**********");
            // 新建檔案命名
            String name = PATH + "位元組緩衝流輸出檔案.txt";
            File file = new File(name);
            // 建立輸入輸出流物件
            FileOutputStream fileOutputStream = new FileOutputStream(file);
            BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(fileOutputStream);
            // 讀寫資料
            long s1 = System.currentTimeMillis();// 測試開始,計時
            writeBytes(bufferedOutputStream);
            long s2 = System.currentTimeMillis();// 測試結束,計時
            bufferedOutputStream.close();
            System.out.println("輸出檔案耗時:" + (s2 - s1) + "ms");
            System.out.println("檔案大小:" + file.length() / 1024 + "KB");
            file.delete();

        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }


    // 字元緩衝流
    @Test
    public void test4() {
        try {
            System.out.println("********方式四:字元緩衝流輸出**********");
            // 新建檔案命名
            String name = PATH + "字元緩衝流輸出檔案.txt";
            File file = new File(name);
            // 建立輸入輸出流物件
            BufferedWriter bufferedWriter = new BufferedWriter(new FileWriter(file));
            // 讀寫資料
            long s1 = System.currentTimeMillis();// 測試開始,計時
            for (int i = 0; i < count; i++) {
                bufferedWriter.write(outputchars);
            }
            long s2 = System.currentTimeMillis();// 測試結束,計時
            bufferedWriter.close();

            System.out.println("輸出檔案耗時:" + (s2 - s1) + "ms");
            System.out.println("檔案大小:" + file.length() / 1024 + "KB");
            file.delete();

        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }

    }

    @Test
    public void test5() {
        System.out.println("--------------------------------------------------------");
        System.out.println("                      測試輸入流                          ");
        System.out.println("--------------------------------------------------------");
    }


    // 位元組流
    @Test
    public void test6() {
        try {
            System.out.println("********方式一:位元組流輸入**********");
            // 新建檔案命名
            // 建立輸入輸出流物件
            long s1 = System.currentTimeMillis();// 測試開始,計時
            FileInputStream fileInputStream = new FileInputStream(inputFile);
            // 讀寫資料
            // 讀寫資料
            while (fileInputStream.read() != -1) {
            }
            fileInputStream.close();
            long s2 = System.currentTimeMillis();// 測試結束,計時
            System.out.println("輸入檔案耗時:" + (s2 - s1) + "ms");
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    // 位元組流
    @Test
    public void test7() {
        try {
            System.out.println("********方式二:字元流輸入**********");
            // 新建檔案命名
            long s1 = System.currentTimeMillis();// 測試開始,計時
            // 建立輸入輸出流物件
            FileReader fileReader = new FileReader(inputFile);
            // 讀寫資料
            while (fileReader.read() != -1) {
            }
            fileReader.close();
            long s2 = System.currentTimeMillis();// 測試結束,計時
            System.out.println("輸入檔案耗時:" + (s2 - s1) + "ms");
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    // 位元組緩衝流
    @Test
    public void test8() {
        try {
            System.out.println("********方式三:位元組緩衝流輸入**********");
            // 新建檔案命名
            long s1 = System.currentTimeMillis();// 測試開始,計時
            BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream(inputFile));
            // 建立輸入輸出流物件
            // 讀寫資料
            while (bufferedInputStream.read() != -1) {
            }
            bufferedInputStream.close();
            long s2 = System.currentTimeMillis();// 測試結束,計時
            System.out.println("輸入檔案耗時:" + (s2 - s1) + "ms");
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }


    // 字元緩衝流
    @Test
    public void test9() {
        try {
            System.out.println("********方式四:字元緩衝流輸入**********");
            // 新建檔案命名
            long s1 = System.currentTimeMillis();// 測試開始,計時
            // 建立輸入輸出流物件
            BufferedReader bufferedReader = new BufferedReader(new FileReader(inputFile));
            // 讀寫資料
            while (bufferedReader.read() != -1) {
            }
            bufferedReader.close();
            long s2 = System.currentTimeMillis();// 測試結束,計時
            System.out.println("輸入檔案耗時:" + (s2 - s1) + "ms");
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }

    }


    /**
     * 位元組輸出
     */
    private void writeBytes(OutputStream fos) throws IOException {
        for (int i = 0; i < count; i++) {
            for (int j = 0; j < outputbytes.length; j++) {
                fos.write(outputbytes[j]);
            }
        }
    }

    /**
     * 字元輸出
     */
    private void writeChars(Writer writer) throws IOException {
        for (int i = 0; i < count; i++) {
            for (int j = 0; j < outputchars.length; j++) {
                writer.write(outputchars[j]);
            }
        }
    }


}

3.2 測試結果

測試結果如下:

--------------------------------------------------------
                      測試輸出流                          
--------------------------------------------------------
********方式一:位元組流輸出**********
輸出檔案耗時:153798ms
檔案大小:35156KB

********方式二:字元流輸出**********
輸出檔案耗時:5503ms
檔案大小:35156KB

********方式三:位元組緩衝流輸出**********
輸出檔案耗時:514ms
檔案大小:35156KB

********方式四:字元緩衝流輸出**********
輸出檔案耗時:600ms
檔案大小:35156KB
--------------------------------------------------------
                      測試輸入流                          
--------------------------------------------------------
********方式一:位元組流輸入**********
輸入檔案耗時:3643276ms

********方式二:字元流輸入**********
輸入檔案耗時:93332ms

********方式三:位元組緩衝流輸入**********
輸入檔案耗時:4700ms

********方式四:字元緩衝流輸入**********
輸入檔案耗時:51538ms

3.3 結果分析

測試發現,如果輸出的物件是整個直接輸出到檔案,使用帶緩衝區的輸出流實際效率更低,實際測試得到結果是:帶緩衝區的輸出流所需時間大約是不帶緩衝區輸出流的兩倍。檢視原始碼可以看到:

 public synchronized void write(byte b[], int off, int len) throws IOException {
        if (len >= buf.length) {
            /* If the request length exceeds the size of the output buffer,
               flush the output buffer and then write the data directly.
               In this way buffered streams will cascade harmlessly. */
            flushBuffer();
            out.write(b, off, len);
            return;
        }
        if (len > buf.length - count) {
            flushBuffer();
        }
        System.arraycopy(b, off, buf, count, len);
        count += len;
 }

其中的註釋已經清楚地寫出來,如果寫入的長度大於緩衝區的大小,則先重新整理快取區,然後直接寫入檔案。簡而言之,就是不使用緩衝區!

因此,筆者重新設計了使用場景,將一次性的輸出改為了一個位元組一個位元組地輸出,上面展示的就是改進後的測試結果。從這一次結果來看,帶緩衝區的位元組輸出流有了非常明顯的優勢,整體的效能提升了將近400倍!

在FileWriter和FileOutputStream的比較中,發現FileOutputStream的速度明顯更慢,檢視原始碼發現:

FileWriter內部呼叫了StreamEncoder來輸出,而StreamEncoder內部維護了一個8192大小的緩衝區。這樣就不難解釋為什麼FileOutputStream使用位元組的方式節省了編碼開銷反而效率更低,原因就在於FileWriter實際是帶有緩衝區的,因此FileWriter在使用了BufferedWriter封裝之後效能只有2倍的提升也就不足為奇了。

四、位元組順序endian

位元組序,或位元組順序("Endian"、"endianness" 或 "byte-order"),描述了計算機如何組織位元組,組成對應的數字。大端位元組序(big-endian):高位位元組在前,低位位元組在後。小端位元組序(little-endian)反之。

筆者使用編寫了測試程式碼來測試C語言中二進位制和文字兩種方式效率區別,程式碼如下:

#define _CRT_SECURE_NO_WARNINGS
#include "stdio.h" 
#include <stdlib.h> 
#include "time.h"  
#define CLOCKS_PER_SEC ((clock_t)1000)  

int main()
{
	FILE* fpRead = fopen("C:\\test.txt", "r");
	if (fpRead == NULL)
	{
		printf("檔案開啟失敗");
		return 0;
	}
	clock_t start, finish;
	int a=0;
	start = clock();
	while (!feof(fpRead))
	{
		a = fgetc(fpRead);
	}
	finish = clock();
	double text_duration = (double)(finish - start) / CLOCKS_PER_SEC;
	printf("\n");
	

	fclose(fpRead);
	
	fpRead = fopen("C:\\test.txt","rb");

	if (fpRead == NULL)
	{
		printf("檔案開啟失敗");
		return 0;
	}
	start = clock();
	while (!feof(fpRead))
	{
		a = fgetc(fpRead);
	}
	finish = clock();
	double binary_duration = (double)(finish - start) / CLOCKS_PER_SEC;
	printf("\n");

	printf("文字方式耗時:%f seconds\n", text_duration);
	printf("二進位制方式耗時:%f seconds\n", binary_duration);

	system("pause");
	return 1;
}

執行結果:

文字方式耗時:3.042000 seconds
二進位制方式耗時:2.796000 seconds

可以看到二進位制的方式效率比文字方式稍微有所提高。

五、綜合對比

根據以上實驗,可以總結得出,位元組流和字元流具有以下區別:

  • 在同樣使用緩衝區的前提下,位元組流比字元流的效率稍微高一點。對於頻繁操作且每次輸入輸出的資料量較小時,使用緩衝區可以帶來明顯的效率提升。
  • 操作物件上,位元組流操作的基本單元為位元組,字元流操作的基本單元為Unicode碼元(字元)。
  • 位元組流通常用於處理二進位制資料,實際上它可以處理任意型別的資料,但它不支援直接寫入或讀取Unicode碼元。而字元流通常處理文字資料,它支援寫入及讀取Unicode碼元。
  • 從原始碼可以看出來,位元組流預設不使用緩衝區,而字元流內部使用了緩衝區

六、總結

在這次部落格編寫過程中,測試位元組流和字元流的效率時曾出現非常令人費解的結果,使用BufferWriter和BufferedOutputSteam封裝的輸出流效率都沒有提高反而有所降低,後來檢視原始碼才發現了問題所在。此外,位元組流的效率明顯低於字元流也令筆者抓狂,最後發現字元流內部維護了緩衝區,問題才迎刃而解。

相關文章