【LWJGL3】LWJGL3的記憶體分配設計,第一篇,棧上分配

scaventz發表於2020-10-16

簡介

LWJGL (Lightweight Java Game Library 3),是一個支援OpenGL,OpenAl,Opengl ES,Vulkan等的Java繫結庫。《我的世界》便是基於LWJGL的作品。為了討論LWJGL在記憶體分配方面的設計,本文將作為一系列文章中的第一篇,用來討論在棧上進行記憶體分配的策略,該策略在LWJGL 3中體現為以 MemoryStack 類為核心的一系列API,旨在為 “容量較小, 生命週期短,而又需要頻繁分配” 的記憶體分配需求提供一個統一、易用、高效能的解決方案。本文如有理解錯誤的地方,歡迎指正。

LWJGL專案地址:https://github.com/LWJGL/lwjgl3

LWJGL官方網站:https://www.lwjgl.org/

 

預備知識

何為“繫結”

當我們說,LWJGL 是一個 OpenGL的繫結庫,這怎麼理解呢?

首先要知道, OpenGL本身已經是一個完備的圖形庫,你可以選擇直接使用它的原生(相對於LWJGL來說的)API,來進行你專案的開發。LWJGL並非要建立一個新的庫,而只是實現可以使用Java語言來進行基於OpenGL的開發。LWJGL提供的API,最後還是通過JNI呼叫native API來實現相關的功能。除了由於C和Java在語言特性上的不同,造成的一些差異外,實際上兩者的API從函式名到函式簽名,都是相同的,這是LWJGL的刻意為之,也是“繫結”一詞的內涵。

 

以下將列舉幾個原生的API,和LWJGL的API,來直觀體現這一點

 序號  原生API(C語言) LWJGL3的API(Java)
 1  

void glBindVertexArray(GLuint array);

glBindVertexArray(@NativeType("GLuint") int array)
 2  

void glBindTexture(GLenum target, GLuint texture);

void glBindTexture(@NativeType("GLenum") int target, @NativeType("GLuint") int texture)
 3  

void glGenBuffers(GLsizei n, GLuint * buffers);

int glGenBuffers()


 附OpenGL API 文件地址:http://docs.gl

 

LWJGL3 在記憶體分配方面需要解決的問題

在原生C語言的OpenGL中,如下這種記憶體分配方式是非常常見的

GLuint vbo;
glGenBuffers(1, &vbo);

上面這段程式碼,我們分配定義了一個變數vbo,這本質上是分配了一塊記憶體, glGenBuffers函式執行結束後,&vbo指向的記憶體區域將被填充一個值,這個值將用於專案中後續的操作。

 

而在Java中,要對這個API進行繫結,則需要考慮:

  1. 由於Java不存在通過&取地址的語法,因此只能傳遞long型別的值作為地址;
  2. 該地址指向的是一塊堆外記憶體,為了統一名詞,本文將統一稱之為堆外直接緩衝區;
  3. 通過JNI進行堆外直接緩衝區的申請,和上面程式碼中那樣簡單的操作相比,顯然效率是較低的,因此這樣的記憶體分配不宜頻繁進行;
  4. 因此勢必要設計為“一次分配,多次使用”。

 

我們將從堆外直接緩衝區的分配開始,逐步介紹解決這些問題的思路。

 

堆外直接緩衝區分配

這很簡單,假設我們需要4個位元組的直接堆外緩衝區,我們可以通過 ByteBuffer buffer = ByteBuffer.allocateDirect(4) 來完成分配。

 

如何得到我們申請到的,這塊直接堆外緩衝區的地址?

這相對比較困難,雖然 Buffer類(ByteBuffer 類繼承了 Buffer)內部有"address" 欄位記錄了地址資訊,但是由於以下兩個原因,我們不能直接獲取到它

  1. 該欄位的修飾符是 private,且沒有提供 public 的 API 用以獲取該欄位的值;
  2. 該欄位名字在不同平臺的JDK實現裡並不相同。

因此我們需要這樣一個方法,它能夠獲取一個堆外的指定大小的緩衝區的地址,如下這個函式的原型就是符合需求的

public static long getVboBufferAddress(ByteBuffer directByteBuffer)

 

如果不考慮跨平臺,在 Oracle JDK 上可以做如下實現,來獲取直接堆外緩衝區的地址:

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 是 sun.misc.Unsafe 類的例項,網上有許多文章已經分享瞭如何使用反射獲取Unsafe類的例項,這裡不再贅述。

 

如果要考慮跨平臺,則因為直接堆外緩衝區地址對應的欄位名在不同平臺的不同,就無法使用 Unsafe類的 objectFieldOffset 的方法來獲取欄位偏移量了。

於是需要另闢蹊徑,來獲取該欄位在物件中的偏移量,這可以採用如下步驟來做到:

  1. 先使用 JNI 提供的 的 NewDirectByteBuffer 函式,在指定地址處獲取一塊容量為0的直接堆外直接緩衝區,這裡有兩點需要理解:
    1. 該地址是指定的,即該地址值是一個魔法值。
    2. 之所以容量為0,是因為這塊緩衝區並不是要用來存東西,而只是用來幫助我們,來找到那個儲存了直接堆外緩衝區地址的欄位 (在 Oracle JDK 上是名為address的欄位)在物件記憶體佈局中的偏移量。
  2. 通過上一步操作,我們現在有了一個 ByteBuffer 物件;
  3. 對該 ByteBuffer 物件從偏移量為0的地址開始掃描,由於該物件內部肯定有一個long型欄位的值為之前指定的魔法值,因此使用魔法值進行逐個比較,就能找到該欄位,同時也就找到了該欄位在物件記憶體佈局中的偏移量。

具體實現如下(這裡的魔法值,為了方便我自己,直接採用了上面程式碼中一次執行結果的 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型別欄位,而不存在錯開的情況。在下猜測這是因為位元組對齊造成的,但不清楚是否只適用於堆外物件。

3. 如果魔法值指向的地址已經被作業系統分配過用於別的用途,是否會有難以預料的影響?

 

但無論如何,LWJGL3 確實是做了如此的實現,而且上面的程式碼, 在下也在自己的專案中做了驗證,確實能夠正常工作。

 

另外,上面程式碼中的 newDirectByteBuffer 是一個native方法,其實現如下。至於下面的原生程式碼是如何生成的,網上有許多文章進行了JNI方面的介紹,本文不再贅訴。

#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的分配和使用在OpenGL中是相當頻繁的操作,如果每次需要vbo時都進行一次堆外記憶體分配,將會大大降低 LWJGL 的執行速度。

 

LWJGL1 和 LWJGL2,以及其他類似的圖形繫結庫,都是通過分配一次緩衝區,然後將這個緩衝區快取起來,進行復用來解決的,這當然能夠解決問題。但lwjgl的作者並沒有滿足於這種做法,他認為這樣做有如下缺點:

  1. 將導致 ugly code(這可能是工程實踐的經驗,在下因為沒有使用過 lwjgl2,因此體驗不深)
  2. 快取起來的 buffer 為 static 變數,而靜態變數無可避免會導致併發問題

 

作者在 lwjgl3中,通過引入 MemoryStack 類來解決了這個問題

 

MemoryStack

我們不直接貼原始碼,而是從需求出發,從解決問題的角度,高屋建瓴地去理解 MemoryStack 的設計思路。

 

我們的需求是:

  1. 要避免頻繁的堆外記憶體分配
  2. 要避免使用單例,避免解決併發問題

不進行頻繁的分配,就意味著要進行快取,而又不能僅快取一個例項,這看似是矛盾的。

但是思考一下,如果是為每個執行緒做一個快取,就剛好能解決了這兩個問題。  

恰好ThreadLocal關鍵字就可以幫我們完成這件事。
為每個執行緒只分配一次堆外緩衝區,然後將其存放到 ThreadLocal 裡。這種方式便可同時滿足我們的上述兩點要求。

 

基於本文到此位置的敘述,如果讓我們去設計MemoryStack,它目前的樣子應該是如下這樣,讓我們為他取命叫 MyMemoryStack

public class MyMemoryStack {

    private ByteBuffer directByteBuffer;

    private static ThreadLocal<MyMemoryStack> tls = ThreadLocal.withInitial(MyMemoryStack::new);

    public MyMemoryStack() {
        directByteBuffer = ByteBuffer.allocateDirect(64 * 1024);
    }

    public static ByteBuffer get() {
        return tls.get().getDirectByteBuffer();
    }

    public ByteBuffer getDirectByteBuffer() {
        return directByteBuffer;
    }
}

 

當我們呼叫LWJGL3 的 glGenBuffers 函式時,便可以像如下這樣使用 MyMemoryStack

package net.scaventz.test.mem;

import org.lwjgl.opengl.GL15C;

import java.nio.ByteBuffer;

/**
 * @author scaventz
 * @date 2020-10-15
 */
public class MyOpenGLBinding {

    public static int glGenBuffers() {
        try {
            ByteBuffer directByteBuffer = MyMemoryStack.get();
            long address = MyMemUtil.getVboBufferAddress2(directByteBuffer);

            // 下面是 LWJGL3 提供的 API,其最終使用 JNI 呼叫了 native API,
            // 由於本文的重點不在這裡,所以無需關心它的細節
            GL15C.nglGenBuffers(1, address);
            return directByteBuffer.get();
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
            return -1;
        }
    }
}

 

package net.scaventz.test.mem;

import java.nio.ByteBuffer;

/**
 * @author scaventz
 * @date 2020-10-15
 */
public class MyMemoryStack {

    private ByteBuffer directByteBuffer;

    private static ThreadLocal<MyMemoryStack> tls = ThreadLocal.withInitial(MyMemoryStack::new);

    public MyMemoryStack() {
        directByteBuffer = ByteBuffer.allocateDirect(64 * 1024);
    }

    public static ByteBuffer get() {
        ByteBuffer directByteBuffer = tls.get().getDirectByteBuffer();
        directByteBuffer.clear();
        return directByteBuffer;
    }

    public ByteBuffer getDirectByteBuffer() {
        return directByteBuffer;
    }
}

 

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 MAGIC_ADDRESS = 720519504;
        ByteBuffer helperBuffer = newDirectByteBuffer(MATIC_ADDRESS, 0);
        long offset = 0;
        while (true) {
            long candidate = UNSAFE.getLong(helperBuffer, offset);
            if (candidate == MAGIC_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;
        }
    }
}

Main函式中,vbo1和vbo2都能正常輸出,這表明我們給 nglGenBuffers 傳遞的 address 值是正確的,MyMemoryStack 如預期正常工作。

 

大方向的問題解決了,接下來需要思考更多的細節。

在同一個執行緒中,我們經常需要分配多種不同型別,大小各異,但生命週期都很短的緩衝區,考慮如下場景:

  1. 程式啟動時,我們已經為當前執行緒分配了固定大小(比如64K bytes)的堆外直接緩衝區,堆內引用型別為 ByteBuffer;
  2. 在應用中,我們需要連續多次使用 glGenBuffers 這樣的 API 時,我們可以將整個 ByteBuffer 的地址傳入,應用能正常工作;但是,ByteBuffer首次傳入之後,下一次使用,則必須先進行clear。如果有不能立即clear的場景,則這種我們寫的 MyMemoryStack 就不適用;
  3. 這種情況下,可以採取另一種方式,將64K的這個大記憶體,視為一個棧,每次需要分配記憶體時,在這64K裡進行劃撥出一個棧楨,大小最大為64K,然後更新棧頂的位置,當 MemoryStack 的例項超出作用域時,讓其自動執行出棧操作(這可以通過讓  MemoryStack 實現 Autocloable 介面來實現
  4. 實際上 LWJGL 的 MemoryStack 正是這樣設計的,並且這也是 “Stack” 的內涵所在。

 

至此,雖然其實際的實現因為效能的原因有許多的優化帶來的複雜性,但MemoryStack 的整體設計思路就已經清晰了。

 

總結

MyMemoryStack 的設計確實相當完美:

  1. 效能方面,該設計已經做了相當大的努力;
  2. 對記憶體的分配和管理做了統一處理,程式碼結構變得清晰;
  3. 語義方面也相當優雅,特別是如果你意識到,實際上在原生API中, GLuint vbo 這樣一個操作本身就是在執行棧上分配,便更能體會到這種設計的美感。

當然 MemoryStack 並非萬金油,由於考慮到通用性,MemoryStack 棧大小不宜過大,因此不適合用來存放大容量資料。

這是 lwjgl 擁有一整套記憶體分配策略 的原因,MemoryStack只是其中之一,但任何可以使用 MemoryStack 的時候,都應該優先使用它,因為它的效率是最高的。

相關文章