(代碼篇)從基礎文件IO說起虛擬內存,內存文件映射,零拷貝


上一篇講解了基礎文件IO的理論發展,這里結合java看看各項理論的具體實現。

傳統IO-intsmaze

傳統文件IO操作的基礎代碼如下:

FileInputStream in = new FileInputStream("D:\\java.txt"); 
in.read();

JAVA虛擬機內部便會調用OS底層的 read()系統調用完成操作,在調用 in.read()的時候就是從內核緩沖區直接返回數據了。

FileInputStream基礎read()內部也是調用的read(char[] arg0, int arg1, int arg2)方法。

    public int read() throws IOException {
        return this.read0();
    }

    private int read0() throws IOException {
        Object arg0 = this.lock;
        synchronized (this.lock) {
           ...int arg2 = this.read(arg1, 0, 2);
           ...   
        }
    }

    public int read(char[] arg0, int arg1, int arg2) throws IOException {
        int arg3 = arg1;
        int arg4 = arg2;
        Object arg5 = this.lock;
        synchronized (this.lock) {
            this.ensureOpen();
            ...return arg6 + this.implRead(arg0, arg3, arg3 + arg4);
           ...
        }
    }

傳統IO的緩沖優化-intsmaze

JAVA提供一個 BufferedInputStream (同理 BufferedOuputStream , BufferedReader/Writer)類來作為緩沖區。 
當程序讀取OS內核緩沖區數據的時候,便發起了一次系統調用操作,而系統調用的代價相對來說是比較高的,涉及到進程用戶態和內核態的上下文切換等一系列操作,所以jdk用如下的包裝:

FileInputStream in = new FileInputStream("D:\\java.txt"); 
BufferedInputStream intsmaze= new BufferedInputStream(in); 
intsmaze.read();

每一次 intsmaze.read(),BufferedInputStream 會根據情況自動為我們預讀更多的字節數據到它自己維護的一個內部字節數組緩沖區中,這樣我們便可以減少系統調用次數,從而達到其緩沖區的目的。所以要明確的一點是BufferedInputStream 的作用不是減少磁盤IO操作次數(這個OS已經幫我們做了,系統read()會因為局部性原理預讀一批數據,供系統IO多次調用,見上篇),而是通過減少系統調用次數來提高性能的。

JDK8 中 BufferedInputStream 的源碼驗證-intsmaze

public class BufferedReader extends Reader {
    private Reader in;
    private char cb[];
    private int nChars, nextChar;
    private static int defaultCharBufferSize = 8192;
    public BufferedReader(Reader in, int sz) {
        this.in = in;
        cb = new char[sz];
        nextChar = nChars = 0;
    }
    public BufferedReader(Reader in) {
        this(in, defaultCharBufferSize);
    }
    public int read() throws IOException {
        synchronized (lock) {
            for (;;) {
                if (nextChar >= nChars) {
                    fill();
                }
                ......
                return cb[nextChar++];
            }
        }
    }
    private void fill() throws IOException {
        ......//判斷邏輯
        int n;  
        n = in.read(cb, dst, cb.length - dst);//從這里可以看到,緩沖類中還是調用的FileReader的read(char cbuf[], int offset, int length)方法
        ......
    }

BufferedInputStream 內部維護着一個字節數組 byte[] buf 來實現緩沖區的功能,調用的 bufferedInputStream.read()方法在返回數據之前有做一個 if 判斷,如果 buf 數組的當前索引不在有效的索引范圍之內,即 if 條件成立,buf 字段維護的緩沖區已經不夠了,這時候會調用內部的 fill() 方法進行填充,而fill()會預讀更多的數據到 buf 數組緩沖區中去,然后再返回當前字節數據,如果 if 條件不成立便直接從 buf緩沖區數組返回數據了。

從緩沖類的read()方法內部我們也可以看到也是調用的FileInputStream的read(char[] arg0, int arg1, int arg2)方法。

 

 

新IO(NIO)-intsmaze

新IO兩大核心對象Channel(通道)和Buffer(緩沖)。

  Channel(通道):新IO中所有數據都需要通過通道Channel傳輸,與傳統的流對象的區別在於,Channel可以將制定文件的部分或全部直接映射成buffer。Channel常用方法,map(),read(),write();map()方法用於將Channel對應的部分或全部數據映射成bytebuffer;read()和write()方法用於從buffer中讀取數據或向buffer中寫入數據。

  Buffer(緩沖):Buffer是一個數組,發送到channel中的所有對象都必須先放到buffer中,從channel中讀取的數據也必須先放到buffer中。Buffer是抽象類,常用的子類是ByteBuffer和CharBuffer(這兩個也是抽象的)。

創建緩沖區-intsmaze

static XxxBuffer allocate(int capacity)//創建一個容量為capacity的XxxBuffer對象,內部創建子類對象為HeapXxxBuffer。
從堆空間中分配了一個Xxx型數組作為備份存儲器來存儲100個Xxx變量。
如果想提供我們自己的數組做緩沖區的備份存儲器,可以調用wrap()函數。以CharBuffer為例子:
char[] myArray=new char[100];
CharBuffer charBuffer=CharBuffer.wrap(myArray);
構造了一個新的緩沖區對象,數據元素會存在於數組中。意味着通過調用put()函數造成的對緩沖區的改動會直接影響這個數組,而且對這個數組的任何改動也會對這個緩沖區對象可見。
帶有offset和length作為參數的wrap()函數版本則會構造一個按照我們提供的offset和length參數值初始化位置和上界的緩沖區。
CharBuffer charBuffer=CharBuffer.wrap(myArray,12,48);

創建了一個position值為12,limit值為60,容量為myArray.length的緩沖區。

讀文件操作-intsmaze

        FileInputStream fis = new FileInputStream(file); 
        FileChannel channel = fis.getChannel();

        byte[] bytes = new byte[1024];
        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
 
        // channel.read(ByteBuffer) 方法就類似於 inputstream.read(byte)
        // 每次read都將讀取 1024個字節到ByteBuffer
        int len;
        while ((len = channel.read(byteBuffer)) != -1) {
            byteBuffer.flip();
        // 類似與InputStrean的read(byte[],offset,len)方法讀取 
            byteBuffer.get(bytes, 0, len);

            byteBuffer.clear(); 
        }
        channel.close();
        fis.close();


    public static ByteBuffer allocate(int capacity) { if (capacity < 0) throw new IllegalArgumentException(); return new HeapByteBuffer(capacity, capacity); }
 

 

直接緩沖區-intsmaze

ByteBuffer還有一個特殊的子類DirectByteBuffer(父抽象類為MappedByteBuffer 注意繼承層次),用於表示channel將磁盤文件的部分或全部內容映射到內存中后得到的結果。
MappedByteBuffer對象有兩種方式獲得,channel的map()方法返回或者ByteBuffer.allocateDirect(capacity)。

注意,其他buffer子類沒有allocateDirect方法,不支持內容映射的;字節緩沖區跟其他緩沖區最明顯的不同在於,它可以成為通道所執行的I/0的源頭/或目標。觀察源碼你會發現通道只接受ByteBuffer作為參數。

 // 分配128MB直接內存
ByteBuffer bb = ByteBuffer.allocateDirect(1024 * 1024 * 128);

public static ByteBuffer allocateDirect(int capacity) { return new DirectByteBuffer(capacity); }


bytebyffer可以是兩種類型,一種是基於直接內存(也就是非堆內存);另一種是非直接內存(也就是堆內存)。 
對於直接內存來說,JVM將會在IO操作上具有更高的性能,因為它直接作用於本地系統的IO操作。

直接內存使用allocateDirect創建,它比申請普通的堆內存需要耗費更高的性能。不過,這部分的數據是在JVM之外的,因此它不會占用應用的內存。當你有很大的數據要緩存,並且它的生命周期又很長,那么就比較適合使用直接內存。只是一般來說,如果不是能帶來很明顯的性能提升,還是推薦直接使用堆內存。

直接內存並不是虛擬機運行時數據區的一部分,也不是Java 虛擬機規范中定義的內存區域。在JDK1.4 中新加入了NIO(New Input/Output)類,引入了一種基於通道(Channel)與緩沖區(Buffer)的I/O 方式,它可以使用native 函數庫直接分配堆外內存,然后通過一個存儲在Java堆中的DirectByteBuffer 對象作為這塊內存的引用進行操作。這樣能在一些場景中顯著提高性能,因為避免了在Java堆和Native堆中來回復制數據。

從數據流的角度,非直接內存是下面這樣的作用鏈:

本地IO-->直接內存-->非直接內存-->直接內存-->本地IO

而直接內存是:

本地IO-->直接內存-->本地IO

 

內存文件映射(屬於直接緩沖區)-intsmaze

按照jdk文檔的官方說法,內存映射文件也屬於JVM中的直接緩沖區。

        File file=new File("D://develop/CodeWorkSpace/1.txt");
        FileInputStream fr = new FileInputStream(file);
        FileChannel fileChannel=fr.getChannel(); 
 //      map()將channel對應的全部或部分數據映射成MappedByteBuffer,第一個參數是映射模式,第二,第三參數就是用於將fileChannel哪些數據映射到buffer中。
 //      MappedByteBuffer mappedByteBuffer=fileChannel.map(FileChannel.MapMode.READ_ONLY, 0, file.length());//將fileChannel全部數據映射到buffer       
        MappedByteBuffer mappedByteBuffer=fileChannel.map(FileChannel.MapMode.READ_ONLY, 0, 20);
        CharBuffer cb=Charset.forName("UTF-8").newDecoder().decode(mappedByteBuffer);
        System.out.println(cb);
        mappedByteBuffer.clear();
        mappedByteBuffer=fileChannel.map(FileChannel.MapMode.READ_ONLY, 21, 20);
        cb=Charset.forName("UTF-8").newDecoder().decode(mappedByteBuffer);
        System.out.println("-------------------------");
        System.out.println(cb);
        mappedByteBuffer.clear();

內存映射文件特別適合於對大文件的操作,JAVA中的限制是最大不得超過Integer.MAX_VALUE,即2G左右,我們可以通過分次映射文件(channel.map)的不同部分來達到操作整個文件的目的。

java中提供了3種內存映射模式-intsmaze

只讀模式:如果程序試圖進行寫操作,則會拋出ReadOnlyBufferException異常;

讀寫模式:通過內存映射文件的方式寫或修改文件內容的話是會立刻反映到磁盤文件中去的,別的進程如果共享了同一個映射文件,那么也會立即看到變化!而不是像標准IO那樣每個進程有各自的內核緩沖區,比如JAVA代碼中,沒有執行IO輸出流的 flush()或者close() 操作,那么對文件的修改不會更新到磁盤去,除非進程運行結束;

專用模式:采用的是OS的“寫時拷貝”原則,即在沒有發生寫操作的情況下,多個進程之間都是共享文件的同一塊物理內存(進程各自的虛擬地址指向同一片物理地址),一旦某個進程進行寫操作,那么將會把受影響的文件數據單獨拷貝一份到進程的私有緩沖區中,不會反映到物理文件中去。

 

MappedByteBuffer,可被通道讀寫-intsmaze

MappedByteBuffer提供的方法: 
load():加載整個文件到內存 
isLoaded():判斷文件數據是否全部加載到了內存 
force():將緩沖區的更改刷新到磁盤 
加載文件所使用的內存是Java堆區之外,並駐留共享內存,允許兩個不同進程共享文件。 
不要頻繁調用MappedByteBuffer.force()方法,這個方法意味着強制操作系統將內存中的內容寫入磁盤,所以如果你每次寫入內存映射文件都調用force()方法,你將不會體會到使用映射字節緩沖的好處,相反,它(的性能)將類似於磁盤IO的性能。

 

直接內存DirectMemory大小設置-intsmaze

直接內存DirectMemory的大小默認為 -Xmx 的JVM堆的最大值,但是並不受其限制(理論上說受限於進程的虛擬地址空間大小,比如 32位的windows上,每個進程有4G的虛擬空間除去 2G為OS內核保留外,再減去 JVM堆的最大值,剩余的才是DirectMemory大小。),而是由JVM參數 MaxDirectMemorySize單獨控制

/**
 * @author:YangLiu
 * @describe: 
 *            測試一:設置JVM參數-Xmx100m,運行異常,因為如果沒設置-XX:MaxDirectMemorySize,則默認與-Xmx參數值相同
 *            ,分配128M直接內存超出限制范圍。
 *            測試用例2:設置JVM參數-Xmx256m,運行正常,因為128M小於256M,屬於范圍內分配。
 *            測試用例3:設置JVM參數-Xmx256m
 *            -XX:MaxDirectMemorySize=100M,運行異常,分配的直接內存128M超過限定的100M。
 *            測試用例4:設置JVM參數
 *            -Xmx768m,運行程序觀察內存使用變化,會發現clean()后內存馬上下降,說明使用clean()方法能有效及時回收直接緩存。
 */
public class DirectByteBufferTest {
    public static void main(String[] args) throws InterruptedException {
        // 分配128MB直接內存
        ByteBuffer bb = ByteBuffer.allocateDirect(1024 * 1024 * 128);

        TimeUnit.SECONDS.sleep(20);
        System.out.println("intsmaze");
        // 清除直接緩存
        ((DirectBuffer) bb).cleaner().clean();
        System.out.println("intsmaze");
    }
}

堆外內存,其實就是不受JVM控制的內存。 
1 減少了垃圾回收的工作 
2 加快了復制的速度。因為堆內在flush到遠程時,會先復制到直接內存(非堆內存),然后在發送;而堆外內存相當於省略掉了這個工作。

 

為了快速構建項目,使用高性能框架是我的職責,但若不去深究底層的細節會讓我失去對技術的熱愛。
探究的過程是痛苦並激動的,痛苦在於完全理解甚至要十天半月甚至沒有機會去應用,激動在於技術的相同性,新的框架不再是我焦慮。
每一個底層細節的攻克,就越發覺得自己對計算機一無所知,這可能就是對知識的敬畏。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM