1.2 w字+!Java IO 基礎知識系統總結 | JavaGuide

首發於:JavaGuide(「Java學習+面試指南」一份涵蓋大部分 Java 程序員所需要掌握的核心知識。)

原文地址:https://javaguide.cn/java/io/io-basis.html

IO 流簡介

IO 即 Input/Output,輸入和輸出。數據輸入到計算機內存的過程即輸入,反之輸出到外部存儲(比如數據庫,文件,遠程主機)的過程即輸出。數據傳輸過程類似於水流,因此稱爲 IO 流。IO 流在 Java 中分爲輸入流和輸出流,而根據數據的處理方式又分爲字節流和字符流。

Java IO 流的 40 多個類都是從如下 4 個抽象類基類中派生出來的。

  • InputStream/Reader: 所有的輸入流的基類,前者是字節輸入流,後者是字符輸入流。
  • OutputStream/Writer: 所有輸出流的基類,前者是字節輸出流,後者是字符輸出流。

字節流

InputStream(字節輸入流)

InputStream用於從源頭(通常是文件)讀取數據(字節信息)到內存中,java.io.InputStream抽象類是所有字節輸入流的父類。

InputStream 常用方法 :

  • read() :返回輸入流中下一個字節的數據。返回的值介於 0 到 255 之間。如果未讀取任何字節,則代碼返回 -1 ,表示文件結束。
  • read(byte b[ ]) : 從輸入流中讀取一些字節存儲到數組 b 中。如果數組 b 的長度爲零,則不讀取。如果沒有可用字節讀取,返回 -1。如果有可用字節讀取,則最多讀取的字節數最多等於 b.length , 返回讀取的字節數。這個方法等價於 read(b, 0, b.length)
  • read(byte b[], int off, int len) :在read(byte b[ ]) 方法的基礎上增加了 off 參數(偏移量)和 len 參數(要讀取的最大字節數)。
  • skip(long n) :忽略輸入流中的 n 個字節 ,返回實際忽略的字節數。
  • available() :返回輸入流中可以讀取的字節數。
  • close() :關閉輸入流釋放相關的系統資源。

從 Java 9 開始,InputStream 新增加了多個實用的方法:

  • readAllBytes() :讀取輸入流中的所有字節,返回字節數組。
  • readNBytes(byte[] b, int off, int len) :阻塞直到讀取 len 個字節。
  • transferTo(OutputStream out) : 將所有字節從一個輸入流傳遞到一個輸出流。

FileInputStream 是一個比較常用的字節輸入流對象,可直接指定文件路徑,可以直接讀取單字節數據,也可以讀取至字節數組中。

FileInputStream 代碼示例:

try (InputStream fis = new FileInputStream("input.txt")) {
    System.out.println("Number of remaining bytes:"
            + fis.available());
    int content;
    long skip = fis.skip(2);
    System.out.println("The actual number of bytes skipped:" + skip);
    System.out.print("The content read from file:");
    while ((content = fis.read()) != -1) {
        System.out.print((char) content);
    }
} catch (IOException e) {
    e.printStackTrace();
}

input.txt 文件內容:

輸出:

Number of remaining bytes:11
The actual number of bytes skipped:2
The content read from file:JavaGuide

不過,一般我們是不會直接單獨使用 FileInputStream ,通常會配合 BufferedInputStream(字節緩衝輸入流,後文會講到)來使用。

像下面這段代碼在我們的項目中就比較常見,我們通過 readAllBytes() 讀取輸入流所有字節並將其直接賦值給一個 String 對象。

// 新建一個 BufferedInputStream 對象
BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("input.txt"));
// 讀取文件的內容並複製到 String 對象中
String result = new String(bufferedInputStream.readAllBytes());
System.out.println(result);

DataInputStream 用於讀取指定類型數據,不能單獨使用,必須結合 FileInputStream

FileInputStream fileInputStream = new FileInputStream("input.txt");
//必須將fileInputStream作爲構造參數才能使用
DataInputStream dataInputStream = new DataInputStream(fileInputStream);
//可以讀取任意具體的類型數據
dataInputStream.readBoolean();
dataInputStream.readInt();
dataInputStream.readUTF();

ObjectInputStream 用於從輸入流中讀取 Java 對象(反序列化),ObjectOutputStream 用於將對象寫入到輸出流(序列化)。

ObjectInputStream input = new ObjectInputStream(new FileInputStream("object.data"));
MyClass object = (MyClass) input.readObject();
input.close();

另外,用於序列化和反序列化的類必須實現 Serializable 接口,對象中如果有屬性不想被序列化,使用 transient 修飾。

OutputStream(字節輸出流)

OutputStream用於將數據(字節信息)寫入到目的地(通常是文件),java.io.OutputStream抽象類是所有字節輸出流的父類。

OutputStream 常用方法 :

  • write(int b) :將特定字節寫入輸出流。
  • write(byte b[ ]) : 將數組b 寫入到輸出流,等價於 write(b, 0, b.length)
  • write(byte[] b, int off, int len) : 在write(byte b[ ]) 方法的基礎上增加了 off 參數(偏移量)和 len 參數(要讀取的最大字節數)。
  • flush() :刷新此輸出流並強制寫出所有緩衝的輸出字節。
  • close() :關閉輸出流釋放相關的系統資源。

FileOutputStream 是最常用的字節輸出流對象,可直接指定文件路徑,可以直接輸出單字節數據,也可以輸出指定的字節數組。

FileOutputStream 代碼示例:

try (FileOutputStream output = new FileOutputStream("output.txt")) {
    byte[] array = "JavaGuide".getBytes();
    output.write(array);
} catch (IOException e) {
    e.printStackTrace();
}

運行結果:

類似於 FileInputStreamFileOutputStream 通常也會配合 BufferedOutputStream(字節緩衝輸出流,後文會講到)來使用。

FileOutputStream fileOutputStream = new FileOutputStream("output.txt");
BufferedOutputStream bos = new BufferedOutputStream(fileOutputStream)

DataOutputStream 用於寫入指定類型數據,不能單獨使用,必須結合 FileOutputStream

// 輸出流
FileOutputStream fileOutputStream = new FileOutputStream("out.txt");
DataOutputStream dataOutputStream = new DataOutputStream(fileOutputStream);
// 輸出任意數據類型
dataOutputStream.writeBoolean(true);
dataOutputStream.writeByte(1);

ObjectOutputStream 用於從輸入流中讀取 Java 對象(ObjectInputStream,反序列化)或者將對象寫入到輸出流(ObjectOutputStream,序列化)。

ObjectOutputStream output = new ObjectOutputStream(new FileOutputStream("file.txt")
Person person = new Person("Guide哥", "JavaGuide作者");
output.writeObject(person);

字符流

不管是文件讀寫還是網絡發送接收,信息的最小存儲單元都是字節。 那爲什麼 I/O 流操作要分爲字節流操作和字符流操作呢?

個人認爲主要有兩點原因:

  • 字符流是由 Java 虛擬機將字節轉換得到的,這個過程還算是比較耗時。
  • 如果我們不知道編碼類型就很容易出現亂碼問題。

亂碼問題這個很容易就可以復現,我們只需要將上面提到的 FileInputStream 代碼示例中的 input.txt 文件內容改爲中文即可,原代碼不需要改動。

輸出:

Number of remaining bytes:9
The actual number of bytes skipped:2
The content read from file:§å®¶å¥½

可以很明顯地看到讀取出來的內容已經變成了亂碼。

因此,I/O 流就乾脆提供了一個直接操作字符的接口,方便我們平時對字符進行流操作。如果音頻文件、圖片等媒體文件用字節流比較好,如果涉及到字符的話使用字符流比較好。

字符流默認採用的是 Unicode 編碼,我們可以通過構造方法自定義編碼。順便分享一下之前遇到的筆試題:常用字符編碼所佔字節數?utf8 :英文佔 1 字節,中文佔 3 字節,unicode:任何字符都佔 2 個字節,gbk:英文佔 1 字節,中文佔 2 字節。

Reader(字符輸入流)

Reader用於從源頭(通常是文件)讀取數據(字符信息)到內存中,java.io.Reader抽象類是所有字符輸入流的父類。

Reader 用於讀取文本, InputStream 用於讀取原始字節。

Reader 常用方法 :

  • read() : 從輸入流讀取一個字符。
  • read(char[] cbuf) : 從輸入流中讀取一些字符,並將它們存儲到字符數組 cbuf中,等價於 read(cbuf, 0, cbuf.length)
  • read(char[] cbuf, int off, int len) :在read(char[] cbuf) 方法的基礎上增加了 off 參數(偏移量)和 len 參數(要讀取的最大字節數)。
  • skip(long n) :忽略輸入流中的 n 個字符 ,返回實際忽略的字符數。
  • close() : 關閉輸入流並釋放相關的系統資源。

InputStreamReader 是字節流轉換爲字符流的橋樑,其子類 FileReader 是基於該基礎上的封裝,可以直接操作字符文件。

// 字節流轉換爲字符流的橋樑
public class InputStreamReader extends Reader {
}
// 用於讀取字符文件
public class FileReader extends InputStreamReader {
}

FileReader 代碼示例:

try (FileReader fileReader = new FileReader("input.txt");) {
    int content;
    long skip = fileReader.skip(3);
    System.out.println("The actual number of bytes skipped:" + skip);
    System.out.print("The content read from file:");
    while ((content = fileReader.read()) != -1) {
        System.out.print((char) content);
    }
} catch (IOException e) {
    e.printStackTrace();
}

input.txt 文件內容:

輸出:

The actual number of bytes skipped:3
The content read from file:我是Guide。

Writer(字符輸出流)

Writer用於將數據(字符信息)寫入到目的地(通常是文件),java.io.Writer抽象類是所有字節輸出流的父類。

Writer 常用方法 :

  • write(int c) : 寫入單個字符。
  • write(char[] cbuf) :寫入字符數組 cbuf,等價於write(cbuf, 0, cbuf.length)
  • write(char[] cbuf, int off, int len) :在write(char[] cbuf) 方法的基礎上增加了 off 參數(偏移量)和 len 參數(要讀取的最大字節數)。
  • write(String str) :寫入字符串,等價於 write(str, 0, str.length())
  • write(String str, int off, int len) :在write(String str) 方法的基礎上增加了 off 參數(偏移量)和 len 參數(要讀取的最大字節數)。
  • append(CharSequence csq) :將指定的字符序列附加到指定的 Writer 對象並返回該 Writer 對象。
  • append(char c) :將指定的字符附加到指定的 Writer 對象並返回該 Writer 對象。
  • flush() :刷新此輸出流並強制寫出所有緩衝的輸出字符。
  • close():關閉輸出流釋放相關的系統資源。

OutputStreamWriter 是字符流轉換爲字節流的橋樑,其子類 FileWriter 是基於該基礎上的封裝,可以直接將字符寫入到文件。

// 字符流轉換爲字節流的橋樑
public class InputStreamReader extends Reader {
}
// 用於寫入字符到文件
public class FileWriter extends OutputStreamWriter {
}

FileWriter 代碼示例:

try (Writer output = new FileWriter("output.txt")) {
    output.write("你好,我是Guide。");
} catch (IOException e) {
    e.printStackTrace();
}

輸出結果:

字節緩衝流

IO 操作是很消耗性能的,緩衝流將數據加載至緩衝區,一次性讀取/寫入多個字節,從而避免頻繁的 IO 操作,提高流的傳輸效率。

字節緩衝流這裏採用了裝飾器模式來增強 InputStreamOutputStream子類對象的功能。

舉個例子,我們可以通過 BufferedInputStream(字節緩衝輸入流)來增強 FileInputStream 的功能。

// 新建一個 BufferedInputStream 對象
BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("input.txt"));

字節流和字節緩衝流的性能差別主要體現在我們使用兩者的時候都是調用 write(int b)read() 這兩個一次只讀取一個字節的方法的時候。由於字節緩衝流內部有緩衝區(字節數組),因此,字節緩衝流會先將讀取到的字節存放在緩存區,大幅減少 IO 次數,提高讀取效率。

我使用 write(int b)read() 方法,分別通過字節流和字節緩衝流複製一個 524.9 mb 的 PDF 文件耗時對比如下:

使用緩衝流複製PDF文件總耗時:15428 毫秒
使用普通字節流複製PDF文件總耗時:2555062 毫秒

兩者耗時差別非常大,緩衝流耗費的時間是字節流的 1/165。

測試代碼如下:

@Test
void copy_pdf_to_another_pdf_buffer_stream() {
    // 記錄開始時間
    long start = System.currentTimeMillis();
    try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream("深入理解計算機操作系統.pdf"));
         BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("深入理解計算機操作系統-副本.pdf"))) {
        int content;
        while ((content = bis.read()) != -1) {
            bos.write(content);
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
    // 記錄結束時間
    long end = System.currentTimeMillis();
    System.out.println("使用緩衝流複製PDF文件總耗時:" + (end - start) + " 毫秒");
}

@Test
void copy_pdf_to_another_pdf_stream() {
    // 記錄開始時間
    long start = System.currentTimeMillis();
    try (FileInputStream fis = new FileInputStream("深入理解計算機操作系統.pdf");
         FileOutputStream fos = new FileOutputStream("深入理解計算機操作系統-副本.pdf")) {
        int content;
        while ((content = fis.read()) != -1) {
            fos.write(content);
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
    // 記錄結束時間
    long end = System.currentTimeMillis();
    System.out.println("使用普通流複製PDF文件總耗時:" + (end - start) + " 毫秒");
}

如果是調用 read(byte b[])write(byte b[], int off, int len) 這兩個寫入一個字節數組的方法的話,只要字節數組的大小合適,兩者的性能差距其實不大,基本可以忽略。

這次我們使用 read(byte b[])write(byte b[], int off, int len) 方法,分別通過字節流和字節緩衝流複製一個 524.9 mb 的 PDF 文件耗時對比如下:

使用緩衝流複製PDF文件總耗時:695 毫秒
使用普通字節流複製PDF文件總耗時:989 毫秒

兩者耗時差別不是很大,緩衝流的性能要略微好一點點。

測試代碼如下:

@Test
void copy_pdf_to_another_pdf_with_byte_array_buffer_stream() {
    // 記錄開始時間
    long start = System.currentTimeMillis();
    try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream("深入理解計算機操作系統.pdf"));
         BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("深入理解計算機操作系統-副本.pdf"))) {
        int len;
        byte[] bytes = new byte[4 * 1024];
        while ((len = bis.read(bytes)) != -1) {
            bos.write(bytes, 0, len);
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
    // 記錄結束時間
    long end = System.currentTimeMillis();
    System.out.println("使用緩衝流複製PDF文件總耗時:" + (end - start) + " 毫秒");
}

@Test
void copy_pdf_to_another_pdf_with_byte_array_stream() {
    // 記錄開始時間
    long start = System.currentTimeMillis();
    try (FileInputStream fis = new FileInputStream("深入理解計算機操作系統.pdf");
         FileOutputStream fos = new FileOutputStream("深入理解計算機操作系統-副本.pdf")) {
        int len;
        byte[] bytes = new byte[4 * 1024];
        while ((len = fis.read(bytes)) != -1) {
            fos.write(bytes, 0, len);
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
    // 記錄結束時間
    long end = System.currentTimeMillis();
    System.out.println("使用普通流複製PDF文件總耗時:" + (end - start) + " 毫秒");
}

BufferedInputStream(字節緩衝輸入流)

BufferedInputStream 從源頭(通常是文件)讀取數據(字節信息)到內存的過程中不會一個字節一個字節的讀取,而是會先將讀取到的字節存放在緩存區,並從內部緩衝區中單獨讀取字節。這樣大幅減少了 IO 次數,提高了讀取效率。

BufferedInputStream 內部維護了一個緩衝區,這個緩衝區實際就是一個字節數組,通過閱讀 BufferedInputStream 源碼即可得到這個結論。

public
class BufferedInputStream extends FilterInputStream {
    // 內部緩衝區數組
    protected volatile byte buf[];
    // 緩衝區的默認大小
    private static int DEFAULT_BUFFER_SIZE = 8192;
    // 使用默認的緩衝區大小
    public BufferedInputStream(InputStream in) {
        this(in, DEFAULT_BUFFER_SIZE);
    }
    // 自定義緩衝區大小
    public BufferedInputStream(InputStream in, int size) {
        super(in);
        if (size <= 0) {
            throw new IllegalArgumentException("Buffer size <= 0");
        }
        buf = new byte[size];
    }
}

緩衝區的大小默認爲 8192 字節,當然了,你也可以通過 BufferedInputStream(InputStream in, int size) 這個構造方法來指定緩衝區的大小。

BufferedOutputStream(字節緩衝輸出流)

BufferedOutputStream 將數據(字節信息)寫入到目的地(通常是文件)的過程中不會一個字節一個字節的寫入,而是會先將要寫入的字節存放在緩存區,並從內部緩衝區中單獨寫入字節。這樣大幅減少了 IO 次數,提高了讀取效率

try (BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("output.txt"))) {
    byte[] array = "JavaGuide".getBytes();
    bos.write(array);
} catch (IOException e) {
    e.printStackTrace();
}

類似於 BufferedInputStreamBufferedOutputStream 內部也維護了一個緩衝區,並且,這個緩存區的大小也是 8192 字節。

字符緩衝流

BufferedReader (字符緩衝輸入流)和 BufferedWriter(字符緩衝輸出流)類似於 BufferedInputStream(字節緩衝輸入流)和BufferedOutputStream(字節緩衝輸入流),內部都維護了一個字節數組作爲緩衝區。不過,前者主要是用來操作字符信息。

打印流

下面這段代碼大家經常使用吧?

System.out.print("Hello!");
System.out.println("Hello!");

System.out 實際是用於獲取一個 PrintStream 對象,print方法實際調用的是 PrintStream 對象的 write 方法。

PrintStream 屬於字節打印流,與之對應的是 PrintWriter (字符打印流)。PrintStreamOutputStream 的子類,PrintWriterWriter 的子類。

public class PrintStream extends FilterOutputStream
    implements Appendable, Closeable {
}
public class PrintWriter extends Writer {
}

隨機訪問流

這裏要介紹的隨機訪問流指的是支持隨意跳轉到文件的任意位置進行讀寫的 RandomAccessFile

RandomAccessFile 的構造方法如下,我們可以指定 mode(讀寫模式)。

// openAndDelete 參數默認爲 false 表示打開文件並且這個文件不會被刪除
public RandomAccessFile(File file, String mode)
    throws FileNotFoundException {
    this(file, mode, false);
}
// 私有方法
private RandomAccessFile(File file, String mode, boolean openAndDelete)  throws FileNotFoundException{
  // 省略大部分代碼
}

讀寫模式主要有下面四種:

  • r : 只讀模式。
  • rw: 讀寫模式
  • rws: 相對於 rwrws 同步更新對“文件的內容”或“元數據”的修改到外部存儲設備。
  • rwd : 相對於 rwrwd 同步更新對“文件的內容”的修改到外部存儲設備。

文件內容指的是文件中實際保存的數據,元數據則是用來描述文件屬性比如文件的大小信息、創建和修改時間。

RandomAccessFile 中有一個文件指針用來表示下一個將要被寫入或者讀取的字節所處的位置。我們可以通過 RandomAccessFileseek(long pos) 方法來設置文件指針的偏移量(距文件開頭 pos 個字節處)。如果想要獲取文件指針當前的位置的話,可以使用 getFilePointer() 方法。

RandomAccessFile 代碼示例:

RandomAccessFile randomAccessFile = new RandomAccessFile(new File("input.txt"), "rw");
System.out.println("讀取之前的偏移量:" + randomAccessFile.getFilePointer() + ",當前讀取到的字符" + (char) randomAccessFile.read() + ",讀取之後的偏移量:" + randomAccessFile.getFilePointer());
// 指針當前偏移量爲 6
randomAccessFile.seek(6);
System.out.println("讀取之前的偏移量:" + randomAccessFile.getFilePointer() + ",當前讀取到的字符" + (char) randomAccessFile.read() + ",讀取之後的偏移量:" + randomAccessFile.getFilePointer());
// 從偏移量 7 的位置開始往後寫入字節數據
randomAccessFile.write(new byte[]{'H', 'I', 'J', 'K'});
// 指針當前偏移量爲 0,回到起始位置
randomAccessFile.seek(0);
System.out.println("讀取之前的偏移量:" + randomAccessFile.getFilePointer() + ",當前讀取到的字符" + (char) randomAccessFile.read() + ",讀取之後的偏移量:" + randomAccessFile.getFilePointer());

input.txt 文件內容:

輸出:

讀取之前的偏移量:0,當前讀取到的字符A,讀取之後的偏移量:1
讀取之前的偏移量:6,當前讀取到的字符G,讀取之後的偏移量:7
讀取之前的偏移量:0,當前讀取到的字符A,讀取之後的偏移量:1

input.txt 文件內容變爲 ABCDEFGHIJK

RandomAccessFilewrite 方法在寫入對象的時候如果對應的位置已經有數據的話,會將其覆蓋掉。

RandomAccessFile randomAccessFile = new RandomAccessFile(new File("input.txt"), "rw");
randomAccessFile.write(new byte[]{'H', 'I', 'J', 'K'});

假設運行上面這段程序之前 input.txt 文件內容變爲 ABCD ,運行之後則變爲 HIJK

RandomAccessFile 比較常見的一個應用就是實現大文件的 斷點續傳 。何謂斷點續傳?簡單來說就是上傳文件中途暫停或失敗(比如遇到網絡問題)之後,不需要重新上傳,只需要上傳那些未成功上傳的文件分片即可。分片(先將文件切分成多個文件分片)上傳是斷點續傳的基礎。

RandomAccessFile 可以幫助我們合併文件分片,示例代碼如下:

我在《Java 面試指北》中詳細介紹了大文件的上傳問題。

RandomAccessFile 的實現依賴於 FileDescriptor (文件描述符) 和 FileChannel (內存映射文件)。

後記

專注 Java 原創乾貨分享,大三開源 JavaGuide (「Java學習+面試指南」一份涵蓋大部分 Java 程序員所需要掌握的核心知識。準備 Java 面試,首選 JavaGuide!),目前已經 120k+ Star。

原創不易,歡迎點贊分享,歡迎關注我在博客園的賬號,我會持續分享原創乾貨!加油,衝!

如果本文對你有幫助的話,歡迎點贊分享,這對我繼續分享&創作優質文章非常重要。感謝 🙏🏻

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章