Netty基礎系列(4) --堆外記憶體與零拷貝

正號先生發表於2019-08-12

前言

到目前為止,我們知道Nio當中有三個最最核心的元件,分別是:Selelctor,Channel,Buffer。在Netty基礎系列(3) --徹底理解NIO 這一篇文章中只是進行了大致的介紹。

我們現在來深入理解一下Buffer在 堆內建立記憶體堆外建立記憶體 的底層原理,與 零拷貝 的具體實現。

Buffer

Buffer是一個抽象類,首先我們來看看Buffer有哪些實現類。

Netty基礎系列(4) --堆外記憶體與零拷貝

我們從上面這張截圖可以看出,Buffer的直接子類有7種。除了Java中Boolean型別。剩餘的7種基本型別都有與之對應的Buffer。不同型別的Buffer儲存的內容也不同,比如說ByteBuffer儲存的就是byte。IntBuffer儲存的就是int。不要想得太複雜,把底層想象成陣列即可


接下來我們著重對ByteBuffer來進行講解。理解了一個其他的理解起來都差不多。

首先我們來看ByteBuffer的繼承關係圖

Netty基礎系列(4) --堆外記憶體與零拷貝

由上面的繼承關係圖可以看出,ByteBuffer的子類有五個,分別為:

HeapByteBuffer:代表的是jvm堆內的快取。
    HeapByteBufferR: 代表的是jvm堆內的只讀快取。
MappedByteBuffer: 直接快取的抽象基類。
    DirectByteBuffer: 代表的是作業系統記憶體的快取。
        DirectByteBufferR: 代表的是作業系統記憶體的只讀快取

上面這幾個類看名字和我的介紹我想你應該知道有什麼區別了,這裡其實只分為兩大類。
分配在堆記憶體的快取分配在作業系統記憶體的快取

HeapByteBuffer

我們首先來看在堆內分配快取的底層原理。

先來看一段程式碼。

    public static void main(String args[]){
        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
    }

我們直接呼叫ByteBuffer的靜態方法建立了一個1024個位元組的ByteBuffer快取。那麼ByteBuffer的靜態方法allocate()在底層到底做了些什麼呢?

我們再來看看ByteBuffer類對於靜態方法allocate()的實現。

public abstract class ByteBuffer extends Buffer implements Comparable<ByteBuffer>
{
    public static ByteBuffer allocate(int capacity) {
        if (capacity < 0)
            throw new IllegalArgumentException();
        return new HeapByteBuffer(capacity, capacity);
    }
}

沒錯,就是很簡單。直接new了一個HeapByteBuffer物件,並指定大小為1024個位元組。這裡暫時不用管capacity是什麼,後面我們會詳細的講解,在這裡capacity就是我們傳入的1024。

到目前為止,我們已經建立了一個HeapByteBuffer物件。我們建立這個物件的意義就是用來對Channel進行讀寫。此時我們記憶體模型已經變成了如下圖所示:

Netty基礎系列(4) --堆外記憶體與零拷貝

對照著上圖我們再來看看之前寫的這個方法。

ByteBuffer byteBuffer = ByteBuffer.allocate(1024);

首先再棧空間的某個棧幀中建立了byteBuffer,接著將其指向堆記憶體中的物件HeapByteBuffer。

好了接下來是我們的重點!!!!

此時作業系統會自動在JVM之外的記憶體中分配一塊記憶體空間,這部分記憶體空間的建立和銷燬完全由作業系統來管理。我們無需在意。

Channel的資料無論是讀還是寫都是與作業系統分配的這塊記憶體打交道而不是我們的堆記憶體,當準備讀資料的時候,Channel將資料讀到作業系統分配的記憶體中,然後再複製到JVM堆記憶體中的HeapByteBuffer物件中。寫操作也是如此,當我們修改了HeapByteBuffer的資料,會將修改後的資料複製到作業系統分配的記憶體中,然後再寫到Channel中。

我們之前學的普通的IO操作底層基本上都是如此,我們思考一下,為什麼不能直接將Channel懟到HeapByteBuffer中呢?

沒錯,如果你有一定的開發經驗,一定會想到垃圾回收器。當傳送垃圾回收的時候,我們的物件在堆記憶體中是會傳送移動的,移動後記憶體地址是會改變的,而io操作並不能追蹤到你改變後的記憶體地址。所以只能在jvm外分配記憶體來運算元據。因為這一塊記憶體從建立到銷燬之間都是不會移動的。

DirectByteBuffer

我們來看看在堆外分配記憶體是如何實現的。

與前文一樣,我們首先來看在作業系統中直接分配記憶體的底層原理。先來看一段程式碼。

    public static void main(String args[]){
        ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024);
    }

與建立堆內快取類似,我們直接呼叫ByteBuffer的靜態方法建立了一個1024個位元組的DirectByteBuffer快取。那麼ByteBuffer的靜態方法allocateDirect()方法與allocate()方法又有什麼區別呢?

我們再來看看ByteBuffer類對於靜態方法allocateDirect()的實現。

public abstract class ByteBuffer extends Buffer implements Comparable<ByteBuffer>
{
      public static ByteBuffer allocateDirect(int capacity) {
        return new DirectByteBuffer(capacity);
    }
}

這裡也是直接new了一個DirectByteBuffer物件,我們進入該物件的建構函式看看幹了些什麼

Netty基礎系列(4) --堆外記憶體與零拷貝

這裡呼叫勒unsafe的allocateMemory(size)方法。我們進去後會發現這是一個native方法,底層呼叫的c語言的程式碼。就是在作業系統記憶體中分配了一個我們指定大小的記憶體用以運算元據。並且記錄了這塊記憶體的地址。

此時我們的記憶體模型如下圖所示:

Netty基礎系列(4) --堆外記憶體與零拷貝

因為記憶體中這塊記憶體不再是作業系統分配的,而是我們java程式碼呼叫native方法,自己分配的記憶體,並且記錄了該記憶體的地址。所以我們運算元據就不需要再堆內操作可以直接在jvm記憶體以外的記憶體操作。此時每次讀寫操作都節省了兩次記憶體複製操作。

這就是我們大名鼎鼎的zero copy(零拷貝)技術。

總結

其實我們多思考一下,這樣的優勢大嗎?其實Channel中IO的操作相對於記憶體的複製來說是慢很多的,即便我們在讀寫資料的時候多了兩次複製的過程對於整體來說影響是不大的。

那麼什麼時候就會體現出零拷貝的優勢呢?有大量併發io操作,並且io操作是短暫完成的。這時由於節省了大量的記憶體copy操作,這些節省的時間積累下來也是非常可觀的。

netty的底層就是用的零拷貝技術,所以netty能做到很好併發,之後我們會分析在netty中零拷貝是如何落實的。

相關文章