【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)方法来获取到这个字段的值。于是问题变成了,如何获取该字段在对象中的偏移量,这可以采用如下步骤来做到:

  1. 先使用 JNI 提供的 的 NewDirectByteBuffer 函数,在指定地址上获取一块容量为0的直接堆外直接缓冲区,这里有两点需要理解:
    1. 因为地址是指定的,所以该地址值是一个魔法值。
    2. 之所以容量为0,是因为这块缓冲区并不是要用来存东西,而只是辅助用于寻找 “address”(当然实际可能不是这个名字)字段在对象中的偏移量。
  2. 对返回的 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的作者并没有满足于这种做法,他认为这样做有如下缺点:

  1. 将导致 ugly code(这可能是工程实践的经验,在下因为没有使用过 lwjgl2,因此体验不深)
  2. 缓存起来的 buffer 为 static 变量,而静态变量无可避免会导致并发问题

 

作者在 lwjgl3中,通过引入 MemoryStack 类来解决了这个问题

 

MemoryStack

我们不直接介绍 MemoryStack,而是介绍一下大体思路,渐进地理解 MemoryStack 是基于怎样地考虑设计出来的。

整理一下现状:

  1. 要避免频繁的堆外内存分配
  2. 要避免使用单例,规避并发问题

 

有没有联想到什么东东呢,对了,这时候ThreadLocal关键字就可以派上用场了。
为每个线程只分配一次堆外缓冲区,然后将其存放到ThreadLocal里。这种方式便可同时满足我们的上述两点要求。

但这依然不够

因为在同一个线程中,我们经常要分配许多的缓冲区,这些缓冲区的大小各异,而且生命周期各不相同,考虑如下场景:

 【LWJGL3】LWJGL3 是怎样在栈上进行内存分配的

要怎么做呢。

"栈"这种数据结构,就很适合用来对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)));
    ……
}

 

这确实是一个十分完美的做法:

  1. 性能方面,该设计已经做了最大的努力
  2. 语义方面也相当优雅,特别是如果你意识到,实际上在原生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;
        }
    }
}

 

上一篇:[Java]对NIO中非阻塞式编程的个人浅见


下一篇:【java】面试官问我,如何实现一个自定义序列化