文件 IO 操做的一些最佳實踐

背景

已通過去的中間件性能挑戰賽,和正在進行中的 第一屆 PolarDB 數據性能大賽 都涉及到了文件操做,合理地設計架構以及正確地壓榨機器的讀寫性能成了比賽中獲取較好成績的關鍵。正在參賽的我收到了幾位公衆號讀者朋友的反饋,他們大多表達出了這樣的煩惱:「對比賽很感興趣,但不知道怎麼入門」,「能跑出成績,但相比前排的選手,成績相差10倍有餘」…爲了能讓更多的讀者參與到以後相相似的比賽中來,我簡單整理一些文件IO操做的最佳實踐,而不涉及總體系統的架構設計,但願經過這篇文章的介紹,讓你可以歡快地參與到以後相似的性能挑戰賽之中來。java

知識點梳理

本文主要關注的 Java 相關的文件操做,理解它們須要一些前置條件,好比 PageCache,Mmap(內存映射),DirectByteBuffer(堆外緩存),順序讀寫,隨機讀寫...不必定須要徹底理解,但至少知道它們是個啥,由於本文將會主要圍繞這些知識點來展開描述。linux

初識 FileChannel 和 MMAP

首先,文件IO類型的比賽最重要的一點,就是選擇好讀寫文件的方式,那 JAVA 中文件IO有多少種呢?原生的讀寫方式大概能夠被分爲三種:普通IO,FileChannel(文件通道),MMAP(內存映射)。區分他們也很簡單,例如 FileWriter,FileReader 存在於 java.io 包中,他們屬於普通IO;FileChannel 存在於 java.nio 包中,屬於 NIO 的一種,可是注意 NIO 並不必定意味着非阻塞,這裏的 FileChannel 就是阻塞的;較爲特殊的是後者 MMAP,它是由 FileChannel 調用 map 方法衍生出來的一種特殊讀寫文件的方式,被稱之爲內存映射。git

使用 FIleChannel 的方式:github

FileChannel fileChannel = new RandomAccessFile(new File("db.data"), "rw").getChannel();
複製代碼

獲取 MMAP 的方式:數組

MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, filechannel.size();
複製代碼

MappedByteBuffer 即是 JAVA 中 MMAP 的操做類。緩存

面向於字節傳輸的傳統 IO 方式遭到了咱們的唾棄,咱們重點探討 FileChannel 和 MMAP 這兩種讀寫方式的區別。安全

FileChannel 讀寫

// 寫
byte[] data = new byte[4096];
long position = 1024L;
//指定 position 寫入 4kb 的數據
fileChannel.write(ByteBuffer.wrap(data), position);
//從當前文件指針的位置寫入 4kb 的數據
fileChannel.write(ByteBuffer.wrap(data));

// 讀
ByteBuffer buffer = ByteBuffer.allocate(4096);
long position = 1024L;
//指定 position 讀取 4kb 的數據
fileChannel.read(buffer,position);
//從當前文件指針的位置讀取 4kb 的數據
fileChannel.read(buffer);
複製代碼

FileChannel 大多數時候是和 ByteBuffer 這個類打交道,你能夠將它理解爲一個 byte[] 的封裝類,提供了豐富的 API 去操做字節,不瞭解的同窗能夠去熟悉下它的 API。值得一提的是,write 和 read 方法均是線程安全的,FileChannel 內部經過一把 private final Object positionLock = new Object(); 鎖來控制併發。微信

FileChannel 爲何比普通 IO 要快呢?這麼說可能不嚴謹,由於你要用對它,FileChannel 只有在一次寫入 4kb 的整數倍時,才能發揮出實際的性能,這得益於 FileChannel 採用了 ByteBuffer 這樣的內存緩衝區,讓咱們能夠很是精準的控制寫盤的大小,這是普通 IO 沒法實現的。4kb 必定快嗎?也不嚴謹,這主要取決你機器的磁盤結構,而且受到操做系統,文件系統,CPU 的影響,例如中間件性能挑戰賽時的那塊盤,一次至少寫入 64kb 才能發揮出最高的 IOPS。多線程

中間件性能挑戰複賽的盤

然而 PolarDB 這塊盤就徹底不同了,可謂是異常彪悍,具體是如何的表現因爲比賽仍在進行中,不予深究,但憑藉着 benchmark everyting 的技巧,咱們徹底能夠測出來。架構

另一點,成就了 FileChannel 的高效,介紹這點以前,我想作一個提問:FileChannel 是直接把 ByteBuffer 中的數據寫入到磁盤嗎?思考幾秒…答案是:NO。ByteBuffer 中的數據和磁盤中的數據還隔了一層,這一層即是 PageCache,是用戶內存和磁盤之間的一層緩存。咱們都知道磁盤 IO 和內存 IO 的速度但是相差了好幾個數量級。咱們能夠認爲 filechannel.write 寫入 PageCache 即是完成了落盤操做,但實際上,操做系統最終幫咱們完成了 PageCache 到磁盤的最終寫入,理解了這個概念,你就應該可以理解 FileChannel 爲何提供了一個 force() 方法,用於通知操做系統進行及時的刷盤。

同理,當咱們使用 FileChannel 進行讀操做時,一樣經歷了:磁盤->PageCache->用戶內存這三個階段,對於平常使用者而言,你能夠忽略掉 PageCache,但做爲挑戰者參賽,PageCache 在調優過程當中是萬萬不能忽視的,關於讀操做這裏不作過多的介紹,咱們再下面的小結中還會再次說起,這裏當作是引出 PageCache 的概念。

MMAP 讀寫

// 寫
byte[] data = new byte[4];
int position = 8;
//從當前 mmap 指針的位置寫入 4b 的數據
mappedByteBuffer.put(data);
//指定 position 寫入 4b 的數據
MappedByteBuffer subBuffer = mappedByteBuffer.slice();
subBuffer.position(position);
subBuffer.put(data);

// 讀
byte[] data = new byte[4];
int position = 8;
//從當前 mmap 指針的位置讀取 4b 的數據
mappedByteBuffer.get(data);
//指定 position 讀取 4b 的數據
MappedByteBuffer subBuffer = mappedByteBuffer.slice();
subBuffer.position(position);
subBuffer.get(data);
複製代碼

FileChannel 已經足夠強大了,MappedByteBuffer 還能玩出什麼花來呢?請允許我賣個關子先,先介紹一下 MappedByteBuffer 的使用注意點。

當咱們執行 fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, 1.5 * 1024 * 1024 * 1024); 以後,觀察一下磁盤上的變化,會馬上得到一個 1.5G 的文件,但此時文件的內容所有是 0(字節 0)。這符合 MMAP 的中文描述:內存映射文件,咱們以後對內存中 MappedByteBuffer 作的任何操做,都會被最終映射到文件之中,

mmap 把文件映射到用戶空間裏的虛擬內存,省去了從內核緩衝區複製到用戶空間的過程,文件中的位置在虛擬內存中有了對應的地址,能夠像操做內存同樣操做這個文件,至關於已經把整個文件放入內存,但在真正使用到這些數據前卻不會消耗物理內存,也不會有讀寫磁盤的操做,只有真正使用這些數據時,也就是圖像準備渲染在屏幕上時,虛擬內存管理系統 VMS 才根據缺頁加載的機制從磁盤加載對應的數據塊到物理內存進行渲染。這樣的文件讀寫文件方式少了數據從內核緩存到用戶空間的拷貝,效率很高

看了稍微官方一點的描述,你可能對 MMAP 有了些許的好奇,有這麼厲害的黑科技存在的話,還有 FileChannel 存在的意義嗎!而且網上不少文章都在說,MMAP 操做大文件性能比 FileChannel 搞出一個數量級!然而,經過我比賽的認識,MMAP 並不是是文件 IO 的銀彈,它只有在一次寫入很小量數據的場景下才能表現出比 FileChannel 稍微優異的性能。緊接着我還要告訴你一些令你沮喪的事,至少在 JAVA 中使用 MappedByteBuffer 是一件很是麻煩而且痛苦的事,主要表現爲三點:

  1. MMAP 使用時必須實現指定好內存映射的大小,而且一次 map 的大小限制在 1.5G 左右,重複 map 又會帶來虛擬內存的回收、從新分配的問題,對於文件不肯定大小的情形實在是太不友好了。
  2. MMAP 使用的是虛擬內存,和 PageCache 同樣是由操做系統來控制刷盤的,雖然能夠經過 force() 來手動控制,但這個時間把握很差,在小內存場景下會很使人頭疼。
  3. MMAP 的回收問題,當 MappedByteBuffer 再也不須要時,能夠手動釋放佔用的虛擬內存,但…方式很是的詭異。
public static void clean(MappedByteBuffer mappedByteBuffer) {
    ByteBuffer buffer = mappedByteBuffer;
    if (buffer == null || !buffer.isDirect() || buffer.capacity() == 0)
        return;
    invoke(invoke(viewed(buffer), "cleaner"), "clean");
}

private static Object invoke(final Object target, final String methodName, final Class<?>... args) {
    return AccessController.doPrivileged(new PrivilegedAction<Object>() {
        public Object run() {
            try {
                Method method = method(target, methodName, args);
                method.setAccessible(true);
                return method.invoke(target);
            } catch (Exception e) {
                throw new IllegalStateException(e);
            }
        }
    });
}

private static Method method(Object target, String methodName, Class<?>[] args) throws NoSuchMethodException {
    try {
        return target.getClass().getMethod(methodName, args);
    } catch (NoSuchMethodException e) {
        return target.getClass().getDeclaredMethod(methodName, args);
    }
}

private static ByteBuffer viewed(ByteBuffer buffer) {
    String methodName = "viewedBuffer";
    Method[] methods = buffer.getClass().getMethods();
    for (int i = 0; i < methods.length; i++) {
        if (methods[i].getName().equals("attachment")) {
            methodName = "attachment";
            break;
        }
    }
    ByteBuffer viewedBuffer = (ByteBuffer) invoke(buffer, methodName);
    if (viewedBuffer == null)
        return buffer;
    else
        return viewed(viewedBuffer);
}
複製代碼

對的,你沒看錯,這麼長的代碼僅僅是爲了幹回收 MappedByteBuffer 這一件事。

因此我建議,優先使用 FileChannel 去完成初始代碼的提交,在必須使用小數據量(例如幾個字節)刷盤的場景下,再換成 MMAP 的實現,其餘場景 FileChannel 徹底能夠 cover(前提是你理解怎麼合理使用 FileChannel)。至於 MMAP 爲何在一次寫入少許數據的場景下表現的比 FileChannel 優異,我尚未查到理論根據,若是你有相關的線索,歡迎留言。理論分析下,FileChannel 一樣是寫入內存,但比 MMAP 多了一次內核緩衝區與用戶空間互相複製的過程,因此在極端場景下,MMAP 表現的更加優秀。至於 MMAP 分配的虛擬內存是否就是真正的 PageCache 這一點,我以爲能夠近似理解成 PageCache。

順序讀比隨機讀快,順序寫比隨機寫快

不管你是機械硬盤仍是 SSD,這個結論都是必定成立的,雖然背後的緣由不太同樣,咱們今天不討論機械硬盤這種古老的存儲介質,重點 foucs 在 SSD 上,來看看在它之上進行的隨機讀寫爲何比順序讀寫要慢。即便各個 SSD 和文件系統的構成具備差別性,但咱們今天的分析一樣具有參考價值。

首先,什麼是順序讀,什麼是隨機讀,什麼是順序寫,什麼是隨機寫?可能咱們剛接觸文件 IO 操做時並不會有這樣的疑惑,但寫着寫着,本身都開始懷疑本身的理解了,不知道你有沒有經歷過這樣相似的階段,反正我有一段時間的確懷疑過。那麼,先來看看兩段代碼:

寫入方式一:64個線程,用戶本身使用一個 atomic 變量記錄寫入指針的位置,併發寫入

ExecutorService executor = Executors.newFixedThreadPool(64);
AtomicLong wrotePosition = new AtomicLong(0);
for(int i=0;i<1024;i++){
    final int index = i;
    executor.execute(()->{
        fileChannel.write(ByteBuffer.wrap(new byte[4*1024]),wrote.getAndAdd(4*1024));
    })
}
複製代碼

寫入方式二:給 write 加了鎖,保證了同步。

ExecutorService executor = Executors.newFixedThreadPool(64);
AtomicLong wrotePosition = new AtomicLong(0);
for(int i=0;i<1024;i++){
    final int index = i;
    executor.execute(()->{
        write(new byte[4*1024]);
    })
}

public synchronized void write(byte[] data){
    fileChannel.write(ByteBuffer.wrap(new byte[4*1024]),wrote.getAndAdd(4*1024));
}
複製代碼

答案是方式二纔算順序寫,順序讀也是同理。對於文件操做,加鎖並非一件很是可怕的事,不敢同步 write/read 纔可怕!有人會問:FileChannel 內部不是已經有 positionLock 保證寫入的線程安全了嗎,爲何還要本身加同步?爲何這樣會快?我用大白話來回答的話就是多線程併發 write 而且不加同步,會致使文件空洞,它的執行次序多是

時序1:thread1 write position[0~4096)

時序2:thread3 write position[8194~12288)

時序2:thread2 write position[4096~8194)

因此並非徹底的「順序寫」。不過你也別擔憂加鎖會致使性能降低,咱們會在下面的小結介紹一個優化:經過文件分片來減小多線程讀寫時鎖的衝突。

在來分析原理,順序讀爲何會比隨機讀要快?順序寫爲何比隨機寫要快?這兩個對比其實都是一個東西在起做用:PageCache,前面咱們已經提到了,它是位於 application buffer(用戶內存)和 disk file(磁盤)之間的一層緩存。

PageCache

以順序讀爲例,當用戶發起一個 fileChannel.read(4kb) 以後,實際發生了兩件事

  1. 操做系統從磁盤加載了 16kb 進入 PageCache,這被稱爲預讀
  2. 操做通從 PageCache 拷貝 4kb 進入用戶內存

最終咱們在用戶內存訪問到了 4kb,爲何順序讀快?很容量想到,當用戶繼續訪問接下來的[4kb,16kb]的磁盤內容時,即是直接從 PageCache 去訪問了。試想一下,當須要訪問 16kb 的磁盤內容時,是發生4次磁盤 IO 快,仍是發生1次磁盤 IO+4 次內存 IO 快呢?答案是顯而易見的,這一切都是 PageCache 帶來的優化。

深度思考:當內存吃緊時,PageCache 的分配會受影響嗎?PageCache 的大小如何肯定,是固定的 16kb 嗎?我能夠監控 PageCache 的命中狀況嗎? PageCache 會在哪些場景失效,若是失效了,咱們又要哪些補救方式呢?

我進行簡單的自問自答,背後的邏輯還須要讀者去推敲:

  • 當內存吃緊時,PageCache 的預讀會受到影響,實測,並無搜到到文獻支持
  • PageCache 是動態調整的,能夠經過 linux 的系統參數進行調整,默認是佔據總內存的 20%
  • github.com/brendangreg… github 上一款工具能夠監控 PageCache
  • 這是頗有意思的一個優化點,若是用 PageCache 作緩存不可控,不妨本身作預讀如何呢?

順序寫的原理和順序讀一致,都是收到了 PageCache 的影響,留給讀者本身推敲一下。

直接內存(堆外) VS 堆內內存

前面 FileChannel 的示例代碼中已經使用到了堆內內存: ByteBuffer.allocate(4 * 1024),ByteBuffer 提供了另外的方式讓咱們能夠分配堆外內存 : ByteBuffer.allocateDirect(4 * 1024)。這就引來的一系列的問題,我何時應該使用堆內內存,何時應該使用直接內存?

我不花太多筆墨去闡述了,直接上對比:

堆內內存 堆外內存
底層實現 數組,JVM 內存 unsafe.allocateMemory(size)返回直接內存
分配大小限制 -Xms-Xmx 配置的 JVM 內存相關,而且數組的大小有限制,在作測試時發現,當 JVM free memory 大於 1.5G 時,ByteBuffer.allocate(900M) 時會報錯 能夠經過 -XX:MaxDirectMemorySize 參數從 JVM 層面去限制,同時受到機器虛擬內存(說物理內存不太準確)的限制
垃圾回收 沒必要多說 當 DirectByteBuffer 再也不被使用時,會出發內部 cleaner 的鉤子,保險起見,能夠考慮手動回收:((DirectBuffer) buffer).cleaner().clean();
拷貝方式 用戶態<->內核態 內核態

關於堆內內存和堆外內存的一些最佳實踐:

  1. 當須要申請大塊的內存時,堆內內存會受到限制,只能分配堆外內存。
  2. 堆外內存適用於生命週期中等或較長的對象。( 若是是生命週期較短的對象,在 YGC 的時候就被回收了,就不存在大內存且生命週期較長的對象在 FGC 對應用形成的性能影響 )。
  3. 直接的文件拷貝操做,或者 I/O 操做。直接使用堆外內存就能少去內存從用戶內存拷貝到系統內存的消耗
  4. 同時,還可使用池+堆外內存 的組合方式,來對生命週期較短,但涉及到 I/O 操做的對象進行堆外內存的再使用( Netty中就使用了該方式 )。在比賽中,儘可能不要出如今頻繁 new byte[] ,建立內存區域再回收也是一筆不小的開銷,使用 ThreadLocal<ByteBuffer>ThreadLocal<byte[]> 每每會給你帶來意外的驚喜~
  5. 建立堆外內存的消耗要大於建立堆內內存的消耗,因此當分配了堆外內存以後,儘量複用它。

黑魔法:UNSAFE

public class UnsafeUtil {
    public static final Unsafe UNSAFE;
    static {
        try {
            Field field = Unsafe.class.getDeclaredField("theUnsafe");
            field.setAccessible(true);
            UNSAFE = (Unsafe) field.get(null);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}
複製代碼

咱們可使用 UNSAFE 這個黑魔法實現不少沒法想象的事,我這裏就稍微介紹一兩點吧。

實現直接內存與內存的拷貝:

ByteBuffer buffer = ByteBuffer.allocateDirect(4 * 1024 * 1024);
long addresses = ((DirectBuffer) buffer).address();
byte[] data = new byte[4 * 1024 * 1024];
UNSAFE.copyMemory(data, 16, null, addresses, 4 * 1024 * 1024);
複製代碼

copyMemory 方法能夠實現內存之間的拷貝,不管是堆內和堆外,1~2 個參數是 source 方,3~4 是 target 方,第 5 個參數是 copy 的大小。若是是堆內的字節數組,則傳遞數組的首地址和 16 這個固定的 ARRAY_BYTE_BASE_OFFSET 偏移常量;若是是堆外內存,則傳遞 null 和直接內存的偏移量,能夠經過 ((DirectBuffer) buffer).address() 拿到。爲何不直接拷貝,而要藉助 UNSAFE?固然是由於它快啊!少年!另外補充:MappedByteBuffer 也可使用 UNSAFE 來 copy 從而達到寫盤/讀盤的效果哦。

至於 UNSAFE 還有那些黑科技,能夠專門去了解下,我這裏就不過多贅述了。

文件分區

前面已經提到了順序讀寫時咱們須要對 write,read 加鎖,而且我一再強調的一點是:加鎖並不可怕,文件 IO 操做並無那麼依賴多線程。可是加鎖以後的順序讀寫必然沒法打滿磁盤 IO,現在系統強勁的 CPU 總不能不壓榨吧?咱們能夠採用文件分區的方式來達到一箭雙鵰的效果:既知足了順序讀寫,又減小了鎖的衝突。

那麼問題又來了,分多少合適呢?文件多了,鎖衝突變下降了;文件太多了,碎片化太過嚴重,單個文件的值太少,緩存也就不容易命中,這樣的 trade off 如何平衡?沒有理論答案,benchmark everything~

Direct IO

linux io

最後咱們來探討一下以前從沒提到的一種 IO 方式,Direct IO,什麼,Java 還有這東西?博主你騙我?以前怎麼告訴我只有三種 IO 方式!別急着罵我,嚴謹來講,這並非 JAVA 原生支持的方式,但能夠經過 JNA/JNI 調用 native 方法作到。從上圖咱們能夠看到 :Direct IO 繞過了 PageCache,但咱們前面說到過,PageCache 但是個好東西啊,幹嗎不用他呢?再仔細推敲一下,還真有一些場景下,Direct IO 能夠發揮做用,沒錯,那就是咱們前面沒怎麼提到的:隨機讀。當使用 fileChannel.read() 這類會觸發 PageCache 預讀的 IO 方式時,咱們其實並不但願操做系統幫咱們幹太多事,除非真的踩了狗屎運,隨機讀都能命中 PageCache,但概率可想而知。Direct IO 雖然被 Linus 無腦噴過,但在隨機讀的場景下,依舊存在其價值,減小了 Block IO Layed(近似理解爲磁盤) 到 Page Cache 的 overhead。

話說回來,Java 怎麼用 Direct IO 呢?有沒有什麼限制呢?前面說過,Java 目前原生並不支持,但也有好心人封裝好了 Java 的 JNA 庫,實現了 Java 的 Direct IO,github 地址:github.com/smacke/jayd…

int bufferSize = 20 * 1024 * 1024;
DirectRandomAccessFile directFile = new DirectRandomAccessFile(new File("dio.data"), "rw", bufferSize);
for(int i= 0;i< bufferSize / 4096;i++){
    byte[] buffer = new byte[4 * 1024];
    directFile.read(buffer);
    directFile.readFully(buffer);
}
directFile.close();
複製代碼

但須要注意的是,只有 Linux 系統才支持 DIO! 因此,少年,是時候上手裝一臺 linux 了。值得一提的是,聽說在 Jdk10 發佈以後,Direct IO 將會獲得原生的支持,讓咱們拭目以待吧!

總結

以上均是我的的實踐積累而來的經驗,有部分結論沒有找到文獻的支撐,因此若有錯誤,歡迎指正。關於 PolarDB 數據性能大賽的比賽分析,等複賽結束後我會專門另起一篇文章,分析下具體如何使用這些優化點,固然這些小技巧其實不少人都知道,決定最後成績的仍是總體設計的架構,以及對文件IO,操做系統,文件系統,CPU 和語言特性的理解。雖然 JAVA 搞這種性能挑戰賽並不吃香,但依舊是樂趣無窮,但願這些文件 IO 的知識可以幫助你,等下次比賽時看到你的身影~

歡迎關注個人微信公衆號:「Kirito的技術分享」,關於文章的任何疑問都會獲得回覆,帶來更多 Java 相關的技術分享。

關注微信公衆號
相關文章
相關標籤/搜索