简介

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

【LWJGL3】LWJGL3的内存分配设计,第一篇,栈上分配的更多相关文章

  1. JVM之对象分配:栈上分配 &amp; TLAB分配

    1. Java对象分配流程 2. 栈上分配 2.1 本质:Java虚拟机提供的一项优化技术 2.2 基本思想: 将线程私有的对象打散分配在栈上 2.3 优点: 2.3.1 可以在函数调用结束后自行销毁 ...

  2. php7 改为从栈上分配内在的思路

    php7的特点是规则上不从堆上分配内存,改为从栈上分配内存, 因为有些场景是从堆上分配内在后,还要手动释放内存,利用栈分配内在快的特点,在有需要的时候,再在堆上分配内在 但是栈上分配的内存,不能返回, ...

  3. 【深入浅出-JVM】(7):栈上分配

    概念 对那些作用于不会逃逸出方法的对象,在分配内存时,不在将对象分配在堆内存中,而是将对象属性打散后分配在线程私有栈内存上,这样随着方法调用结束,栈上分配打散的对象也被回收掉,不在增加 GC 额外压力 ...

  4. 如何限制一个类只在堆上分配和栈上分配(StackOnly HeapOnly)

    [本文链接] http://www.cnblogs.com/hellogiser/p/stackonly-heaponly.html [题目] 如何限制一个类只在堆上分配和栈上分配? [代码]  C+ ...

  5. Java中的栈上分配

    博客搬家自https://my.oschina.net/itsyizu/blog/ 什么是栈上分配 栈上分配是java虚拟机提供的一种优化技术,基本思想是对于那些线程私有的对象(指的是不可能被其他线程 ...

  6. JVM之--Java内存结构(第一篇)

    最近在和同事朋友聊天的时候,发现一个很让人思考的问题,很多人总觉得JVM将java和操作系统隔离开来,导致很多人不用熟悉操作系统,甚至不用了解JVM本身即可完全掌握Java这一门技术,其实个人的观点是 ...

  7. JAVA高级篇(二、JVM内存模型、内存管理之第一篇)

    JVM内存结构如 Java堆(Heap),是Java虚拟机所管理的内存中最大的一块.Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建.此内存区域的唯一目的就是存放对象实例,几乎所有的对象实 ...

  8. 栈上分配存储器的方法 alloca 抽样

    声明一个局部变量,必须分配在堆栈上,但有或没有它的方法 当然,,那是 alloca 下面的代码显示了可变长度参数转换,alloca 要使用 int main(int argc, char ** arg ...

  9. (转)在.NET程序运行过程中,什么是堆,什么是栈?什么情况下会在堆(栈)上分配数据?它们有性能上的区别吗?“结构”对象可能分配在堆上吗?什么情况下会发生,有什么需要注意的吗?

    转自:http://www.cnblogs.com/xiaoyao2011/archive/2011/09/09/2172427.html 在.NET程序运行过程中,什么是堆,什么是栈? 堆也就是托管 ...

随机推荐

  1. 在Eclipse for mac中配置tomcat,使web项目自动部署到tomcat

    jdk.tomcat的配置就不多说了,网上一大堆. 一.发现问题 在eclipse中新建Dynamic Web Project,配置好本地的tomcat并写好代码后选择Run on Server,但运 ...

  2. MySQL到MsSQL的迁移工具——SSMA

    SQL Server迁移助手(SSMA)团队开发了针对MySQL的迁移助手Microsoft SQL Server Migration Assistant 2008 for MySQL.微软同时发布了 ...

  3. textfield控制光标开始位置

    //    UIView *paddingView1 = [[UIView alloc] initWithFrame:CGRectMake(0, 64, self.view.frame.size.wi ...

  4. Google File System翻译(转)

    摘要 我们设计实现了google文件系统,一个面向大规模分布式数据密集性应用的可扩展分布式文件系统.它运行在廉价的商品化硬件上提供容错功能,为大量的客户端提供高的整体性能. 尽管与现有的分布式文件系统 ...

  5. VS2012新建项目出错:未找到与约束

    VS2012中,选择新建linq to sql 类,结果出错: 未找到与约束ContractName Microsoft.VisualStudio.Text.ITextDocumentFactoryS ...

  6. postma概念与使用

    Postman是google开发的一款功能强大的网页调试与发送网页HTTP请求,并能运行测试用例的的Chrome插件.Postman作为一个chrome的插件,你可以打开chrome,在chrome ...

  7. .NET/C# 优化心得

    网上的优化千篇一律,遂干脆自己写一个,总结总结网上说的与自己的想法. 1.关于sql方面的优化,详见Mysql语句的优化 2.对于不常更新的网页,使用静态页,使用 cdn 加速. 3.关于主从同步,如 ...

  8. uvalive 3353 Optimal Bus Route Design

    题意: 给出n个点,以及每个点到其他点的有向距离,要求设计线路使得每一个点都在一个环中,如果设计的线路拥有最小值,那么这个线路就是可选的.输出这个最小值或者说明最小线路不存在. 思路: 在DAG的最小 ...

  9. 聚类分析K均值算法讲解

    聚类分析及K均值算法讲解 吴裕雄 当今信息大爆炸时代,公司企业.教育科学.医疗卫生.社会民生等领域每天都在产生大量的结构多样的数据.产生数据的方式更是多种多样,如各类的:摄像头.传感器.报表.海量网络 ...

  10. Oracle redo与undo

    Undo and redo Oracle最重要的两部分数据,undo 与redo,redo(重做信息)是oracle在线(或归档)重做日志文件中记录的信息,可以利用redo重放事务信息,undo(撤销 ...