【LWJGL3】LWJGL3的内存分配设计,第一篇,栈上分配

时间:2024-12-10 14:35:26

简介

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;
  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 的时候,都应该优先使用它,因为它的效率是最高的。