【LWJGL3】LWJGL3 是怎樣在棧上進行記憶體分配的
簡介
LWJGL是一個支援OpenGL,Opengl ES,Vulkan等圖形API的Java繫結庫。通過JNI與特定平臺上的這些本地庫繫結起來,使得可以使用Java進行相關的應用開發,同時還實現了跨平臺的統一的API。
遊戲/圖形學相關的開發,目前看主要還是C/C++的領域,使用Java來進行開發較為少見。除了生態方面的原因之外,效能方面的考慮也是其中一個方面的原因。
本文將討論 LWJGL繫結庫圍繞MemoryStack類設計的,高效能的記憶體分配方式。
LWJGL3 需要解決的問題
在原生c語言的opengl,有如下常用操作
GLuint vbo;
glGenBuffers(1, &vbo);
這段程式碼是用於請求顯示卡分配儲存空間,這是opengl中非常常用的一個操作。
該API的官方文件見 http://docs.gl/gl4/glGenBuffers
而在LWJGL中要完成這個原本簡單的操作,也就是說要實現一個Java版本的glGenBuffers(int num,long vbo)函式,該怎麼做呢。
由於Java不存在通過&一個變數進行取地址值的語法,因此只能傳遞long型別的值作為地址,該地址指向的是一個堆外緩衝區。
那麼Java如何做到,在堆外分配緩衝區,同時獲取到這個緩衝區的地址值?
我們可以通過 ByteBuffer buffer = ByteBuffer.allocateDirect(4) 來獲取一個堆外緩衝區,但是我們還需要這塊緩衝區的地址
因此我們需要這樣一個方法,它能夠獲取一個堆外的指定大小的緩衝區的地址,如下這個函式的原型就是符合需求的
public static long getVboBufferAddress(ByteBuffer directByteBuffer)
函式原型有了,但是到底怎麼實現呢。ByteBuffer 的地址,其實是儲存在該物件的欄位中,在我使用的Oracle JDK 8平臺上,該欄位名為 addres。
如果不考慮跨平臺(該欄位的名字在不同的JDK實現裡並不相同),可以做如下實現,來獲取address的值:
public static long getVboBufferAddress1(ByteBuffer directByteBuffer) throws NoSuchFieldException { Field address= Buffer.class.getDeclaredField("address"); long addressOffset = UNSAFE.objectFieldOffset(address); long addr = UNSAFE.getLong(directByteBuffer, addressOffset); return addr; }
這裡的UNSAFE是我在工具類裡定義的一個私有靜態變數,該工具類的完整程式碼附在文末,類名為MyMemUtil
如果要考慮跨平臺,則因為 address 欄位名在不同平臺的不同,就無法使用 Unsafe類的 objectFieldOffset 的方法了。
針對這個問題的思路是,只要能找到這個欄位在物件中的偏移量,那麼無論在什麼平臺上,我們都能通過Unsafe::getLong(obj, offset)方法來獲取到這個欄位的值。於是問題變成了,如何獲取該欄位在物件中的偏移量,這可以採用如下步驟來做到:
- 先使用 JNI 提供的 的 NewDirectByteBuffer 函式,在指定地址上獲取一塊容量為0的直接堆外直接緩衝區,這裡有兩點需要理解:
- 因為地址是指定的,所以該地址值是一個魔法值。
- 之所以容量為0,是因為這塊緩衝區並不是要用來存東西,而只是輔助用於尋找 “address”(當然實際可能不是這個名字)欄位在物件中的偏移量。
- 對返回的 ByteBuffer 物件進行掃描,由於該物件裡肯定有個欄位的值等於我們的魔法值,因此使用魔法值進行逐個比較,就能找到該欄位,同時也就找到了該欄位在物件中的偏移量。
具體實現如下(這裡的魔法值,直接採用了上面程式碼中一次執行結果的 addr的值)
/** * 考慮跨平臺的情況 * * @param directByteBuffer * @return * @throws NoSuchFieldException */ public static long getVboBufferAddress2(ByteBuffer directByteBuffer) throws NoSuchFieldException { long MATIC_ADDRESS = 720519504; ByteBuffer helperBuffer = newDirectByteBuffer(MATIC_ADDRESS, 0); long offset = 0; while (true) { long candidate = UNSAFE.getLong(helperBuffer, offset); if (candidate == MATIC_ADDRESS) { break; } else { offset += 8; } } long addr = UNSAFE.getLong(directByteBuffer, offset); return addr; }
這個程式碼是能夠正常發揮作用的,在下已經在自己的專案中進行了驗證。
但是這裡有兩個細節在下也不是很明白,
1. 根據在下所知的物件記憶體佈局的知識,64位虛擬機器下,物件前12個位元組是由Mark Word和型別指標組成的物件頭,所以理論上從第13個位元組開始搜尋應該更快,但是在下試了一下,這樣是不行的;
2. 從實踐結果上看,無論物件內部各個欄位的排列順序,最終都能通過getLong找到包括魔法值在內的所有long型別欄位,而不存在錯開的情況。在下猜測這是因為位元組對齊造成的,但不清楚是否只適用於堆外物件。
上面程式碼中的 newDirectByteBuffer 是一個native方法,其實現如下
#include "net_scaventz_test_mem_MyMemUtil.h" JNIEXPORT jobject JNICALL Java_net_scaventz_test_mem_MyMemUtil_newDirectByteBuffer (JNIEnv* __env, jclass clazz, jlong address, jlong capacity) { void* addr = (void*)(intptr_t) address; return (*__env)->NewDirectByteBuffer(__env, addr, capacity); }
這就結束了嗎,不,由於 LWJGL 是一個圖形庫,天然對效能有較高要求,而到此位置,僅僅是為了完成分配一個緩衝區並獲得該緩衝區地址這麼一個操作,我們就建立了兩個緩衝區:
- 第一個是容量為0的helperBuffer,用於協助計算地址欄位在物件中的偏移量。顯然該操作可以進行優化,只需要在LWJGL啟動時執行一次就可以了;
- 第二個是真正的緩衝區directByteBuffer的分配
顯然對於 directByteBuffer 的分配,無論如何相比於使用native api時的那種棧上分配,都是低效的。如果每次需要vbo時都進行一次這個操作,將會大大降低 lwjgl的執行速度。
在下看官網的介紹,據說lwjgl1和lwjgl2,以及其他類似的圖形繫結庫,都是通過分配一次緩衝區,然後將這個緩衝區快取起來,進行復用來解決的,這當然能夠解決問題。但lwjgl的作者並沒有滿足於這種做法,他認為這樣做有如下缺點:
- 將導致 ugly code(這可能是工程實踐的經驗,在下因為沒有使用過 lwjgl2,因此體驗不深)
- 快取起來的 buffer 為 static 變數,而靜態變數無可避免會導致併發問題
作者在 lwjgl3中,通過引入 MemoryStack 類來解決了這個問題
MemoryStack
我們不直接介紹 MemoryStack,而是介紹一下大體思路,漸進地理解 MemoryStack 是基於怎樣地考慮設計出來的。
整理一下現狀:
- 要避免頻繁的堆外記憶體分配
- 要避免使用單例,規避併發問題
有沒有聯想到什麼東東呢,對了,這時候ThreadLocal關鍵字就可以派上用場了。
為每個執行緒只分配一次堆外緩衝區,然後將其存放到ThreadLocal裡。這種方式便可同時滿足我們的上述兩點要求。
但這依然不夠
因為在同一個執行緒中,我們經常要分配許多的緩衝區,這些緩衝區的大小各異,而且生命週期各不相同,考慮如下場景:
要怎麼做呢。
"棧"這種資料結構,就很適合用來對bigBuffer進行劃分。我們讓每個buffer(對應與上面的buffer1到buffer3)在申請記憶體時,都從該bigBuffer這個棧(stack)裡劃出一幀(frame) ,幀大小為這個buffer本身的大小,只要不超過bigBuffer大小就行了,然後進行棧頂指標移動,分配就完成了。這樣每個buffer屬於各自的一幀。而當一個buffer生命週期結束時,只需要pop出棧即可。
MemoryStack 這個類便是這樣設計的。
而且由於在使用 MemoryStack時,約定以如下模板的方式使用,出棧的順序也就不會是一個問題了,MemoryStack實現了 AutoCloseable 介面,try塊執行結束後,MemoryStack 的 pop 方法將執行 pop 操作對該幀出棧
try (MemoryStack stack = MemoryStack.stackPush()) { glUniformMatrix4fv(location, false, value.get(stack.mallocFloat(16))); …… }
這確實是一個十分完美的做法:
- 效能方面,該設計已經做了最大的努力
- 語義方面也相當優雅,特別是如果你意識到,實際上在原生API中, GLuint vbo 這樣一個操作本身就是在執行棧上分配,便更能體會到這種設計的美感。
當然 MemoryStack 並非萬金油,由於棧分配通常用於“頻分而又較小容量的分配”,因此棧大小不宜過大,實際上lwjgl3中預設棧大小是64K,可以自定義,但也不宜過大,因此不適合用來存放大容量資料。
這是 lwjgl 擁有一整套記憶體分配策略 的原因,MemoryStack只是其中之一,但任何可以使用 MemoryStack 的時候,都應該優先使用它,因為它的效率是最高的。
附:官方對lwjgl3記憶體分配策略FAQ
https://github.com/LWJGL/lwjgl3-wiki/wiki/1.3.-Memory-FAQ
附:MyMemUtil.java
package net.scaventz.test.mem; import sun.misc.Unsafe; import java.lang.reflect.Field; import java.nio.Buffer; import java.nio.ByteBuffer; /** * @author scaventz * @date 2020-10-12 */ public class MyMemUtil { private static Unsafe UNSAFE = getUnsafe(); static { System.loadLibrary("mydll"); } public static native ByteBuffer newDirectByteBuffer(long address, long capacity); /** * 不考慮跨平臺的情況 * * @param directByteBuffer * @return * @throws NoSuchFieldException */ public static long getVboBufferAddress1(ByteBuffer directByteBuffer) throws NoSuchFieldException { Field address = Buffer.class.getDeclaredField("address"); long addressOffset = UNSAFE.objectFieldOffset(address); long addr = UNSAFE.getLong(directByteBuffer, addressOffset); return addr; } /** * 考慮跨平臺的情況 * * @param directByteBuffer * @return * @throws NoSuchFieldException */ public static long getVboBufferAddress2(ByteBuffer directByteBuffer) throws NoSuchFieldException { long MATIC_ADDRESS = 720519504; ByteBuffer helperBuffer = newDirectByteBuffer(MATIC_ADDRESS, 0); long offset = 0; while (true) { long candidate = UNSAFE.getLong(helperBuffer, offset); if (candidate == MATIC_ADDRESS) { break; } else { offset += 8; } } long addr = UNSAFE.getLong(directByteBuffer, offset); return addr; } private static Unsafe getUnsafe() { try { Field field = Unsafe.class.getDeclaredField("theUnsafe"); field.setAccessible(true); Unsafe unsafe = (Unsafe) field.get(null); return unsafe; } catch (Exception e) { return null; } } }