目錄
前言
關於 NIO 裏的零拷貝,很多博客提及的都是關於磁盤到網絡的拷貝,他們寫得很清楚了。總結起來就是,關於磁盤到網絡(或磁盤到磁盤)的拷貝,與底層系統有關,Java 做的是封裝。這種零拷貝是不能給我們 Java 程序操作數據的。因爲 Java 程序在這裏面起到的作用僅僅是發一個“系統調用”(以及封裝),其它工作都是內核完成的。
現在的 Java 程序員,更多關注地是 Java 程序(內存)到網絡之間的拷貝。因爲關於磁盤的讀寫往往是通過數據庫來做的,而不是通過 FileChannel 來讀文件。本文想講明白的,就是內存到網絡的零拷貝。
相關知識
內核
內核是操作系統的軟件,它封裝了最底層的細節,提供接口,保證安全。Java 程序要調用內核的接口,就涉及 2 次模式切換。調用:從用戶模式到內核模式;返回:從內核模式到用戶模式。這是耗性能的。內核模式(也叫內核態)擁有比用戶模式更大的權限。
系統調用
關於 Java 裏的 IO 這一塊,相關代碼大量調用了 JNI(Java Native Interface),JNI 是由 c/c++ 寫的。而這些底層語言關於 IO 這一塊,調用的是“系統調用”,“系統調用”是系統內核提供的接口。
虛擬內存
對於 Linux 系統,每個進程分配的內存是虛擬內存,虛擬內存以頁爲單位分配,並且有頁表能找到物理內存的位置。虛擬內存讓進程以爲自己有連續的內存空間。
DirectByteBuffer 與 HeapByteBuffer 的關係
我們創建一個 DirectByteBuffer:
類 ByteBuffer
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}
底層是通過 c++ 的 malloc 方法分配內存。這個內存是堆外內存,也就是直接內存。
SocketChannelImpl 的源碼得在 OpenJDK 中看,它裏面有 write 和 read 方法,我們只看 write,因爲它們是類似的。
public int write(ByteBuffer buf) throws IOException {
if (buf == null)
throw new NullPointerException();
synchronized (writeLock) {
ensureWriteOpen();
int n = 0;
try {
begin();
synchronized (stateLock) {
if (!isOpen())
return 0;
writerThread = NativeThread.current();
}
for (;;) {
//這裏
n = IOUtil.write(fd, buf, -1, nd);
if ((n == IOStatus.INTERRUPTED) && isOpen())
continue;
return IOStatus.normalize(n);
}
} finally {
writerCleanup();
end(n > 0 || (n == IOStatus.UNAVAILABLE));
synchronized (stateLock) {
if ((n <= 0) && (!isOutputOpen))
throw new AsynchronousCloseException();
}
assert IOStatus.check(n);
}
}
}
類 IOUtil
static int write(FileDescriptor fd, ByteBuffer src, long position,
NativeDispatcher nd)
throws IOException
{
//如果是DirectBuffer
if (src instanceof DirectBuffer)
return writeFromNativeBuffer(fd, src, position, nd);
//不是DirectBuffer,就是一種堆內Buffer,Java裏沒有HeapBuffer這個接口
// Substitute a native buffer
int pos = src.position();
int lim = src.limit();
assert (pos <= lim);
int rem = (pos <= lim ? lim - pos : 0);
//還是要創建一個臨時的DirectBuffer
ByteBuffer bb = Util.getTemporaryDirectBuffer(rem);
try {
bb.put(src);
bb.flip();
// Do not update src until we see how many bytes were written
src.position(pos);
//還是要調用這個方法
int n = writeFromNativeBuffer(fd, bb, position, nd);
if (n > 0) {
// now update src
src.position(pos + n);
}
return n;
} finally {
Util.offerFirstTemporaryDirectBuffer(bb);
}
}
- 如果src爲DirectBuffer,那麼就直接調用writeFromNativeBuffer
- 否則src爲一個HeapBuffer(Java中沒有這個接口),先通過getTemporaryDirectBuffer創建一個臨時的DirectBuffer,然後將HeapBuffer中的數據拷貝到這個臨時的DirectBuffer,最後再調用writeFromNativeBuffer發送數據
writeFromNative本質是JVM發起了系統調用,將直接內存地址給內核操作。內核由於權限最高,所以可以通過我們發起JNI調用時傳遞的直接內存地址來幫我們直接操作堆外內存,也就減少了我們正常方式中需要將數據從用戶態內存(堆內內存和堆外內存)拷貝到內核態內存。
爲什麼不能讓內核系統直接操作堆內內存?因爲 JVM 不讓。
總結一下上面的內容:
在 NIO 裏,通過 Buffer 的方式,Java 程序與外設(網卡、磁盤)交流,必須通過堆外內存。
如果不用 DirectBuffer 的內存複製過程:堆內內存 => 堆外內存 == 內核內存=> 外設(磁盤或者網卡緩存,它們與內核之間的數據讀寫不由 CPU 完成)
其中,堆外內存 == 內核內存 是因爲:用戶態的邏輯地址和內核態的邏輯地址使用的是同一個物理空間,內核態直接操作了用戶態內存。
面試題:NIO 的零拷貝體現在哪裏?
從上面的內容就可以知道 NIO 的零拷貝是怎麼回事了:
- 使用 DirectBuffer 不僅省去了數據在堆內內存與堆外內存之間的拷貝
- 而且用戶態的邏輯地址和內核態的邏輯地址使用的是同一個物理空間,內核態直接操作了用戶態內存,省去了數據在用戶態與內核態之間的拷貝。CPU不需要爲數據在內存之間的拷貝消耗資源。
面試題:Netty 的零拷貝體現在哪裏?
Netty 是基於 NIO 的,所以上面的兩點要先答出來。除了這兩點外,Netty 還有自己的一點:
- Netty 提供了組合 Buffer 對象,可以聚合多個 ByteBuffer 對象,用戶可以像操作一個 Buffer 那樣方便的對組合 Buffer 進行操作,避免了傳統通過內存拷貝的方式將幾個小 Buffer 合併成一個大的 Buffer。
關於文件傳輸
其實答完上面幾點就已經能讓面試官刮目相看了。但文章看開頭也說了,本文講述的是內存到網絡的零拷貝,還有關於磁盤到網絡/磁盤到磁盤的零拷貝在文章開頭大致講述了一下。在這裏簡單總結一下怎麼講給面試官:
- 關於磁盤到網絡/磁盤到磁盤的零拷貝,NIO/Netty 是通過 transferTo 完成的,transferTo 發出系統調用,零拷貝由系統內核完成。(也就是說零拷貝能到那種程度,取決於你的操作系統)
更多詳情請看文章末尾的參考文章
關於 TCP 緩衝區的思考
堆內內存 => 堆外內存 == 內核內存=> 網卡 的過程中,TCP 緩衝區在哪兒?
TCP 緩衝區在內核中,這是可以肯定的。但問題是現在內核操作的內存其實是 Java 申請的堆外內存,之後就要傳輸數據到網卡了,也沒有再複製到 TCP 緩衝區這一步,那麼 TCP 緩衝區到底在哪裏呢?
其實,TCP 緩衝區保存的也是內存的地址。這樣來看,似乎就沒什麼問題了。堆外內存,內核內存,TCP 緩衝區用了同一塊物理內存。
如果有誤,歡迎指正。