1. 程式人生 > 實用技巧 >Linux、JDK、Netty中的NIO與零拷貝

Linux、JDK、Netty中的NIO與零拷貝

一、先理解核心空間與使用者空間

Linux 按照特權等級,把程序的執行空間分為核心空間和使用者空間,分別對應著下圖中, CPU 特權等級分為4個,Linux 使用 Ring 0 和 Ring 3。

  • 核心空間(Ring 0)具有最高許可權,可以直接訪問所有資源,;
  • 使用者空間(Ring 3)只能訪問受限資源,不能直接訪問記憶體等硬體裝置,必須通過系統呼叫陷入到核心中,才能訪問這些特權資源。

上面的Ring圖可以簡化成:

核心從本質上看是一種軟體——控制計算機的硬體資源,並提供上層應用程式執行的環境。使用者態即上層應用程式的活動空間,應用程式的執行必須依託於核心提供的資源,包括CPU資源、儲存資源、I/O資源等。為了使上層應用能夠訪問到這些資源,核心必須為上層應用提供訪問的介面:即系統呼叫

系統呼叫是作業系統的最小功能單位,通過提供一些基本功能的介面供應用程式呼叫來排程核心空間管理的資源

可以把系統呼叫比作一個漢字的一個“筆畫”,而一個“漢字”就代表一個上層應用。因此,有時候如果要實現一個完整的漢字(給某個變數分配記憶體空間),就必須呼叫很多的系統呼叫(筆畫)。如果從實現者(程式設計師)的角度來看,這勢必會加重程式設計師的負擔,良好的程式設計方法是:重視上層的業務邏輯操作,而儘可能避免底層複雜的實現細節。庫函式正是為了將程式設計師從複雜的細節中解脫出來而提出的一種有效方法。它實現對系統呼叫的封裝,將簡單的業務邏輯介面呈現給使用者,方便使用者呼叫,從這個角度上看,庫函式就像是組成漢字的“偏旁”。

Shell是一個特殊的應用程式,俗稱命令列,本質上是一個命令直譯器,它下通系統呼叫,上通各種應用,通常充當著一種“膠水”的角色,來連線各個小功能程式,讓不同程式能夠以一個清晰的介面協同工作,從而增強各個程式的功能。通常短短的幾行Shell指令碼就可以實現一個非常大的功能,原因就是這些Shell語句通常都對系統呼叫做了一層封裝。

二、使用者態與核心態切換的損失

當程式執行從使用者態切換到核心態,那麼處在使用者態的執行緒需要先儲存當前的資料以及執行的指令,方便回到使用者態時繼續執行,這中間還有很多其他的事情需要做,例如CPU暫存器需要儲存和載入, 系統排程器的程式碼需要執行, TLB例項需要重新載入, CPU 的pipeline需要刷掉。

TLB

頁表一般都很大,並且存放在記憶體中,所以處理器引入MMU後,讀取指令、資料需要訪問兩次記憶體:首先通過查詢頁表得到實體地址,然後訪問該實體地址讀取指令、資料。為了減少因為MMU導致的處理器效能下降,引入了TLB,TLB是Translation Lookaside Buffer的簡稱,可翻譯為“地址轉換後援緩衝器”,也可簡稱為“快表”。簡單地說,TLB就是頁表的Cache,其中儲存了當前最可能被訪問到的頁表項,其內容是部分頁表項的一個副本。只有在TLB無法完成地址翻譯任務時,才會到記憶體中查詢頁表,這樣就減少了頁表查詢導致的處理器效能下降。

頁表

是一種特殊的資料結構,放在系統空間的頁表區,存放邏輯頁與物理頁幀的對應關係。 每一個程序都擁有一個自己的頁表,PCB表中有指標指向頁表。

MMU

記憶體管理單元(英語:memory management unit,縮寫為MMU),有時稱作分頁記憶體管理單元(英語:paged memory management unit,縮寫為PMMU)。 它是一種負責處理中央處理器(CPU)的記憶體訪問請求的計算機硬體。

TLB中的項由兩部分組成:標識和資料。標識中存放的是虛地址的一部分,而資料部分中存放物理頁號、儲存保護資訊以及其他一些輔助資訊。虛地址與TLB中項的對映方式有三種:全關聯方式、直接對映方式、分組關聯方式。OR1200處理器中實現的是直接對映方式,所以本書只對直接對映方式作介紹。直接對映方式是指每一個虛擬地址只能對映到TLB中唯一的一個表項。假設記憶體頁大小是8KB,TLB中有64項,採用直接對映方式時的TLB變換原理如圖所示:

CPU 的Pipeline

在CPU中由5—6個不同功能的電路單元組成一條指令處理流水線,然後將一條指令分成5—6步後再由這些電路單元分別執行,這樣就能實現在一個CPU時鐘週期完成一條指令,因此提高CPU的運算速度運算速度。

三、檔案傳送的基本流程

1、DMA之前的檔案拷貝流程

DMA之前傳統的IO拷貝時序圖:

使用I/O 中斷方式讀取資料步驟:

  1. 使用者程序向 CPU 發起 read 系統呼叫讀取資料,由使用者態切換為核心態,然後一直阻塞等待資料的返回;
  2. CPU 在接收到指令以後對磁碟發起 I/O 請求,將磁碟資料先放入磁碟控制器緩衝區;
  3. 資料準備完成以後,磁碟向 CPU 發起 I/O 中斷;
  4. CPU 收到 I/O 中斷以後將磁碟緩衝區中的資料拷貝到核心緩衝區,然後再從核心緩衝區拷貝到使用者緩衝區;
  5. 使用者程序由核心態切換回使用者態,解除阻塞狀態,然後等待 CPU 的下一個執行時間鍾。

2、DMA之後檔案讀取流程

2.1 DMA複製與CPU複製的區別

CPU複製

在 DMA 技術出現之前,應用程式與磁碟之間的 I/O 操作都是通過 CPU 的中斷完成的。每次使用者程序讀取磁碟資料時,都需要 CPU 中斷將資料讀進暫存器,然後發起 I/O 請求等待資料讀取和拷貝完成,然後寫進其它地方,每次的 I/O 中斷都導致 CPU 的上下文切換。

DMA複製

DMA(Direct Memory Access,直接儲存器訪問) ,在DMA之前的CPU複製,需要CPU將資料讀進暫存器(區別於暫存器),然後寫進其它地方,這個過程中,CPU被擠佔,而DMA在拷貝時不影響CPU去執行其他任務。

具體流程:CPU對DMA控制器初始化,向I/O介面發出操作命令,I/O介面提出DMA請求。DMA控制器對DMA請求判別優先順序及遮蔽,向匯流排裁決邏輯提出匯流排請求。當CPU執行完當前匯流排週期即可釋放匯流排控制權。此時,匯流排裁決邏輯輸出匯流排應答,表示DMA已經響應,通過DMA控制器通知I/O介面開始DMA傳輸。

2.2 DMA複製流程

系統從磁碟上讀取資料,DMA複製進核心的頁快取,

然後通過CPU複製讀取給使用者的快取空間,

然後通過CPU寫進Socket緩衝區域,

最後通過DMA複製傳輸進入網路。

2.3 DMA拷貝示意圖

由圖可知:DMA拷貝,需要經過四次資料拷貝,四次上下文切換,即使使用了DMA來處理與硬體的通訊,CPU仍然需要處理兩次資料拷貝,與此同時,在使用者態與核心態也發生了多次上下文切換,無疑也加重了CPU負擔。

2.4 DMA下的IO拷貝時序圖

CPU 從繁重的 I/O 操作中解脫,資料讀取操作的流程如下:

  1. 使用者程序向 CPU 發起 read 系統呼叫讀取資料,由使用者態切換為核心態,然後一直阻塞等待資料的返回;
  2. CPU 在接收到指令以後對 DMA 磁碟控制器發起排程指令;
  3. DMA 磁碟控制器對磁碟發起 I/O 請求,將磁碟資料先放入磁碟控制器緩衝區,CPU 全程不參與此過程;
  4. 資料讀取完成後,DMA 磁碟控制器會接受到磁碟的通知,將資料從磁碟控制器緩衝區拷貝到核心緩衝區;
  5. DMA 磁碟控制器向 CPU 發出資料讀完的訊號,由 CPU 負責將資料從核心緩衝區拷貝到使用者緩衝區;
  6. 使用者程序由核心態切換回使用者態,解除阻塞狀態,然後等待 CPU 的下一個執行時間鍾。

四、零拷貝流程

1、 零拷貝的原理

Linux 零拷貝技術主要有 3 個實現思路:使用者態直接 I/O、減少資料拷貝次數以及寫時複製技術

1.1 使用者態直接 I/O

應用程式可以直接訪問硬體儲存,作業系統核心只是輔助資料傳輸。這種方式依舊存在使用者空間和核心空間的上下文切換,硬體上的資料直接拷貝至了使用者空間,不經過核心空間。因此,直接 I/O 不存在核心空間緩衝區和使用者空間緩衝區之間的資料拷貝

1.2 減少資料拷貝次數

在資料傳輸過程中,避免資料在使用者空間緩衝區和系統核心空間緩衝區之間的CPU拷貝,以及資料在系統核心空間內的CPU拷貝,這也是當前主流零拷貝技術的實現思路。

1.3 寫時複製技術

寫時複製指的是當多個程序共享同一塊資料時,如果其中一個程序需要對這份資料進行修改,那麼將其拷貝到自己的程序地址空間中,如果只是資料讀取操作則不需要進行拷貝操作。

2 、使用者態直接 I/O

使用者態直接 I/O 使得應用程序或執行在使用者態(user space)下的庫函式直接訪問硬體裝置,資料直接跨過核心進行傳輸直接從使用者態地址空間寫入到磁碟中,核心在資料傳輸過程除了進行必要的虛擬儲存配置工作之外,不參與任何其他工作,這種方式能夠直接繞過核心,極大提高了效能。對於一些應用程式,例如:資料庫。他們更傾向於自己的快取機制,這樣可以提供更好的緩衝機制提高資料庫的讀寫效能。

2.1 直接I/O圖示

2.2 直接I/O 設計與實現

要在塊裝置中執行直接 I/O,程序必須在開啟檔案的時候設定對檔案的訪問模式為 O_DIRECT,這樣就等於告訴作業系統程序在接下來使用 read() 或者 write() 系統呼叫去讀寫檔案的時候使用的是直接 I/O 方式,所傳輸的資料均不經過作業系統核心快取空間。使用直接 I/O 讀寫資料必須要注意緩衝區對齊( buffer alignment )以及緩衝區的大小的問題,即對應 read() 以及 write() 系統呼叫的第二個和第三個引數。這裡邊說的對齊指的是檔案系統塊大小的對齊,緩衝區的大小也必須是該塊大小的整數倍。

2.3 直接I/O 缺點

  1. 這種方法只能適用於那些不需要核心緩衝區處理的應用程式,這些應用程式通常在程序地址空間有自己的資料快取機制,稱為自快取應用程式,如資料庫管理系統就是一個代表。
  2. 這種方法直接操作磁碟 I/O,由於 CPU 和磁碟 I/O 之間的執行時間差距,會造成資源的浪費,解決這個問題需要和非同步 I/O 結合使用。

3、 減少資料拷貝之mmap

一種零拷貝方式是使用 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)。

3.1 mmap減少資料拷貝流程圖

3.2 mmap+write拷貝流程

基於 mmap + write 系統呼叫的零拷貝方式,整個拷貝過程會發生 4 次上下文切換,1 次 CPU 拷貝和 2 次 DMA 拷貝,使用者程式讀寫資料的流程如下:

  1. 使用者程序通過 mmap() 函式向核心 (kernel) 發起系統呼叫,上下文從使用者態 (user space) 切換為核心態(kernel space);
  2. 將使用者程序的核心空間的讀緩衝區 (read buffer) 與使用者空間的快取區 (user buffer) 進行記憶體地址對映;
  3. CPU 利用 DMA 控制器將資料從主存或硬碟拷貝到核心空間 (kernel space) 的讀緩衝區 (read buffer);
  4. 上下文從核心態 (kernel space) 切換回使用者態 (user space),mmap 系統呼叫執行返回
  5. 使用者程序通過write() 函式向核心 (kernel) 發起系統呼叫,上下文從使用者態 (user space) 切換為核心態(kernel space);
  6. CPU 將讀緩衝區 (read buffer) 中的資料拷貝到的網路緩衝區 (socket buffer) ;
  7. CPU 利用 DMA 控制器將資料從網路緩衝區 (socket buffer) 拷貝到網絡卡進行資料傳輸;
  8. 上下文從核心態 (kernel space) 切換回使用者態 (user space) ,write 系統呼叫執行返回;

3.3 mmap+write拷貝缺陷:

mmap 主要的用處是提高 I/O 效能,特別是針對大檔案。對於小檔案,記憶體對映檔案反而會導致碎片空間的浪費,因為記憶體對映總是要對齊頁邊界,最小單位是 4 KB,一個 5 KB 的檔案將會對映佔用 8 KB 記憶體,也就會浪費 3 KB 記憶體。

另外 mmap 隱藏著一個陷阱,當使用 mmap 對映一個檔案時,如果這個檔案被另一個程序所截獲,那麼 write 系統呼叫會因為訪問非法地址被 SIGBUS 訊號終止,SIGBUS 預設會殺死程序併產生一個 coredump,如果伺服器被這樣終止那損失就可能不小。

解決這個問題通常使用檔案的租借鎖:首先為檔案申請一個租借鎖,當其他程序想要截斷這個檔案時,核心會發送一個實時的 RT_SIGNAL_LEASE 訊號,告訴當前程序有程序在試圖破壞檔案,這樣 write 在被 SIGBUS 殺死之前,會被中斷,返回已經寫入的位元組數,並設定 errno 為 success。

通常的做法是在 mmap 之前加鎖,操作完之後解鎖。

4、減少資料拷貝之sendfile

sendfile 系統呼叫在 Linux 核心版本 2.1 中被引入,目的是簡化通過網路在兩個通道之間進行的資料傳輸過程。sendfile 系統呼叫的引入,不僅減少了 CPU 拷貝的次數,還減少了上下文切換的次數,它的虛擬碼如下:

sendfile(socket_fd, file_fd, len);

通過 sendfile 系統呼叫,資料可以直接在核心空間內部進行 I/O 傳輸,從而省去了資料在使用者空間和核心空間之間的來回拷貝。與 mmap 記憶體對映方式不同的是,sendfile 呼叫中 I/O 資料對使用者空間是完全不可見的。也就是說,這是一次完全意義上的資料傳輸過程。

4.1 sendfile拷貝圖示

4.2 sendfile拷貝流程

基於 sendfile 系統呼叫的零拷貝方式,整個拷貝過程會發生 2 次上下文切換,1 次 CPU 拷貝和 2 次 DMA 拷貝,使用者程式讀寫資料的流程如下:

  1. 使用者程序通過 sendfile() 函式向核心 (kernel) 發起系統呼叫,上下文從使用者態 (user space) 切換為核心態(kernel space)。
  2. CPU 利用 DMA 控制器將資料從主存或硬碟拷貝到核心空間 (kernel space) 的讀緩衝區 (read buffer)。
  3. CPU 將讀緩衝區 (read buffer) 中的資料拷貝到的網路緩衝區 (socket buffer)。
  4. CPU 利用 DMA 控制器將資料從網路緩衝區 (socket buffer) 拷貝到網絡卡進行資料傳輸。
  5. 上下文從核心態 (kernel space) 切換回使用者態 (user space),sendfile 系統呼叫執行返回。

相比較於 mmap 記憶體對映的方式,sendfile 少了 2 次上下文切換,但是仍然有 1 次 CPU 拷貝操作。sendfile 存在的問題是使用者程式不能對資料進行修改,而只是單純地完成了一次資料傳輸過程。

4.3 sendfile拷貝缺點

只能適用於那些不需要使用者態處理的應用程式。

5、減少資料拷貝之sendfile + DMA

常規 sendfile 還有一次核心態的拷貝操作,能不能也把這次拷貝給去掉呢?

還真有,這種 DMA 輔助的 sendfile。

Linux 2.4 版本的核心對 sendfile 系統呼叫進行修改,為 DMA 拷貝引入了 gather 操作。它將核心空間 (kernel space) 的讀緩衝區 (read buffer) 中對應的資料描述資訊 (記憶體地址、地址偏移量) 記錄到相應的網路緩衝區( (socket buffer) 中,由 DMA 根據記憶體地址、地址偏移量將資料批量地從讀緩衝區 (read buffer) 拷貝到網絡卡裝置中,這樣就省去了核心空間中僅剩的 1 次 CPU 拷貝操作,sendfile 的虛擬碼如下:

Copysendfile(socket_fd, file_fd, len);

在硬體的支援下,sendfile 拷貝方式不再從核心緩衝區的資料拷貝到 socket 緩衝區,取而代之的僅僅是緩衝區檔案描述符和資料長度的拷貝,這樣 DMA 引擎直接利用 gather 操作將頁快取中資料打包傳送到網路中即可,本質就是和虛擬記憶體對映的思路類似。

5.1 sendfile + DMA示意圖

5.2 sendfile+DMA拷貝流程

基於 sendfile + DMA gather copy 系統呼叫的零拷貝方式,整個拷貝過程會發生 2 次上下文切換、0 次 CPU 拷貝以及 2 次 DMA 拷貝,使用者程式讀寫資料的流程如下:

  1. 使用者程序通過 sendfile()函式向核心 (kernel) 發起系統呼叫,上下文從使用者態 (user space) 切換為核心態(kernel space)。
  2. CPU 利用 DMA 控制器將資料從主存或硬碟拷貝到核心空間 (kernel space) 的讀緩衝區 (read buffer)。
  3. CPU 把讀緩衝區 (read buffer) 的檔案描述符(file descriptor)和資料長度拷貝到網路緩衝區(socket buffer)。
  4. 基於已拷貝的檔案描述符 (file descriptor) 和資料長度,CPU 利用 DMA 控制器的 gather/scatter 操作直接批量地將資料從核心的讀緩衝區 (read buffer) 拷貝到網絡卡進行資料傳輸。
  5. 上下文從核心態 (kernel space) 切換回使用者態 (user space),sendfile 系統呼叫執行返回。

5.3 sendfile+DMA拷貝缺點

sendfile + DMA gather copy 拷貝方式同樣存在使用者程式不能對資料進行修改的問題,而且本身需要硬體的支援,它只適用於將資料從檔案拷貝到 socket 套接字上的傳輸過程。

6、減少資料拷貝之splice

sendfile 只適用於將資料從檔案拷貝到 socket 套接字上,同時需要硬體的支援,這也限定了它的使用範圍。Linux 在 2.6.17 版本引入 splice 系統呼叫,不僅不需要硬體支援,還實現了兩個檔案描述符之間的資料零拷貝。splice 的虛擬碼如下:

Copysplice(fd_in, off_in, fd_out, off_out, len, flags);

splice 系統呼叫可以在核心空間的讀緩衝區 (read buffer) 和網路緩衝區 (socket buffer) 之間建立管道 (pipeline),從而避免了兩者之間的 CPU 拷貝操作。

6.1 splice流程示意圖

6.2 splice流程

基於 splice 系統呼叫的零拷貝方式,整個拷貝過程會發生 2 次上下文切換,0 次 CPU 拷貝以及 2 次 DMA 拷貝,使用者程式讀寫資料的流程如下:

  1. 使用者程序通過 splice() 函式向核心(kernel)發起系統呼叫,上下文從使用者態 (user space) 切換為核心態(kernel space);
  2. CPU 利用 DMA 控制器將資料從主存或硬碟拷貝到核心空間 (kernel space) 的讀緩衝區 (read buffer);
  3. CPU 在核心空間的讀緩衝區 (read buffer) 和網路緩衝區(socket buffer)之間建立管道 (pipeline);
  4. CPU 利用 DMA 控制器將資料從網路緩衝區 (socket buffer) 拷貝到網絡卡進行資料傳輸;
  5. 上下文從核心態 (kernel space) 切換回使用者態 (user space),splice 系統呼叫執行返回。

splice 拷貝方式也同樣存在使用者程式不能對資料進行修改的問題。除此之外,它使用了 Linux 的管道緩衝機制,可以用於任意兩個檔案描述符中傳輸資料,但是它的兩個檔案描述符引數中有一個必須是管道裝置。

7、寫時複製

在某些情況下,核心緩衝區可能被多個程序所共享,如果某個程序想要這個共享區進行 write 操作,由於 write 不提供任何的鎖操作,那麼就會對共享區中的資料造成破壞,寫時複製的引入就是 Linux 用來保護資料的。

寫時複製指的是當多個程序共享同一塊資料時,如果其中一個程序需要對這份資料進行修改,那麼就需要將其拷貝到自己的程序地址空間中。這樣做並不影響其他程序對這塊資料的操作,每個程序要修改的時候才會進行拷貝,所以叫寫時拷貝。這種方法在某種程度上能夠降低系統開銷,如果某個程序永遠不會對所訪問的資料進行更改,那麼也就永遠不需要拷貝。

缺點:

需要 MMU 的支援,MMU 需要知道程序地址空間中哪些頁面是隻讀的,當需要往這些頁面寫資料時,發出一個異常給作業系統核心,核心會分配新的儲存空間來供寫入的需求。

8、緩衝區共享

緩衝區共享方式完全改寫了傳統的 I/O 操作,傳統的 Linux I/O 介面支援資料在應用程式地址空間作業系統核心之間交換,這種交換操作導致所有的資料都需要進行拷貝。

如果採用 fbufs 這種方法,需要交換的是包含資料的緩衝區,這樣就消除了多餘的拷貝操作。應用程式將 fbuf 傳遞給作業系統核心,這樣就能減少傳統的 write 系統呼叫所產生的資料拷貝開銷。

同樣的應用程式通過 fbuf 來接收資料,這樣也可以減少傳統 read 系統呼叫所產生的資料拷貝開銷。

fbuf 的思想是每個程序都維護著一個緩衝區池,這個緩衝區池能被同時對映到使用者空間 (user space) 和核心態 (kernel space),核心和使用者共享這個緩衝區池,這樣就避免了一系列的拷貝操作。

缺點:

緩衝區共享的難度在於管理共享緩衝區池需要應用程式、網路軟體以及裝置驅動程式之間的緊密合作,而且如何改寫 API 目前還處於試驗階段並不成熟。

9、Linux零拷貝對比

無論是傳統 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

五、Netty中的零拷貝

1、JDK零拷貝 - MappedByteBuffer

MappedByteBuffer 是 NIO 基於記憶體對映 (mmap) 這種零拷貝方式的提供的一種實現,它繼承自 ByteBuffer。FileChannel 定義了一個 map()方法,它可以把一個檔案從 position 位置開始的 size 大小的區域對映為記憶體映像檔案。抽象方法 map() 方法在 FileChannel 中的定義如下:

Copypublic abstract MappedByteBuffer map(MapMode mode, long position, long size)
        throws IOException;
  • mode:限定記憶體對映區域(MappedByteBuffer)對記憶體映像檔案的訪問模式,包括只可讀(READ_ONLY)、可讀可寫(READ_WRITE)和寫時拷貝(PRIVATE)三種模式。
  • position:檔案對映的起始地址,對應記憶體對映區域(MappedByteBuffer)的首地址。
  • size:檔案對映的位元組長度,從 position 往後的位元組數,對應記憶體對映區域(MappedByteBuffer)的大小。

MappedByteBuffer 相比 ByteBuffer 新增了 fore()、load() 和 isLoad() 三個重要的方法:

  • fore():對於處於 READ_WRITE 模式下的緩衝區,把對緩衝區內容的修改強制重新整理到本地檔案。
  • load():將緩衝區的內容載入實體記憶體中,並返回這個緩衝區的引用。
  • isLoaded():如果緩衝區的內容在實體記憶體中,則返回 true,否則返回 false。

下面給出一個利用 MappedByteBuffer 對檔案進行讀寫的使用示例:

Copyprivate final static String CONTENT = "我要測試零拷貝寫入資料";
private final static String FILE_NAME = "/Users/yangyue/Downloads/1.txt";
public static void main(String[] args) {
  Path path = Paths.get(FILE_NAME);
  byte[] bytes = CONTENT.getBytes(Charset.forName("UTF-8"));
  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();
  }
}

開啟檔案通道 fileChannel 並提供讀許可權、寫許可權和資料清空許可權,通過 fileChannel 對映到一個可寫的記憶體緩衝區 mappedByteBuffer,將目標資料寫入 mappedByteBuffer,通過 force() 方法把緩衝區更改的內容強制寫入本地檔案。

測試讀檔案:

Copypublic static void read(){
  Path path = Paths.get(FILE_NAME);
  int length = CONTENT.getBytes(Charset.forName("UTF-8")).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);
      System.out.println(content);
    }
  } catch (IOException e) {
    e.printStackTrace();
  }
}

map()方法是java.nio.channels.FileChannel 的抽象方法,由子類 sun.nio.ch.FileChannelImpl.java 實現,下面是和記憶體對映相關的核心程式碼:

Copypublic MappedByteBuffer map(MapMode var1, long var2, long var4) throws IOException {
    if (var4 == 0L) {
      var7 = 0L;
      FileDescriptor var38 = new FileDescriptor();
      if (this.writable && var6 != 0) {
        var17 = Util.newMappedByteBuffer(0, 0L, var38, (Runnable)null);
        return var17;
      }
      var17 = Util.newMappedByteBufferR(0, 0L, var38, (Runnable)null);
      return var17;
    }
  var12 = (int)(var2 % allocationGranularity);
  long var36 = var2 - (long)var12;
  var10 = var4 + (long)var12;

  try {
    var7 = this.map0(var6, var36, var10);
  } catch (OutOfMemoryError var31) {
    System.gc();
    try {
      Thread.sleep(100L);
    } catch (InterruptedException var30) {
      Thread.currentThread().interrupt();
    }
    try {
      var7 = this.map0(var6, var36, var10);
    } catch (OutOfMemoryError var29) {
      throw new IOException("Map failed", var29);
    }
  }
  FileDescriptor var13;
  try {
    var13 = this.nd.duplicateForMapping(this.fd);
  } catch (IOException var28) {
    unmap0(var7, var10);
    throw var28;
  }
  assert IOStatus.checkAll(var7);
  assert var7 % allocationGranularity == 0L;
  int var35 = (int)var4;
  FileChannelImpl.Unmapper var15 = new FileChannelImpl.Unmapper(var7, var10, var35, var13);
  if (this.writable && var6 != 0) {
    var37 = Util.newMappedByteBuffer(var35, var7 + (long)var12, var13, var15);
    return var37;
  } else {
    var37 = Util.newMappedByteBufferR(var35, var7 + (long)var12, var13, var15);
    return var37;
  }
}

map()方法通過本地方法 map0()為檔案分配一塊虛擬記憶體,作為它的記憶體對映區域,然後返回這塊記憶體對映區域的起始地址。

檔案對映需要在 Java 堆中建立一個 MappedByteBuffer 的例項。如果第一次檔案對映導致 OOM,則手動觸發垃圾回收,休眠 100ms 後再嘗試對映,如果失敗則丟擲異常。

通過 Util 的 newMappedByteBuffer (可讀可寫)方法或者 newMappedByteBufferR(僅讀) 方法方法反射建立一個 DirectByteBuffer 例項,其中 DirectByteBuffer 是 MappedByteBuffer 的子類。

map() 方法返回的是記憶體對映區域的起始地址,通過(起始地址 + 偏移量)就可以獲取指定記憶體的資料。這樣一定程度上替代了read()write()方法,底層直接採用 sun.misc.Unsafe 類的 getByte()putByte()方法對資料進行讀寫。

Copyprivate 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 這個原始檔裡面:https://github.com/openjdk/jdk/blob/a619f36d115f1c6ebda15d7165de95dc44ebb1fd/src/java.base/windows/native/libnio/ch/FileChannelImpl.c

MappedByteBuffer 的特點和不足之處:

  • MappedByteBuffer 使用是堆外的虛擬記憶體,因此分配(map)的記憶體大小不受 JVM 的 -Xmx 引數限制,但是也是有大小限制的。
  • 如果當檔案超出 Integer.MAX_VALUE 位元組限制時,可以通過 position 引數重新 map 檔案後面的內容。
  • MappedByteBuffer 在處理大檔案時效能的確很高,但也存在記憶體佔用、檔案關閉不確定等問題,被其開啟的檔案只有在垃圾回收的才會被關閉,而且這個時間點是不確定的。
  • MappedByteBuffer 提供了檔案對映記憶體的 mmap() 方法,也提供了釋放對映記憶體的 unmap() 方法。然而 unmap() 是 FileChannelImpl 中的私有方法,無法直接顯示呼叫。因此,使用者程式需要通過 Java 反射的呼叫 sun.misc.Cleaner 類的 clean() 方法手動釋放對映佔用的記憶體區域。

2、JDK零拷貝之DirectByteBuffer

DirectByteBuffer 是 Java NIO 用於實現堆外記憶體的一個很重要的類,而 Netty 用 DirectByteBuffer 作為PooledDirectByteBufUnpooledDirectByteBuf 的內部資料容器(區別於 HeapByteBuf 直接用 byte[] 作為資料容器)。

DirectByteBuffer 的物件引用位於 Java 記憶體模型的堆裡面,JVM 可以對 DirectByteBuffer 的物件進行記憶體分配和回收管理,一般使用 DirectByteBuffer 的靜態方法 allocateDirect()建立 DirectByteBuffer 例項並分配記憶體。

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

DirectByteBuffer 記憶體分配是呼叫底層的 Unsafe 類提供的基礎方法 allocateMemory()直接分配堆外記憶體:

CopyDirectByteBuffer(int cap) {                   // package-private

  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)) {
    // Round up to page boundary
    address = base + ps - (base & (ps - 1));
  } else {
    address = base;
  }
  cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
  att = null;



}

那麼 DirectByteBuffer 和零拷貝有什麼關係?我們看一下 DirectByteBuffer 的類名:

Copyclass DirectByteBuffer extends MappedByteBuffer implements DirectBuffer {
  
}

可以看到她繼承了 MappedByteBuffer,而 MappedByteBuffer 的 map() 方法會通過 Util.newMappedByteBuffer()來建立一個緩衝區例項。

3、基於 sendfile 實現的 FileChannel

FileChannel 是一個用於檔案讀寫、對映和操作的通道,同時它在併發環境下是執行緒安全的,基於 FileInputStream、FileOutputStream 或者 RandomAccessFile 的 getChannel()方法可以建立並開啟一個檔案通道。FileChannel 定義了 transferFrom()transferTo()兩個抽象方法,它通過在通道和通道之間建立連線實現資料傳輸的。

transferTo():通過 FileChannel 把檔案裡面的源資料寫入一個 WritableByteChannel 的目的通道。

transferFrom():把一個源通道 ReadableByteChannel 中的資料讀取到當前 FileChannel 的檔案裡面。

這兩個方法也是 java.nio.channels.FileChannel 的抽象方法,由子類 sun.nio.ch.FileChannelImpl.java 實現。transferTo()transferFrom() 底層都是基於 sendfile 實現資料傳輸的,其中 FileChannelImpl.java 定義了 3 個常量,用於標示當前作業系統的核心是否支援 sendfile 以及 sendfile 的相關特性。

Copyprivate static volatile boolean transferSupported = true;
private static volatile boolean pipeSupported = true;
private static volatile boolean fileSupported = true;

transferSupported:用於標記當前的系統核心是否支援sendfile()呼叫,預設為 true。

pipeSupported:用於標記當前的系統核心是否支援檔案描述符(fd)基於管道(pipe)的sendfile()呼叫,預設為 true。

fileSupported:用於標記當前的系統核心是否支援檔案描述符(fd)基於檔案(file)的 sendfile()呼叫,預設為 true。

4、Netty零拷貝

Netty 中的零拷貝和上面提到的作業系統層面上的零拷貝不太一樣, 我們所說的 Netty 零拷貝完全是基於(Java 層面)使用者態的,它的更多的是偏向於資料操作優化這樣的概念,具體表現在以下幾個方面:

  • Netty 通過 DefaultFileRegion 類對java.nio.channels.FileChanneltranferTo()方法進行包裝,在檔案傳輸時可以將檔案緩衝區的資料直接傳送到目的通道(Channel);
  • ByteBuf 可以通過 wrap 操作把位元組陣列、ByteBuf、ByteBuffer 包裝成一個 ByteBuf 物件, 進而避免了拷貝操作;
  • ByteBuf 支援 slice 操作, 因此可以將 ByteBuf 分解為多個共享同一個儲存區域的 ByteBuf,避免了記憶體的拷貝;
  • Netty 提供了 CompositeByteBuf 類,它可以將多個 ByteBuf 合併為一個邏輯上的 ByteBuf,避免了各個 ByteBuf 之間的拷貝。