拷貝(Zero-copy)技術(shù)指在計算機執(zhí)行操作時,CPU 不需要先將數(shù)據(jù)從一個內(nèi)存區(qū)域復制到另一個內(nèi)存區(qū)域,從而可以減少上下文切換以及 CPU 的拷貝時間。
它的作用是在數(shù)據(jù)報從網(wǎng)絡(luò)設(shè)備到用戶程序空間傳遞的過程中,減少數(shù)據(jù)拷貝次數(shù),減少系統(tǒng)調(diào)用,實現(xiàn) CPU 的零參與,徹底消除 CPU 在這方面的負載。
實現(xiàn)零拷貝用到的最主要技術(shù)是 DMA 數(shù)據(jù)傳輸技術(shù)和內(nèi)存區(qū)域映射技術(shù):
- 零拷貝機制可以減少數(shù)據(jù)在內(nèi)核緩沖區(qū)和用戶進程緩沖區(qū)之間反復的 I/O 拷貝操作。
- 零拷貝機制可以減少用戶進程地址空間和內(nèi)核地址空間之間因為上下文切換而帶來的 CPU 開銷。
物理內(nèi)存和虛擬內(nèi)存
由于操作系統(tǒng)的進程與進程之間是共享 CPU 和內(nèi)存資源的,因此需要一套完善的內(nèi)存管理機制防止進程之間內(nèi)存泄漏的問題。
為了更加有效地管理內(nèi)存并減少出錯,現(xiàn)代操作系統(tǒng)提供了一種對主存的抽象概念,即虛擬內(nèi)存(Virtual Memory)。
虛擬內(nèi)存為每個進程提供了一個一致的、私有的地址空間,它讓每個進程產(chǎn)生了一種自己在獨享主存的錯覺(每個進程擁有一片連續(xù)完整的內(nèi)存空間)。
物理內(nèi)存
物理內(nèi)存(Physical Memory)是相對于虛擬內(nèi)存(Virtual Memory)而言的。
物理內(nèi)存指通過物理內(nèi)存條而獲得的內(nèi)存空間,而虛擬內(nèi)存則是指將硬盤的一塊區(qū)域劃分來作為內(nèi)存。內(nèi)存主要作用是在計算機運行時為操作系統(tǒng)和各種程序提供臨時儲存。
在應(yīng)用中,自然是顧名思義,物理上,真實存在的插在主板內(nèi)存槽上的內(nèi)存條的容量的大小。
虛擬內(nèi)存
虛擬內(nèi)存是計算機系統(tǒng)內(nèi)存管理的一種技術(shù)。它使得應(yīng)用程序認為它擁有連續(xù)的可用的內(nèi)存(一個連續(xù)完整的地址空間)。
而實際上,虛擬內(nèi)存通常是被分隔成多個物理內(nèi)存碎片,還有部分暫時存儲在外部磁盤存儲器上,在需要時進行數(shù)據(jù)交換,加載到物理內(nèi)存中來。
目前,大多數(shù)操作系統(tǒng)都使用了虛擬內(nèi)存,如 windows 系統(tǒng)的虛擬內(nèi)存、linux 系統(tǒng)的交換空間等等。
虛擬內(nèi)存地址和用戶進程緊密相關(guān),一般來說不同進程里的同一個虛擬地址指向的物理地址是不一樣的,所以離開進程談虛擬內(nèi)存沒有任何意義。每個進程所能使用的虛擬地址大小和 CPU 位數(shù)有關(guān)。
在 32 位的系統(tǒng)上,虛擬地址空間大小是 2^32=4G,在 64 位系統(tǒng)上,虛擬地址空間大小是 2^64=16G,而實際的物理內(nèi)存可能遠遠小于虛擬內(nèi)存的大小。
每個用戶進程維護了一個單獨的頁表(Page Table),虛擬內(nèi)存和物理內(nèi)存就是通過這個頁表實現(xiàn)地址空間的映射的。
下面給出兩個進程 A、B 各自的虛擬內(nèi)存空間以及對應(yīng)的物理內(nèi)存之間的地址映射示意圖:

當進程執(zhí)行一個程序時,需要先從內(nèi)存中讀取該進程的指令,然后執(zhí)行,獲取指令時用到的就是虛擬地址。
這個虛擬地址是程序鏈接時確定的(內(nèi)核加載并初始化進程時會調(diào)整動態(tài)庫的地址范圍)。
為了獲取到實際的數(shù)據(jù),CPU 需要將虛擬地址轉(zhuǎn)換成物理地址,CPU 轉(zhuǎn)換地址時需要用到進程的頁表(Page Table),而頁表(Page Table)里面的數(shù)據(jù)由操作系統(tǒng)維護。
其中頁表(Page Table)可以簡單的理解為單個內(nèi)存映射(Memory MApping)的鏈表(當然實際結(jié)構(gòu)很復雜)。
里面的每個內(nèi)存映射(Memory Mapping)都將一塊虛擬地址映射到一個特定的地址空間(物理內(nèi)存或者磁盤存儲空間)。
每個進程擁有自己的頁表(Page Table),和其他進程的頁表(Page Table)沒有關(guān)系。
通過上面的介紹,我們可以簡單的將用戶進程申請并訪問物理內(nèi)存(或磁盤存儲空間)的過程總結(jié)如下:
- 用戶進程向操作系統(tǒng)發(fā)出內(nèi)存申請請求。
- 系統(tǒng)會檢查進程的虛擬地址空間是否被用完,如果有剩余,給進程分配虛擬地址。
- 系統(tǒng)為這塊虛擬地址創(chuàng)建內(nèi)存映射(Memory Mapping),并將它放進該進程的頁表(Page Table)。
- 系統(tǒng)返回虛擬地址給用戶進程,用戶進程開始訪問該虛擬地址。
- CPU 根據(jù)虛擬地址在此進程的頁表(Page Table)中找到了相應(yīng)的內(nèi)存映射(Memory Mapping),但是這個內(nèi)存映射(Memory Mapping)沒有和物理內(nèi)存關(guān)聯(lián),于是產(chǎn)生缺頁中斷。
- 操作系統(tǒng)收到缺頁中斷后,分配真正的物理內(nèi)存并將它關(guān)聯(lián)到頁表相應(yīng)的內(nèi)存映射(Memory Mapping)。中斷處理完成后,CPU 就可以訪問內(nèi)存了
- 當然缺頁中斷不是每次都會發(fā)生,只有系統(tǒng)覺得有必要延遲分配內(nèi)存的時候才用的著,也即很多時候在上面的第 3 步系統(tǒng)會分配真正的物理內(nèi)存并和內(nèi)存映射(Memory Mapping)進行關(guān)聯(lián)。
在用戶進程和物理內(nèi)存(磁盤存儲器)之間引入虛擬內(nèi)存主要有以下的優(yōu)點:
- 地址空間:提供更大的地址空間,并且地址空間是連續(xù)的,使得程序編寫、鏈接更加簡單。
- 進程隔離:不同進程的虛擬地址之間沒有關(guān)系,所以一個進程的操作不會對其他進程造成影響。
- 數(shù)據(jù)保護:每塊虛擬內(nèi)存都有相應(yīng)的讀寫屬性,這樣就能保護程序的代碼段不被修改,數(shù)據(jù)塊不能被執(zhí)行等,增加了系統(tǒng)的安全性。
- 內(nèi)存映射:有了虛擬內(nèi)存之后,可以直接映射磁盤上的文件(可執(zhí)行文件或動態(tài)庫)到虛擬地址空間。
這樣可以做到物理內(nèi)存延時分配,只有在需要讀相應(yīng)的文件的時候,才將它真正的從磁盤上加載到內(nèi)存中來,而在內(nèi)存吃緊的時候又可以將這部分內(nèi)存清空掉,提高物理內(nèi)存利用效率,并且所有這些對應(yīng)用程序都是透明的。
- 共享內(nèi)存:比如動態(tài)庫只需要在內(nèi)存中存儲一份,然后將它映射到不同進程的虛擬地址空間中,讓進程覺得自己獨占了這個文件。
進程間的內(nèi)存共享也可以通過映射同一塊物理內(nèi)存到進程的不同虛擬地址空間來實現(xiàn)共享。
- 物理內(nèi)存管理:物理地址空間全部由操作系統(tǒng)管理,進程無法直接分配和回收,從而系統(tǒng)可以更好的利用內(nèi)存,平衡進程間對內(nèi)存的需求。
內(nèi)核空間和用戶空間
操作系統(tǒng)的核心是內(nèi)核,獨立于普通的應(yīng)用程序,可以訪問受保護的內(nèi)存空間,也有訪問底層硬件設(shè)備的權(quán)限。
為了避免用戶進程直接操作內(nèi)核,保證內(nèi)核安全,操作系統(tǒng)將虛擬內(nèi)存劃分為兩部分,一部分是內(nèi)核空間(Kernel-space),一部分是用戶空間(User-space)。
在 Linux 系統(tǒng)中,內(nèi)核模塊運行在內(nèi)核空間,對應(yīng)的進程處于內(nèi)核態(tài);而用戶程序運行在用戶空間,對應(yīng)的進程處于用戶態(tài)。
內(nèi)核進程和用戶進程所占的虛擬內(nèi)存比例是 1:3,而 Linux x86_32 系統(tǒng)的尋址空間(虛擬存儲空間)為 4G(2 的 32 次方),將最高的 1G 的字節(jié)(從虛擬地址 0xC0000000 到 0xFFFFFFFF)供內(nèi)核進程使用,稱為內(nèi)核空間。
而較低的 3G 的字節(jié)(從虛擬地址 0x00000000 到 0xBFFFFFFF),供各個用戶進程使用,稱為用戶空間。
下圖是一個進程的用戶空間和內(nèi)核空間的內(nèi)存布局:

內(nèi)核空間
內(nèi)核空間總是駐留在內(nèi)存中,它是為操作系統(tǒng)的內(nèi)核保留的。應(yīng)用程序是不允許直接在該區(qū)域進行讀寫或直接調(diào)用內(nèi)核代碼定義的函數(shù)的。
上圖左側(cè)區(qū)域為內(nèi)核進程對應(yīng)的虛擬內(nèi)存,按訪問權(quán)限可以分為進程私有和進程共享兩塊區(qū)域:
- 進程私有的虛擬內(nèi)存:每個進程都有單獨的內(nèi)核棧、頁表、task 結(jié)構(gòu)以及 mem_map 結(jié)構(gòu)等。
- 進程共享的虛擬內(nèi)存:屬于所有進程共享的內(nèi)存區(qū)域,包括物理存儲器、內(nèi)核數(shù)據(jù)和內(nèi)核代碼區(qū)域。
用戶空間
每個普通的用戶進程都有一個單獨的用戶空間,處于用戶態(tài)的進程不能訪問內(nèi)核空間中的數(shù)據(jù),也不能直接調(diào)用內(nèi)核函數(shù)的 ,因此要進行系統(tǒng)調(diào)用的時候,就要將進程切換到內(nèi)核態(tài)才行。
用戶空間包括以下幾個內(nèi)存區(qū)域:
- 運行時棧:由編譯器自動釋放,存放函數(shù)的參數(shù)值,局部變量和方法返回值等。每當一個函數(shù)被調(diào)用時,該函數(shù)的返回類型和一些調(diào)用的信息被存儲到棧頂,調(diào)用結(jié)束后調(diào)用信息會被彈出并釋放掉內(nèi)存。
棧區(qū)是從高地址位向低地址位增長的,是一塊連續(xù)的內(nèi)在區(qū)域,最大容量是由系統(tǒng)預先定義好的,申請的棧空間超過這個界限時會提示溢出,用戶能從棧中獲取的空間較小。
- 運行時堆:用于存放進程運行中被動態(tài)分配的內(nèi)存段,位于 BSS 和棧中間的地址位。由卡發(fā)人員申請分配(malloc)和釋放(free)。堆是從低地址位向高地址位增長,采用鏈式存儲結(jié)構(gòu)。
頻繁地 malloc/free 造成內(nèi)存空間的不連續(xù),產(chǎn)生大量碎片。當申請堆空間時,庫函數(shù)按照一定的算法搜索可用的足夠大的空間。因此堆的效率比棧要低的多。
- 代碼段:存放 CPU 可以執(zhí)行的機器指令,該部分內(nèi)存只能讀不能寫。通常代碼區(qū)是共享的,即其他執(zhí)行程序可調(diào)用它。假如機器中有數(shù)個進程運行相同的一個程序,那么它們就可以使用同一個代碼段。
- 未初始化的數(shù)據(jù)段:存放未初始化的全局變量,BSS 的數(shù)據(jù)在程序開始執(zhí)行之前被初始化為 0 或 NULL。
- 已初始化的數(shù)據(jù)段:存放已初始化的全局變量,包括靜態(tài)全局變量、靜態(tài)局部變量以及常量。
- 內(nèi)存映射區(qū)域:例如將動態(tài)庫,共享內(nèi)存等虛擬空間的內(nèi)存映射到物理空間的內(nèi)存,一般是 mmap 函數(shù)所分配的虛擬內(nèi)存空間。
Linux 的內(nèi)部層級結(jié)構(gòu)
內(nèi)核態(tài)可以執(zhí)行任意命令,調(diào)用系統(tǒng)的一切資源,而用戶態(tài)只能執(zhí)行簡單的運算,不能直接調(diào)用系統(tǒng)資源。用戶態(tài)必須通過系統(tǒng)接口(System Call),才能向內(nèi)核發(fā)出指令。
比如,當用戶進程啟動一個 bash 時,它會通過 getpid() 對內(nèi)核的 pid 服務(wù)發(fā)起系統(tǒng)調(diào)用,獲取當前用戶進程的 ID。
當用戶進程通過 cat 命令查看主機配置時,它會對內(nèi)核的文件子系統(tǒng)發(fā)起系統(tǒng)調(diào)用:
- 內(nèi)核空間可以訪問所有的 CPU 指令和所有的內(nèi)存空間、I/O 空間和硬件設(shè)備。
- 用戶空間只能訪問受限的資源,如果需要特殊權(quán)限,可以通過系統(tǒng)調(diào)用獲取相應(yīng)的資源。
- 用戶空間允許頁面中斷,而內(nèi)核空間則不允許。
- 內(nèi)核空間和用戶空間是針對線性地址空間的。
- x86 CPU 中用戶空間是 0-3G 的地址范圍,內(nèi)核空間是 3G-4G 的地址范圍。
x86_64 CPU 用戶空間地址范圍為0x0000000000000000–0x00007fffffffffff,內(nèi)核地址空間為 0xffff880000000000-最大地址。
- 所有內(nèi)核進程(線程)共用一個地址空間,而用戶進程都有各自的地址空間。
有了用戶空間和內(nèi)核空間的劃分后,Linux 內(nèi)部層級結(jié)構(gòu)可以分為三部分,從最底層到最上層依次是硬件、內(nèi)核空間和用戶空間,如下圖所示:

Linux I/O 讀寫方式
Linux 提供了輪詢、I/O 中斷以及 DMA 傳輸這 3 種磁盤與主存之間的數(shù)據(jù)傳輸機制。其中輪詢方式是基于死循環(huán)對 I/O 端口進行不斷檢測。
I/O 中斷方式是指當數(shù)據(jù)到達時,磁盤主動向 CPU 發(fā)起中斷請求,由 CPU 自身負責數(shù)據(jù)的傳輸過程。
DMA 傳輸則在 I/O 中斷的基礎(chǔ)上引入了 DMA 磁盤控制器,由 DMA 磁盤控制器負責數(shù)據(jù)的傳輸,降低了 I/O 中斷操作對 CPU 資源的大量消耗。
I/O 中斷原理
在 DMA 技術(shù)出現(xiàn)之前,應(yīng)用程序與磁盤之間的 I/O 操作都是通過 CPU 的中斷完成的。
每次用戶進程讀取磁盤數(shù)據(jù)時,都需要 CPU 中斷,然后發(fā)起 I/O 請求等待數(shù)據(jù)讀取和拷貝完成,每次的 I/O 中斷都導致 CPU 的上下文切換:
- 用戶進程向 CPU 發(fā)起 read 系統(tǒng)調(diào)用讀取數(shù)據(jù),由用戶態(tài)切換為內(nèi)核態(tài),然后一直阻塞等待數(shù)據(jù)的返回。
- CPU 在接收到指令以后對磁盤發(fā)起 I/O 請求,將磁盤數(shù)據(jù)先放入磁盤控制器緩沖區(qū)。
- 數(shù)據(jù)準備完成以后,磁盤向 CPU 發(fā)起 I/O 中斷。
- CPU 收到 I/O 中斷以后將磁盤緩沖區(qū)中的數(shù)據(jù)拷貝到內(nèi)核緩沖區(qū),然后再從內(nèi)核緩沖區(qū)拷貝到用戶緩沖區(qū)。
- 用戶進程由內(nèi)核態(tài)切換回用戶態(tài),解除阻塞狀態(tài),然后等待 CPU 的下一個執(zhí)行時間鐘。
DMA 傳輸原理
DMA 的全稱叫直接內(nèi)存存取(Direct Memory Access),是一種允許外圍設(shè)備(硬件子系統(tǒng))直接訪問系統(tǒng)主內(nèi)存的機制。
也就是說,基于 DMA 訪問方式,系統(tǒng)主內(nèi)存于硬盤或網(wǎng)卡之間的數(shù)據(jù)傳輸可以繞開 CPU 的全程調(diào)度。
目前大多數(shù)的硬件設(shè)備,包括磁盤控制器、網(wǎng)卡、顯卡以及聲卡等都支持 DMA 技術(shù)。
整個數(shù)據(jù)傳輸操作在一個 DMA 控制器的控制下進行的。CPU 除了在數(shù)據(jù)傳輸開始和結(jié)束時做一點處理外(開始和結(jié)束時候要做中斷處理),在傳輸過程中 CPU 可以繼續(xù)進行其他的工作。
這樣在大部分時間里,CPU 計算和 I/O 操作都處于并行操作,使整個計算機系統(tǒng)的效率大大提高。
有了 DMA 磁盤控制器接管數(shù)據(jù)讀寫請求以后,CPU 從繁重的 I/O 操作中解脫,數(shù)據(jù)讀取操作的流程如下:
- 用戶進程向 CPU 發(fā)起 read 系統(tǒng)調(diào)用讀取數(shù)據(jù),由用戶態(tài)切換為內(nèi)核態(tài),然后一直阻塞等待數(shù)據(jù)的返回。
- CPU 在接收到指令以后對 DMA 磁盤控制器發(fā)起調(diào)度指令。
- DMA 磁盤控制器對磁盤發(fā)起 I/O 請求,將磁盤數(shù)據(jù)先放入磁盤控制器緩沖區(qū),CPU 全程不參與此過程。
- 數(shù)據(jù)讀取完成后,DMA 磁盤控制器會接受到磁盤的通知,將數(shù)據(jù)從磁盤控制器緩沖區(qū)拷貝到內(nèi)核緩沖區(qū)。
- DMA 磁盤控制器向 CPU 發(fā)出數(shù)據(jù)讀完的信號,由 CPU 負責將數(shù)據(jù)從內(nèi)核緩沖區(qū)拷貝到用戶緩沖區(qū)。
- 用戶進程由內(nèi)核態(tài)切換回用戶態(tài),解除阻塞狀態(tài),然后等待 CPU 的下一個執(zhí)行時間鐘。
傳統(tǒng) I/O 方式
為了更好的理解零拷貝解決的問題,我們首先了解一下傳統(tǒng) I/O 方式存在的問題。
在 Linux 系統(tǒng)中,傳統(tǒng)的訪問方式是通過 write() 和 read() 兩個系統(tǒng)調(diào)用實現(xiàn)的,通過 read() 函數(shù)讀取文件到到緩存區(qū)中,然后通過 write() 方法把緩存中的數(shù)據(jù)輸出到網(wǎng)絡(luò)端口。
偽代碼如下:
- read(file_fd, tmp_buf, len);
- write(socket_fd, tmp_buf, len);
下圖分別對應(yīng)傳統(tǒng) I/O 操作的數(shù)據(jù)讀寫流程,整個過程涉及 2 次 CPU 拷貝、2 次 DMA 拷貝,總共 4 次拷貝,以及 4 次上下文切換。
下面簡單地闡述一下相關(guān)的概念:
- 上下文切換:當用戶程序向內(nèi)核發(fā)起系統(tǒng)調(diào)用時,CPU 將用戶進程從用戶態(tài)切換到內(nèi)核態(tài);當系統(tǒng)調(diào)用返回時,CPU 將用戶進程從內(nèi)核態(tài)切換回用戶態(tài)。
- CPU 拷貝:由 CPU 直接處理數(shù)據(jù)的傳送,數(shù)據(jù)拷貝時會一直占用 CPU 的資源。
- DMA 拷貝:由 CPU 向DMA磁盤控制器下達指令,讓 DMA 控制器來處理數(shù)據(jù)的傳送,數(shù)據(jù)傳送完畢再把信息反饋給 CPU,從而減輕了 CPU 資源的占有率。
傳統(tǒng)讀操作
當應(yīng)用程序執(zhí)行 read 系統(tǒng)調(diào)用讀取一塊數(shù)據(jù)的時候,如果這塊數(shù)據(jù)已經(jīng)存在于用戶進程的頁內(nèi)存中,就直接從內(nèi)存中讀取數(shù)據(jù)。
如果數(shù)據(jù)不存在,則先將數(shù)據(jù)從磁盤加載數(shù)據(jù)到內(nèi)核空間的讀緩存(read buffer)中,再從讀緩存拷貝到用戶進程的頁內(nèi)存中。
- read(file_fd, tmp_buf, len);
基于傳統(tǒng)的 I/O 讀取方式,read 系統(tǒng)調(diào)用會觸發(fā) 2 次上下文切換,1 次 DMA 拷貝和 1 次 CPU 拷貝。
發(fā)起數(shù)據(jù)讀取的流程如下:
- 用戶進程通過 read() 函數(shù)向內(nèi)核(kernel)發(fā)起系統(tǒng)調(diào)用,上下文從用戶態(tài)(user space)切換為內(nèi)核態(tài)(kernel space)。
- CPU 利用 DMA 控制器將數(shù)據(jù)從主存或硬盤拷貝到內(nèi)核空間(kernel space)的讀緩沖區(qū)(read buffer)。
- CPU 將讀緩沖區(qū)(read buffer)中的數(shù)據(jù)拷貝到用戶空間(user space)的用戶緩沖區(qū)(user buffer)。
- 上下文從內(nèi)核態(tài)(kernel space)切換回用戶態(tài)(user space),read 調(diào)用執(zhí)行返回。
傳統(tǒng)寫操作
當應(yīng)用程序準備好數(shù)據(jù),執(zhí)行 write 系統(tǒng)調(diào)用發(fā)送網(wǎng)絡(luò)數(shù)據(jù)時,先將數(shù)據(jù)從用戶空間的頁緩存拷貝到內(nèi)核空間的網(wǎng)絡(luò)緩沖區(qū)(socket buffer)中,然后再將寫緩存中的數(shù)據(jù)拷貝到網(wǎng)卡設(shè)備完成數(shù)據(jù)發(fā)送。
- write(socket_fd, tmp_buf, len);
基于傳統(tǒng)的 I/O 寫入方式,write() 系統(tǒng)調(diào)用會觸發(fā) 2 次上下文切換,1 次 CPU 拷貝和 1 次 DMA 拷貝。
用戶程序發(fā)送網(wǎng)絡(luò)數(shù)據(jù)的流程如下:
- 用戶進程通過 write() 函數(shù)向內(nèi)核(kernel)發(fā)起系統(tǒng)調(diào)用,上下文從用戶態(tài)(user space)切換為內(nèi)核態(tài)(kernel space)。
- CPU 將用戶緩沖區(qū)(user buffer)中的數(shù)據(jù)拷貝到內(nèi)核空間(kernel space)的網(wǎng)絡(luò)緩沖區(qū)(socket buffer)。
- CPU 利用 DMA 控制器將數(shù)據(jù)從網(wǎng)絡(luò)緩沖區(qū)(socket buffer)拷貝到網(wǎng)卡進行數(shù)據(jù)傳輸。
- 上下文從內(nèi)核態(tài)(kernel space)切換回用戶態(tài)(user space),write 系統(tǒng)調(diào)用執(zhí)行返回。
零拷貝方式
在 Linux 中零拷貝技術(shù)主要有 3 個實現(xiàn)思路:
- 用戶態(tài)直接 I/O:應(yīng)用程序可以直接訪問硬件存儲,操作系統(tǒng)內(nèi)核只是輔助數(shù)據(jù)傳輸。
這種方式依舊存在用戶空間和內(nèi)核空間的上下文切換,硬件上的數(shù)據(jù)直接拷貝至了用戶空間,不經(jīng)過內(nèi)核空間。因此,直接 I/O 不存在內(nèi)核空間緩沖區(qū)和用戶空間緩沖區(qū)之間的數(shù)據(jù)拷貝。
- 減少數(shù)據(jù)拷貝次數(shù):在數(shù)據(jù)傳輸過程中,避免數(shù)據(jù)在用戶空間緩沖區(qū)和系統(tǒng)內(nèi)核空間緩沖區(qū)之間的 CPU 拷貝,以及數(shù)據(jù)在系統(tǒng)內(nèi)核空間內(nèi)的 CPU 拷貝,這也是當前主流零拷貝技術(shù)的實現(xiàn)思路。
- 寫時復制技術(shù):寫時復制指的是當多個進程共享同一塊數(shù)據(jù)時,如果其中一個進程需要對這份數(shù)據(jù)進行修改,那么將其拷貝到自己的進程地址空間中,如果只是數(shù)據(jù)讀取操作則不需要進行拷貝操作。
用戶態(tài)直接 I/O
用戶態(tài)直接 I/O 使得應(yīng)用進程或運行在用戶態(tài)(user space)下的庫函數(shù)直接訪問硬件設(shè)備。
數(shù)據(jù)直接跨過內(nèi)核進行傳輸,內(nèi)核在數(shù)據(jù)傳輸過程除了進行必要的虛擬存儲配置工作之外,不參與任何其他工作,這種方式能夠直接繞過內(nèi)核,極大提高了性能。
用戶態(tài)直接 I/O 只能適用于不需要內(nèi)核緩沖區(qū)處理的應(yīng)用程序,這些應(yīng)用程序通常在進程地址空間有自己的數(shù)據(jù)緩存機制,稱為自緩存應(yīng)用程序,如數(shù)據(jù)庫管理系統(tǒng)就是一個代表。
其次,這種零拷貝機制會直接操作磁盤 I/O,由于 CPU 和磁盤 I/O 之間的執(zhí)行時間差距,會造成大量資源的浪費,解決方案是配合異步 I/O 使用。
mmap+write
一種零拷貝方式是使用 mmap+write 代替原來的 read+write 方式,減少了 1 次 CPU 拷貝操作。
mmap 是 Linux 提供的一種內(nèi)存映射文件方法,即將一個進程的地址空間中的一段虛擬地址映射到磁盤文件地址,mmap+write 的偽代碼如下:
- tmp_buf = mmap(file_fd, len);
- write(socket_fd, tmp_buf, len);
使用 mmap 的目的是將內(nèi)核中讀緩沖區(qū)(read buffer)的地址與用戶空間的緩沖區(qū)(user buffer)進行映射。
從而實現(xiàn)內(nèi)核緩沖區(qū)與應(yīng)用程序內(nèi)存的共享,省去了將數(shù)據(jù)從內(nèi)核讀緩沖區(qū)(read buffer)拷貝到用戶緩沖區(qū)(user buffer)的過程。
然而內(nèi)核讀緩沖區(qū)(read buffer)仍需將數(shù)據(jù)拷貝到內(nèi)核寫緩沖區(qū)(socket buffer),大致的流程如下圖所示:
基于 mmap+write 系統(tǒng)調(diào)用的零拷貝方式,整個拷貝過程會發(fā)生 4 次上下文切換,1 次 CPU 拷貝和 2 次 DMA 拷貝。
用戶程序讀寫數(shù)據(jù)的流程如下:
- 用戶進程通過 mmap() 函數(shù)向內(nèi)核(kernel)發(fā)起系統(tǒng)調(diào)用,上下文從用戶態(tài)(user space)切換為內(nèi)核態(tài)(kernel space)。
- 將用戶進程的內(nèi)核空間的讀緩沖區(qū)(read buffer)與用戶空間的緩存區(qū)(user buffer)進行內(nèi)存地址映射。
- CPU 利用 DMA 控制器將數(shù)據(jù)從主存或硬盤拷貝到內(nèi)核空間(kernel space)的讀緩沖區(qū)(read buffer)。
- 上下文從內(nèi)核態(tài)(kernel space)切換回用戶態(tài)(user space),mmap 系統(tǒng)調(diào)用執(zhí)行返回。
- 用戶進程通過 write() 函數(shù)向內(nèi)核(kernel)發(fā)起系統(tǒng)調(diào)用,上下文從用戶態(tài)(user space)切換為內(nèi)核態(tài)(kernel space)。
- CPU 將讀緩沖區(qū)(read buffer)中的數(shù)據(jù)拷貝到網(wǎng)絡(luò)緩沖區(qū)(socket buffer)。
- CPU 利用 DMA 控制器將數(shù)據(jù)從網(wǎng)絡(luò)緩沖區(qū)(socket buffer)拷貝到網(wǎng)卡進行數(shù)據(jù)傳輸。
- 上下文從內(nèi)核態(tài)(kernel space)切換回用戶態(tài)(user space),write 系統(tǒng)調(diào)用執(zhí)行返回。
mmap 主要的用處是提高 I/O 性能,特別是針對大文件。對于小文件,內(nèi)存映射文件反而會導致碎片空間的浪費。
因為內(nèi)存映射總是要對齊頁邊界,最小單位是 4 KB,一個 5 KB 的文件將會映射占用 8 KB 內(nèi)存,也就會浪費 3 KB 內(nèi)存。
mmap 的拷貝雖然減少了 1 次拷貝,提升了效率,但也存在一些隱藏的問題。
當 mmap 一個文件時,如果這個文件被另一個進程所截獲,那么 write 系統(tǒng)調(diào)用會因為訪問非法地址被 SIGBUS 信號終止,SIGBUS 默認會殺死進程并產(chǎn)生一個 coredump,服務(wù)器可能因此被終止。
Sendfile
Sendfile 系統(tǒng)調(diào)用在 Linux 內(nèi)核版本 2.1 中被引入,目的是簡化通過網(wǎng)絡(luò)在兩個通道之間進行的數(shù)據(jù)傳輸過程。
Sendfile 系統(tǒng)調(diào)用的引入,不僅減少了 CPU 拷貝的次數(shù),還減少了上下文切換的次數(shù),它的偽代碼如下:
- sendfile(socket_fd, file_fd, len);
通過 Sendfile 系統(tǒng)調(diào)用,數(shù)據(jù)可以直接在內(nèi)核空間內(nèi)部進行 I/O 傳輸,從而省去了數(shù)據(jù)在用戶空間和內(nèi)核空間之間的來回拷貝。
與 mmap 內(nèi)存映射方式不同的是, Sendfile 調(diào)用中 I/O 數(shù)據(jù)對用戶空間是完全不可見的。也就是說,這是一次完全意義上的數(shù)據(jù)傳輸過程。
基于 Sendfile 系統(tǒng)調(diào)用的零拷貝方式,整個拷貝過程會發(fā)生 2 次上下文切換,1 次 CPU 拷貝和 2 次 DMA 拷貝。
用戶程序讀寫數(shù)據(jù)的流程如下:
- 用戶進程通過 sendfile() 函數(shù)向內(nèi)核(kernel)發(fā)起系統(tǒng)調(diào)用,上下文從用戶態(tài)(user space)切換為內(nèi)核態(tài)(kernel space)。
- CPU 利用 DMA 控制器將數(shù)據(jù)從主存或硬盤拷貝到內(nèi)核空間(kernel space)的讀緩沖區(qū)(read buffer)。
- CPU 將讀緩沖區(qū)(read buffer)中的數(shù)據(jù)拷貝到的網(wǎng)絡(luò)緩沖區(qū)(socket buffer)。
- CPU 利用 DMA 控制器將數(shù)據(jù)從網(wǎng)絡(luò)緩沖區(qū)(socket buffer)拷貝到網(wǎng)卡進行數(shù)據(jù)傳輸。
- 上下文從內(nèi)核態(tài)(kernel space)切換回用戶態(tài)(user space),Sendfile 系統(tǒng)調(diào)用執(zhí)行返回。
相比較于 mmap 內(nèi)存映射的方式,Sendfile 少了 2 次上下文切換,但是仍然有 1 次 CPU 拷貝操作。
Sendfile 存在的問題是用戶程序不能對數(shù)據(jù)進行修改,而只是單純地完成了一次數(shù)據(jù)傳輸過程。
Sendfile+DMA gather copy
Linux 2.4 版本的內(nèi)核對 Sendfile 系統(tǒng)調(diào)用進行修改,為 DMA 拷貝引入了 gather 操作。
它將內(nèi)核空間(kernel space)的讀緩沖區(qū)(read buffer)中對應(yīng)的數(shù)據(jù)描述信息(內(nèi)存地址、地址偏移量)記錄到相應(yīng)的網(wǎng)絡(luò)緩沖區(qū)( socket buffer)中,由 DMA 根據(jù)內(nèi)存地址、地址偏移量將數(shù)據(jù)批量地從讀緩沖區(qū)(read buffer)拷貝到網(wǎng)卡設(shè)備中。
這樣就省去了內(nèi)核空間中僅剩的 1 次 CPU 拷貝操作,Sendfile 的偽代碼如下:
- sendfile(socket_fd, file_fd, len);
在硬件的支持下,Sendfile 拷貝方式不再從內(nèi)核緩沖區(qū)的數(shù)據(jù)拷貝到 socket 緩沖區(qū),取而代之的僅僅是緩沖區(qū)文件描述符和數(shù)據(jù)長度的拷貝。
這樣 DMA 引擎直接利用 gather 操作將頁緩存中數(shù)據(jù)打包發(fā)送到網(wǎng)絡(luò)中即可,本質(zhì)就是和虛擬內(nèi)存映射的思路類似。
基于 Sendfile+DMA gather copy 系統(tǒng)調(diào)用的零拷貝方式,整個拷貝過程會發(fā)生 2 次上下文切換、0 次 CPU 拷貝以及 2 次 DMA 拷貝。
用戶程序讀寫數(shù)據(jù)的流程如下:
- 用戶進程通過 sendfile() 函數(shù)向內(nèi)核(kernel)發(fā)起系統(tǒng)調(diào)用,上下文從用戶態(tài)(user space)切換為內(nèi)核態(tài)(kernel space)。
- CPU 利用 DMA 控制器將數(shù)據(jù)從主存或硬盤拷貝到內(nèi)核空間(kernel space)的讀緩沖區(qū)(read buffer)。
- CPU 把讀緩沖區(qū)(read buffer)的文件描述符(file descriptor)和數(shù)據(jù)長度拷貝到網(wǎng)絡(luò)緩沖區(qū)(socket buffer)。
- 基于已拷貝的文件描述符(file descriptor)和數(shù)據(jù)長度,CPU 利用 DMA 控制器的 gather/scatter 操作直接批量地將數(shù)據(jù)從內(nèi)核的讀緩沖區(qū)(read buffer)拷貝到網(wǎng)卡進行數(shù)據(jù)傳輸。
- 上下文從內(nèi)核態(tài)(kernel space)切換回用戶態(tài)(user space),Sendfile 系統(tǒng)調(diào)用執(zhí)行返回。
Sendfile+DMA gather copy 拷貝方式同樣存在用戶程序不能對數(shù)據(jù)進行修改的問題,而且本身需要硬件的支持,它只適用于將數(shù)據(jù)從文件拷貝到 socket 套接字上的傳輸過程。
Splice
Sendfile 只適用于將數(shù)據(jù)從文件拷貝到 socket 套接字上,同時需要硬件的支持,這也限定了它的使用范圍。
Linux 在 2.6.17 版本引入 Splice 系統(tǒng)調(diào)用,不僅不需要硬件支持,還實現(xiàn)了兩個文件描述符之間的數(shù)據(jù)零拷貝。
Splice 的偽代碼如下:
- splice(fd_in, off_in, fd_out, off_out, len, flags);
Splice 系統(tǒng)調(diào)用可以在內(nèi)核空間的讀緩沖區(qū)(read buffer)和網(wǎng)絡(luò)緩沖區(qū)(socket buffer)之間建立管道(pipeline),從而避免了兩者之間的 CPU 拷貝操作。
基于 Splice 系統(tǒng)調(diào)用的零拷貝方式,整個拷貝過程會發(fā)生 2 次上下文切換,0 次 CPU 拷貝以及 2 次 DMA 拷貝。
用戶程序讀寫數(shù)據(jù)的流程如下:
- 用戶進程通過 splice() 函數(shù)向內(nèi)核(kernel)發(fā)起系統(tǒng)調(diào)用,上下文從用戶態(tài)(user space)切換為內(nèi)核態(tài)(kernel space)。
- CPU 利用 DMA 控制器將數(shù)據(jù)從主存或硬盤拷貝到內(nèi)核空間(kernel space)的讀緩沖區(qū)(read buffer)。
- CPU 在內(nèi)核空間的讀緩沖區(qū)(read buffer)和網(wǎng)絡(luò)緩沖區(qū)(socket buffer)之間建立管道(pipeline)。
- CPU 利用 DMA 控制器將數(shù)據(jù)從網(wǎng)絡(luò)緩沖區(qū)(socket buffer)拷貝到網(wǎng)卡進行數(shù)據(jù)傳輸。
- 上下文從內(nèi)核態(tài)(kernel space)切換回用戶態(tài)(user space),Splice 系統(tǒng)調(diào)用執(zhí)行返回。
Splice 拷貝方式也同樣存在用戶程序不能對數(shù)據(jù)進行修改的問題。除此之外,它使用了 Linux 的管道緩沖機制,可以用于任意兩個文件描述符中傳輸數(shù)據(jù),但是它的兩個文件描述符參數(shù)中有一個必須是管道設(shè)備。
寫時復制
在某些情況下,內(nèi)核緩沖區(qū)可能被多個進程所共享,如果某個進程想要這個共享區(qū)進行 write 操作,由于 write 不提供任何的鎖操作,那么就會對共享區(qū)中的數(shù)據(jù)造成破壞,寫時復制的引入就是 Linux 用來保護數(shù)據(jù)的。
寫時復制指的是當多個進程共享同一塊數(shù)據(jù)時,如果其中一個進程需要對這份數(shù)據(jù)進行修改,那么就需要將其拷貝到自己的進程地址空間中。
這樣做并不影響其他進程對這塊數(shù)據(jù)的操作,每個進程要修改的時候才會進行拷貝,所以叫寫時拷貝。
這種方法在某種程度上能夠降低系統(tǒng)開銷,如果某個進程永遠不會對所訪問的數(shù)據(jù)進行更改,那么也就永遠不需要拷貝。
緩沖區(qū)共享
緩沖區(qū)共享方式完全改寫了傳統(tǒng)的 I/O 操作,因為傳統(tǒng) I/O 接口都是基于數(shù)據(jù)拷貝進行的,要避免拷貝就得去掉原先的那套接口并重新改寫。
所以這種方法是比較全面的零拷貝技術(shù),目前比較成熟的一個方案是在 Solaris 上實現(xiàn)的 fbuf(Fast Buffer,快速緩沖區(qū))。
fbuf 的思想是每個進程都維護著一個緩沖區(qū)池,這個緩沖區(qū)池能被同時映射到用戶空間(user space)和內(nèi)核態(tài)(kernel space),內(nèi)核和用戶共享這個緩沖區(qū)池,這樣就避免了一系列的拷貝操作。
緩沖區(qū)共享的難度在于管理共享緩沖區(qū)池需要應(yīng)用程序、網(wǎng)絡(luò)軟件以及設(shè)備驅(qū)動程序之間的緊密合作,而且如何改寫 API 目前還處于試驗階段并不成熟。
Linux 零拷貝對比
無論是傳統(tǒng) I/O 拷貝方式還是引入零拷貝的方式,2 次 DMA Copy 是都少不了的,因為兩次 DMA 都是依賴硬件完成的。
下面從 CPU 拷貝次數(shù)、DMA 拷貝次數(shù)以及系統(tǒng)調(diào)用幾個方面總結(jié)一下上述幾種 I/O 拷貝方式的差別:
JAVA NIO 零拷貝實現(xiàn)
在 Java NIO 中的通道(Channel)就相當于操作系統(tǒng)的內(nèi)核空間(kernel space)的緩沖區(qū)。
而緩沖區(qū)(Buffer)對應(yīng)的相當于操作系統(tǒng)的用戶空間(user space)中的用戶緩沖區(qū)(user buffer):
- 通道(Channel)是全雙工的(雙向傳輸),它既可能是讀緩沖區(qū)(read buffer),也可能是網(wǎng)絡(luò)緩沖區(qū)(socket buffer)。
- 緩沖區(qū)(Buffer)分為堆內(nèi)存(HeapBuffer)和堆外內(nèi)存(DirectBuffer),這是通過 malloc() 分配出來的用戶態(tài)內(nèi)存。
堆外內(nèi)存(DirectBuffer)在使用后需要應(yīng)用程序手動回收,而堆內(nèi)存(HeapBuffer)的數(shù)據(jù)在 GC 時可能會被自動回收。
因此,在使用 HeapBuffer 讀寫數(shù)據(jù)時,為了避免緩沖區(qū)數(shù)據(jù)因為 GC 而丟失,NIO 會先把 HeapBuffer 內(nèi)部的數(shù)據(jù)拷貝到一個臨時的 DirectBuffer 中的本地內(nèi)存(native memory)。
這個拷貝涉及到 sun.misc.Unsafe.copyMemory() 的調(diào)用,背后的實現(xiàn)原理與 memcpy() 類似。
最后,將臨時生成的 DirectBuffer 內(nèi)部的數(shù)據(jù)的內(nèi)存地址傳給 I/O 調(diào)用函數(shù),這樣就避免了再去訪問 Java 對象處理 I/O 讀寫。
MappedByteBuffer
MappedByteBuffer 是 NIO 基于內(nèi)存映射(mmap)這種零拷貝方式提供的一種實現(xiàn),它繼承自 ByteBuffer。
FileChannel 定義了一個 map() 方法,它可以把一個文件從 position 位置開始的 size 大小的區(qū)域映射為內(nèi)存映像文件。
抽象方法 map() 方法在 FileChannel 中的定義如下:
- public abstract MappedByteBuffer map(MapMode mode, long position, long size)
- throws IOException;
Mode:限定內(nèi)存映射區(qū)域(MappedByteBuffer)對內(nèi)存映像文件的訪問模式,包括只可讀(READ_ONLY)、可讀可寫(READ_WRITE)和寫時拷貝(PRIVATE)三種模式。
Position:文件映射的起始地址,對應(yīng)內(nèi)存映射區(qū)域(MappedByteBuffer)的首地址。
Size:文件映射的字節(jié)長度,從 Position 往后的字節(jié)數(shù),對應(yīng)內(nèi)存映射區(qū)域(MappedByteBuffer)的大小。
MappedByteBuffer 相比 ByteBuffer 新增了三個重要的方法:
- fore():對于處于 READ_WRITE 模式下的緩沖區(qū),把對緩沖區(qū)內(nèi)容的修改強制刷新到本地文件。
- load():將緩沖區(qū)的內(nèi)容載入物理內(nèi)存中,并返回這個緩沖區(qū)的引用。
- isLoaded():如果緩沖區(qū)的內(nèi)容在物理內(nèi)存中,則返回 true,否則返回 false。
下面給出一個利用 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";
寫文件數(shù)據(jù):打開文件通道 fileChannel 并提供讀權(quán)限、寫權(quán)限和數(shù)據(jù)清空權(quán)限,通過 fileChannel 映射到一個可寫的內(nèi)存緩沖區(qū) mappedByteBuffer,將目標數(shù)據(jù)寫入 mappedByteBuffer,通過 force() 方法把緩沖區(qū)更改的內(nèi)容強制寫入本地文件。
- @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();
- }
- }
讀文件數(shù)據(jù):打開文件通道 fileChannel 并提供只讀權(quán)限,通過 fileChannel 映射到一個只可讀的內(nèi)存緩沖區(qū) mappedByteBuffer,讀取 mappedByteBuffer 中的字節(jié)數(shù)組即可得到文件數(shù)據(jù)。
- @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() 方法的底層實現(xiàn)原理。map() 方法是 java.nio.channels.FileChannel 的抽象方法,由子類 sun.nio.ch.FileChannelImpl.java 實現(xiàn)。
下面是和內(nèi)存映射相關(guān)的核心代碼:
- 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() 為文件分配一塊虛擬內(nèi)存,作為它的內(nèi)存映射區(qū)域,然后返回這塊內(nèi)存映射區(qū)域的起始地址:
- 文件映射需要在 Java 堆中創(chuàng)建一個 MappedByteBuffer 的實例。如果第一次文件映射導致 OOM,則手動觸發(fā)垃圾回收,休眠 100ms 后再嘗試映射,如果失敗則拋出異常。
- 通過 Util 的 newMappedByteBuffer(可讀可寫)方法或者 newMappedByteBufferR(僅讀)方法反射創(chuàng)建一個 DirectByteBuffer 實例,其中 DirectByteBuffer 是 MappedByteBuffer 的子類。
map() 方法返回的是內(nèi)存映射區(qū)域的起始地址,通過(起始地址+偏移量)就可以獲取指定內(nèi)存的數(shù)據(jù)。
這樣一定程度上替代了 read() 或 write() 方法,底層直接采用 sun.misc.Unsafe 類的 getByte() 和 putByte() 方法對數(shù)據(jù)進行讀寫。
- private native long map0(int prot, long position, long mapSize) throws IOException;
上面是本地方法(native method)map0 的定義,它通過 JNI(Java Native Interface)調(diào)用底層 C 的實現(xiàn)。
這個 native 函數(shù)(Java_sun_nio_ch_FileChannelImpl_map0)的實現(xiàn)位于 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() 函數(shù)最終是通過 mmap64() 這個函數(shù)對 Linux 底層內(nèi)核發(fā)出內(nèi)存映射的調(diào)用, mmap64() 函數(shù)的原型如下:
- #include <sys/mman.h>
- void *mmap64(void *addr, size_t len, int prot, int flags, int fd, off64_t offset);
下面詳細介紹一下 mmap64() 函數(shù)各個參數(shù)的含義以及參數(shù)可選值:
addr:文件在用戶進程空間的內(nèi)存映射區(qū)中的起始地址,是一個建議的參數(shù),通常可設(shè)置為 0 或 NULL,此時由內(nèi)核去決定真實的起始地址。
當 flags 為 MAP_FIXED 時,addr 就是一個必選的參數(shù),即需要提供一個存在的地址。
len:文件需要進行內(nèi)存映射的字節(jié)長度。
prot:控制用戶進程對內(nèi)存映射區(qū)的訪問權(quán)限:
- PROT_READ:讀權(quán)限。
- PROT_WRITE:寫權(quán)限。
- PROT_EXEC:執(zhí)行權(quán)限。
- PROT_NONE:無權(quán)限。
flags:控制內(nèi)存映射區(qū)的修改是否被多個進程共享:
- MAP_PRIVATE:對內(nèi)存映射區(qū)數(shù)據(jù)的修改不會反映到真正的文件,數(shù)據(jù)修改發(fā)生時采用寫時復制機制。
- MAP_SHARED:對內(nèi)存映射區(qū)的修改會同步到真正的文件,修改對共享此內(nèi)存映射區(qū)的進程是可見的。
- MAP_FIXED:不建議使用,這種模式下 addr 參數(shù)指定的必須提供一個存在的 addr 參數(shù)。
fd:文件描述符。每次 map 操作會導致文件的引用計數(shù)加 1,每次 unmap 操作或者結(jié)束進程會導致引用計數(shù)減 1。
offset:文件偏移量。進行映射的文件位置,從文件起始地址向后的位移量。
下面總結(jié)一下 MappedByteBuffer 的特點和不足之處:
- MappedByteBuffer 使用是堆外的虛擬內(nèi)存,因此分配(map)的內(nèi)存大小不受 JVM 的 -Xmx 參數(shù)限制,但是也是有大小限制的。
- 如果當文件超出 Integer.MAX_VALUE 字節(jié)限制時,可以通過 position 參數(shù)重新 map 文件后面的內(nèi)容。
- MappedByteBuffer 在處理大文件時性能的確很高,但也存在內(nèi)存占用、文件關(guān)閉不確定等問題,被其打開的文件只有在垃圾回收的才會被關(guān)閉,而且這個時間點是不確定的。
- MappedByteBuffer 提供了文件映射內(nèi)存的 mmap() 方法,也提供了釋放映射內(nèi)存的 unmap() 方法。然而 unmap() 是 FileChannelImpl 中的私有方法,無法直接顯示調(diào)用。
因此,用戶程序需要通過 Java 反射的調(diào)用 sun.misc.Cleaner 類的 clean() 方法手動釋放映射占用的內(nèi)存區(qū)域。
- 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
DirectByteBuffer 的對象引用位于 Java 內(nèi)存模型的堆里面,JVM 可以對 DirectByteBuffer 的對象進行內(nèi)存分配和回收管理。
一般使用 DirectByteBuffer 的靜態(tài)方法 allocateDirect() 創(chuàng)建 DirectByteBuffer 實例并分配內(nèi)存。
- public static ByteBuffer allocateDirect(int capacity) {
- return new DirectByteBuffer(capacity);
- }
DirectByteBuffer 內(nèi)部的字節(jié)緩沖區(qū)位在于堆外的(用戶態(tài))直接內(nèi)存,它是通過 Unsafe 的本地方法 allocateMemory() 進行內(nèi)存分配,底層調(diào)用的是操作系統(tǒng)的 malloc() 函數(shù)。
- 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 時還會創(chuàng)建一個 Deallocator 線程,并通過 Cleaner 的 freeMemory() 方法來對直接內(nèi)存進行回收操作,freeMemory() 底層調(diào)用的是操作系統(tǒng)的 free() 函數(shù)。
- 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 分配的是系統(tǒng)本地的內(nèi)存,不在 JVM 的管控范圍之內(nèi),因此直接內(nèi)存的回收和堆內(nèi)存的回收不同,直接內(nèi)存如果使用不當,很容易造成 OutOfMemoryError。
說了這么多,那么 DirectByteBuffer 和零拷貝有什么關(guān)系?前面有提到在 MappedByteBuffer 進行內(nèi)存映射時,它的 map() 方法會通過 Util.newMappedByteBuffer() 來創(chuàng)建一個緩沖區(qū)實例。
初始化的代碼如下:
- 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 的具體實現(xiàn)類。
實際上,Util.newMappedByteBuffer() 方法通過反射機制獲取 DirectByteBuffer 的構(gòu)造器,然后創(chuàng)建一個 DirectByteBuffer 的實例,對應(yīng)的是一個單獨用于內(nèi)存映射的構(gòu)造方法:
- 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;
- }
因此,除了允許分配操作系統(tǒng)的直接內(nèi)存以外,DirectByteBuffer 本身也具有文件內(nèi)存映射的功能,這里不做過多說明。
我們需要關(guān)注的是,DirectByteBuffer 在 MappedByteBuffer 的基礎(chǔ)上提供了內(nèi)存映像文件的隨機讀取 get() 和寫入 write() 的操作。
內(nèi)存映像文件的隨機讀操作:
- public byte get() {
- return ((unsafe.getByte(ix(nextGetIndex()))));
- }
- public byte get(int i) {
- return ((unsafe.getByte(ix(checkIndex(i)))));
- }
內(nè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;
- }
內(nèi)存映像文件的隨機讀寫都是借助 ix() 方法實現(xiàn)定位的, ix() 方法通過內(nèi)存映射空間的內(nèi)存首地址(address)和給定偏移量 i 計算出指針地址,然后由 unsafe 類的 get() 和 put() 方法和對指針指向的數(shù)據(jù)進行讀取或?qū)懭搿?/p>
- private long ix(int i) {
- return address + ((long)i << 0);
- }
FileChannel
FileChannel 是一個用于文件讀寫、映射和操作的通道,同時它在并發(fā)環(huán)境下是線程安全的。
基于 FileInputStream、FileOutputStream 或者 RandomaccessFile 的 getChannel() 方法可以創(chuàng)建并打開一個文件通道。
FileChannel 定義了 transferFrom() 和 transferTo() 兩個抽象方法,它通過在通道和通道之間建立連接實現(xiàn)數(shù)據(jù)傳輸?shù)摹?/p>
transferTo():通過 FileChannel 把文件里面的源數(shù)據(jù)寫入一個 WritableByteChannel 的目的通道。
- public abstract long transferTo(long position, long count, WritableByteChannel target)
- throws IOException;
transferFrom():把一個源通道 ReadableByteChannel 中的數(shù)據(jù)讀取到當前 FileChannel 的文件里面。
- public abstract long transferFrom(ReadableByteChannel src, long position, long count)
- throws IOException;
下面給出 FileChannel 利用 transferTo() 和 transferFrom() 方法進行數(shù)據(jù)傳輸?shù)氖褂檬纠?/p>
- 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";
首先在類加載根路徑下創(chuàng)建 source.txt 和 target.txt 兩個文件,對源文件 source.txt 文件寫入初始化數(shù)據(jù)。
- @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 可以是任意的單向字節(jié)寫通道 WritableByteChannel;而對于 transferFrom() 方法而言,源通道 fromChannel 可以是任意的單向字節(jié)讀通道 ReadableByteChannel。
其中,F(xiàn)ileChannel、SocketChannel 和 DatagramChannel 等通道實現(xiàn)了 WritableByteChannel 和 ReadableByteChannel 接口,都是同時支持讀寫的雙向通道。
為了方便測試,下面給出基于 FileChannel 完成 channel-to-channel 的數(shù)據(jù)傳輸示例。
通過 transferTo() 將 fromChannel 中的數(shù)據(jù)拷貝到 toChannel:
- @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);
- }
- }
通過 transferFrom() 將 fromChannel 中的數(shù)據(jù)拷貝到 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() 方法的底層實現(xiàn)原理,這兩個方法也是 java.nio.channels.FileChannel 的抽象方法,由子類 sun.nio.ch.FileChannelImpl.java 實現(xiàn)。
transferTo() 和 transferFrom() 底層都是基于 Sendfile 實現(xiàn)數(shù)據(jù)傳輸?shù)模渲?FileChannelImpl.java 定義了 3 個常量,用于標示當前操作系統(tǒng)的內(nèi)核是否支持 Sendfile 以及 Sendfile 的相關(guān)特性。
- private static volatile boolean transferSupported = true;
- private static volatile boolean pipeSupported = true;
- private static volatile boolean fileSupported = true;
transferSupported:用于標記當前的系統(tǒng)內(nèi)核是否支持 sendfile() 調(diào)用,默認為 true。
pipeSupported:用于標記當前的系統(tǒng)內(nèi)核是否支持文件描述符(fd)基于管道(pipe)的 sendfile() 調(diào)用,默認為 true。
fileSupported:用于標記當前的系統(tǒng)內(nèi)核是否支持文件描述符(fd)基于文件(file)的 sendfile() 調(diào)用,默認為 true。
下面以 transferTo() 的源碼實現(xiàn)為例。FileChannelImpl 首先執(zhí)行 transferToDirectly() 方法,以 Sendfile 的零拷貝方式嘗試數(shù)據(jù)拷貝。
如果系統(tǒng)內(nèi)核不支持 Sendfile,進一步執(zhí)行 transferToTrustedChannel() 方法,以 mmap 的零拷貝方式進行內(nèi)存映射,這種情況下目的通道必須是 FileChannelImpl 或者 SelChImpl 類型。
如果以上兩步都失敗了,則執(zhí)行 transferToArbitraryChannel() 方法,基于傳統(tǒng)的 I/O 方式完成讀寫,具體步驟是初始化一個臨時的 DirectBuffer,將源通道 FileChannel 的數(shù)據(jù)讀取到 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() 方法的實現(xiàn),也就是 transferTo() 通過 Sendfile 實現(xiàn)零拷貝的精髓所在。
可以看到,transferToDirectlyInternal() 方法先獲取到目的通道 WritableByteChannel 的文件描述符 targetFD,獲取同步鎖然后執(zhí)行 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() 調(diào)用本地方法 transferTo0() ,嘗試以 Sendfile 的方式進行數(shù)據(jù)傳輸。
如果系統(tǒng)內(nèi)核完全不支持 Sendfile,比如 Windows 操作系統(tǒng),則返回 UNSUPPORTED 并把 transferSupported 標識為 false。
如果系統(tǒng)內(nèi)核不支持 Sendfile 的一些特性,比如說低版本的 Linux 內(nèi)核不支持 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)調(diào)用底層 C 的函數(shù)。
這個 native 函數(shù)(Java_sun_nio_ch_FileChannelImpl_transferTo0)同樣位于 JDK 源碼包下的 native/sun/nio/ch/FileChannelImpl.c 源文件里面。
JNI 函數(shù) Java_sun_nio_ch_FileChannelImpl_transferTo0() 基于條件編譯對不同的系統(tǒng)進行預編譯,下面是 JDK 基于 Linux 系統(tǒng)內(nèi)核對 transferTo() 提供的調(diào)用封裝。
- #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 系統(tǒng)而言,transferTo0() 函數(shù)底層會執(zhí)行 sendfile64 這個系統(tǒng)調(diào)用完成零拷貝操作,sendfile64() 函數(shù)的原型如下:
- #include <sys/sendfile.h>
- ssize_t sendfile64(int out_fd, int in_fd, off_t *offset, size_t count);
下面簡單介紹一下 sendfile64() 函數(shù)各個參數(shù)的含義:
- out_fd:待寫入的文件描述符。
- in_fd:待讀取的文件描述符。
- offset:指定 in_fd 對應(yīng)文件流的讀取位置,如果為空,則默認從起始位置開始。
- count:指定在文件描述符 in_fd 和 out_fd 之間傳輸?shù)淖止?jié)數(shù)。
在 Linux 2.6.3 之前,out_fd 必須是一個 socket,而從 Linux 2.6.3 以后,out_fd 可以是任何文件。
也就是說,sendfile64() 函數(shù)不僅可以進行網(wǎng)絡(luò)文件傳輸,還可以對本地文件實現(xiàn)零拷貝操作。
其它的零拷貝實現(xiàn)
Netty 零拷貝
Netty 中的零拷貝和上面提到的操作系統(tǒng)層面上的零拷貝不太一樣, 我們所說的 Netty 零拷貝完全是基于(Java 層面)用戶態(tài)的,它的更多的是偏向于數(shù)據(jù)操作優(yōu)化這樣的概念。
具體表現(xiàn)在以下幾個方面:
- Netty 通過 DefaultFileRegion 類對 java.nio.channels.FileChannel 的 tranferTo() 方法進行包裝,在文件傳輸時可以將文件緩沖區(qū)的數(shù)據(jù)直接發(fā)送到目的通道(Channel)。
- ByteBuf 可以通過 wrap 操作把字節(jié)數(shù)組、ByteBuf、ByteBuffer 包裝成一個 ByteBuf 對象, 進而避免了拷貝操作。
- ByteBuf 支持 Slice 操作, 因此可以將 ByteBuf 分解為多個共享同一個存儲區(qū)域的 ByteBuf,避免了內(nèi)存的拷貝。
- Netty 提供了 CompositeByteBuf 類,它可以將多個 ByteBuf 合并為一個邏輯上的 ByteBuf,避免了各個 ByteBuf 之間的拷貝。
其中第 1 條屬于操作系統(tǒng)層面的零拷貝操作,后面 3 條只能算用戶層面的數(shù)據(jù)操作優(yōu)化。
RocketMQ 和 Kafka 對比
RocketMQ 選擇了 mmap+write 這種零拷貝方式,適用于業(yè)務(wù)級消息這種小塊文件的數(shù)據(jù)持久化和傳輸。
而 Kafka 采用的是 Sendfile 這種零拷貝方式,適用于系統(tǒng)日志消息這種高吞吐量的大塊文件的數(shù)據(jù)持久化和傳輸。
但是值得注意的一點是,Kafka 的索引文件使用的是 mmap+write 方式,數(shù)據(jù)文件使用的是 Sendfile 方式。
總結(jié)
本文開篇詳述了 Linux 操作系統(tǒng)中的物理內(nèi)存和虛擬內(nèi)存,內(nèi)核空間和用戶空間的概念以及 Linux 內(nèi)部的層級結(jié)構(gòu)。
在此基礎(chǔ)上,進一步分析和對比傳統(tǒng) I/O 方式和零拷貝方式的區(qū)別,然后介紹了 Linux 內(nèi)核提供的幾種零拷貝實現(xiàn)。
包括內(nèi)存映射 mmap、Sendfile、Sendfile+DMA gather copy 以及 Splice 幾種機制,并從系統(tǒng)調(diào)用和拷貝次數(shù)層面對它們進行了對比。
接下來從源碼著手分析了 Java NIO 對零拷貝的實現(xiàn),主要包括基于內(nèi)存映射(mmap)方式的 MappedByteBuffer 以及基于 Sendfile 方式的 FileChannel。
最后在篇末簡單的闡述了一下 Netty 中的零拷貝機制,以及 RocketMQ 和 Kafka 兩種消息隊列在零拷貝實現(xiàn)方式上的區(qū)別。
作者:陳林
簡介:五年研發(fā)與架構(gòu)經(jīng)驗,曾任職 SAP 中國研發(fā)中心后端研發(fā)、上海冰鑒科技信息科技有限公司架構(gòu)師助理,目前擔任成都 ThoughtWorks 有限公司高級咨詢師與研發(fā)人員。熟悉大數(shù)據(jù)、高并發(fā)、負載均衡、緩存、數(shù)據(jù)庫、消息中間件、搜索引擎、容器和自動化等領(lǐng)域。個人學習能力強,技術(shù)熱情高,熱愛開源和寫技術(shù)博客,善于溝通和分享。