一、前言
所謂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封裝的輸出流效率都沒有提高反而有所降低,后來查看源碼才發現了問題所在。此外,字節流的效率明顯低於字符流也令筆者抓狂,最后發現字符流內部維護了緩沖區,問題才迎刃而解。