ByteBuffer的源碼中有這樣一段注釋:
A byte buffer is either direct or non-direct. Given a direct byte buffer, the Java virtual machine will make a best effort to perform native I/O operations directly upon it. That is, it will attempt to avoid copying the buffer's content to (or from) an intermediate buffer before (or after) each invocation of one of the underlying operating system's native I/O operations.
大概意思是說ByteBuffer分為direct與heap兩種,如果使用direct版本的ByteBuffer,JVM會盡可能的直接在這個ByteBuffer上做IO操作。進而省去了将資料在中間buffer上來回複制帶來的開銷。
看到這裡你當然是一頭霧水了,不過不要慌,本文會詳盡的分析direct memory與IO之間的關系。
1. 什麼是direct memory?
Java應用程式執行時會啟動一個Java程序,這個程序的使用者位址空間可以被分成兩份:JVM資料區 + direct memory。
通俗的說,JVM資料區就是Java代碼可以直接操作的那部分記憶體,由heap/stack/pc/method area等組成,GC也工作在這一片區域裡。
direct memory則是額外劃分出來的一段記憶體區域,無法用Java代碼直接操作,GC無法直接控制direct memory,全靠手工維護。
2. direct memory是怎麼來的?
我們且來跟蹤一下ByteBuffer.allocateDirect()方法的調用流程:
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}
// Primary constructor
//
DirectByteBuffer(int cap) { // package-private
super(-1, 0, cap, cap);
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize();
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
Bits.reserveMemory(size, cap);//記錄已經申請了多少direct memory
long base = 0;
try {
base = unsafe.allocateMemory(size);//申請記憶體
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
unsafe.setMemory(base, size, (byte) 0);//初始化記憶體
if (pa && (base % ps != 0)) {
// Round up to page boundary
address = base + ps - (base & (ps - 1));
} else {
address = base;
}
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));//注冊Cleaner
att = null;
}
其中比較重要的是調用了Unsafe.allocateMemory與Unsafe.setMemory這兩個native方法來申請并初始化記憶體
我們且來跟蹤一下這兩個方法
Unsafe的實際實作位于src/share/vm/prims/unsafe.cpp
Unsafe.allocateMemory的實作則在這裡:
UNSAFE_ENTRY(jlong, Unsafe_AllocateMemory(JNIEnv *env, jobject unsafe, jlong size))
UnsafeWrapper("Unsafe_AllocateMemory");
size_t sz = (size_t)size;
if (sz != (julong)size || size < 0) {
THROW_0(vmSymbols::java_lang_IllegalArgumentException());
}
if (sz == 0) {
return 0;
}
//前面都是檢查參數
sz = round_to(sz, HeapWordSize);//沒找到round_to方法的定義,但是應該為了記憶體對齊而額外申請一點記憶體做padding
void* x = os::malloc(sz, mtInternal);//直接調用malloc
if (x == NULL) {
THROW_0(vmSymbols::java_lang_OutOfMemoryError());
}
//Copy::fill_to_words((HeapWord*)x, sz / HeapWordSize);
return addr_to_java(x);//将傳回的記憶體位址轉成long類型并傳回給Java應用
UNSAFE_END
可以看到是直接調用了malloc方法來申請的一片記憶體空間
Unsafe.setMemory的實作在這裡:
UNSAFE_ENTRY(void, Unsafe_SetMemory(JNIEnv *env, jobject unsafe, jlong addr, jlong size, jbyte value))
UnsafeWrapper("Unsafe_SetMemory");
size_t sz = (size_t)size;
if (sz != (julong)size || size < 0) {
THROW(vmSymbols::java_lang_IllegalArgumentException());
}
//檢查參數
char* p = (char*) addr_from_java(addr);//将從Java應用傳來的long型變量強制轉成char指針,現在p指向的就是那一塊direct memory的起始位置了
Copy::fill_to_memory_atomic(p, sz, value);
UNSAFE_END
可以看到是調用了Copy::fill_to_memory_atomic方法來将指定的記憶體空間清空。
現在我們就明白了,這些direct memory,其實就跟一般的c語言程式設計裡一樣,是直接用malloc方法申請的。
JVM會将malloc方法的傳回值(申請到的記憶體空間的首位址)轉換成long類型的address變量,然後返還給Java應用程式。
Java應用程式在需要操作direct memory的時候,會調用native方法将address傳給JVM,然後JVM就能對這塊記憶體為所欲為了。
3. Java應用程式是如何通路direct memory的?
以DirectByteBuffer.get()方法為例
public byte get() {
return ((unsafe.getByte(ix(nextGetIndex()))));
}
邏輯看起來很簡單,就是直接調用Unsafe的getByte方法來從指定的記憶體位址擷取資料(偏移量已經給你算好了,隻用取記憶體資料就行了)
有趣的是,我找了一圈沒有發現Unsafe.getByte()方法的native實作,可能是因為這個方法太經常調用了,處于性能緣故JVM已經把它搞成intrinsics的了。
也就是說,跑在JVM内部的Java代碼無法直接操作direct memory裡的資料,需要經過Unsafe帶來的中間層,而這必然也會帶來一定的開銷,是以操作direct memory比heap memory要慢一些。
4. 為什麼說direct memory更加适合IO操作?
因為在JVM層面來看,所謂的direct memory就是在程序空間中申請的一段記憶體,而且指向direct memory的指針是固定不變的,是以可以直接用direct memory作為參數來執行各種系統調用,比方說read/pread/mmap等。
而為什麼heap memory不能直接用于系統IO呢,因為GC會移動heap memory裡的對象的位置。如果強行用heap memory來搞系統IO的話,IO操作的中途出現的GC會導緻緩沖區位置移動,然後程式就跑飛了。
除非采用一定的手段将這個對象pin住,但是hotspot不提供單個對象層面的object pinning,一定要pin的話就隻能暫時禁用gc了,也就是把整個Java堆都給pin住,這顯然代價太高了。
總結一下就是:heap memory不可能直接用于系統IO,資料隻能先讀到direct memory裡去,然後再複制到heap memory。
5. 執行個體說明
就用上一篇中提到的FileChannel.read()方法作為例子,而且使用heap memory作為緩沖區,其調用流程如下:
- 先申請一塊臨時的direct memory
- 調用native的FileDispatcherImpl.pread0或者FileDispatcherImpl.read0,将step1中申請的direct memory的位址傳進去
- jvm調用Linux提供的read或者pread系統調用,傳入direct memory對應的記憶體空間指針,以及正在操作的fd
- 觸發中斷,程序從使用者态進入到核心态(1-3步全是在使用者态中完成)
- 作業系統檢查kernel中維護的buffer cache是否有資料,如果沒有,給磁盤發送指令,讓磁盤将資料拷貝到buffer cache裡
- 作業系統将buffer cache中的資料複制到step3中傳入的指針對應的記憶體裡
- 觸發中斷,程序從核心态退回到使用者态(5-6步全在核心态中完成)
- FileDispatcherImpl.pread0或者FileDispatcherImpl.read0方法傳回,此時臨時建立的direct memory中已經有使用者需要的資料了
- 将direct memory裡的資料複制到heap memory中(這中間又要調用Unsafe裡的一些方法,例如copyMemory)
- 現在heap memory中終于有我們想要的資料了。
總結一下,資料的流轉過程是:hard disk -> kernel buffer cache -> direct memory -> heap memory
中間調用了一次系統調用,觸發了兩次中斷。
流程看起來相當複雜,有優化的辦法嗎?當然是有的:
- 可以直接使用direct memory作為緩沖區,這樣就砍掉了direct memory -> heap memory的耗費
- 也可以使用記憶體映射檔案,也就是FileChannel.map,砍掉中間的kernel buffer cache這一段