天天看點

java nio 從記憶體讀資訊_java NIO讀取檔案并通過socket發送,最少拷貝了幾次?堆外記憶體和所謂的零拷貝到底是什麼關系...

本文屬于java InputStream和OutputStream讀取檔案并通過socket發送,到底涉及幾次拷貝

的後話,從BIO過度到NIO,可以更好的了解堆外記憶體的作用和所謂的零拷貝,首先還是解釋一下零拷貝的概念。

核心的零拷貝

核心的零拷貝,指的是不需要消耗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 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 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);

}

}

代碼有點長:

n = transferTo0(thisFDVal, position, icount, targetFDVal);

再來跟蹤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并不是一次拷貝都沒有,而是在不同的場景下盡可能減少拷貝次數。

參考文章