动态内存分配的弊端和解决
在游戏编程中,以new或者malloc在堆上进行动态内存的分配是非常低效的操作。低效的原因两个:
(1).堆分配器(heap allocation)是通用的设施,它必须处理任何大小的分配请求,从1字节到1000兆字节。这需要非常大的管理开销,导致malloc/free函数变得非常缓慢。
(2) 在多数的操作系统上, malloc/free必然会从用户模式(user mode)切换至内核模式(kernel mode),处理请求,在切换至原来的程序。这些上下文切换(context-switch)可能会耗费非常多的时间。因此,游戏开发中一个常见的经验法则是: 维持最低限度的堆分配,并且永不在紧凑循环中使用堆分配
以上摘抄自《游戏引擎架构》5.2内存管理
为了解决上面动态分配内存的弊端,可以采取定制分配器----本质上就是动态的预先分配一大片内存,然后在这一大片预先分配的内存中随时可以取出我们需要的,要多少使用多少,不要了在“还”回去。
定制分配器(CustomAllocator)存在很多种, 比如堆栈分配器(StatckAllocator),池分配器(PoolAllocator),单帧分配器(single-frame allocator),双缓冲分配器(double-buffered allocator). 下面的博客主要是介绍堆栈分配器(StatckAllocator)和池分配器(PoolAllocator)的实现。
堆栈分配器(StatckAllocator)
堆栈分配器,采用堆栈的数据结构。预先分配一大块连续的内存(用new), 并且以byte(unsigned char)为基本单位
堆栈的数据结构:
typedef unsigned char byte;
class StackAllocator
{
private:
//默认1024 byte - 1M 大小
static const size_t DEFAULT_SIZE = 1024;
byte* dataPtr = nullptr;
byte* headPtr = nullptr;
size_t size;
//析构对象Vector
vector<StackAllocatorDestructor> DestructorObjectArray;
public:
StackAllocator(size_t inDataSize = DEFAULT_SIZE):
size(inDataSize)
{
dataPtr = new byte[inDataSize];
headPtr = dataPtr;
}
}
预选分配内存以及构建新对象
安排指针headPtr 指向堆栈的顶端,指针以下是未分配的内存,指针以上是已经分配的内存. 当需要分配新对象时,在预先分配的内存上构建新对象,把headptr指针往下移动
template<typename ObjectType, typename...Args>
ObjectType* Allocate(size_t numObject, Args...args)
{
if (numObject <= 0)
return nullptr;
size_t totalObjectSize = sizeof(ObjectType) * numObject;
if(headPtr + totalObjectSize <= dataPtr + size)
{
ObjectType* initObjectPtr = reinterpret_cast<ObjectType*>(headPtr);
headPtr = headPtr + totalObjectSize;
for (size_t index = 0; index < numObject; ++index)
{
ObjectType* newObjectPtr = new (std::addressof(initObjectPtr[index])) ObjectType(std::forward<Args>(args)...);
AddDestroyObjectToArray(newObjectPtr);
}
return initObjectPtr;
}
else
{
return nullptr;
}
}
析构对象集合
注意在添加新对象的时候进行AddDestroyObjectToArray,把新对象添加到析构对象集合里,为了后面在不释放内存的情况下调用对象的析构函数
//析构对象Vector
vector<StackAllocatorDestructor> DestructorObjectArray;
//存在默认构造函数
template<typename ObjectType>
typename std::enable_if<std::is_trivially_destructible<ObjectType>::value>::type
AddDestroyObjectToArray(ObjectType* ObjectPtr)
{
}
//不存在默认构造函数
template<typename ObjectType>
typename std::enable_if<!std::is_trivially_destructible<ObjectType>::value>::type
AddDestroyObjectToArray(ObjectType* ObjectPtr)
{
DestructorObjectArray.push_back(StackAllocatorDestructor(*ObjectPtr));
}
“释放”已分配对象
这里 “释放”之所以加了双引号,是因为预先分配的内存并没有释放,“释放”已分配对象是回退headptr指针的位置,往前移动,并且调用已分配对象的析构函数。当然,堆栈分配器的释放策略采用了 “标记(Marker)”的办法,就是在已经分配堆栈地址上做一个标记,代表了目前堆栈的顶端。 利用地址标志(StackAllocatorMarker)返回到这个地址上,也就是headptr回退为标志所在的地址,并且从当前地址到标志所在的地址所构建的所有对象都得调用析构函数,如下图所示:
class StackAllocatorMarker
{
private:
byte* headPtr = nullptr;
size_t allocateObjectNum = 0;
public:
StackAllocatorMarker(byte* inHeadPtr, size_t inAllocateObjectNum) :
headPtr(inHeadPtr),
allocateObjectNum(inAllocateObjectNum)
{
}
byte* GetHeadPtr() const
{
return headPtr;
}
size_t GetAllocateObjectNum() const
{
return allocateObjectNum;
}
};
class StackAllocator
{
StackAllocatorMarker GetAllocatorMarker()
{
return StackAllocatorMarker(headPtr, DestructorObjectArray.size());
}
void ReleaseToMarker(const StackAllocatorMarker& allocatorMarker)
{
headPtr = allocatorMarker.GetHeadPtr();
for (size_t index = DestructorObjectArray.size(); index > allocatorMarker.GetAllocateObjectNum(); --index)
{
DestructorObjectArray.back()();
DestructorObjectArray.pop_back();
}
}
void ReleaseAll()
{
headPtr = dataPtr;
for (size_t index = 0; index < DestructorObjectArray.size(); ++index)
{
DestructorObjectArray.back()();
DestructorObjectArray.pop_back();
}
}
}
代码示例
class Object
{
private:
int age;
public:
Object(int inAge):
age(inAge)
{
}
void PrintInfo()
{
printf("age = %d\n", age);
}
~Object()
{
printf("destruction\n");
}
};
int main()
{
StackAllocator stackAllocator;
StackAllocatorMarker maker1 = stackAllocator.GetAllocatorMarker();
Object* objectPtr = stackAllocator.Allocate<Object>(10, 1);
for (size_t index = 0; index < 10; ++index)
{
objectPtr[index].PrintInfo();
}
objectPtr[10].PrintInfo();
stackAllocator.ReleaseToMarker(maker1);
system("pause");
return 0;
}
上面的输出的结果中 前10个 “age”都是1,而第11个age是错乱结果。这是因为我仅仅分配10个object对象,第11个未分配。
内存对齐
很多处理器实际上只能正常的读/写已经对齐的数据块。在不对齐的数据块读取数据,会读取多个对齐数据,对多个数据块的数据进行(mark)和移位(shift),并进行or操作得到最后需要的值,有些消耗。如分别在0x6A341174或0x6A341173读取一个4字节数据,像下面所示,在0x6A341174读取数据一次操作,而0x6A341173读取数据多次操作,消耗比对齐的明显多些。
所以分配数据初始地址得对齐,对齐内存,《游戏引起架构》给出了一种利用掩码(mask)方法,代码如下:
内存对齐方法一:
typedef unsigned char byte;
typedef unsigned int uint32;
// alignSize为对齐大小,必须是2的幂指数(一般是4或者16)
void* GetAlignAadressOne(void* address, byte alignSize)
{
uint32 rawAddress = reinterpret_cast<uint32>(address);
// 使用掩码去除地址低位部分,计算“错位”量,从而计算调整量
uint32 mask = (alignSize - 1);
uint32 misalignment = (rawAddress & mask);
uint32 adjustment = alignSize - misalignment;
// 计算调整后的地址,并把它以指针类型返回
uint32 alignedAdress = rawAddress + adjustment;
return (void*)alignedAdress;
}
内存对齐方法二:
Game Engine Tutorial [005] - Stack Allocator Praxis利用求余数的方法实现内存对齐,对比上面那种实现,说实话我更喜欢求余数的实现,我尝试过,这种方法得到的地址总是对齐并且不存在多余的地址偏移,而《游戏引擎架构》实现的方式也是对齐的,但是有时候多出了一个对齐单位的偏移
代码如下:
// alignSize为对齐大小,必须是2的幂指数(一般是4或者16)
void* GetAlignAadressTwo(void* address, byte alignSize)
{
uint32 rawAddress = reinterpret_cast<uint32>(address);
uint32 alignAdress = rawAddress + alignSize - 1;
alignAdress -= (alignAdress % alignSize);
return (void*)alignAdress;
}
代码示例
class Object
{
private:
int a;
bool b;
int c;
public:
Object()
{
}
};
int main()
{
cout << "object size = " << sizeof(Object) << endl;
Object* a = new Object;
cout << "raw adress = " << a << endl;
void* aligned1 = GetAlignAadressOne((void*)a, 8);
void* aligned2 = GetAlignAadressTwo((void*)a, 8);
cout << "one align adress1 = " << aligned1 << endl;
cout << "one align adress2 = " << aligned2 << endl;
system("pause");
return 0;
}
参考资料
[1].《游戏引擎架构》5.2章节-内存管理
[2].Game Engine Tutorial [004] - Stack Allocator Theorie
[3].Game Engine Tutorial [005] - Stack Allocator Praxis