Java IO 底層原理

引言

一提到 IO,就繞不開對 page cache(頁緩存)的討論,頁緩存是操作系統爲了提升磁盤讀寫性能在應用進程與磁盤之間加設的提供預讀和異步刷盤機制的內核緩衝區。java 的 IO 操作是建立在操作系統的 IO 之上的,從最基礎的 read/write 系統調用,到具有零拷貝特性的 sendfile、mmap,在 java 中都能看到它們的身影。本文的主要目的是縱觀全局,鳥瞰 java IO 體系,並指出每種 IO 方式的特點與使用場景。整篇文章會圍繞下圖作分步講解,爲了簡單起見,這裏主要以寫操作爲例。
IO 數據流向圖

一、普通 IO

看綠色箭頭指示的數據流向,每次寫操作都會調用 write 系統調用,將數據寫入到內核空間頁緩存中然後返回,注意這時候數據還沒有被寫入到磁盤,操作系統中會有個定時任務負責將符合條件的數據寫入到磁盤(這一過程簡稱刷盤),應用進程無需關心。下面是普通 IO 示例代碼:

import java.io.FileOutputStream;
import java.io.IOException;

/**
 * @author debo
 * @date 2020-06-25
 */
public class FileOutputStreamTest {
    private static final long COUNT = 1000_0000L;

    public static void main(String[] args) throws IOException {
        FileOutputStream fos = new FileOutputStream("/home/debo/tmp.txt");
        String msg = "你好,world!";
        long start = System.currentTimeMillis();
        for (int i = 0; i < COUNT; i++) {
            // 每次都會產生write系統調用
            fos.write(msg.getBytes());
        }
        fos.close();
        System.out.println(String.format("耗時:%d毫秒", System.currentTimeMillis() - start));
    }
}

這個程序循環一千萬次寫操作,也就是產生一千萬次的 write 系統調用,程序執行完耗時 14 秒左右。

二、帶緩衝區的 IO

紅色箭頭表示了另一種 IO 方式,在程序進行寫操作的時候,並不是每次都會產生 write 系統調用,而是會在用戶空間開闢一個緩衝區,先將數據暫存在這個緩衝區,等緩衝區滿或者手動 flush() 的時候,纔會調用 write 系統調用將數據寫到 page cache,代碼如下:

import java.io.BufferedOutputStream;
import java.io.FileOutputStream;
import java.io.IOException;

/**
 * @author debo
 * @date 2020-06-25
 */
public class BufferedOutputStreamTest {
    private static final long COUNT = 1000_0000L;

    public static void main(String[] args) throws IOException {
        BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("/home/debo/tmp.txt"));
        String msg = "你好,world!";
        long start = System.currentTimeMillis();
        for (int i = 0; i < COUNT; i++) {
            // 先寫到緩衝區,等緩衝區滿纔會產生write系統調用將數據寫入page cache
            bos.write(msg.getBytes());
        }
        bos.close();
        System.out.println(String.format("耗時:%d毫秒", System.currentTimeMillis() - start));
    }
}

這個程序執行完耗時 0.9 秒,與不帶緩衝區的 IO 相比性能提升 15 倍!如果執行一億次循環,性能提升會更明顯。

這個帶緩衝區的 BufferedOutputStream 性能提升的法寶就是:每次調用 write() 方法並不會產生實際的 write 系統調用,而是會先將數據存放於 BufferedOutputStream 實例內部的緩衝區中(緩衝區默認大小 8 KB),等緩衝區滿、或者手動調用 BufferedOutputStream.flush()close() 方法時,纔會真正調用 write 系統調用將緩衝區數據寫入 page cache,這樣就比不帶緩衝區的 IO 少了很多次的系統調用,性能自然就大大提升了。

三、page cache 刷盤策略

既然數據都在 page cache 中,那麼什麼時候會被寫入磁盤呢?其實操作系統有一個定時任務會定時查看是否滿足刷盤條件(比如 page cache 佔用內存空間超過設定值等),如果滿足,就會將數據寫入磁盤。此外,操作系統也提供了手動刷盤的系統調用,當應用進程調用 fsync 或 fdatasync 系統調用時,就會將 page cache 數據同步寫入磁盤直到成功返回。所以很多支持持久化的中間件(比如 redis)都會提供以下幾種刷盤策略:

  • 依賴操作系統的自動刷盤機制
  • 每次寫完數據後都調用 fsync 強制刷盤
  • 折衷方案,以固定的時間間隔調用 fsync 強制刷盤,比如 1 秒刷一次

java 中是如何控制手動刷盤的呢?如果用的是流式 IO(OutputStream 的子類),是沒有提供相應 API 的,但可以調用以下實例的方法來完成手動刷盤:

  • 使用 RandomAccessFile 讀寫文件時,在 RandomAccessFile.write() 後使用 RandomAccessFile.getFD().sync() 方法手動刷盤
  • 使用 FileChannel 讀寫文件時,在 FileChannel.write() 後使用 FileChannel.force() 方法手動刷盤
  • 使用 MappedByteBuffer 讀寫文件時,在 MappedByteBuffer.put() 後使用 MappedByteBuffer.force() 方法手動刷盤

關於 RandomAccessFile 、FileChannel 以及 MappedByteBuffer 的詳細使用,請參考 這篇文章

四、繞過 page cache 的 IO

每次數據都寫入頁緩存在某些場合下會存在問題,試想一下,在還沒來得及刷盤的情況下,突然斷電了,那麼在 page cache 中的數據就丟失了,這對於一些要求數據強一致性和完整性的服務是無法接受的,比如 MySQL 數據庫等。通常情況下,這類應用會繞過 page cache,將數據直接寫入磁盤,如圖中藍色路徑所示。應用進程在調用 open 系統調用創建文件描述符的時候,只要設置 flag 參數爲 O_DIRECT,那麼接下來的讀寫操作都將繞過 page cache 而直接寫入磁盤。不過 java 中並沒有提供此類操作的API,要想在 java 中實現同樣的功能,可以使用 JNI 技術調用封裝了該功能的 C 語言代碼。

五、直接操縱磁盤

open(O_DIRECT) 系統調用雖然繞過了 page cache,但是是在操作系統的文件系統規範下完成的。在以 EXT3 爲文件系統的操作系統中寫入一批數據到磁盤,然後將磁盤卸載,裝載到另一個以 EXT3 爲文件系統的操作系統中時,之前寫的那批數據在新操作系統中是可以被識別的。而使用 dd 等 Linux 系統自帶的軟件,可以繞過文件系統,直接向磁盤中寫入最純粹(RAW)的數據,如圖中紫色箭頭所示。通過這種方式寫入的數據,操作系統是無法識別的,如果將磁盤卸載後裝載到另一臺電腦中,磁盤數據是不會被讀出來的。

六、mmap 系統調用

mmap 系統調用可以將文件的一個指定區域直接映射到用戶進程的虛擬地址空間,這樣當用戶進程操作文件時,就像操作分配給自己的內存一樣。更詳細地說,就是以普通方式去讀寫文件時,會產生 read/write 系統調用,而通過 mmap 方式操作文件時,在文件讀寫的過程中不會產生系統調用。

這麼說的話,mmap 比普通方式更高效嗎?其實不然。將文件映射到內存這一過程的代價是很昂貴的,如果是一個很小的文件(幾十 KB),只需要很少的 read/write 操作就能將文件讀取到內存或寫入到磁盤,如果是用 mmap,所需的代價可能會更大。因此,mmap 在讀寫大文件的時候比較有優勢。

以 mmap 方式讀寫文件時,是直接讀寫內核空間的 page cache,而不需要經由用戶空間到內核空間的內存拷貝。既然涉及到 page cache,因此也會存在斷電後數據可能丟失的情況。針對需要確保數據強一致性和完整性的場合,mmap 提供了 msync 系統調用來手動將 page cache 中的數據同步寫入到磁盤。在 java 中使用 MappedByteBuffer 來表示這塊內存映射區域,相應的手動刷盤 API 爲 MappedByteBuffer.force()

下面是 mmap 寫操作的簡單示例:

import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel.MapMode;

/**
 * @author debo
 * @date 2020-06-25
 */
public class MappedByteBufferWriteTest {
    private static final long COUNT = 1000_0000L;

    public static void main(String[] args) throws IOException {
        RandomAccessFile raf = new RandomAccessFile("/home/debo/tmp.txt", "rw");
        String msg = "你好,world!";
        // 內存映射區域總大小
        long size = msg.getBytes().length * COUNT;
        long start = System.currentTimeMillis();
        MappedByteBuffer map = raf.getChannel().map(MapMode.READ_WRITE, 0, size);
        for (int i = 0; i < COUNT; i++) {
            map.put(msg.getBytes());
        }
        raf.close();
        System.out.println(String.format("耗時:%d毫秒", System.currentTimeMillis() - start));
    }
}

MappedByteBuffer 的詳細使用,請參考 這篇文章

七、使用 strace 追蹤系統調用

依前面所說,mmap 讀寫不產生系統調用,FileOutputStream 每次讀寫都會產生系統調用。作爲 java 程序員,無法直觀地去感受這些結論,除非懂 c/c++,去探究 JVM 源碼,這樣的話就太大動干戈了。好在 Linux 中有很多現成的工具可以追蹤這些系統調用,下面以 strace 命令爲例來演示如何追蹤 java 程序底層產生的系統調用。

以上面的 FileOutputStreamTest 程序爲例,修改該程序,將循環次數 COUNT 改成 10 以方便演示,然後在命令行執行此程序:

javac FileOutputStreamTest.java && strace -ff -o out java FileOutputStreamTest

執行完程序後,會在當前工作目錄下生成若干 out.pid 文件,如圖所示
在這裏插入圖片描述
然後用以下命令查看最大的 out.pid 文件,這裏是 out.20927

cat out.20927 | grep -C 20 world

輸出結果如圖所示:
在這裏插入圖片描述
可以看到,在 openat 系統調用(作用是創建文件,同 open 系統調用)之後,共產生了 10 次 write 系統調用,這和程序中的循環次數相吻合,也印證了 FileOutputStream.write() 寫操作每次都會產生系統調用這一結論。

參考資料

漫談linux文件IO

洞悉MySQL底層架構:遊走在緩衝與磁盤之間

Linux 中直接 I/O 機制的介紹

從內核文件系統看文件讀寫過程

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