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作為緩沖區,其調用流程如下:
1. 先申請一塊臨時的direct memory
2. 調用native的FileDispatcherImpl.pread0或者FileDispatcherImpl.read0,將step1中申請的direct memory的地址傳進去
3. jvm調用Linux提供的read或者pread系統調用,傳入direct memory對應的內存空間指針,以及正在操作的fd
4. 觸發中斷,進程從用戶態進入到內核態(1-3步全是在用戶態中完成)
5. 操作系統檢查kernel中維護的buffer cache是否有數據,如果沒有,給磁盤發送命令,讓磁盤將數據拷貝到buffer cache里
6. 操作系統將buffer cache中的數據復制到step3中傳入的指針對應的內存里
7. 觸發中斷,進程從內核態退回到用戶態(5-6步全在內核態中完成)
8. FileDispatcherImpl.pread0或者FileDispatcherImpl.read0方法返回,此時臨時創建的direct memory中已經有用戶需要的數據了
9. 將direct memory里的數據復制到heap memory中(這中間又要調用Unsafe里的一些方法,例如copyMemory)
10. 現在heap memory中終於有我們想要的數據了。
總結一下,數據的流轉過程是:hard disk -> kernel buffer cache -> direct memory -> heap memory
中間調用了一次系統調用,觸發了兩次中斷。
流程看起來相當復雜,有優化的辦法嗎?當然是有的:
a. 可以直接使用direct memory作為緩沖區,這樣就砍掉了direct memory -> heap memory的耗費
b. 也可以使用內存映射文件,也就是FileChannel.map,砍掉中間的kernel buffer cache這一段
參考資料