我打算用Java为我的粒子系统实现一个对象池,然后在Wikipedia上找到了它。换句话说,它说不值得在Java和C#等托管语言中使用对象池,因为与非托管语言(如C ++)中的数百种分配相比,分配只需要进行数十次操作。
但是众所周知,每条指令都可能会损害游戏性能。例如,一个MMO中的客户端池:客户端进入和退出池的速度不会太快。但是粒子可能在一秒钟内更新数十次。
问题是:使用对象池来管理语言中的粒子(特别是那些死掉并很快被重新创建的粒子)是否值得?
我打算用Java为我的粒子系统实现一个对象池,然后在Wikipedia上找到了它。换句话说,它说不值得在Java和C#等托管语言中使用对象池,因为与非托管语言(如C ++)中的数百种分配相比,分配只需要进行数十次操作。
但是众所周知,每条指令都可能会损害游戏性能。例如,一个MMO中的客户端池:客户端进入和退出池的速度不会太快。但是粒子可能在一秒钟内更新数十次。
问题是:使用对象池来管理语言中的粒子(特别是那些死掉并很快被重新创建的粒子)是否值得?
Answers:
是的。
分配时间不是唯一的因素。分配会产生副作用,例如引发垃圾回收通过,这不仅会对性能产生负面影响,而且还会对性能产生不可预测的影响。具体取决于您的语言和平台选择。
池化通常还可以提高池中对象的引用局部性,例如,将它们全部保留在连续的数组中。这可以在迭代池的内容(或至少其活动部分)的同时提高性能,因为迭代中的下一个对象将倾向于已经在数据高速缓存中。
即使在托管语言中(尤其是在使用XNA的360上),尝试避免在游戏的最内层循环中进行任何分配的传统智慧仍然适用。原因只是略有不同。
对于Java来说,合并对象不是很有用*,因为对于仍然存在的对象,第一个GC周期会将它们重新洗改到内存中,将它们移出“ Eden”空间,并可能在此过程中失去空间局部性。
- 在任何语言中,池化复杂的资源总是非常有用,因为破坏和创建类似的线程非常昂贵。这些可能值得合并,因为创建和销毁它们的开销几乎与与资源的对象句柄关联的内存无关。但是,粒子不适合此类别。
当您将对象快速分配到Eden空间时,Java使用顺序分配器提供快速的突发分配。这种顺序分配策略非常快,比malloc
C语言中的要快,因为它只是池化已经以直接顺序方式分配的内存,但是它的缺点是您无法释放单个内存块。如果您只是想为数据结构超快速地分配东西,而您不需要从中删除任何东西,只需添加所有东西然后使用它,然后再将整个东西扔掉,这也是C语言中的一个有用技巧。
由于无法释放单个对象的不利影响,Java GC在第一个周期后将使用较慢的,用途更广泛的内存分配器将从Eden空间分配的所有内存复制到新的内存区域,该分配器的确允许内存被释放在不同线程中的单个块中。然后,它可以将整个在Eden空间中分配的内存扔掉,而不必理会现在已复制并驻留在内存中其他位置的单个对象。在第一个GC周期之后,您的对象最终可能会在内存中碎片化。
由于对象可能会在第一个GC周期之后最终变得碎片化,因此对象池的好处主要是为了改善内存访问模式(引用的局部性)并减少分配/取消分配的开销,因此损失了很多。通常,只要始终分配新粒子并在它们在伊甸园空间中还很新鲜之前以及它们变得“旧”并可能散布在内存中之前使用它们,您通常会获得更好的参考位置。但是,非常有用的方法(例如使性能与Java中的C相匹敌)是避免为粒子使用对象,并池化原始的原始原始数据。举一个简单的例子,而不是:
class Particle
{
public float x;
public float y;
public boolean alive;
}
做类似的事情:
class Particles
{
// X positions of all particles. Resize on demand using
// 'java.util.Arrays.copyOf'. We do not use an ArrayList
// since we want to work directly with contiguously arranged
// primitive types for optimal memory access patterns instead
// of objects managed by GC.
public float x[];
// Y positions of all particles.
public float y[];
// Alive/dead status of all particles.
public bool alive[];
}
现在要为现有粒子重用内存,您可以执行以下操作:
class Particles
{
// X positions of all particles.
public float x[];
// Y positions of all particles.
public float y[];
// Alive/dead status of all particles.
public bool alive[];
// Next free position of all particles.
public int next_free[];
// Index to first free particle available to reclaim
// for insertion. A value of -1 means the list is empty.
public int first_free;
}
现在,当nth
粒子死亡时,为了使其可重复使用,将其推入空闲列表,如下所示:
alive[n] = false;
next_free[n] = first_free;
first_free = n;
添加新粒子时,请查看是否可以从空闲列表中弹出索引:
if (first_free != -1)
{
int index = first_free;
// Pop the particle from the free list.
first_free = next_free[first_free];
// Overwrite the particle data:
x[index] = px;
y[index] = py;
alive[index] = true;
next_free[index] = -1;
}
else
{
// If there are no particles in the free list
// to overwrite, add new particle data to the arrays,
// resizing them if needed.
}
这不是最令人愉快的代码,但是使用此代码,您应该能够获得非常快速的粒子模拟,并且顺序粒子处理始终非常易于缓存,因为所有粒子数据将始终连续存储。这种类型的SoA代表还减少了内存使用,因为我们不必担心填充,用于反射/动态分派的对象元数据,并且可以将热场与冷场分开(例如,我们不必担心数据在物理过程中,像粒子颜色这样的字段会通过,因此将其加载到缓存行中只会浪费(不使用并逐出它)是浪费的)。
为了使代码更易于使用,可能值得编写自己的基本可调整大小的容器,这些容器存储浮点数组,整数数组和布尔数组。同样,您不能使用泛型,并且ArrayList
这里(至少自上次检查以来)不能使用泛型,因为这需要使用GC管理的对象,而不是连续的原始数据。我们要使用的连续数组int
,例如,不是由GC管理的数组,Integer
在离开Eden空间后,它们不一定是连续的。
使用原始类型的数组,始终可以保证它们是连续的,因此您将获得极为理想的引用局部性(对于顺序粒子处理而言,它带来了很大的不同)以及对象池旨在提供的所有好处。对于一个对象数组,它有点类似于一个指针数组,该数组开始以连续的方式指向对象,假设您一次将它们全部分配到Eden空间中,但是在GC循环之后,可以指向整个对象。放置在内存中。