什么是零拷貝
關于零拷貝,WIKI 上給出的定義如下:
「Zero-copy」 describes computer operations in which the CPU does not perform the task of copying data from one memory area to another. This is frequently used to save CPU cycles and memory bandwidth when transmitting a file over a network.
所謂「零拷貝」描述的是計算機操作系統(tǒng)當中,CPU不執(zhí)行將數(shù)據(jù)從一個內(nèi)存區(qū)域,拷貝到另外一個內(nèi)存區(qū)域的任務。通過網(wǎng)絡傳輸文件時,這樣通??梢怨?jié)省 CPU 周期和內(nèi)存帶寬。
從描述中已經(jīng)了解到零拷貝技術給我們帶來的好處:
1、節(jié)省了 CPU 周期,空出的 CPU 可以完成更多其他的任務
2、減少了內(nèi)存區(qū)域之間數(shù)據(jù)拷貝,節(jié)省內(nèi)存帶寬
3、減少用戶態(tài)和內(nèi)核態(tài)之間數(shù)據(jù)拷貝,提升數(shù)據(jù)傳輸效率
4、應用零拷貝技術,減少用戶態(tài)和內(nèi)核態(tài)之間的上下文切換
傳統(tǒng) IO 數(shù)據(jù)拷貝原理
在正式分析零拷貝機制原理之前,我們先來看下傳統(tǒng) IO 在數(shù)據(jù)拷貝的基本原理,從數(shù)據(jù)拷貝 (I/O 拷貝) 的次數(shù)以及上下文切換的次數(shù)進行對比分析。
傳統(tǒng) IO:
1、JVM 進程內(nèi)發(fā)起 read() 系統(tǒng)調(diào)用,操作系統(tǒng)由用戶態(tài)空間切換到內(nèi)核態(tài)空間(第一次上下文切換)
2、通過 DMA 引擎建數(shù)據(jù)從磁盤拷貝到內(nèi)核態(tài)空間的輸入的 socket 緩沖區(qū)中(第一次拷貝)
3、將內(nèi)核態(tài)空間緩沖區(qū)的數(shù)據(jù)原封不動的拷貝到用戶態(tài)空間的緩存區(qū)中(第二次拷貝),同時內(nèi)核態(tài)空間切換到用戶態(tài)空間(第二次上下文切換),read() 系統(tǒng)調(diào)用結束
4、JVM 進程內(nèi)業(yè)務邏輯代碼執(zhí)行
5、JVM 進程內(nèi)發(fā)起 write() 系統(tǒng)調(diào)用
6、操作系統(tǒng)由用戶態(tài)空間切換到內(nèi)核態(tài)空間(第三次上下文切換),將用戶態(tài)空間的緩存區(qū)數(shù)據(jù)原封不動的拷貝到內(nèi)核態(tài)空間輸出的 socket 緩存區(qū)中(第三次拷貝)
7、write() 系統(tǒng)調(diào)用返回,操作系統(tǒng)由內(nèi)核態(tài)空間切換到用戶態(tài)空間(第四次上下文切換),通過 DMA 引擎將數(shù)據(jù)從內(nèi)核態(tài)空間的 socket 緩存區(qū)數(shù)據(jù)拷貝到協(xié)議引擎中(第四次拷貝)
傳統(tǒng) IO 方式,一共在用戶態(tài)空間與內(nèi)核態(tài)空間之間發(fā)生了 4 次上下文的切換,4 次數(shù)據(jù)的拷貝過程,其中包括 2 次 DMA 拷貝和 2 次 I/O 拷貝(內(nèi)核態(tài)與用戶應用程序之間發(fā)生的拷貝)。
內(nèi)核空間緩沖區(qū)的一大用處是為了減少磁盤I/O操作,因為它會從磁盤中預讀更多的數(shù)據(jù)到緩沖區(qū)中。而使用 BufferedInputStream 的用處是減少 「系統(tǒng)調(diào)用」。
什么是DMA
DMA(Direct Memory Access)—直接內(nèi)存訪問 :DMA是允許外設組件將 I/O 數(shù)據(jù)直接傳送到主存儲器中并且傳輸不需要 CPU 的參與,以此將 CPU 解放出來去完成其他的事情。
sendfile 數(shù)據(jù)零拷貝原理
sendfile 數(shù)據(jù)零拷貝:
顯然,在傳統(tǒng) IO 中,用戶態(tài)空間與內(nèi)核態(tài)空間之間的復制是完全不必要的,因為用戶態(tài)空間僅僅起到了一種數(shù)據(jù)轉存媒介的作用,除此之外沒有做任何事情。
Linux 提供了 sendfile() 用來減少我們前面提到的數(shù)據(jù)拷貝和的上下文切換次數(shù)。
如下圖所示:
1、發(fā)起 sendfile() 系統(tǒng)調(diào)用,操作系統(tǒng)由用戶態(tài)空間切換到內(nèi)核態(tài)空間(第一次上下文切換)
2、通過 DMA 引擎將數(shù)據(jù)從磁盤拷貝到內(nèi)核態(tài)空間的輸入的 socket 緩沖區(qū)中(第一次拷貝)
3、將數(shù)據(jù)從內(nèi)核空間拷貝到與之關聯(lián)的 socket 緩沖區(qū)(第二次拷貝)
4、將 socket 緩沖區(qū)的數(shù)據(jù)拷貝到協(xié)議引擎中(第三次拷貝)
5、sendfile() 系統(tǒng)調(diào)用結束,操作系統(tǒng)由用戶態(tài)空間切換到內(nèi)核態(tài)空間(第二次上下文切換)
根據(jù)以上過程,一共有 2 次的上下文切換,3 次的 I/O 拷貝。我們看到從用戶空間到內(nèi)核空間并沒有出現(xiàn)數(shù)據(jù)拷貝,從操作系統(tǒng)角度來看,這個就是零拷貝。內(nèi)核空間出現(xiàn)了復制的原因: 通常的硬件在通過DMA訪問時期望的是連續(xù)的內(nèi)存空間。
支持 scatter-gather 特性的 sendfile 數(shù)據(jù)零拷貝:
這次相比 sendfile() 數(shù)據(jù)零拷貝,減少了一次從內(nèi)核空間到與之相關的 socket 緩沖區(qū)的數(shù)據(jù)拷貝。
基本流程:
1、發(fā)起 sendfile() 系統(tǒng)調(diào)用,操作系統(tǒng)由用戶態(tài)空間切換到內(nèi)核態(tài)空間(第一次上下文切換)
2、通過 DMA 引擎將數(shù)據(jù)從磁盤拷貝到內(nèi)核態(tài)空間的輸入的 socket 緩沖區(qū)中(第一次拷貝)
3、將描述符信息會拷貝到相應的 socket 緩沖區(qū)當中,該描述符包含了兩方面的信息:
a)?kernel buffer的內(nèi)存地址;
b)?kernel buffer的偏移量。
4、DMA gather copy 根據(jù) socket 緩沖區(qū)中描述符提供的位置和偏移量信息直接將內(nèi)核空間緩沖區(qū)中的數(shù)據(jù)拷貝到協(xié)議引擎上(第二次拷貝),這樣就避免了最后一次 I/O 數(shù)據(jù)拷貝。
5、sendfile() 系統(tǒng)調(diào)用結束,操作系統(tǒng)由用戶態(tài)空間切換到內(nèi)核態(tài)空間(第二次上下文切換)
下面這個圖更進一步理解:
Linux/Unix 操作系統(tǒng)下可以通過下面命令查看是否支持 scatter-gather 特性。
ethtool -k eth0 | grep scatter-gatherscatter-gather: on
許多的 web server 都已經(jīng)支持了零拷貝技術,比如 Apache、Tomcat。
sendfile 零拷貝消除了所有內(nèi)核空間緩沖區(qū)與用戶空間緩沖區(qū)之間的數(shù)據(jù)拷貝過程,因此 sendfile 零拷貝 I/O 的實現(xiàn)是完成在內(nèi)核空間中完成的,這對于應用程序來說就無法對數(shù)據(jù)進行操作了。
mmap 數(shù)據(jù)零拷貝原理
如果需要對數(shù)據(jù)做操作,Linux 提供了mmap 零拷貝來實現(xiàn)。
mmap 零拷貝:
通過上圖看到,一共發(fā)生了 4 次的上下文切換,3 次的 I/O 拷貝,包括 2 次 DMA 拷貝和 1 次的 I/O 拷貝,相比于傳統(tǒng) IO 減少了一次 I/O 拷貝。使用 mmap() 讀取文件時,只會發(fā)生第一次從磁盤數(shù)據(jù)拷貝到 OS 文件系統(tǒng)緩沖區(qū)的操作。
1)在什么場景下使用 mmap() 去訪問文件會更高效?
對文件執(zhí)行隨機訪問時,如果使用 read() 或 write(),則意味著較低的 cache 命中率。這種情況下使用 mmap() 通常將更高效。
多個進程同時訪問同一個文件時(無論是順序訪問還是隨機訪問),如果使用mmap(),那么操作系統(tǒng)緩沖區(qū)的文件內(nèi)容可以在多個進程之間共享,從操作系統(tǒng)角度來看,使用 mmap() 可以大大節(jié)省內(nèi)存。
2)什么場景下沒有使用 mmap() 的必要?
訪問小文件時,直接使用 read() 或 write() 將更加高效。
單個進程對文件執(zhí)行順序訪問時 (sequential access),使用 mmap() 幾乎不會帶來性能上的提升。譬如說,使用 read() 順序讀取文件時,文件系統(tǒng)會使用 read-ahead 的方式提前將文件內(nèi)容緩存到文件系統(tǒng)的緩沖區(qū),因此使用 read() 將很大程度上可以命中緩存。
Java 中 NIO 零拷貝實現(xiàn)
Java NIO 中的通道(Channel)相當于操作系統(tǒng)的內(nèi)核空間(kernel space)的緩沖區(qū),而緩沖區(qū)(Buffer)對應的相當于操作系統(tǒng)的用戶空間(user space)中的用戶緩沖區(qū)(user buffer)。
通道(Channel)是全雙工的(雙向傳輸),它既可能是讀緩沖區(qū)(read buffer),也可能是網(wǎng)絡緩沖區(qū)(socket buffer)。
緩沖區(qū)(Buffer)分為堆內(nèi)存(HeapBuffer)和堆外內(nèi)存(DirectBuffer),這是通過 malloc() 分配出來的用戶態(tài)內(nèi)存。
Java NIO 引入了用于通道的緩沖區(qū)的 ByteBuffer。
ByteBuffer有三個主要的實現(xiàn):
1、HeapByteBuffer
調(diào)用 ByteBuffer.allocate() 方法時使用到 HeapByteBuffer。這個緩存區(qū)域是在 JVM 進程的堆上分配的,可以獲得如GC支持和緩存優(yōu)化的優(yōu)勢。
但它不是頁面對齊的,這意味著若需通過JNI與本地代碼交談,JVM將不得不復制到對齊的緩沖區(qū)空間。
2、DirectByteBuffer
調(diào)用 ByteBuffer.allocateDirect() 方法時使用。 JVM 會使用 malloc() 在堆空間之外分配內(nèi)存空間。 由于它的內(nèi)存空間不由 JVM 管理,所以你的內(nèi)存空間是頁面對齊的,不受GC影響。但需要自己管理這個內(nèi)存,注意分配和釋放內(nèi)存來防止內(nèi)存泄漏。
3、MappedByteBuffer
調(diào)用 FileChannel.map() 時使用。與DirectByteBuffer類似,這也是 JVM 堆外部分配內(nèi)存空間。它基本上作為操作系統(tǒng) mmap() 系統(tǒng)調(diào)用的包裝函數(shù),以便代碼直接操作映射的物理內(nèi)存數(shù)據(jù)。
Java IO 與 NIO 實戰(zhàn)案例分析
下面我們通過代碼示例來對比下傳統(tǒng) IO 與使用了零拷貝技術的 NIO 之間的差異。
我們通過服務端開啟 socket 監(jiān)聽,然后客戶端連接的服務端進行數(shù)據(jù)的傳輸,數(shù)據(jù)傳輸文件大小為 237M。
零拷貝技術的 NIO,這里咱們通過剛剛介紹的 HeapByteBuffer 來實戰(zhàn)對比一下。
1、構建傳統(tǒng)IO的socket服務端,監(jiān)聽8898端口。
public class OldIOServer {
public static void main(String[] args) throws Exception {
try (ServerSocket serverSocket = new ServerSocket(8898)) {
while (true) {
Socket socket = serverSocket.accept();
DataInputStream inputStream = new DataInputStream(socket.getInputStream());
byte[] bytes = new byte[4096];
// 從socket中讀取字節(jié)數(shù)據(jù)
while (true) {
// 讀取的字節(jié)數(shù)大小,-1則表示數(shù)據(jù)已被讀完
int readCount = inputStream.read(bytes, 0, bytes.length);
if (-1 == readCount) {
break;
}
}
}
}
}
}
2、構建傳統(tǒng) IO 的客戶端,連接服務端的 8898 端口,并從磁盤讀取 237M 的數(shù)據(jù)文件向服務端 socket 中發(fā)起寫請求。
public class OldIOClient {
public static void main(String[] args) throws Exception {
Socket socket = new Socket();
socket.connect(new InetSocketAddress(“l(fā)ocalhost”, 8898)); // 連接服務端socket 8899端口
// 設置一個大的文件, 237M
try (FileInputStream fileInputStream = new FileInputStream(new File(“/Users/david/Downloads/jdk-8u144-macosx-x64.dmg”));
// 定義一個輸出流
DataOutputStream dataOutputStream = new DataOutputStream(socket.getOutputStream());) {
// 讀取文件數(shù)據(jù)
// 定義byte緩存
byte[] buffer = new byte[4096];
int readCount; // 每一次讀取的字節(jié)數(shù)
int total = 0; // 讀取的總字節(jié)數(shù)
long startTime = System.currentTimeMillis();
while ((readCount = fileInputStream.read(buffer)) 》 0) {
total += readCount; //累加字節(jié)數(shù)
dataOutputStream.write(buffer); // 寫入到輸出流中
}
System.out.println(“發(fā)送的總字節(jié)數(shù):” + total + “, 耗時:” + (System.currentTimeMillis() - startTime));
}
}
}
運行結果:發(fā)送的總字節(jié)數(shù):237607747,耗時:450 (400~600毫秒之間)
接下來,我們通過使用 JDK 提供的 NIO 的方式實現(xiàn)數(shù)據(jù)傳輸與上述傳統(tǒng) IO 做對比。
1、構建基于 NIO 的服務端,監(jiān)聽 8899 端口。
public class NewIOServer {
public static void main(String[] args) throws Exception {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.bind(new InetSocketAddress(8899));
ByteBuffer byteBuffer = ByteBuffer.allocate(4096);
while (true) {
SocketChannel socketChannel = serverSocketChannel.accept();
socketChannel.configureBlocking(false); // 這里設置為阻塞模式
int readCount = socketChannel.read(byteBuffer);
while (-1 != readCount) {
readCount = socketChannel.read(byteBuffer);
// 這里一定要調(diào)用下rewind方法,將position重置為0開始位置
byteBuffer.rewind();
}
}
}
}
2、構建基于 NIO 的客戶端,連接NIO的服務端 8899 端口,通過
FileChannel.transferTo 傳輸 237M 的數(shù)據(jù)文件。
public class NewIOClient {
public static void main(String[] args) throws Exception {
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress(“l(fā)ocalhost”, 8899));
socketChannel.configureBlocking(true);
String fileName = “/Users/david/Downloads/jdk-8u144-macosx-x64.dmg”;
FileInputStream fileInputStream = new FileInputStream(fileName);
FileChannel fileChannel = fileInputStream.getChannel();
long startTime = System.currentTimeMillis();
long transferCount = fileChannel.transferTo(0, fileChannel.size(), socketChannel); // 目標channel
System.out.println(“發(fā)送的總字節(jié)數(shù):” + transferCount + “,耗時:” + (System.currentTimeMillis() - startTime));
fileChannel.close();
}
}
運行結果:發(fā)送的總字節(jié)數(shù):237607747,耗時:161(100到300毫秒之間)
結合運行結果,基于 NIO 零拷貝技術要比傳統(tǒng) IO 傳輸效率高 3倍多。所以,后續(xù)當設計大文件數(shù)據(jù)傳輸時可以優(yōu)先采用類似 NIO 的方式實現(xiàn)。
這里我們使用了 FileChannel,其中調(diào)用的 transferTo() 方法將數(shù)據(jù)從 FileChannel傳輸?shù)狡渌?channel 中,如果操作系統(tǒng)底層支持的話 transferTo、transferFrom 會使用相關的零拷貝技術來實現(xiàn)數(shù)據(jù)的傳輸。所以,這里是否使用零拷貝必須依賴于底層的系統(tǒng)實現(xiàn)。
FileChannel.transferTo 方法:
public abstract long transferTo(long position,
long count,
WritableByteChannel target) throws IOException
將字節(jié)從此通道的文件傳輸?shù)浇o定的可寫入字節(jié)通道。
試圖讀取從此通道的文件中給定 position 處開始的 count 個字節(jié),并將其寫入目標通道。
此方法的調(diào)用不一定傳輸所有請求的字節(jié);
是否傳輸取決于通道的性質(zhì)和狀態(tài)。
如果此通道的文件從給定的 position 處開始所包含的字節(jié)數(shù)小于 count 個字節(jié),或者如果目標通道是非阻塞的并且其輸出緩沖區(qū)中的自由空間少于 count 個字節(jié),則所傳輸?shù)淖止?jié)數(shù)要小于請求的字節(jié)數(shù)。
此方法不修改此通道的位置。
如果給定的位置大于該文件的當前大小,則不傳輸任何字節(jié)。
如果目標通道中有該位置,則從該位置開始寫入各字節(jié),然后將該位置增加寫入的字節(jié)數(shù)。
與從此通道讀取并將內(nèi)容寫入目標通道的簡單循環(huán)語句相比,此方法可能高效得多。
很多操作系統(tǒng)可將字節(jié)直接從文件系統(tǒng)緩存?zhèn)鬏數(shù)侥繕送ǖ?,而無需實際復制各字節(jié)。
position - 文件中的位置,從此位置開始傳輸;
必須為非負數(shù)
count - 要傳輸?shù)淖畲笞止?jié)數(shù);
必須為非負數(shù)
target - 目標通道
返回:實際已傳輸?shù)淖止?jié)數(shù),可能為零
FileChannel.transferFrom 方法:
public abstract long transferFrom(ReadableByteChannel src,
long position,
long count) throws IOException
將字節(jié)從給定的可讀取字節(jié)通道傳輸?shù)酱送ǖ赖奈募小?/p>
試著從源通道中最多讀取 count 個字節(jié),并將其寫入到此通道的文件中從給定 position 處開始的位置。
此方法的調(diào)用不一定傳輸所有請求的字節(jié);
是否傳輸取決于通道的性質(zhì)和狀態(tài)。
如果源通道的剩余空間小于 count 個字節(jié),或者如果源通道是非阻塞的并且其輸入緩沖區(qū)中直接可用的空間小于 count 個字節(jié),則所傳輸?shù)淖止?jié)數(shù)要小于請求的字節(jié)數(shù)。
此方法不修改此通道的位置。
如果給定的位置大于該文件的當前大小,則不傳輸任何字節(jié)。
如果該位置在源通道中,則從該位置開始讀取各字節(jié),然后將該位置增加讀取的字節(jié)數(shù)。
與從源通道讀
取并將內(nèi)容寫入此通道的簡單循環(huán)語句相比,此方法可能高效得多。
很多操作系統(tǒng)可將字節(jié)直接從源通道傳輸?shù)轿募到y(tǒng)緩存,而無需實際復制各字節(jié)。
參數(shù):
src - 源通道
position - 文件中的位置,從此位置開始傳輸;
必須為非負數(shù)
count - 要傳輸?shù)淖畲笞止?jié)數(shù);
必須為非負數(shù)
返回:實際已傳輸?shù)淖止?jié)數(shù),可能為零
發(fā)生相應的異常的情況:
異常拋出:
IllegalArgumentException - 如果關于參數(shù)的前提不成立
NonReadableChannelException - 如果不允許從此通道進行讀取操作
NonWritableChannelException - 如果目標通道不允許進行寫入操作
ClosedChannelException - 如果此通道或目標通道已關閉
AsynchronousCloseException - 如果正在進行傳輸時另一個線程關閉了任一通道
ClosedByInterruptException - 如果正在進行傳輸時另一個線程中斷了當前線程,因此關閉了兩個通道并將當前線程設置為中斷
IOException - 如果發(fā)生其他 I/O 錯誤
評論