已同步更新到微信公眾號,手機閱讀更舒適~
零拷貝機制原理分析之前,我們先來看下傳統IO在數據拷貝的基本原理,從數據拷貝(I/O拷貝)的次數以及上下文切換的次數進行對比分析。
傳統IO:
1、JVM進程內發起read()系統調用,操作系統由用戶態空間切換到內核態空間(第一次上下文切換)
2、通過DMA引擎建數據從磁盤拷貝到內核態空間的輸入的socket緩沖區中(第一次拷貝)
3、將內核態空間緩沖區的數據原封不動的拷貝到用戶態空間的緩存區中(第二次拷貝),同時內核態空間切換到用戶態空間(第二次上下文切換),read()系統調用結束
4、JVM進程內業務邏輯代碼執行
5、JVM進程內發起write()系統調用
6、操作系統由用戶態空間切換到內核態空間(第三次上下文切換),將用戶態空間的緩存區數據原封不動的拷貝到內核態空間輸出的socket緩存區中(第三次拷貝)
7、write()系統調用返回,操作系統由內核態空間切換到用戶態空間(第四次上下文切換),通過DMA引擎將數據從內核態空間的socket緩存區數據拷貝到協議引擎中(第四次拷貝)
傳統IO方式,一共在用戶態空間與內核態空間之間發生了4次上下文的切換,4次數據的拷貝過程,其中包括2次DMA拷貝和2次I/O拷貝(內核態與用戶應用程序之間發生的拷貝)。
內核空間緩沖區的一大用處是為了減少磁盤I/O操作,因為它會從磁盤中預讀更多的數據到緩沖區中。而使用BufferedInputStream的用處是減少“系統調用”。
DMA:
DMA(Direct Memory Access) —直接內存訪問 :DMA是允許外設組件將I/O數據直接傳送到主存儲器中并且傳輸不需要CPU的參與,以此將CPU解放出來去完成其他的事情。
sendfile數據零拷貝:
顯然,在傳統IO中,用戶態空間與內核態空間之間的復制是完全不必要的,因為用戶態空間僅僅起到了一種數據轉存媒介的作用,除此之外沒有做任何事情。
Linux 提供了sendfile()用來減少我們前面提到的數據拷貝和的上下文切換次數
1、發起sendfile()系統調用,操作系統由用戶態空間切換到內核態空間(第一次上下文切換)
2、通過DMA引擎建數據從磁盤拷貝到內核態空間的輸入的socket緩沖區中(第一次拷貝)
3、將數據從內核空間拷貝到與之關聯的socket緩沖區(第二次拷貝)
4、將socket緩沖區的數據拷貝到協議引擎中(第三次拷貝)
5、sendfile()系統調用結束,操作系統由用戶態空間切換到內核態空間(第二次上下文切換)
根據以上過程,一共有2次的上下文切換,3次的I/O拷貝。我們看到從用戶空間到內核空間并沒有出現數據拷貝,從操作系統角度來看,這個就是零拷貝。內核空間出現了復制的原因: 通常的硬件在通過DMA訪問時期望的是連續的內存空間。
支持scatter-gather特性的sendfile數據零拷貝
這次相比sendfile()數據零拷貝,減少了一次從內核空間到與之相關的socket緩沖區的數據拷貝。
1、發起sendfile()系統調用,操作系統由用戶態空間切換到內核態空間(第一次上下文切換)
2、通過DMA引擎建數據從磁盤拷貝到內核態空間的輸入的socket緩沖區中(第一次拷貝)
3、將描述符信息會拷貝到相應的socket緩沖區當中,該描述符包含了兩方面的信息:a)kernel buffer的內存地址;b)kernel buffer的偏移量。
4、DMA gather copy根據socket緩沖區中描述符提供的位置和偏移量信息直接將內核空間緩沖區中的數據拷貝到協議引擎上(第二次拷貝),這樣就避免了最后一次I/O數據拷貝。
5、sendfile()系統調用結束,操作系統由用戶態空間切換到內核態空間(第二次上下文切換)
下面這個圖更進一步理解:
Linux/Unix操作系統下可以通過下面命令查看是否支持scatter-gather特性。
# ethtool -k eth0 | grep scatter-gather
scatter-gather: on
tx-scatter-gather: on
tx-scatter-gather-fraglist: on
許多的web server都已經支持了零拷貝技術,比如Apache、Tomcat。
sendfile零拷貝消除了所有內核空間緩沖區與用戶空間緩沖區之間的數據拷貝過程,因此sendfile零拷貝I/O的實現是完成在內核空間中完成的,這對于應用程序來說就無法對數據進行操作了。
如果需要對數據做操作,Linux提供了mmap零拷貝來實現。
mmap零拷貝:
通過上圖看到,一共發生了4次的上下文切換,3次的I/O拷貝,包括2次DMA拷貝和1次的I/O拷貝,相比于傳統IO減少了一次I/O拷貝。使用mmap()讀取文件時,只會發生第一次從磁盤數據拷貝到OS文件系統緩沖區的操作。
1)在什么場景下使用mmap()去訪問文件會更高效?
對文件執行隨機訪問時,如果使用read()或write(),則意味著較低的 cache 命中率。這種情況下使用mmap()通常將更高效。
多個進程同時訪問同一個文件時(無論是順序訪問還是隨機訪問),如果使用mmap(),那么操作系統緩沖區的文件內容可以在多個進程之間共享,從操作系統角度來看,使用mmap()可以大大節省內存。
2)什么場景下沒有使用mmap()的必要?
訪問小文件時,直接使用read()或write()將更加高效。
單個進程對文件執行順序訪問時(sequential access),使用mmap()幾乎不會帶來性能上的提升。譬如說,使用read()順序讀取文件時,文件系統會使用 read-ahead 的方式提前將文件內容緩存到文件系統的緩沖區,因此使用read()將很大程度上可以命中緩存。
下面我們通過代碼示例來對比下傳統IO與使用了零拷貝技術的NIO之間的差異。
我們通過服務端開啟socket監聽,然后客戶端連接的服務端進行數據的傳輸,數據傳輸文件大小為237M。
1、構建傳統IO的socket服務端,監聽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中讀取字節數據
while (true) {
// 讀取的字節數大小,-1則表示數據已被讀完
int readCount = inputStream.read(bytes, 0, bytes.length);
if (-1 == readCount) {
break;
}
}
}
}
}
}
2、構建傳統IO的客戶端,連接服務端的8898端口,并從磁盤讀取237M的數據文件向服務端socket中發起寫請求。
public class OldIOClient {
public static void main(String[] args) throws Exception {
Socket socket = new Socket();
socket.connect(new InetSocketAddress("localhost", 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());) {
// 讀取文件數據
// 定義byte緩存
byte[] buffer = new byte[4096];
int readCount; // 每一次讀取的字節數
int total = 0; // 讀取的總字節數
long startTime = System.currentTimeMillis();
while ((readCount = fileInputStream.read(buffer)) > 0) {
total += readCount; //累加字節數
dataOutputStream.write(buffer); // 寫入到輸出流中
}
System.out.println("發送的總字節數:" + total + ", 耗時:" + (System.currentTimeMillis() - startTime));
}
}
}
運行結果:發送的總字節數:237607747, 耗時:450 (400~600毫秒之間)
接下來,我們通過使用JDK提供的NIO的方式實現數據傳輸與上述傳統IO做對比。
1、構建基于NIO的服務端,監聽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);
// 這里一定要調用下rewind方法,將position重置為0開始位置
byteBuffer.rewind();
}
}
}
}
2、構建基于NIO的客戶端,連接NIO的服務端8899端口,通過FileChannel.transferTo傳輸237M的數據文件。
public class NewIOClient {
public static void main(String[] args) throws Exception {
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("localhost", 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("發送的總字節數:" + transferCount + ",耗時:" + (System.currentTimeMillis() - startTime));
fileChannel.close();
}
}
運行結果:發送的總字節數:237607747,耗時:161(100到300毫秒之間)
結合運行結果,基于NIO零拷貝技術要比傳統IO傳輸效率高3倍多。所以,后續當設計大文件數據傳輸時可以優先采用類似NIO的方式實現。
這里我們使用了FileChannel,其中調用的transferTo()方法將數據從FileChannel傳輸到其他的channel中,如果操作系統底層支持的話transferTo、transferFrom會使用相關的零拷貝技術來實現數據的傳輸。所以,這里是否使用零拷貝必須依賴于底層的系統實現。
FileChannel.transferTo方法
public abstract long transferTo(long position,
long count,
WritableByteChannel target) throws IOException
將字節從此通道的文件傳輸到給定的可寫入字節通道。
試圖讀取從此通道的文件中給定 position 處開始的 count 個字節,并將其寫入目標通道。此方法的調用不一定傳輸所有請求的字節;是否傳輸取決于通道的性質和狀態。如果此通道的文件從給定的 position 處開始所包含的字節數小于 count 個字節,或者如果目標通道是非阻塞的并且其輸出緩沖區中的自由空間少于 count 個字節,則所傳輸的字節數要小于請求的字節數。
此方法不修改此通道的位置。如果給定的位置大于該文件的當前大小,則不傳輸任何字節。如果目標通道中有該位置,則從該位置開始寫入各字節,然后將該位置增加寫入的字節數。
與從此通道讀取并將內容寫入目標通道的簡單循環語句相比,此方法可能高效得多。很多操作系統可將字節直接從文件系統緩存傳輸到目標通道,而無需實際復制各字節。
參數:
position - 文件中的位置,從此位置開始傳輸;必須為非負數
count - 要傳輸的最大字節數;必須為非負數
target - 目標通道
返回:
實際已傳輸的字節數,可能為零
FileChannel.transferFrom方法
public abstract long transferFrom(ReadableByteChannel src,
long position,
long count) throws IOException
將字節從給定的可讀取字節通道傳輸到此通道的文件中。
試著從源通道中最多讀取 count 個字節,并將其寫入到此通道的文件中從給定 position 處開始的位置。此方法的調用不一定傳輸所有請求的字節;是否傳輸取決于通道的性質和狀態。如果源通道的剩余空間小于 count 個字節,或者如果源通道是非阻塞的并且其輸入緩沖區中直接可用的空間小于 count 個字節,則所傳輸的字節數要小于請求的字節數。
此方法不修改此通道的位置。如果給定的位置大于該文件的當前大小,則不傳輸任何字節。如果該位置在源通道中,則從該位置開始讀取各字節,然后將該位置增加讀取的字節數。
與從源通道讀取并將內容寫入此通道的簡單循環語句相比,此方法可能高效得多。很多操作系統可將字節直接從源通道傳輸到文件系統緩存,而無需實際復制各字節。
參數:
src - 源通道
position - 文件中的位置,從此位置開始傳輸;必須為非負數
count - 要傳輸的最大字節數;必須為非負數
返回:
實際已傳輸的字節數,可能為零
發生相應的異常的情況:
異常拋出:
IllegalArgumentException - 如果關于參數的前提不成立
NonReadableChannelException - 如果不允許從此通道進行讀取操作
NonWritableChannelException - 如果目標通道不允許進行寫入操作
ClosedChannelException - 如果此通道或目標通道已關閉
AsynchronousCloseException - 如果正在進行傳輸時另一個線程關閉了任一通道
ClosedByInterruptException - 如果正在進行傳輸時另一個線程中斷了當前線程,因此關閉了兩個通道并將當前線程設置為中斷
IOException - 如果發生其他 I/O 錯誤
參考資料:
http://xcorpion.tech/2016/09/10/It-s-all-about-buffers-zero-copy-mmap-and-Java-NIO/
http://www.lxweimin.com/p/e76e3580e356
http://www.linuxjournal.com/node/6345
http://senlinzhan.github.io/2017/03/25/%E7%BD%91%E7%BB%9C%E7%BC%96%E7%A8%8B%E4%B8%AD%E7%9A%84zerocpoy%E6%8A%80%E6%9C%AF/
jdk官方文檔