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

  • 2019 年 10 月 3 日
  • 筆記

前言

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

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

Buffer

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

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


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

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

由上面的繼承關係圖可以看出,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進行讀寫。此時我們記憶體模型已經變成了如下圖所示:

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

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對象,我們進入該對象的構造函數看看幹了些什麼

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

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

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

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

總結

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

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

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