相關IO專題
JAVA IO專題一:java InputStream和OutputStream讀取文件并通過socket發送,到底涉及幾次拷貝
JAVA IO專題二:java NIO讀取文件并通過socket發送,最少拷貝了幾次?堆外內存和所謂的零拷貝到底是什么關系
JAVA IO專題三:java的內存映射和應用場景
JAVA IO專題四:java順序IO原理以及對應的應用場景
內核的零拷貝
內核的零拷貝,指的是不需要消耗CPU資源,完全交給DMA來處理,內核空間的數據沒有多余的拷貝。主要經歷了這么幾個發展歷程:
一、傳統的read + send
1、先調用操作系統的read函數,由DMA將文件拷貝到內核,然后CPU把內核數據拷貝到用戶緩沖區(堆外內存)
2、調用操作系統的send函數,由CPU把用戶緩沖區的數據拷貝到socket緩沖區,最后DMA把socket緩沖區數據拷貝到網卡進行發送。
這個過程中內核數據拷貝到用戶空間,用戶空間又拷貝回內存,有兩次多余的拷貝。
二、sendfile初始版本
直接調用sendfile來發送文件,流程如下:
1、首先通過 DMA將數據從磁盤讀取到內核
2、然后通過 CPU將數據從內核拷貝到socket緩沖區
3、最終通過 DMA將socket緩沖區數據拷貝到網卡發送
sendfile 與 read + send 方式相比,少了一次 CPU的拷貝。但是從上述過程中也可以發現從內核緩沖區拷貝到socket緩沖區是沒必要的。
三、sendfile改進版本,真正的零拷貝
內核為2.4或者以上版本的linux系統上,改進后的處理過程如下:
1、DMA 將磁盤數據拷貝到內核緩沖區,向socket緩沖區中追加當前要發送的數據在內核緩沖區中的位置和偏移量
2、DMA gather copy 根據 socket緩沖區中的位置和偏移量,直接將內核緩沖區中的數據拷貝到網卡上。
經過上述過程,數據只經過了 2 次 copy 就從磁盤傳送出去了。并且沒有CPU的參與。
java的零拷貝
一、利用directBuffer
在上一篇文章JAVA IO專題一:java InputStream和OutputStream讀取文件并通過socket發送,到底涉及幾次拷貝中,我們提到了基于BIO讀取文件發送消息,一共涉及六次拷貝,其中堆外和堆內內存的拷貝是多余的,我們可以利用directBuffer來減少這兩次拷貝:
//打開文件通道
FileChannel fileChannel = FileChannel.open(Paths.get("/test.txt"));
//申請堆外內存
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024);
//讀取到堆外內存
fileChannel.read(byteBuffer);
//打開socket通道
SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("localhost", 9099));
//堆外內存寫入socket通道
socketChannel.write(byteBuffer);
每一行代碼都有清楚的注釋,我們主要來看一下fileChannel.read、socketChannel.write做了什么:
- fileChannel.read 分析
//FileChannelImpl
public int read(ByteBuffer dst) throws IOException {
... 忽略了一堆不重要代碼
synchronized (positionLock) {
int n = 0;
int ti = -1;
try {
do {
// 調用IOUtil,根據文件描述符fd讀取數據到直接緩沖區dst中
n = IOUtil.read(fd, dst, -1, nd);
} while ((n == IOStatus.INTERRUPTED) && isOpen());
return IOStatus.normalize(n);
} finally {
threads.remove(ti);
end(n > 0);
assert IOStatus.check(n);
}
}
}
//IOUtil
static int read(FileDescriptor fd, ByteBuffer dst, long position,
NativeDispatcher nd)
throws IOException
{
ByteBuffer bb = Util.getTemporaryDirectBuffer(dst.remaining());
try {
int n = readIntoNativeBuffer(fd, bb, position, nd);
bb.flip();
if (n > 0)
dst.put(bb);
return n;
} finally {
Util.offerFirstTemporaryDirectBuffer(bb);
}
}
private static int readIntoNativeBuffer(FileDescriptor fd, ByteBuffer bb,
long position, NativeDispatcher nd)
throws IOException
{
int pos = bb.position();
int lim = bb.limit();
assert (pos <= lim);
int rem = (pos <= lim ? lim - pos : 0);
if (rem == 0)
return 0;
int n = 0;
if (position != -1) {
n = nd.pread(fd, ((DirectBuffer)bb).address() + pos,
rem, position);
} else {
//第一次讀取會走到這里,否則走上面的分支
n = nd.read(fd, ((DirectBuffer)bb).address() + pos, rem);
}
if (n > 0)
bb.position(pos + n);
return n;
}
//FileDispatcherImpl
int read(FileDescriptor fd, long address, int len) throws IOException {
return read0(fd, address, len);
}
這里的調用鏈比較深,我們一步一步梳理:
- 調用fileChannel.read實際是走到了FileChannelImpl.read方法,然后走到
n = IOUtil.read(fd, dst, -1, nd);
調用IOUtil的read,傳入了文件描述符、directBuffer - IOUtil 調用自己的
readIntoNativeBuffer
方法,字面意思是講數據讀取到native緩存,即堆外內存 - IOUtil 的
readIntoNativeBuffer
方法調用n = nd.read(fd, ((DirectBuffer)bb).address() + pos, rem);
,即NativeDispatcher 的read方法,傳入文件描述符,堆外內存地址以及要讀取的長度 - 這里的 NativeDispatcher 實現類為 FileDispatcherImpl,實際調用的是native方法read0,并傳入了文件描述符、堆外內存地址和讀取長度
我們簡單看一下native的read0方法做了什么:
// 以下內容來自于 jdk/src/solairs/native/sun/nio/ch/FileDispatcherImpl.c
JNIEXPORT jint JNICALL
Java_sun_nio_ch_FileDispatcherImpl_read0(JNIEnv *env, jclass clazz,
jobject fdo, jlong address, jint len)
{
//拿到文件描述符
jint fd = fdval(env, fdo);
//根據地址拿到堆外內存的指針
void *buf = (void *)jlong_to_ptr(address);
//直接調用系統函數read把文件描述符讀取到buf中
return convertReturnVal(env, read(fd, buf, len), JNI_TRUE);
}
可以看到native的read0方法是直接調用系統函數read,根據jvm傳過來的堆外內存地址,將文件數據讀取到堆外內存中(read方法的作用在內核零拷貝小節里已經提到了)。即直接操作堆外內存,而不使用DirectByteBuffer的時候,還需要將堆外內存拷貝到堆內進行讀寫JAVA IO專題一:java InputStream和OutputStream讀取文件并通過socket發送,到底涉及幾次拷貝),因此使用堆外內存+channel的方式,可以避免堆內外內存拷貝,一定程度上也能提高效率。
- socketChannel.write 分析
//SocketChannelImpl.java
public int write(ByteBuffer buf) throws IOException {
synchronized (writeLock) {
... 忽略不重要代碼
int n = 0;
try {
for (;;) {
//調用IOUtil.write寫數據
n = IOUtil.write(fd, buf, -1, nd);
if ((n == IOStatus.INTERRUPTED) && isOpen())
continue;
return IOStatus.normalize(n);
}
} finally {
writerCleanup();
}
}
}
//IOUtil.java
static int write(FileDescriptor fd, ByteBuffer src, long position,
NativeDispatcher nd)
throws IOException
{
if (src instanceof DirectBuffer)
//directBuffer直接走這里
return writeFromNativeBuffer(fd, src, position, nd);
}
private static int writeFromNativeBuffer(FileDescriptor fd, ByteBuffer bb,
long position, NativeDispatcher nd) throws IOException
{
int pos = bb.position();
int lim = bb.limit();
assert (pos <= lim);
int rem = (pos <= lim ? lim - pos : 0);
int written = 0;
if (rem == 0)
return 0;
if (position != -1) {
written = nd.pwrite(fd,
((DirectBuffer)bb).address() + pos,
rem, position);
} else {
//調用SocketDispatcher寫數據
written = nd.write(fd, ((DirectBuffer)bb).address() + pos, rem);
}
if (written > 0)
bb.position(pos + written);
return written;
}
//SocketDispatcher.java
int write(FileDescriptor fd, long address, int len) throws IOException {
//直接調用了FileDispatcherImpl的native方法write0
return FileDispatcherImpl.write0(fd, address, len);
}
在看native方法之前還是先做簡單的梳理:
- socketChannel.write 實際調用了SocketChannelImpl.write,然后調用
IOUtil.write(fd, buf, -1, nd);
傳入文件描述符和堆外內存引用 -
IOUtil.write
調用自己的私有方法writeFromNativeBuffer
,內部調用了written = nd.write(fd, ((DirectBuffer)bb).address() + pos, rem);
,將文件描述符、堆外內存地址交給了NativeDispatcher - 此處的NativeDispatcher實際是 SocketDispatcher,里面直接調用了
FileDispatcherImpl.write0(fd, address, len);
native方法
接著跟蹤FileDispatcherImpl.write0(fd, address, len);
這個native方法:
// 以下內容來自于 jdk/src/solairs/native/sun/nio/ch/FileDispatcherImpl.c
JNIEXPORT jint JNICALL
Java_sun_nio_ch_FileDispatcherImpl_write0(JNIEnv *env, jclass clazz,
jobject fdo, jlong address, jint len)
{
//轉換文件描述符
jint fd = fdval(env, fdo);
//轉換為堆外內存指針
void *buf = (void *)jlong_to_ptr(address);
//直接調用系統函數write將堆外內存數據發送出去
return convertReturnVal(env, write(fd, buf, len), JNI_FALSE);
}
可以看到native的write0方法是直接調用系統函數write將堆外內存數據發送出去(write方法的作用在內核零拷貝小節里已經提到了)。
-
小結
fileChannel和socketChannel配合directBuffer,本質上區別不大,都是配合系統函數write和read對文件描述符,直接操作堆外內存。因此相比較于BIO可以省去兩次拷貝。
二、channel.transferTo
java中的零拷貝就是依賴操作系統的sendfile函數來實現的,提供了channel.transferTo方法,允許將一個channel的數據直接發送到另一個channel,接下來我們通過示例代碼和具體的源碼來分析和驗證前面的說法。
示例代碼如下:
//打開socketChannel
SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("localhost", 9099));
//
FileChannel fileChannel = FileChannel.open(Paths.get("/test.txt"));
fileChannel.transferTo(0, fileChannel.size(), socketChannel);
只用了一行代碼fileChannel.transferTo(0, fileChannel.size(), socketChannel);
就把文件數據寫到了socket,繼續看源碼:
//FileChannelImpl.java
public long transferTo(long position, long count,
WritableByteChannel target)
throws IOException
{
... 忽略不重要代碼
long sz = size();
if (position > sz)
return 0;
int icount = (int)Math.min(count, Integer.MAX_VALUE);
if ((sz - position) < icount)
icount = (int)(sz - position);
long n;
//先嘗試直接tranfer,如果內核支持的話
if ((n = transferToDirectly(position, icount, target)) >= 0)
return n;
//嘗試mappedTransfer,只適用于受信任的channel類型
if ((n = transferToTrustedChannel(position, icount, target)) >= 0)
return n;
//channel不受信任的話,會走最慢的方式
return transferToArbitraryChannel(position, icount, target);
}
// FileChannelimpl.java
private long transferToDirectly(long position, int icount,
WritableByteChannel target)
throws IOException
{
if (!transferSupported)
//系統不支持就直接返回
return IOStatus.UNSUPPORTED;
FileDescriptor targetFD = null;
if (target instanceof FileChannelImpl) { //如果目標是fileChannel則走這里
if (!fileSupported)
return IOStatus.UNSUPPORTED_CASE;
targetFD = ((FileChannelImpl)target).fd;
} else if (target instanceof SelChImpl) {
//SocketChannel實現了SelChImpl接口,因此會走這里
if ((target instanceof SinkChannelImpl) && !pipeSupported)
return IOStatus.UNSUPPORTED_CASE;
//給targetFD賦值
targetFD = ((SelChImpl)target).getFD();
}
if (targetFD == null)
return IOStatus.UNSUPPORTED;
//將fileChannel和socketChannel對應的fd轉換為具體的值
int thisFDVal = IOUtil.fdVal(fd);
int targetFDVal = IOUtil.fdVal(targetFD);
//不支持自己給自己傳輸
if (thisFDVal == targetFDVal)
return IOStatus.UNSUPPORTED;
long n = -1;
int ti = -1;
try {
begin();
ti = threads.add();
if (!isOpen())
return -1;
do {
//調用native方法transferTo0
n = transferTo0(thisFDVal, position, icount, targetFDVal);
} while ((n == IOStatus.INTERRUPTED) && isOpen());
if (n == IOStatus.UNSUPPORTED_CASE) {
if (target instanceof SinkChannelImpl)
pipeSupported = false;
if (target instanceof FileChannelImpl)
fileSupported = false;
return IOStatus.UNSUPPORTED_CASE;
}
if (n == IOStatus.UNSUPPORTED) {
// Don't bother trying again
transferSupported = false;
return IOStatus.UNSUPPORTED;
}
return IOStatus.normalize(n);
} finally {
threads.remove(ti);
end (n > -1);
}
}
代碼有點長:
- 調用FileChannelImpl的transferTo,會嘗試三種情況,如果系統支持零拷貝,則走 transferToDirectly
- transferToDirectly 方法前面做了各種判斷,其實可以理解為直接調用了
n = transferTo0(thisFDVal, position, icount, targetFDVal);
native方法
再來跟蹤transferTo0:
// 以下內容來自于 jdk/src/solairs/native/sun/nio/ch/FileChannelImpl.c
JNIEXPORT jlong JNICALL
Java_sun_nio_ch_FileChannelImpl_transferTo0(JNIEnv *env, jobject this,
jint srcFD,
jlong position, jlong count,
jint dstFD)
{
#if defined(__linux__)
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;
}
這個方法里其實有linux、solaris、APPLE等多個平臺的實現,這里只截取linux下的實現,可以看到是直接調用了系統函數sendfile來實現的數據發送,具體的拷貝次數則要看linux內核的版本了。
總結
-
NIO讀取文件并通過socket發送,最少拷貝幾次?
直接調用channel.transferTo,同時linux內核版本大于等于2.4,則可以將拷貝次數降低到2次,并且CPU不參與拷貝。 -
堆外內存和所謂的零拷貝到底是什么關系
筆者理解網上說的零拷貝,可以理解為內核層面的零拷貝和java層面的零拷貝,所謂的0并不是一次拷貝都沒有,而是在不同的場景下盡可能減少拷貝次數。