Java NIO分析(11): 零拷貝技術以及NIO的支持
前面已經講了Selector
,SocketChannel
和DirectBuffer
, 這些是NIO網絡編程中最核心的組件
接下來我們會再講幾點非核心的優化(不代表不重要, 只是API不佔NIO設計的大頭):
- 文件傳輸(File Transfer): 文件內容直接發送到網卡, 或者從網卡直接讀到文件裏
- 內存映射文件(Memory-mapped Files): 將文件的一塊映射到內存
這兩項本質上都基於零拷貝(zero copy)
技術。
1. 零拷貝?
1.1 簡介
零拷貝(Zero-Copy)是指計算機在執行操作時,CPU不需要先將數據從某處內存複製到一個特定區域,從而節省CPU時鐘週期和內存帶寬 —-維基百科
拿常用的網絡文件傳輸過程舉個栗子:
- DMA
read
讀取磁盤文件內容到內核緩衝區 - copy內核緩衝區數據到應用進程緩衝區
- 從應用進程緩衝區copy數據到socket緩衝區
DMA copy
給網卡發送
畫個圖:
可以清楚得看到,有2次copy是沒必要的, 就是上面的2和3,還會平白增加2次用戶態和內核態上下文切換, 在高併發場景下,這些會很致命。
1.2 Zero-Copy分類
解決上面這個問題有幾個思路
- 直接I/O: 應用進程直接操作硬件存儲
- 避免在用戶空間和內核空間地址之間拷貝數據
- 優化
頁緩存
和應用進程緩衝區
的傳輸
1和2都是避免應用程序地址空間和內核地址空間兩者之間的緩衝區拷貝, 3是從傳輸的角度優化,因爲DMA進行數據傳輸基本不需要CPU參與,但是用戶地址空間的緩衝區和內核的頁緩存
傳輸沒有類似DMA的手段, 3就是從這個角度優化。
1.3 Linux的解決方案
直接I/O
和傳輸優化
都涉及到硬件層面我們暫且不講,主要講避免上下文切換和數據來回拷貝
這個思路, Linux內核提供了
- mmap: 內存映射文件, 即將文件的一段直接映射到內存,內核和應用進程共用一塊內存地址,這樣就不需要拷貝了
- sendfile: 從上圖的內核緩衝區直接複製到socket緩衝區, 不需要嚮應用進程緩衝區拷貝
如圖,mmap
將buffer映射到了用戶空間,操作的是同一塊內存,也不需要切換了, 但是mmap
有個缺點就是, 如果其他進程在向這個文件write
, 那麼會被認爲是一個錯誤的存儲訪問
而sendfile
則沒有映射, 保留了mmap
的不需要來回拷貝優點,適用於應用進程不需要對讀取的數據做任何處理的場景,如圖:
2.6以後還提供了splice
, splice可以在內核態將數據整塊的從A複製到B地址。
2. NIO中的零拷貝
NIO中通過FileChannel
來提供Zero-Copy
的支持,分別是
- FileChannel.map: 將文件的一部分映射到內存
- FileChannel.transferTo: 將本Channel的文件字節轉移到指定的可寫Channel
FileChannel.map
的基本用法如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
/** * 測試FileChannel的用法 * * @author sound2gd * */ public class FileChannnelTest { public static void main(String[] args) { File file = new File("src/com/cris/chapter15/f6/FileChannnelTest.java"); try ( // FileInputStream打開的FileChannel只能讀取 FileChannel fc = new FileInputStream(file).getChannel(); // FileOutputStream打開的FileChannel只能寫入 FileChannel fo = new FileOutputStream("src/com/cris/chapter15/f6/a.txt").getChannel();) { // 將FileChannel的數據全部映射成ByteBuffer MappedByteBuffer mbb = fc.map(MapMode.READ_ONLY, 0, file.length()); // 使用UTF-8的字符集來創建解碼器 Charset charset = Charset.forName("UTF-8"); // 直接將buffer裏的數據全部輸出 fo.write(mbb); mbb.clear(); // 創建解碼器 CharsetDecoder decoder = charset.newDecoder(); // 使用解碼器將byteBuffer轉換爲CharBuffer CharBuffer decode = decoder.decode(mbb); System.out.println(decode); } catch (Exception e) { } } } |
這就是一個基本的例子,用於文件複製,可以看到fo.write(mbb)
的時候,是將mbb Buffer
的數據輸出到另一個文件的,看起來就像是
在內存中,而不是在文件裏, 這就是內存映射文件.
我們來看看map的實現
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
public MappedByteBuffer map(MapMode mode, long position, long size) ...省略非關鍵代碼 try { // 調用map0這個native方法 addr = map0(imode, mapPosition, mapSize); } catch (OutOfMemoryError x) { // An OutOfMemoryError may indicate that we've exhausted memory // so force gc and re-attempt map // gc下防止內存不夠 System.gc(); try { // 等待gc結束 Thread.sleep(100); } catch (InterruptedException y) { Thread.currentThread().interrupt(); } try { // 再試一次 addr = map0(imode, mapPosition, mapSize); } catch (OutOfMemoryError y) { // After a second OOME, fail throw new IOException("Map failed", y); } } ... } private native long map0(int prot, long position, long length) throws IOException; |
打開FileChannelImpl.c
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
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; } // 所以還是使用的mmap這個API 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); } |
可以看到,還是使用的我們mmap
的api, 瞭解一些底層知識還是有必要的, JVM很多東西都是對底層的一層封裝.
另一個API transferTo
同理,最後調用的是transferTo0
方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
JNIEXPORT jlong JNICALL Java_sun_nio_ch_FileChannelImpl_transferTo0(JNIEnv *env, jobject this, jint srcFD, jlong position, jlong count, jint dstFD) { off64_t offset = (off64_t)position; // 調用sendfile方法 jlong n = sendfile64(dstFD, srcFD, &offset, (size_t)count); if (n < 0) { if (errno == EAGAIN) return IOS_UNAVAILABLE; if ((errno == EINVAL) && ((ssize_t)count >= 0)) return IOS_UNSUPPORTED_CASE; if (errno == EINTR) { return IOS_INTERRUPTED; } JNU_ThrowIOExceptionWithLastError(env, "Transfer failed"); return IOS_THROWN; } return n; } |
可以看到封裝的是sendfile
這個方法,這裏看的是jvm在linux系統的的實現。
3. 總結
本文主要介紹了Linux中Zero-Copy零拷貝
的概念,分類和解決方案。
同時介紹了NIO對Zero-Copy
的支持, 分別是FileChannel.map
以及FileChannel.transferTo
.
在高併發場景下,這點提升是很關鍵的,著名框架Netty
, Kafka
都大量使用了零拷貝的API, 是其高性能的原因之一。