零拷貝(Zero-copy)技術指在計算機執行操做時,CPU 不須要先將數據從一個內存區域複製到另外一個內存區域,從而能夠減小上下文切換以及 CPU 的拷貝時間。它的做用是在數據報從網絡設備到用戶程序空間傳遞的過程當中,減小數據拷貝次數,減小系統調用,實現 CPU 的零參與,完全消除 CPU 在這方面的負載。實現零拷貝用到的最主要技術是 DMA 數據傳輸技術和內存區域映射技術。java
因爲操做系統的進程與進程之間是共享 CPU 和內存資源的,所以須要一套完善的內存管理機制防止進程之間內存泄漏的問題。爲了更加有效地管理內存並減小出錯,現代操做系統提供了一種對主存的抽象概念,便是虛擬內存(Virtual Memory)。虛擬內存爲每一個進程提供了一個一致的、私有的地址空間,它讓每一個進程產生了一種本身在獨享主存的錯覺(每一個進程擁有一片連續完整的內存空間)。linux
物理內存(Physical memory)是相對於虛擬內存(Virtual Memory)而言的。物理內存指經過物理內存條而得到的內存空間,而虛擬內存則是指將硬盤的一塊區域劃分來做爲內存。內存主要做用是在計算機運行時爲操做系統和各類程序提供臨時儲存。在應用中,天然是顧名思義,物理上,真實存在的插在主板內存槽上的內存條的容量的大小。算法
虛擬內存是計算機系統內存管理的一種技術。 它使得應用程序認爲它擁有連續的可用的內存(一個連續完整的地址空間)。而實際上,虛擬內存一般是被分隔成多個物理內存碎片,還有部分暫時存儲在外部磁盤存儲器上,在須要時進行數據交換,加載到物理內存中來。 目前,大多數操做系統都使用了虛擬內存,如 Windows 系統的虛擬內存、Linux 系統的交換空間等等。數據庫
虛擬內存地址和用戶進程緊密相關,通常來講不一樣進程裏的同一個虛擬地址指向的物理地址是不同的,因此離開進程談虛擬內存沒有任何意義。每一個進程所能使用的虛擬地址大小和 CPU 位數有關。在 32 位的系統上,虛擬地址空間大小是 2 ^ 32 = 4G,在 64位系統上,虛擬地址空間大小是 2 ^ 64 = 2 ^ 34G,而實際的物理內存可能遠遠小於虛擬內存的大小。每一個用戶進程維護了一個單獨的頁表(Page Table),虛擬內存和物理內存就是經過這個頁表實現地址空間的映射的。下面給出兩個進程 A、B 各自的虛擬內存空間以及對應的物理內存之間的地址映射示意圖:編程
當進程執行一個程序時,須要先從先內存中讀取該進程的指令,而後執行,獲取指令時用到的就是虛擬地址。這個虛擬地址是程序連接時肯定的(內核加載並初始化進程時會調整動態庫的地址範圍)。爲了獲取到實際的數據,CPU 須要將虛擬地址轉換成物理地址,CPU 轉換地址時須要用到進程的頁表(Page Table),而頁表(Page Table)裏面的數據由操做系統維護。後端
其中頁表(Page Table)能夠簡單的理解爲單個內存映射(Memory Mapping)的鏈表(固然實際結構很複雜),裏面的每一個內存映射(Memory Mapping)都將一塊虛擬地址映射到一個特定的地址空間(物理內存或者磁盤存儲空間)。每一個進程擁有本身的頁表(Page Table),和其它進程的頁表(Page Table)沒有關係。數組
經過上面的介紹,咱們能夠簡單的將用戶進程申請並訪問物理內存(或磁盤存儲空間)的過程總結以下:緩存
在用戶進程和物理內存(磁盤存儲器)之間引入虛擬內存主要有如下的優勢:安全
操做系統的核心是內核,獨立於普通的應用程序,能夠訪問受保護的內存空間,也有訪問底層硬件設備的權限。爲了不用戶進程直接操做內核,保證內核安全,操做系統將虛擬內存劃分爲兩部分,一部分是內核空間(Kernel-space),一部分是用戶空間(User-space)。 在 Linux 系統中,內核模塊運行在內核空間,對應的進程處於內核態;而用戶程序運行在用戶空間,對應的進程處於用戶態。bash
內核進程和用戶進程所佔的虛擬內存比例是 1:3,而 Linux x86_32 系統的尋址空間(虛擬存儲空間)爲 4G(2的32次方),將最高的 1G 的字節(從虛擬地址 0xC0000000 到 0xFFFFFFFF)供內核進程使用,稱爲內核空間;而較低的 3G 的字節(從虛擬地址 0x00000000 到 0xBFFFFFFF),供各個用戶進程使用,稱爲用戶空間。下圖是一個進程的用戶空間和內核空間的內存佈局:
內核空間老是駐留在內存中,它是爲操做系統的內核保留的。應用程序是不容許直接在該區域進行讀寫或直接調用內核代碼定義的函數的。上圖左側區域爲內核進程對應的虛擬內存,按訪問權限能夠分爲進程私有和進程共享兩塊區域。
每一個普通的用戶進程都有一個單獨的用戶空間,處於用戶態的進程不能訪問內核空間中的數據,也不能直接調用內核函數的 ,所以要進行系統調用的時候,就要將進程切換到內核態才行。用戶空間包括如下幾個內存區域:
內核態能夠執行任意命令,調用系統的一切資源,而用戶態只能執行簡單的運算,不能直接調用系統資源。用戶態必須經過系統接口(System Call),才能向內核發出指令。好比,當用戶進程啓動一個 bash 時,它會經過 getpid() 對內核的 pid 服務發起系統調用,獲取當前用戶進程的 ID;當用戶進程經過 cat 命令查看主機配置時,它會對內核的文件子系統發起系統調用。
有了用戶空間和內核空間的劃分後,Linux 內部層級結構能夠分爲三部分,從最底層到最上層依次是硬件、內核空間和用戶空間,以下圖所示:
Linux 提供了輪詢、I/O 中斷以及 DMA 傳輸這 3 種磁盤與主存之間的數據傳輸機制。其中輪詢方式是基於死循環對 I/O 端口進行不斷檢測。I/O 中斷方式是指當數據到達時,磁盤主動向 CPU 發起中斷請求,由 CPU 自身負責數據的傳輸過程。 DMA 傳輸則在 I/O 中斷的基礎上引入了 DMA 磁盤控制器,由 DMA 磁盤控制器負責數據的傳輸,下降了 I/O 中斷操做對 CPU 資源的大量消耗。
在 DMA 技術出現以前,應用程序與磁盤之間的 I/O 操做都是經過 CPU 的中斷完成的。每次用戶進程讀取磁盤數據時,都須要 CPU 中斷,而後發起 I/O 請求等待數據讀取和拷貝完成,每次的 I/O 中斷都致使 CPU 的上下文切換。
DMA 的全稱叫直接內存存取(Direct Memory Access),是一種容許外圍設備(硬件子系統)直接訪問系統主內存的機制。也就是說,基於 DMA 訪問方式,系統主內存於硬盤或網卡之間的數據傳輸能夠繞開 CPU 的全程調度。目前大多數的硬件設備,包括磁盤控制器、網卡、顯卡以及聲卡等都支持 DMA 技術。
整個數據傳輸操做在一個 DMA 控制器的控制下進行的。CPU 除了在數據傳輸開始和結束時作一點處理外(開始和結束時候要作中斷處理),在傳輸過程當中 CPU 能夠繼續進行其餘的工做。這樣在大部分時間裏,CPU 計算和 I/O 操做都處於並行操做,使整個計算機系統的效率大大提升。有了 DMA 磁盤控制器接管數據讀寫請求之後,CPU 從繁重的 I/O 操做中解脫,數據讀取操做的流程以下:
爲了更好的理解零拷貝解決的問題,咱們首先了解一下傳統 I/O 方式存在的問題。在 Linux 系統中,傳統的訪問方式是經過 write() 和 read() 兩個系統調用實現的,經過 read() 函數讀取文件到到緩存區中,而後經過 write() 方法把緩存中的數據輸出到網絡端口,僞代碼以下:
read(file_fd, tmp_buf, len);
write(socket_fd, tmp_buf, len);
複製代碼
下圖分別對應傳統 I/O 操做的數據讀寫流程,整個過程涉及 2 次 CPU 拷貝、2 次 DMA 拷貝總共 4 次拷貝,以及 4 次上下文切換,下面簡單地闡述一下相關的概念。
當應用程序執行 read 系統調用讀取一塊數據的時候,若是這塊數據已經存在於用戶進程的頁內存中,就直接從內存中讀取數據;若是數據不存在,則先將數據從磁盤加載數據到內核空間的讀緩存(read buffer)中,再從讀緩存拷貝到用戶進程的頁內存中。
read(file_fd, tmp_buf, len);
複製代碼
基於傳統的 I/O 讀取方式,read 系統調用會觸發 2 次上下文切換,1 次 DMA 拷貝和 1 次 CPU 拷貝,發起數據讀取的流程以下:
當應用程序準備好數據,執行 write 系統調用發送網絡數據時,先將數據從用戶空間的頁緩存拷貝到內核空間的網絡緩衝區(socket buffer)中,而後再將寫緩存中的數據拷貝到網卡設備完成數據發送。
write(socket_fd, tmp_buf, len);
複製代碼
基於傳統的 I/O 寫入方式,write() 系統調用會觸發 2 次上下文切換,1 次 CPU 拷貝和 1 次 DMA 拷貝,用戶程序發送網絡數據的流程以下:
在 Linux 中零拷貝技術主要有 3 個實現思路:用戶態直接 I/O、減小數據拷貝次數以及寫時複製技術。
用戶態直接 I/O 使得應用進程或運行在用戶態(user space)下的庫函數直接訪問硬件設備,數據直接跨過內核進行傳輸,內核在數據傳輸過程除了進行必要的虛擬存儲配置工做以外,不參與任何其餘工做,這種方式可以直接繞過內核,極大提升了性能。
用戶態直接 I/O 只能適用於不須要內核緩衝區處理的應用程序,這些應用程序一般在進程地址空間有本身的數據緩存機制,稱爲自緩存應用程序,如數據庫管理系統就是一個表明。其次,這種零拷貝機制會直接操做磁盤 I/O,因爲 CPU 和磁盤 I/O 之間的執行時間差距,會形成大量資源的浪費,解決方案是配合異步 I/O 使用。
一種零拷貝方式是使用 mmap + write 代替原來的 read + write 方式,減小了 1 次 CPU 拷貝操做。mmap 是 Linux 提供的一種內存映射文件方法,即將一個進程的地址空間中的一段虛擬地址映射到磁盤文件地址,mmap + write 的僞代碼以下:
tmp_buf = mmap(file_fd, len);
write(socket_fd, tmp_buf, len);
複製代碼
使用 mmap 的目的是將內核中讀緩衝區(read buffer)的地址與用戶空間的緩衝區(user buffer)進行映射,從而實現內核緩衝區與應用程序內存的共享,省去了將數據從內核讀緩衝區(read buffer)拷貝到用戶緩衝區(user buffer)的過程,然而內核讀緩衝區(read buffer)仍需將數據到內核寫緩衝區(socket buffer),大體的流程以下圖所示:
基於 mmap + write 系統調用的零拷貝方式,整個拷貝過程會發生 4 次上下文切換,1 次 CPU 拷貝和 2 次 DMA 拷貝,用戶程序讀寫數據的流程以下:
mmap 主要的用處是提升 I/O 性能,特別是針對大文件。對於小文件,內存映射文件反而會致使碎片空間的浪費,由於內存映射老是要對齊頁邊界,最小單位是 4 KB,一個 5 KB 的文件將會映射佔用 8 KB 內存,也就會浪費 3 KB 內存。
mmap 的拷貝雖然減小了 1 次拷貝,提高了效率,但也存在一些隱藏的問題。當 mmap 一個文件時,若是這個文件被另外一個進程所截獲,那麼 write 系統調用會由於訪問非法地址被 SIGBUS 信號終止,SIGBUS 默認會殺死進程併產生一個 coredump,服務器可能所以被終止。
sendfile 系統調用在 Linux 內核版本 2.1 中被引入,目的是簡化經過網絡在兩個通道之間進行的數據傳輸過程。sendfile 系統調用的引入,不只減小了 CPU 拷貝的次數,還減小了上下文切換的次數,它的僞代碼以下:
sendfile(socket_fd, file_fd, len);
複製代碼
經過 sendfile 系統調用,數據能夠直接在內核空間內部進行 I/O 傳輸,從而省去了數據在用戶空間和內核空間之間的來回拷貝。與 mmap 內存映射方式不一樣的是, sendfile 調用中 I/O 數據對用戶空間是徹底不可見的。也就是說,這是一次徹底意義上的數據傳輸過程。
基於 sendfile 系統調用的零拷貝方式,整個拷貝過程會發生 2 次上下文切換,1 次 CPU 拷貝和 2 次 DMA 拷貝,用戶程序讀寫數據的流程以下:
相比較於 mmap 內存映射的方式,sendfile 少了 2 次上下文切換,可是仍然有 1 次 CPU 拷貝操做。sendfile 存在的問題是用戶程序不能對數據進行修改,而只是單純地完成了一次數據傳輸過程。
Linux 2.4 版本的內核對 sendfile 系統調用進行修改,爲 DMA 拷貝引入了 gather 操做。它將內核空間(kernel space)的讀緩衝區(read buffer)中對應的數據描述信息(內存地址、地址偏移量)記錄到相應的網絡緩衝區( socket buffer)中,由 DMA 根據內存地址、地址偏移量將數據批量地從讀緩衝區(read buffer)拷貝到網卡設備中,這樣就省去了內核空間中僅剩的 1 次 CPU 拷貝操做,sendfile 的僞代碼以下:
sendfile(socket_fd, file_fd, len);
複製代碼
在硬件的支持下,sendfile 拷貝方式再也不從內核緩衝區的數據拷貝到 socket 緩衝區,取而代之的僅僅是緩衝區文件描述符和數據長度的拷貝,這樣 DMA 引擎直接利用 gather 操做將頁緩存中數據打包發送到網絡中便可,本質就是和虛擬內存映射的思路相似。
基於 sendfile + DMA gather copy 系統調用的零拷貝方式,整個拷貝過程會發生 2 次上下文切換、0 次 CPU 拷貝以及 2 次 DMA 拷貝,用戶程序讀寫數據的流程以下:
sendfile + DMA gather copy 拷貝方式一樣存在用戶程序不能對數據進行修改的問題,並且自己須要硬件的支持,它只適用於將數據從文件拷貝到 socket 套接字上的傳輸過程。
sendfile 只適用於將數據從文件拷貝到 socket 套接字上,同時須要硬件的支持,這也限定了它的使用範圍。Linux 在 2.6.17 版本引入 splice 系統調用,不只不須要硬件支持,還實現了兩個文件描述符之間的數據零拷貝。splice 的僞代碼以下:
splice(fd_in, off_in, fd_out, off_out, len, flags);
複製代碼
splice 系統調用能夠在內核空間的讀緩衝區(read buffer)和網絡緩衝區(socket buffer)之間創建管道(pipeline),從而避免了二者之間的 CPU 拷貝操做。
基於 splice 系統調用的零拷貝方式,整個拷貝過程會發生 2 次上下文切換,0 次 CPU 拷貝以及 2 次 DMA 拷貝,用戶程序讀寫數據的流程以下:
splice 拷貝方式也一樣存在用戶程序不能對數據進行修改的問題。除此以外,它使用了 Linux 的管道緩衝機制,能夠用於任意兩個文件描述符中傳輸數據,可是它的兩個文件描述符參數中有一個必須是管道設備。
在某些狀況下,內核緩衝區可能被多個進程所共享,若是某個進程想要這個共享區進行 write 操做,因爲 write 不提供任何的鎖操做,那麼就會對共享區中的數據形成破壞,寫時複製的引入就是 Linux 用來保護數據的。
寫時複製指的是當多個進程共享同一塊數據時,若是其中一個進程須要對這份數據進行修改,那麼就須要將其拷貝到本身的進程地址空間中。這樣作並不影響其餘進程對這塊數據的操做,每一個進程要修改的時候纔會進行拷貝,因此叫寫時拷貝。這種方法在某種程度上可以下降系統開銷,若是某個進程永遠不會對所訪問的數據進行更改,那麼也就永遠不須要拷貝。
緩衝區共享方式徹底改寫了傳統的 I/O 操做,由於傳統 I/O 接口都是基於數據拷貝進行的,要避免拷貝就得去掉原先的那套接口並從新改寫,因此這種方法是比較全面的零拷貝技術,目前比較成熟的一個方案是在 Solaris 上實現的 fbuf(Fast Buffer,快速緩衝區)。
fbuf 的思想是每一個進程都維護着一個緩衝區池,這個緩衝區池能被同時映射到用戶空間(user space)和內核態(kernel space),內核和用戶共享這個緩衝區池,這樣就避免了一系列的拷貝操做。
緩衝區共享的難度在於管理共享緩衝區池須要應用程序、網絡軟件以及設備驅動程序之間的緊密合做,並且如何改寫 API 目前還處於試驗階段並不成熟。
不管是傳統 I/O 拷貝方式仍是引入零拷貝的方式,2 次 DMA Copy 是都少不了的,由於兩次 DMA 都是依賴硬件完成的。下面從 CPU 拷貝次數、DMA 拷貝次數以及系統調用幾個方面總結一下上述幾種 I/O 拷貝方式的差異。
拷貝方式 | CPU拷貝 | DMA拷貝 | 系統調用 | 上下文切換 |
---|---|---|---|---|
傳統方式(read + write) | 2 | 2 | read / write | 4 |
內存映射(mmap + write) | 1 | 2 | mmap / write | 4 |
sendfile | 1 | 2 | sendfile | 2 |
sendfile + DMA gather copy | 0 | 2 | sendfile | 2 |
splice | 0 | 2 | splice | 2 |
在 Java NIO 中的通道(Channel)就至關於操做系統的內核空間(kernel space)的緩衝區,而緩衝區(Buffer)對應的至關於操做系統的用戶空間(user space)中的用戶緩衝區(user buffer)。
堆外內存(DirectBuffer)在使用後須要應用程序手動回收,而堆內存(HeapBuffer)的數據在 GC 時可能會被自動回收。所以,在使用 HeapBuffer 讀寫數據時,爲了不緩衝區數據由於 GC 而丟失,NIO 會先把 HeapBuffer 內部的數據拷貝到一個臨時的 DirectBuffer 中的本地內存(native memory),這個拷貝涉及到 sun.misc.Unsafe.copyMemory() 的調用,背後的實現原理與 memcpy() 相似。 最後,將臨時生成的 DirectBuffer 內部的數據的內存地址傳給 I/O 調用函數,這樣就避免了再去訪問 Java 對象處理 I/O 讀寫。
MappedByteBuffer 是 NIO 基於內存映射(mmap)這種零拷貝方式的提供的一種實現,它繼承自 ByteBuffer。FileChannel 定義了一個 map() 方法,它能夠把一個文件從 position 位置開始的 size 大小的區域映射爲內存映像文件。抽象方法 map() 方法在 FileChannel 中的定義以下:
public abstract MappedByteBuffer map(MapMode mode, long position, long size) throws IOException;
複製代碼
MappedByteBuffer 相比 ByteBuffer 新增了 fore()、load() 和 isLoad() 三個重要的方法:
下面給出一個利用 MappedByteBuffer 對文件進行讀寫的使用示例:
private final static String CONTENT = "Zero copy implemented by MappedByteBuffer";
private final static String FILE_NAME = "/mmap.txt";
private final static String CHARSET = "UTF-8";
複製代碼
@Test
public void writeToFileByMappedByteBuffer() {
Path path = Paths.get(getClass().getResource(FILE_NAME).getPath());
byte[] bytes = CONTENT.getBytes(Charset.forName(CHARSET));
try (FileChannel fileChannel = FileChannel.open(path, StandardOpenOption.READ,
StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
MappedByteBuffer mappedByteBuffer = fileChannel.map(READ_WRITE, 0, bytes.length);
if (mappedByteBuffer != null) {
mappedByteBuffer.put(bytes);
mappedByteBuffer.force();
}
} catch (IOException e) {
e.printStackTrace();
}
}
複製代碼
@Test
public void readFromFileByMappedByteBuffer() {
Path path = Paths.get(getClass().getResource(FILE_NAME).getPath());
int length = CONTENT.getBytes(Charset.forName(CHARSET)).length;
try (FileChannel fileChannel = FileChannel.open(path, StandardOpenOption.READ)) {
MappedByteBuffer mappedByteBuffer = fileChannel.map(READ_ONLY, 0, length);
if (mappedByteBuffer != null) {
byte[] bytes = new byte[length];
mappedByteBuffer.get(bytes);
String content = new String(bytes, StandardCharsets.UTF_8);
assertEquals(content, "Zero copy implemented by MappedByteBuffer");
}
} catch (IOException e) {
e.printStackTrace();
}
}
複製代碼
下面介紹 map() 方法的底層實現原理。map() 方法是 java.nio.channels.FileChannel 的抽象方法,由子類 sun.nio.ch.FileChannelImpl.java 實現,下面是和內存映射相關的核心代碼:
public MappedByteBuffer map(MapMode mode, long position, long size) throws IOException {
int pagePosition = (int)(position % allocationGranularity);
long mapPosition = position - pagePosition;
long mapSize = size + pagePosition;
try {
addr = map0(imode, mapPosition, mapSize);
} catch (OutOfMemoryError x) {
System.gc();
try {
Thread.sleep(100);
} catch (InterruptedException y) {
Thread.currentThread().interrupt();
}
try {
addr = map0(imode, mapPosition, mapSize);
} catch (OutOfMemoryError y) {
throw new IOException("Map failed", y);
}
}
int isize = (int)size;
Unmapper um = new Unmapper(addr, mapSize, isize, mfd);
if ((!writable) || (imode == MAP_RO)) {
return Util.newMappedByteBufferR(isize, addr + pagePosition, mfd, um);
} else {
return Util.newMappedByteBuffer(isize, addr + pagePosition, mfd, um);
}
}
複製代碼
map() 方法經過本地方法 map0() 爲文件分配一塊虛擬內存,做爲它的內存映射區域,而後返回這塊內存映射區域的起始地址。
map() 方法返回的是內存映射區域的起始地址,經過(起始地址 + 偏移量)就能夠獲取指定內存的數據。這樣必定程度上替代了 read() 或 write() 方法,底層直接採用 sun.misc.Unsafe 類的 getByte() 和 putByte() 方法對數據進行讀寫。
private native long map0(int prot, long position, long mapSize) throws IOException;
複製代碼
上面是本地方法(native method)map0 的定義,它經過 JNI(Java Native Interface)調用底層 C 的實現,這個 native 函數(Java_sun_nio_ch_FileChannelImpl_map0)的實現位於 JDK 源碼包下的 native/sun/nio/ch/FileChannelImpl.c 這個源文件裏面。
JNIEXPORT jlong JNICALL Java_sun_nio_ch_FileChannelImpl_map0(JNIEnv *env, jobject this, jint prot, jlong off, jlong len) {
void *mapAddress = 0;
jobject fdo = (*env)->GetObjectField(env, this, chan_fd);
jint fd = fdval(env, fdo);
int protections = 0;
int flags = 0;
if (prot == sun_nio_ch_FileChannelImpl_MAP_RO) {
protections = PROT_READ;
flags = MAP_SHARED;
} else if (prot == sun_nio_ch_FileChannelImpl_MAP_RW) {
protections = PROT_WRITE | PROT_READ;
flags = MAP_SHARED;
} else if (prot == sun_nio_ch_FileChannelImpl_MAP_PV) {
protections = PROT_WRITE | PROT_READ;
flags = MAP_PRIVATE;
}
mapAddress = mmap64(
0, /* Let OS decide location */
len, /* Number of bytes to map */
protections, /* File permissions */
flags, /* Changes are shared */
fd, /* File descriptor of mapped file */
off); /* Offset into file */
if (mapAddress == MAP_FAILED) {
if (errno == ENOMEM) {
JNU_ThrowOutOfMemoryError(env, "Map failed");
return IOS_THROWN;
}
return handle(env, -1, "Map failed");
}
return ((jlong) (unsigned long) mapAddress);
}
複製代碼
能夠看出 map0() 函數最終是經過 mmap64() 這個函數對 Linux 底層內核發出內存映射的調用, mmap64() 函數的原型以下:
#include <sys/mman.h>
void *mmap64(void *addr, size_t len, int prot, int flags, int fd, off64_t offset);
複製代碼
下面詳細介紹一下 mmap64() 函數各個參數的含義以及參數可選值:
下面總結一下 MappedByteBuffer 的特色和不足之處:
public static void clean(final Object buffer) throws Exception {
AccessController.doPrivileged((PrivilegedAction<Void>) () -> {
try {
Method getCleanerMethod = buffer.getClass().getMethod("cleaner", new Class[0]);
getCleanerMethod.setAccessible(true);
Cleaner cleaner = (Cleaner) getCleanerMethod.invoke(buffer, new Object[0]);
cleaner.clean();
} catch(Exception e) {
e.printStackTrace();
}
});
}
複製代碼
DirectByteBuffer 的對象引用位於 Java 內存模型的堆裏面,JVM 能夠對 DirectByteBuffer 的對象進行內存分配和回收管理,通常使用 DirectByteBuffer 的靜態方法 allocateDirect() 建立 DirectByteBuffer 實例並分配內存。
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}
複製代碼
DirectByteBuffer 內部的字節緩衝區位在於堆外的(用戶態)直接內存,它是經過 Unsafe 的本地方法 allocateMemory() 進行內存分配,底層調用的是操做系統的 malloc() 函數。
DirectByteBuffer(int cap) {
super(-1, 0, cap, cap);
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize();
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
Bits.reserveMemory(size, cap);
long base = 0;
try {
base = unsafe.allocateMemory(size);
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
unsafe.setMemory(base, size, (byte) 0);
if (pa && (base % ps != 0)) {
address = base + ps - (base & (ps - 1));
} else {
address = base;
}
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}
複製代碼
除此以外,初始化 DirectByteBuffer 時還會建立一個 Deallocator 線程,並經過 Cleaner 的 freeMemory() 方法來對直接內存進行回收操做,freeMemory() 底層調用的是操做系統的 free() 函數。
private static class Deallocator implements Runnable {
private static Unsafe unsafe = Unsafe.getUnsafe();
private long address;
private long size;
private int capacity;
private Deallocator(long address, long size, int capacity) {
assert (address != 0);
this.address = address;
this.size = size;
this.capacity = capacity;
}
public void run() {
if (address == 0) {
return;
}
unsafe.freeMemory(address);
address = 0;
Bits.unreserveMemory(size, capacity);
}
}
複製代碼
因爲使用 DirectByteBuffer 分配的是系統本地的內存,不在 JVM 的管控範圍以內,所以直接內存的回收和堆內存的回收不一樣,直接內存若是使用不當,很容易形成 OutOfMemoryError。
說了這麼多,那麼 DirectByteBuffer 和零拷貝有什麼關係?前面有提到在 MappedByteBuffer 進行內存映射時,它的 map() 方法會經過 Util.newMappedByteBuffer() 來建立一個緩衝區實例,初始化的代碼以下:
static MappedByteBuffer newMappedByteBuffer(int size, long addr, FileDescriptor fd, Runnable unmapper) {
MappedByteBuffer dbb;
if (directByteBufferConstructor == null)
initDBBConstructor();
try {
dbb = (MappedByteBuffer)directByteBufferConstructor.newInstance(
new Object[] { new Integer(size), new Long(addr), fd, unmapper });
} catch (InstantiationException | IllegalAccessException | InvocationTargetException e) {
throw new InternalError(e);
}
return dbb;
}
private static void initDBBRConstructor() {
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
try {
Class<?> cl = Class.forName("java.nio.DirectByteBufferR");
Constructor<?> ctor = cl.getDeclaredConstructor(
new Class<?>[] { int.class, long.class, FileDescriptor.class,
Runnable.class });
ctor.setAccessible(true);
directByteBufferRConstructor = ctor;
} catch (ClassNotFoundException | NoSuchMethodException |
IllegalArgumentException | ClassCastException x) {
throw new InternalError(x);
}
return null;
}});
}
複製代碼
DirectByteBuffer 是 MappedByteBuffer 的具體實現類。實際上,Util.newMappedByteBuffer() 方法經過反射機制獲取 DirectByteBuffer 的構造器,而後建立一個 DirectByteBuffer 的實例,對應的是一個單獨用於內存映射的構造方法:
protected DirectByteBuffer(int cap, long addr, FileDescriptor fd, Runnable unmapper) {
super(-1, 0, cap, cap, fd);
address = addr;
cleaner = Cleaner.create(this, unmapper);
att = null;
}
複製代碼
所以,除了容許分配操做系統的直接內存之外,DirectByteBuffer 自己也具備文件內存映射的功能,這裏不作過多說明。咱們須要關注的是,DirectByteBuffer 在 MappedByteBuffer 的基礎上提供了內存映像文件的隨機讀取 get() 和寫入 write() 的操做。
public byte get() {
return ((unsafe.getByte(ix(nextGetIndex()))));
}
public byte get(int i) {
return ((unsafe.getByte(ix(checkIndex(i)))));
}
複製代碼
public ByteBuffer put(byte x) {
unsafe.putByte(ix(nextPutIndex()), ((x)));
return this;
}
public ByteBuffer put(int i, byte x) {
unsafe.putByte(ix(checkIndex(i)), ((x)));
return this;
}
複製代碼
內存映像文件的隨機讀寫都是藉助 ix() 方法實現定位的, ix() 方法經過內存映射空間的內存首地址(address)和給定偏移量 i 計算出指針地址,而後由 unsafe 類的 get() 和 put() 方法和對指針指向的數據進行讀取或寫入。
private long ix(int i) {
return address + ((long)i << 0);
}
複製代碼
FileChannel 是一個用於文件讀寫、映射和操做的通道,同時它在併發環境下是線程安全的,基於 FileInputStream、FileOutputStream 或者 RandomAccessFile 的 getChannel() 方法能夠建立並打開一個文件通道。FileChannel 定義了 transferFrom() 和 transferTo() 兩個抽象方法,它經過在通道和通道之間創建鏈接實現數據傳輸的。
public abstract long transferTo(long position, long count, WritableByteChannel target) throws IOException;
複製代碼
public abstract long transferFrom(ReadableByteChannel src, long position, long count) throws IOException;
複製代碼
下面給出 FileChannel 利用 transferTo() 和 transferFrom() 方法進行數據傳輸的使用示例:
private static final String CONTENT = "Zero copy implemented by FileChannel";
private static final String SOURCE_FILE = "/source.txt";
private static final String TARGET_FILE = "/target.txt";
private static final String CHARSET = "UTF-8";
複製代碼
首先在類加載根路徑下建立 source.txt 和 target.txt 兩個文件,對源文件 source.txt 文件寫入初始化數據。
@Before
public void setup() {
Path source = Paths.get(getClassPath(SOURCE_FILE));
byte[] bytes = CONTENT.getBytes(Charset.forName(CHARSET));
try (FileChannel fromChannel = FileChannel.open(source, StandardOpenOption.READ,
StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
fromChannel.write(ByteBuffer.wrap(bytes));
} catch (IOException e) {
e.printStackTrace();
}
}
複製代碼
對於 transferTo() 方法而言,目的通道 toChannel 能夠是任意的單向字節寫通道 WritableByteChannel;而對於 transferFrom() 方法而言,源通道 fromChannel 能夠是任意的單向字節讀通道 ReadableByteChannel。其中,FileChannel、SocketChannel 和 DatagramChannel 等通道實現了 WritableByteChannel 和 ReadableByteChannel 接口,都是同時支持讀寫的雙向通道。爲了方便測試,下面給出基於 FileChannel 完成 channel-to-channel 的數據傳輸示例。
@Test
public void transferTo() throws Exception {
try (FileChannel fromChannel = new RandomAccessFile(
getClassPath(SOURCE_FILE), "rw").getChannel();
FileChannel toChannel = new RandomAccessFile(
getClassPath(TARGET_FILE), "rw").getChannel()) {
long position = 0L;
long offset = fromChannel.size();
fromChannel.transferTo(position, offset, toChannel);
}
}
複製代碼
@Test
public void transferFrom() throws Exception {
try (FileChannel fromChannel = new RandomAccessFile(
getClassPath(SOURCE_FILE), "rw").getChannel();
FileChannel toChannel = new RandomAccessFile(
getClassPath(TARGET_FILE), "rw").getChannel()) {
long position = 0L;
long offset = fromChannel.size();
toChannel.transferFrom(fromChannel, position, offset);
}
}
複製代碼
下面介紹 transferTo() 和 transferFrom() 方法的底層實現原理,這兩個方法也是 java.nio.channels.FileChannel 的抽象方法,由子類 sun.nio.ch.FileChannelImpl.java 實現。transferTo() 和 transferFrom() 底層都是基於 sendfile 實現數據傳輸的,其中 FileChannelImpl.java 定義了 3 個常量,用於標示當前操做系統的內核是否支持 sendfile 以及 sendfile 的相關特性。
private static volatile boolean transferSupported = true;
private static volatile boolean pipeSupported = true;
private static volatile boolean fileSupported = true;
複製代碼
下面以 transferTo() 的源碼實現爲例。FileChannelImpl 首先執行 transferToDirectly() 方法,以 sendfile 的零拷貝方式嘗試數據拷貝。若是系統內核不支持 sendfile,進一步執行 transferToTrustedChannel() 方法,以 mmap 的零拷貝方式進行內存映射,這種狀況下目的通道必須是 FileChannelImpl 或者 SelChImpl 類型。若是以上兩步都失敗了,則執行 transferToArbitraryChannel() 方法,基於傳統的 I/O 方式完成讀寫,具體步驟是初始化一個臨時的 DirectBuffer,將源通道 FileChannel 的數據讀取到 DirectBuffer,再寫入目的通道 WritableByteChannel 裏面。
public long transferTo(long position, long count, WritableByteChannel target) throws IOException {
// 計算文件的大小
long sz = size();
// 校驗起始位置
if (position > sz)
return 0;
int icount = (int)Math.min(count, Integer.MAX_VALUE);
// 校驗偏移量
if ((sz - position) < icount)
icount = (int)(sz - position);
long n;
if ((n = transferToDirectly(position, icount, target)) >= 0)
return n;
if ((n = transferToTrustedChannel(position, icount, target)) >= 0)
return n;
return transferToArbitraryChannel(position, icount, target);
}
複製代碼
接下來重點分析一下 transferToDirectly() 方法的實現,也就是 transferTo() 經過 sendfile 實現零拷貝的精髓所在。能夠看到,transferToDirectlyInternal() 方法先獲取到目的通道 WritableByteChannel 的文件描述符 targetFD,獲取同步鎖而後執行 transferToDirectlyInternal() 方法。
private long transferToDirectly(long position, int icount, WritableByteChannel target) throws IOException {
// 省略從target獲取targetFD的過程
if (nd.transferToDirectlyNeedsPositionLock()) {
synchronized (positionLock) {
long pos = position();
try {
return transferToDirectlyInternal(position, icount,
target, targetFD);
} finally {
position(pos);
}
}
} else {
return transferToDirectlyInternal(position, icount, target, targetFD);
}
}
複製代碼
最終由 transferToDirectlyInternal() 調用本地方法 transferTo0() ,嘗試以 sendfile 的方式進行數據傳輸。若是系統內核徹底不支持 sendfile,好比 Windows 操做系統,則返回 UNSUPPORTED 並把 transferSupported 標識爲 false。若是系統內核不支持 sendfile 的一些特性,好比說低版本的 Linux 內核不支持 DMA gather copy 操做,則返回 UNSUPPORTED_CASE 並把 pipeSupported 或者 fileSupported 標識爲 false。
private long transferToDirectlyInternal(long position, int icount, WritableByteChannel target, FileDescriptor targetFD) throws IOException {
assert !nd.transferToDirectlyNeedsPositionLock() ||
Thread.holdsLock(positionLock);
long n = -1;
int ti = -1;
try {
begin();
ti = threads.add();
if (!isOpen())
return -1;
do {
n = transferTo0(fd, position, icount, targetFD);
} while ((n == IOStatus.INTERRUPTED) && isOpen());
if (n == IOStatus.UNSUPPORTED_CASE) {
if (target instanceof SinkChannelImpl)
pipeSupported = false;
if (target instanceof FileChannelImpl)
fileSupported = false;
return IOStatus.UNSUPPORTED_CASE;
}
if (n == IOStatus.UNSUPPORTED) {
transferSupported = false;
return IOStatus.UNSUPPORTED;
}
return IOStatus.normalize(n);
} finally {
threads.remove(ti);
end (n > -1);
}
}
複製代碼
本地方法(native method)transferTo0() 經過 JNI(Java Native Interface)調用底層 C 的函數,這個 native 函數(Java_sun_nio_ch_FileChannelImpl_transferTo0)一樣位於 JDK 源碼包下的 native/sun/nio/ch/FileChannelImpl.c 源文件裏面。JNI 函數 Java_sun_nio_ch_FileChannelImpl_transferTo0() 基於條件編譯對不一樣的系統進行預編譯,下面是 JDK 基於 Linux 系統內核對 transferTo() 提供的調用封裝。
#if defined(__linux__) || defined(__solaris__)
#include <sys/sendfile.h>
#elif defined(_AIX)
#include <sys/socket.h>
#elif defined(_ALLBSD_SOURCE)
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/uio.h>
#define lseek64 lseek
#define mmap64 mmap
#endif
JNIEXPORT jlong JNICALL
Java_sun_nio_ch_FileChannelImpl_transferTo0(JNIEnv *env, jobject this,
jobject srcFDO,
jlong position, jlong count,
jobject dstFDO)
{
jint srcFD = fdval(env, srcFDO);
jint dstFD = fdval(env, dstFDO);
#if defined(__linux__)
off64_t offset = (off64_t)position;
jlong n = sendfile64(dstFD, srcFD, &offset, (size_t)count);
return n;
#elif defined(__solaris__)
result = sendfilev64(dstFD, &sfv, 1, &numBytes);
return result;
#elif defined(__APPLE__)
result = sendfile(srcFD, dstFD, position, &numBytes, NULL, 0);
return result;
#endif
}
複製代碼
對 Linux、Solaris 以及 Apple 系統而言,transferTo0() 函數底層會執行 sendfile64 這個系統調用完成零拷貝操做,sendfile64() 函數的原型以下:
#include <sys/sendfile.h>
ssize_t sendfile64(int out_fd, int in_fd, off_t *offset, size_t count);
複製代碼
下面簡單介紹一下 sendfile64() 函數各個參數的含義:
在 Linux 2.6.3 以前,out_fd 必須是一個 socket,而從 Linux 2.6.3 之後,out_fd 能夠是任何文件。也就是說,sendfile64() 函數不只能夠進行網絡文件傳輸,還能夠對本地文件實現零拷貝操做。
Netty 中的零拷貝和上面提到的操做系統層面上的零拷貝不太同樣, 咱們所說的 Netty 零拷貝徹底是基於(Java 層面)用戶態的,它的更多的是偏向於數據操做優化這樣的概念,具體表如今如下幾個方面:
其中第 1 條屬於操做系統層面的零拷貝操做,後面 3 條只能算用戶層面的數據操做優化。
RocketMQ 選擇了 mmap + write 這種零拷貝方式,適用於業務級消息這種小塊文件的數據持久化和傳輸;而 Kafka 採用的是 sendfile 這種零拷貝方式,適用於系統日誌消息這種高吞吐量的大塊文件的數據持久化和傳輸。可是值得注意的一點是,Kafka 的索引文件使用的是 mmap + write 方式,數據文件使用的是 sendfile 方式。
消息隊列 | 零拷貝方式 | 優勢 | 缺點 |
---|---|---|---|
RocketMQ | mmap + write | 適用於小塊文件傳輸,頻繁調用時,效率很高 | 不能很好的利用 DMA 方式,會比 sendfile 多消耗 CPU,內存安全性控制複雜,須要避免 JVM Crash 問題 |
Kafka | sendfile | 能夠利用 DMA 方式,消耗 CPU 較少,大塊文件傳輸效率高,無內存安全性問題 | 小塊文件效率低於 mmap 方式,只能是 BIO 方式傳輸,不能使用 NIO 方式 |
本文開篇詳述了 Linux 操做系統中的物理內存和虛擬內存,內核空間和用戶空間的概念以及 Linux 內部的層級結構。在此基礎上,進一步分析和對比傳統 I/O 方式和零拷貝方式的區別,而後介紹了 Linux 內核提供的幾種零拷貝實現,包括內存映射 mmap、sendfile、sendfile + DMA gather copy 以及 splice 幾種機制,並從系統調用和拷貝次數層面對它們進行了對比。接下來從源碼着手分析了 Java NIO 對零拷貝的實現,主要包括基於內存映射(mmap)方式的 MappedByteBuffer 以及基於 sendfile 方式的 FileChannel。最後在篇末簡單的闡述了一下 Netty 中的零拷貝機制,以及 RocketMQ 和 Kafka 兩種消息隊列在零拷貝實現方式上的區別。
本賬號將持續分享後端技術乾貨,包括虛擬機基礎,多線程編程,高性能框架,異步、緩存和消息中間件,分佈式和微服務,架構學習和進階等學習資料和文章。