【JAVA】普通IO數據拷貝次數的問題探討
最近看到網上有些文章在討論JAVA中普通文件IO讀/寫的時候經過了幾次數據拷貝,如果從系統調用開始分析,以讀取文件為例,數據的讀取過程如下(以快取I/O為例):
- 應用程式調用read函數發起系統調用,此時由用戶空間切換到內核空間;
- 內核通過DMA從磁碟拷貝數據到內核緩衝區;
- 將內核緩衝區的數據拷貝到用戶空間的緩衝區,回到用戶空間;
整個讀取過程發生了兩次數據拷貝,一次是DMA將磁碟上的文件數據拷貝到內核緩衝區,一次是將內核緩衝區的數據拷貝到用戶緩衝區。
在JAVA中,JVM劃分了堆記憶體,平時創建的對象基本都在堆中,不過也可以通過NIO包下的ByteBuffer
申請堆外記憶體DirectByteBuffer
:
ByteBuffer.allocateDirect(size);
無論是普通IO或者是NIO,在進行文件讀寫的時候一般都會創建一個buffer作為數據的緩衝區,讀寫相關方法底層是通過調用native函數(JNI調用)來實現的,在進行讀寫時將buffer傳遞給JNI。
JNI一般使用C/C++程式碼實現,JNI底層調用C函數庫時,要求buffer所在記憶體地址上的內容不能失效,但是JVM在進行垃圾回收的時候有可能對對象進行移動,導致地址發生變化,所以通過NIO進行文件讀取的時候,從源碼中可以明顯看到對buffer的對象類型進行了判斷,如果buffer是DirectByteBuffer
類型,使用的是堆外記憶體,直接使用即可,反之則認為使用的是堆內記憶體,此時需要先申請一塊堆外記憶體作為堆外記憶體buffer,然後進行系統調用,進行數據讀取,讀取完畢後將堆外記憶體buffer的內容再拷回JVM堆內記憶體buffer中,這裡一般是沒有疑問的。
比較有疑問的點是在普通IO中,讀寫文件傳入的是位元組數組byte[]
,一種說法是數組一般分配的是連續的記憶體空間,即使記憶體地址發生了變化,根據數組的首地址依舊可以找到整個數組的記憶體,所以使用普通IO進行文件讀寫的時候,不需要重新分配堆外記憶體,直接使用堆內的位元組數組即可,為了探究普通IO到底有沒有重新申請堆外記憶體,接下來我們去看下源碼。
普通IO
首先來看一下使用普通IO進行文件讀取的例子,創建一個文件輸入流和位元組數組,通過輸入流讀取文件到位元組數組中,這裡的位元組數組佔用的是JVM的堆內記憶體:
// 創建輸入流
try (InputStream is = new FileInputStream("/document/123.txt")) {
// 創建位元組數組(堆內記憶體)
byte[] bytes = new byte[1024];
int len = 0;
// 通過read方法讀取數據到bytes數組
while ((len = is.read(bytes)) != -1){
String content = new String(bytes, 0, len);
System.out.print(content);
}
is.read(bytes);
} catch (Exception e) {
e.printStackTrace();
}
由於輸入流使用的FileInputStream
,所以讀取文件會進入到FileInputStream
中的read
方法,可以看到裡面又調用了readBytes
方法,readBytes
是一個native方法,裡面傳入了三個參數,分別為存放數據的位元組數組、讀取文件的起始位置和讀取數據的長度:
public class FileInputStream extends InputStream {
/**
* 讀取數據
*/
public int read(byte b[]) throws IOException {
return readBytes(b, 0, b.length);
}
/**
* 讀取位元組數據
* @param b 數據讀取後放入的位元組數組
* @param off 讀取起始位置
* @param len 讀取數據的長度
* @exception IOException If an I/O error has occurred.
*/
private native int readBytes(byte b[], int off, int len) throws IOException;
}
接下來就需要去readBytes
中看下到底有沒有使用傳入的堆內記憶體進行數據拷貝,由於readBytes
是native方法,所以需要藉助openjdk源碼來查看具體的實現過程。
openjdk源碼下載地址://hg.openjdk.java.net/
這裡以openjdk1.8為例,看一下readBytes
的實現過程。
readBytes
方法在源碼解壓後的src\share\native\java\io\io_util.h
文件中,它的處理邏輯如下:
- 創建一個字元數組
stackBuf
(堆外記憶體),大小為BUF_SIZE
,從BUF_SIZE
的定義中可以看出大小為8192位元組 - 對讀取數據長度進行判斷,如果大於8192,則根據長度重新分配一塊記憶體(堆外記憶體)作為數據緩衝區賦給
buf
變數,如果小於就使用預先分配的字元數組stackBuf
賦給buf
變數 - 調用
IO_Read
函數讀取數據到buf
變數中,IO_Read
函數中進行了系統調用,通過DMA從磁碟讀取數據到內核緩衝區 - 調用
SetByteArrayRegion
將buf
數據拷貝到bytes數組中
從readBytes
的處理邏輯來看,並沒有直接使用傳入的位元組數組(堆內記憶體)進行數據拷貝,而是重新分配了記憶體,這裡分配的是堆外記憶體,然後進行系統調用從磁碟讀取數據到內核緩衝區,再將內核緩衝區的數據拷貝到這裡分配的堆外記憶體中,最後調用SetByteArrayRegion
將堆外記憶體的數據拷貝到堆內記憶體位元組數組中。
/* 最大buffer大小
*/
#define BUF_SIZE 8192
// bytes對應傳入的位元組數組(堆內記憶體),off對應起始位置,len對應讀取數據的長度
jint
readBytes(JNIEnv *env, jobject this, jbyteArray bytes,
jint off, jint len, jfieldreadBytesID fid)
{
jint nread;
// 創建一個字元數組,大小為BUF_SIZE,這裡分配的是堆外記憶體
char stackBuf[BUF_SIZE];
// 數據緩衝區
char *buf = NULL;
FD fd;
// 校驗bytes是否為空
if (IS_NULL(bytes)) {
JNU_ThrowNullPointerException(env, NULL);
return -1;
}
// 校驗是否越界
if (outOfBounds(env, off, len, bytes)) {
JNU_ThrowByName(env, "java/lang/IndexOutOfBoundsException", NULL);
return -1;
}
if (len == 0) { // 如果讀取數據長度為0直接返回
return 0;
} else if (len > BUF_SIZE) { // 如果讀取長度大於BUF_SIZE
buf = malloc(len); // 分配記憶體(堆外記憶體)
if (buf == NULL) {
JNU_ThrowOutOfMemoryError(env, NULL);
return 0;
}
} else {
// 使用預先分配的數組
buf = stackBuf;
}
fd = GET_FD(this, fid);
if (fd == -1) {
JNU_ThrowIOException(env, "Stream Closed");
nread = -1;
} else {
// 數據讀取
nread = IO_Read(fd, buf, len);
if (nread > 0) {
// 將數據拷貝到堆內記憶體bytes中
(*env)->SetByteArrayRegion(env, bytes, off, nread, (jbyte *)buf);
} else if (nread == -1) {
JNU_ThrowIOExceptionWithLastError(env, "Read error");
} else { /* EOF */
nread = -1;
}
}
if (buf != stackBuf) {
free(buf);
}
return nread;
}
由於作業系統不同,系統調用的方法也不同,這裡以UNIX為例,看下IO_Read
函數的具體實現。
IO_Read
函數的定義在解壓後的src\solaris\native\java\io\io_util_md.h
文件中,可以看到IO_Read
指向的是handleRead
方法:
#define IO_Read handleRead
在handleRead
在src\solaris\native\java\io\io_util_md.c
中實現,可以看到裡面進行了系統調用,通過read函數讀取數據:
ssize_t
handleRead(FD fd, void *buf, jint len)
{
ssize_t result;
// 進行系統調用,通過read函數讀取數據
RESTARTABLE(read(fd, buf, len), result);
return result;
}
普通IO數據讀取流程總結
- 發起JNI調用,創建堆外緩衝區;
- JNI中發起read系統調用,此時需要由用戶空間切換到內核空間;
- 進入到內核空間,DMA讀取文件數據到內核緩衝區;
- 將內核緩衝區的數據拷貝到用戶緩衝區,切換回用戶空間;
- 將堆外緩衝區的數據拷貝到JVM堆內緩衝區中;
普通IO文件讀取過程中並沒有因為使用位元組數組而減少一次拷貝,讀取過程中數據發生了三次拷貝,分別是從DMA讀取數據到內核緩衝區、從內核緩衝區拷貝到用戶空間的堆外緩衝區和從堆外緩衝區拷貝到JVM堆內緩衝區。
文件寫入的邏輯與讀取類似,具體可以通過源碼查看。
NIO
接下來再來看下NIO讀取文件的過程。
使用NIO的FileChannel
讀取文件的例子:
try (FileInputStream fileInputStream = new FileInputStream("/document/123.txt")) {
// 獲取文件對應的channel
FileChannel channel = fileInputStream.getChannel();
// 分配buffer
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 將數據讀取到buffer
channel.read(buffer);
} catch (Exception e) {
e.printStackTrace();
}
接下來進入到FileChannelImpl
的read方法中,由於jdk中沒有sun包下面的源碼,IDEA只能通過反編譯查看源碼,有些參數會是var1、var2…這樣的變數名,不便於閱讀,所以還可以藉助openjdk中的源碼來查看實現,當然也可以從網上下載sun包的源碼,放入jdk的源碼包中。
FileChannelImpl
在src/share/classes/sun/nio/ch/FileChannelImpl.java
中,裡面又是通過IOUtil
的read
方法讀取數據放入buffer中的:
public class FileChannelImpl extends FileChannel {
public int read(ByteBuffer dst) throws IOException {
ensureOpen();
if (!readable)
throw new NonReadableChannelException();
synchronized (positionLock) {
int n = 0;
int ti = -1;
try {
begin();
ti = threads.add();
if (!isOpen())
return 0;
do {
// 通過IOUtil的read方法讀取數據,fd為文件描述符,dst為傳入的buffer
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
在src/share/classes/sun/nio/ch/IOUtil.java
中,可以看到首先對傳入的buffer類型進行了判斷:
- 如果是
DirectBuffer
,直接調用readIntoNativeBuffer
讀取數據即可; - 如果不是
DirectBuffer
,表示佔用的堆內記憶體,此時需要Util
的getTemporaryDirectBuffer
申請一塊堆外記憶體,然後調用readIntoNativeBuffer
讀取數據;
public class IOUtil {
static int read(FileDescriptor fd, ByteBuffer dst, long position,
NativeDispatcher nd)
throws IOException
{
if (dst.isReadOnly())
throw new IllegalArgumentException("Read-only buffer");
// 如果目標buffer是DirectBuffer
if (dst instanceof DirectBuffer)
return readIntoNativeBuffer(fd, dst, position, nd); // 直接讀取數據
// 重新分配一塊native buffer,也就是堆外記憶體
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);
}
}
}
Util
在src/share/classes/sun/nio/ch/Util.java
中。
在Util
中,使用了ThreadLocal
快取了每個執行緒申請的記憶體buffer,在調用
getTemporaryDirectBuffer
方法獲取記憶體時,首先會根據大小從ThreadLocal
中獲取是否有滿足條件的buffer,如果有直接返回即可,如果大小不夠則重新申請,可以看到申請的是堆外記憶體:
public class Util {
// Per-thread cache of temporary direct buffers
private static ThreadLocal<BufferCache> bufferCache =
new ThreadLocal<BufferCache>()
{
@Override
protected BufferCache initialValue() {
// 初始化,創建一個BufferCache
return new BufferCache();
}
};
/**
* Returns a temporary buffer of at least the given size
*/
public static ByteBuffer getTemporaryDirectBuffer(int size) {
// 先從快取中獲取
BufferCache cache = bufferCache.get();
ByteBuffer buf = cache.get(size);
// 如果獲取不為空
if (buf != null) {
return buf;
} else {
// 如果沒有合適的buffer則重新申請
if (!cache.isEmpty()) {
buf = cache.removeFirst();
free(buf);
}
// 申請堆外記憶體
return ByteBuffer.allocateDirect(size);
}
}
}
ByteBuffer
的allocateDirect
方法返回的是DirectByteBuffer
:
public abstract class ByteBuffer extends Buffer implements Comparable<ByteBuffer>
public static ByteBuffer allocateDirect(int capacity) {
// 創建DirectByteBuffer
return new DirectByteBuffer(capacity);
}
}
參考
JAVA IO專題一:java InputStream和OutputStream讀取文件並通過socket發送,到底涉及幾次拷貝