前言
上一節(堆外內存與零拷貝)當中我們從jvm堆內存的視角解釋了一波零拷貝原理,但是僅僅這樣還是不夠的。
為了徹底搞懂零拷貝,我們趁熱打鐵,接着上一節來繼續講解零拷貝的底層原理。
感受一下NIO的速度
之前的章節中我們說過,Nio並不能解決網絡傳輸的速度。但是為什么很多人卻說Nio的速度比傳統IO快呢?
沒錯,zero copy。我們先拋出一個案例,然后根據案例來講解底層原理。
首先,我們實現一個IO的服務端接受數據,然后分別用傳統IO傳輸方式和NIO傳輸方式來直觀對比傳輸相同大小的文件所耗費的時間。
服務端代碼如下:
public class OldIOServer {
public static void main(String[] args) throws Exception {
ServerSocket serverSocket = new ServerSocket(8899);
while (true) {
Socket socket = serverSocket.accept();
DataInputStream dataInputStream = new DataInputStream(socket.getInputStream());
try {
byte[] byteArray = new byte[4096];
while (true) {
int readCount = dataInputStream.read(byteArray, 0, byteArray.length);
if (-1 == readCount) {
break;
}
}
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
}
這個是最普通的socket編程的服務端,沒什么好多說的。就是綁定本地的8899端口,死循環不斷接受數據。
傳統IO傳輸
public class OldIOClient {
public static void main(String[] args) throws Exception {
Socket socket = new Socket("localhost", 8899);
String fileName = "C:\\Users\\Administrator\\Desktop\\test.zip"; //大小兩百M的文件
InputStream inputStream = new FileInputStream(fileName);
DataOutputStream dataOutputStream = new DataOutputStream(socket.getOutputStream());
byte[] buffer = new byte[4096];
long readCount;
long total = 0;
long startTime = System.currentTimeMillis();
while ((readCount = inputStream.read(buffer)) >= 0) {
total += readCount;
dataOutputStream.write(buffer);
}
System.out.println("發送總字節數: " + total + ", 耗時: " + (System.currentTimeMillis() - startTime));
dataOutputStream.close();
socket.close();
inputStream.close();
}
}
客戶端向服務端發送一個119M大小的文件。計算一下耗時用了多久
由於我的筆記本性能太渣,大概平均每次消耗的時間大概是 500ms左右。值得注意的是,我們客戶端和服務端分配的緩存大小都是4096個字節。如果將這個字節分配的更小一點,那么所耗時間將會更多。因為上述傳統的IO實際表現並不是我們想象的那樣直接將文件讀到內存,然后發送。
實際情況是什么樣的呢?我們在后續分析。
NIO傳輸
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 = "C:\\Users\\Administrator\\Desktop\\test.zip"; //大小200M的文件
FileChannel fileChannel = new FileInputStream(fileName).getChannel();
long startTime = System.currentTimeMillis();
long transferCount = fileChannel.transferTo(0, fileChannel.size(), socketChannel); //1
System.out.println("發送總字節數:" + transferCount + ",耗時: " + (System.currentTimeMillis() - startTime));
fileChannel.close();
}
}
NIO編程不熟的同學沒關系,后面會有一篇專門的章節來講。
這里我們來關注一下注釋1關於FileChannel的transferTo方法。(方法的doc文檔很長。我刪除了很多,只看重點)
/**
* Transfers bytes from this channel's file to the given writable byte
* channel.
*
* <p> This method is potentially much more efficient than a simple loop
* that reads from this channel and writes to the target channel. Many
* operating systems can transfer bytes directly from the filesystem cache
* to the target channel without actually copying them. </p>
*/
public abstract long transferTo(long position, long count,
WritableByteChannel target)
throws IOException;
翻譯一下:
將文件channel的數據寫到指定的channel
這個方法可能比簡單的將數據從一個channel循環讀到另一個channel更有效,
許多操作系統可以直接從文件系統緩存傳輸字節到目標通道,**而不實際復制它們**。
意思是我們調用FileChannel的transferTo方法就實現了零拷貝(想實現零拷貝並不止這一種方法,有更優雅的方法,這里只是作為一個演示)。當然也要看你操作系統支不支持底層zero copy。因為這部分工作其實是操作系統來完成的。
我的電腦平均執行下來大概在200ms左右。比傳統IO快了300ms。
底層原理
大家也可以用自己的電腦運行一下上述代碼,看看NIO傳輸一個文件比IO傳輸一個文件快多少。
在上訴代碼中,樓主這里指定的緩存只有4096個字節,而傳送的文件大小有125581592個字節。
在前面我們分析過,對於傳統的IO而言,讀取的緩存滿了以后會有兩次零拷貝過程。那么換算下來傳輸這個文件大概在內存中進行了6w多次無意義的內存拷貝,這6w多次拷貝在我的筆記本上大概所耗費的時間就是300ms左右。這就是導致NIO比傳統IO快的更本原因。
傳統IO底層時序圖

由上圖我們可以看到。當我們想將磁盤中的數據通過網絡發送的時候,
- 底層調用的了sendfile()方法,然后切換用戶態(User space)->內核態(Kemel space)。
- 從本地磁盤獲取數據。獲取的數據存儲在內核態的內存空間內。
- 將數據復制到用戶態內存空間里。
- 切換內核態->用戶態。
- 用戶操作數據,這里就是我們編寫的java代碼的具體操作。
- 調用操作系統的write()方法,將數據復制到內核態的socket buffer中。
- 切換用戶態->內核態。
- 發送數據。
- 發送完畢以后,切換內核態->用戶態。繼續執行我們編寫的java代碼。
由上圖可以看出。傳統的IO發送一次數據,進行了兩次“無意義”的內存拷貝。雖然內存拷貝對於整個IO來說耗時是可以忽略不計的。但是操作達到一定次數以后,就像我們上面案例的代碼。就會由量變引起質變。導致速率大大降低。
linux2.4版本前的NIO時序圖

- 底層調用的了sendfile()方法,然后切換用戶態(User space)->內核態(Kemel space)。
- 從本地磁盤獲取數據。獲取的數據存儲在內核態的內存空間內。
- 將內核緩存中的數據拷貝到socket緩沖中。
- 將socket緩存的數據發送。
- 發送完畢以后,切換內核態->用戶態。繼續執行我們編寫的java代碼。
可以看出,即便我們使用了NIO,其實在我們的緩存中依舊會有一次內存拷貝。拷貝到socket buffer(也就是發送緩存區)中。
到這里我們可以看到,用戶態已經不需要再緩存數據了。也就是少了用戶態和系統態之間的數據拷貝過程。也少了兩次用戶態與內核態上下文切換的過程。但是還是不夠完美。因為在底層還是執行了一次拷貝。
要想實現真真意義上的零拷貝,還是需要操作系統的支持,操作系統支持那就支持。不支持你代碼寫出花了也不會支持。所以在linux2.4版本以后,零拷貝進化為以下模式。
linux2.4版本后的NIO時序圖

這里的步驟與上面的步驟是類似的。看圖可以看出,到這里內存中才真正意義上實現了零拷貝。
很多人就會發問了。為什么少了一次內核緩存的數據拷貝到socket緩存的操作?
不急,聽我慢慢道來~
我們再來看另一張NIO的流程圖:

上面這個圖稍稍有點復雜,都看到這里了,別半途而廢。多看幾遍是能看懂的!
首先第一條黑線我們可以看出,在NIO只切換了兩次用戶態與內核態之間的上下文切換。
我們重點看這張圖下面的部分。
首先我們將硬盤(hard drive)上的數據復制到內核態緩存中(kemel buffer)。然后發生了一次拷貝(CPU copy)到socket緩存中(socket buffer)。最后再通過協議引擎將數據發送出去。
在linux2.4版本前的的確是這樣。但是!!!!
在linux2.4版本以后,上圖中的從內核態緩存中(kemel buffer)的拷貝到socket緩存中(socket buffer)的就不再是數據了。而是對內核態緩存中數據的描述符(也就是指針)。協議引擎發送數據的時候其實是通過socket緩存中的描述符。找到了內核態緩存中的數據。再將數據發送出去。這樣就實現了真正的零拷貝。
總結
我們花了兩篇文章,一篇從jvm堆內存的角度出發(堆外內存與零拷貝),以及本篇從操作體統底層出發來講解零拷貝。足以說明零拷貝的重要性,各位可千萬得重視喲,就算你覺得不重要,面試也是會經常被問到,如果你能把上面的流程講明白,我相信一定也是一大亮點~
