默认分配器及其性能测试
一、概述
项目介绍
在 C/C++ 中,内存管理是一个非常棘手的问题,我们在编写一个程序的时候几乎不可避免的要遇到内存的分配逻辑,这时候随之而来的有这样一些问题:是否有足够的内存可供分配? 分配失败了怎么办? 如何管理自身的内存使用情况? 等等一系列问题。在一个高可用的软件中,如果我们仅仅单纯的向操作系统去申请内存,当出现内存不足时就退出软件,是明显不合理的。正确的思路应该是在内存不足的时,考虑如何管理并优化自身已经使用的内存,这样才能使得软件变得更加可用。本次项目我们将实现一个内存池,并使用一个栈结构来测试我们的内存池提供的分配性能。最终,我们要实现的内存池在栈结构中的性能,要远高于使用 std::allocator 和 std::vector
项目涉及的知识点
- C++ 中的内存分配器
std::allocator
- 内存池技术
- 手动实现模板链式栈
- 链式栈和列表栈的性能比较
二、内存池简介
内存池是池化技术中的一种形式。通常我们在编写程序的时候回使用 new
delete
这些关键字来向操作系统申请内存,而这样造成的后果就是每次申请内存和释放内存的时候,都需要和操作系统的系统调用打交道,从堆中分配所需的内存。如果这样的操作太过频繁,就会找成大量的内存碎片进而降低内存的分配性能,甚至出现内存分配失败的情况。
而内存池就是为了解决这个问题而产生的一种技术。从内存分配的概念上看,**内存申请无非就是向内存分配方索要一个指针,当向操作系统申请内存时,操作系统需要进行复杂的内存管理调度之后,才能正确的分配出一个相应的指针。**而这个分配的过程中,我们还面临着分配失败的风险。
所以,每一次进行内存分配,就会消耗一次分配内存的时间,设这个时间为 T,那么进行 n 次分配总共消耗的时间就是 nT;如果我们一开始就确定好我们可能需要多少内存,那么在最初的时候就分配好这样的一块内存区域,当我们需要内存的时候,直接从这块已经分配好的内存中使用即可,那么总共需要的分配时间仅仅只有 T。当 n 越大时,节约的时间就越多。
三、主函数设计
我们要设计实现一个高性能的内存池,那么自然避免不了需要对比已有的内存,而比较内存池对内存的分配性能,就需要实现一个需要对内存进行动态分配的结构(比如:链表栈),为此,在 /home/shiyanlou/
目录下新建 main.cpp
文件,并向其中添加如下的代码:
#include <iostream> // std::cout, std::endl
#include <cassert> // assert()
#include <ctime> // clock()
#include <vector> // std::vector
#include "MemoryPool.hpp" // MemoryPool<T>
#include "StackAlloc.hpp" // StackAlloc<T, Alloc>
// 插入元素个数
#define ELEMS 10000000
// 重复次数
#define REPS 100
int main()
{
clock_t start;
// 使用 STL 默认分配器
StackAlloc<int, std::allocator<int> > stackDefault;
start = clock();
for (int j = 0; j < REPS; j++) {
assert(stackDefault.empty());
for (int i = 0; i < ELEMS; i++)
stackDefault.push(i);
for (int i = 0; i < ELEMS; i++)
stackDefault.pop();
}
std::cout << "Default Allocator Time: ";
std::cout << (((double)clock() - start) / CLOCKS_PER_SEC) << "\n\n";
// 使用内存池
StackAlloc<int, MemoryPool<int> > stackPool;
start = clock();
for (int j = 0; j < REPS; j++) {
assert(stackPool.empty());
for (int i = 0; i < ELEMS; i++)
stackPool.push(i);
for (int i = 0; i < ELEMS; i++)
stackPool.pop();
}
std::cout << "MemoryPool Allocator Time: ";
std::cout << (((double)clock() - start) / CLOCKS_PER_SEC) << "\n\n";
return 0;
}
在上面的两段代码中,StackAlloc
是一个链表栈,接受两个模板参数,第一个参数是栈中的元素类型,第二个参数就是栈使用的内存分配器。
因此,这个内存分配器的模板参数就是整个比较过程中唯一的变量,使用默认分配器的模板参数为 std::allocator<int>
,而使用内存池的模板参数为 MemoryPool<int>
。
std::allocator 是 C++标准库中提供的默认分配器,他的特点就在于我们在 使用 new 来申请内存构造新对象的时候,势必要调用类对象的默认构造函数,而使用 std::allocator 则可以将内存分配和对象的构造这两部分逻辑给分离开来,使得分配的内存是原始、未构造的。
下面我们来实现这个链表栈。
四、模板链表栈
栈的结构非常的简单,没有什么复杂的逻辑操作,其成员函数只需要考虑两个基本的操作:入栈、出栈。为了操作上的方便,我们可能还需要这样一些方法:判断栈是否空、清空栈、获得栈顶元素。
StackAlloc.h
#pragma once
#include <memory>
template <typename T>
struct StackNode_ {
T data;
StackNode_* prev;
};
// T 为存储的对象类型, Alloc 为使用的分配器, 并默认使用 std::allocator 作为对象的分配器
template <typename T, typename Alloc = std::allocator<T> >
class StackAlloc {
public:
typedef StackNode_<T> Node;
typedef typename Alloc::template rebind<Node>::other allocator;
//默认构造
StackAlloc() {
head_ = 0;
}
//默认析构
~StackAlloc(){
clear();
}
//当栈中元素为空时返回true
bool empty() {
return head_ == 0;
}
//释放栈中所有元素
void clear() {
Node* curr = head_;
//依次出栈
while (curr != 0){
Node* tmp = curr->prev;
allocator_.destroy(curr);
allocator_.deallocate(curr, 1);
curr = tmp;
}
head_ = 0;
}
//压栈
void push(T element) {
Node* newNode = allocator_.allocate(1);
//调用节点的构造函数
allocator_.construct(newNode, Node());
//入栈操作
newNode->data = element;
newNode->prev = head_;
head_ = newNode;
}
//出栈
T pop() {
//出栈操作,返回出栈元素
T result = head_->data;
Node* tmp = head_->prev;
allocator_.destroy(head_);
allocator_.deallocate(head_, 1);
head_ = tmp;
return result;
}
//返回栈顶元素
T top() {
return head_->data;
}
private:
allocator allocator_;
//栈顶
Node* head_;
};
实现高性能内存池
三、设计内存池
在上一节实验中,我们在模板链表栈中使用了默认构造器来管理栈操作中的元素内存,一共涉及到了 **rebind < T >::other, allocate(), dealocate(), construct(), destroy()**这些关键性的接口。所以为了让代码直接可用,我们同样应该在内存池中设计同样的接口:
#ifndef MEMORY_POOL_HPP
#define MEMORY_POOL_HPP
#include <climits>
#include <cstddef>
template <typename T, size_t BlockSize = 4096>
class MemoryPool {
public:
//使用typedef 简化类型书写
typedef T* pointer;
//定义rebind<U>::other 接口
template <typename U>
struct rebind {
typedef MemoryPool<U> other;
};
// 默认构造, 初始化所有的槽指针
// C++11 使用了 noexcept 来显式的声明此函数不会抛出异常
MemoryPool() noexcept {
currentBlock_ = nullptr;
currentSlot_ = nullptr;
lastSlot_ = nullptr;
freeSlot_ = nullptr;
}
//销毁一个现有的内存池
~MemortyPool() noexcept {
//循环销毁内存池中分配的内存区块
slot_pointer_ curr = currentBlock_;
while (curr != nullptr) {
slot_pointer_ next = curr->next;
operator delete(reinterpret_cast<void*>(curr));
curr = next;
}
}
// 同一时间只能分配一个对象, n 和 hint 会被忽略
pointer allocate(size_t n = 1, const T* hint = 0) {
//如果有空闲的对象槽,那么直接将空闲区域交付出去
if (freeSlot_ != nullptr) {
pointer result = reinterpret_cast<pointer>(freeSlot_);
freeSlot_ = freeSlot_->next;
return result;
}
else {
//如果对象槽不够用,则分配一个新的内存区块
if (currentSlot_ >= lastSlot_) {
//分配一个新的内存区块,并指向前一个内存区块
data_pointer_ newBlock = reinterpret_cast<data_pointer_>(operator new(BlockSize));
reinterpret_cast<slot_pointer_>(newBlock)->next = currentBlock_;
currentBlock_ = reinterpret_cast<slot_pointer_>(newBlock);
// 填补整个区块来满足元素内存区域的对齐要求
data_pointer_ body = newBlock + sizeof(slot_pointer_);
uintptr_t result = reinterpret_cast<uintptr_t>(body);
size_t bodyPadding = (alignof(slot_type_) - result) % alignof(slot_type_);
currentSlot_ = reinterpret_cast<slot_pointer_>(body + bodyPadding);
lastSlot_ = reinterpret_cast<slot_pointer_>(newBlock + BlockSize - sizeof(slot_type_));
}
return reinterpret_cast<pointer>(currentSlot_++);
}
}
// 销毁指针 p 指向的内存区块
void deallocate(pointer p, size_t n = 1) {
if (p != nullptr) {
// reinterpret_cast 是强制类型转换符
// 要访问 next 必须强制将 p 转成 slot_pointer_
reinterpret_cast<slot_pointer_>(p)->next = freeSlot_;
freeSlot_ = reinterpret_cast<slot_pointer_>(p);
}
}
// 调用构造函数
template <typename U, typename... Args>
void construct(U* p, Args&& ... args) {
new (p) U(std::forward<Args>(args)...);
}
// 销毁内存池中的对象, 即调用对象的析构函数
template <typename U>
void destroy(U* p) {
p->~U();
}
private:
// 用于存储内存池中的对象槽,
// 要么被实例化为一个存放对象的槽,
// 要么被实例化为一个指向存放对象槽的槽指针
union Slot_ {
T element;
Slot_* next;
};
//数据指针
typedef char* data_pointer_;
//对象槽
typedef Slot_ slot_type_;
//对象槽指针
typedef Slot_* slot_pointer_;
//指向当前内存块
slot_pointer_ currentBlock_;
//指向当前内存区块的一个对象槽
slot_pointer_ currentSlot_;
//指向当前内存区块的最后一个对象槽
slot_pointer_ lastSlot_;
//指向当前内存区块中的空闲对象槽
slot_pointer_ freeSlot_;
//检查定义的内存池大小是否过小
static_assert(BlockSize >= 2 * sizeof(slot_type_), "BlockSize too small.");
};
#endif
附录
C/C++ assert()函数用法总结 https://www.cnblogs.com/lvchaoshun/p/7816288.html
allocate 从主存分配出空间 但是还没有构造 也就是里边啥也没装。 (这一块大空间也叫内存池)
deallocate 是将分配出的空间(没有被使用的空间) 放回主存。
而 construct 是将已经从主存分配出的空间(内存池) 取适当的大小"构造" 出一个特定的值。
destroy 是将已经被构造的空间(已有值) 析构成没有被使用的空间放回内存池(allocate的大小 小于 128字节的时候 大于128会调用free放回主存)。
/*
allocator<T> a; 定义名为a的allocator对象,可以分配内存或构造T类型的对象。
a.allocate(n); 分配原始的构造内存以保存T类型的n个对象.
a.deallocate( p, n ) 释放内存,在名为p的T指针中包含的地址处保存T类型的n个对象。
a.construct( p, t ) 在T指针p所指向的内存中构造一个新元素。运行T类型的复制构造函数用t初始化该对象
a.destroy(p) 运行T*指针p所指向的对象的析构函数
*/
#include <memory>
#include <iostream>
using namespace std;
class Animal
{
public:
#if 1 //即使为0,没有默认构造也是可以,
Animal() : num(0)
{
cout << "Animal constructor default" << endl;
}
#endif
Animal(int _num) : num(_num)
{
cout << "Animal constructor param" << endl;
}
~Animal()
{
cout << "Animal destructor" << endl;
}
void show()
{
cout << this->num << endl;
}
private:
int num;
};
int main()
{
allocator<Animal> alloc; //1.
Animal *a = alloc.allocate(5); //2.
//3.
alloc.construct(a, 1);
alloc.construct(a + 1);
alloc.construct(a + 2, 3);
alloc.construct(a + 3);
alloc.construct(a + 4, 5);
//4.
a->show(); // 1
(a + 1)->show(); // 0
(a + 2)->show(); // 3
(a + 3)->show(); // 0
(a + 4)->show(); // 5
//5.
for (int i = 0; i < 5; i++)
{
alloc.destroy(a + i);
}
//对象销毁之后还可以继续构建,因为构建和内存的分配是分离的
//6.
alloc.deallocate(a, 5);
cin.get();
return 0;
}
- 问题一:定义这个
typedef char* data_pointer_
有什么作用,在定义变量newBlock时,为什么不直接用slotpointer去定义,而且之后的currentBlock_ = reinterpret_cast<slot_pointer_>(newBlock) 语句 又把datapointer类型转换成了slotpointer,所以定义datapointer 是不是多余的? - 问题二:内存区域对齐这里, size_t bodyPadding = (alignof(slottype) - result) % alignof(slottype) 我没理解为什么要这么做,大部分情况下(alignof(slottype) - result)的值都是负的,而通过size_t转换之后,会变成一个很大的正数,这应该怎么理解?
将 data_pointer_ 强转为 slot_pointer_ 并不意味着两者就是同样的东西,一个 Block 里面可以包含若干个 Slot,而把一块 Block 内存强行转换到 Slot 内存,指针只会去解释一个 Slot大小的内存空间,而后面的空间并没有被使用,所以 data_pointer_ 并非多余,data_pointer_ 的任务是去申请一块 Block, 而之后使用这块内存是通过强转为 slot_pointer_ 来进行的,因此非常有必要的。
在一个 Block 中,第一个 Slot 会被解释为一个 slot_pointer_,并指向前一个申请的 Block。而一个指针占用的内存空间和一个模板参数所占用的空间并不相同,所以要求得一个 Block 内的第一个 Slot,所以需要额外将整个区块进行填补,满足内存对齐(所以 body 是由 newBlock 与 sizeof(slot_pointer_) 而来,而非 sizeof(slot_type_))。
此外解答关于 size_t 的疑问,size_t 其本身是一个无符号的类型,在解释某个结果的二进制序列负数时候时自然会将其表示为一个很大的正数,而最后参与运算时,还是会进行隐式转换。不过要注意的是,这里的 bodyPadding 并不是负数(并且大部分情况下计算结果均为 0)。
alignof & alignas 内存对齐 sizeof 占内存大小
直接上代码测试是入门神器,以结构体为例,解释“对齐”和“补齐”概念。
#include <iostream>
struct Empty {};
struct Foo {
int f2;
double f1;
char c;
};
struct alignas(16) FooNew
{
int f2;
double f1;
char c;
};
int main()
{
std::cout << "alignment of empty class: " << alignof(Empty) << '\n'
<< "alignment of pointer : " << alignof(int*) << '\n'
<< "alignment of char : " << alignof(char) << '\n'
<< "alignment of Foo : " << alignof(Foo) << '\n'
<< "Size of Foo: " << sizeof(Foo) << '\n'
<< "alignment of FooNew : " << alignof(FooNew) << '\n';
}
结果是:
alignment of empty class: 1
alignment of pointer : 8
alignment of char : 1
alignment of Foo : 8
Size of Foo: 24
alignment of FooNew : 16
总之,对齐是某种类型的初始位置在内存上的限定,补齐是对该类型大小的限定,两者共同组成了该类型在内存上的排布规则,提高操作效率。
#include <iostream>
//以起始位置分析
class node1 {
//未指定内存对齐,默认以类中占用最大的元素大小对齐,x64系统,所以p指针会占用8个字节
char c; //c的起始位置为0,占用1,占用位置为0
char* p; //p起始位置需要是8的倍数,所以占用位置为8~15(实测是4个字节)
int a; //a的起始位置需要是4的倍数,即16~19
short b; //b的起始位置需要是2的倍数,即20~21
};
// 根据上面的分析,node1的占用应该是从0~21即22个字节,由于整个类需要以8字节对齐,即占用需要是8的倍数,所以总共占用应该是24。
// 以占用分析
class node2 {
// 未指定内存对齐,默认以类中占用最大的元素大小对齐
// 如果里面含有结构体或类类型,则该类型的对齐大小以其中的占用最大元素大小来定,即8
int a; //a大小为4字节,按4字节对齐,占用8字节内存块的前4字节
char b; //b大小为1,按1字节对齐,由于后面的float类型的c需要4个字节
float c; //所以,而这个8字节的内存块放不下,需要将c存到下一个内存块
node1 n; //导致b与c之间会有3个字节的空闲。n占用24个字节,显然c后面剩余的那个
//内存块的4节点放不下,n也需要存到下一个内存块
};
// 根据上面的分析node2占用应该为4+1+3+8+24=40
int main(void)
{
std::cout << "node1: " << sizeof(node1) << std::endl;
std::cout << "node2: " << sizeof(node2) << std::endl;
std::cout << alignof (node2) << std::endl;
std::cout << sizeof (short) << std::endl;
return 0;
}
C++11的左值引用与右值引用总结
左值和右值
- 变量本质即存储空间的名称,编译后变为对应地址。
- 左值是可以放在赋值号左边可以被赋值的值;左值必须要在内存中有实体;
- 右值当在赋值号右边取出值赋给其他变量的值;右值可以在内存也可以在CPU寄存器。
- 一个对象被用作右值时,使用的是它的内容(值),被当作左值时,使用的是它的地址。
左值引用
一个C++引用声明后必须被初始化,否则编译不过,初始化之后就相当于一个变量(地址为初始化时所引用变量的地址)。由于拥有共同的地址,而且也是同一个类型,所以对其操作就相当于对原对象的操作,用法和普通变量相同。与指针最大的区别:**指针是一种数据类型,而引用不是。**当其用作函数传参时,传递的就是变量的左值即地址。
#include<iostream>
using namespace std;
void change(int & a){
a=12;
cout<<"引用类型的形参a地址是: "<<&a<<endl;
}
int main() {
int a = 3;
int & ref_a = a; //引用类型的变量 定义的时候 必须初始化,否则编译不过;
cout << "a 地址: " << &a << endl;
cout << "ref_a 地址:" << &ref_a << endl;
int * p_a = &a;
ref_a = 5;
cout << "a is: " << a << endl;
*p_a = 8;
cout << "赋值 *p_a为 8, then a is: " << a << endl;
cout << "引用 ref_a is: " << ref_a << endl;
int c = 33;
ref_a = c; //引用类型初始化后,不能再引用别的对象,等价于其所引用的类型的变量;
cout << "c 地址:" << &c << endl;
cout << "ref_a 地址: " << &ref_a << endl;
cout << "a is:" << a << endl;
change(a);
cout << "a is:" << a << endl;
}
输出为:
a 地址: 0x28fed4
ref_a 地址:0x28fed4
a is: 5
赋值 *p_a为 8, then a is: 8
引用 ref_a is: 8
c 地址:0x28fed0
ref_a 地址: 0x28fed4
a is:33
引用类型的形参a地址是: 0x28fed4
a is:12
函数返回值为引用类型,这个使用也比较常见
例子:
#include<iostream>
/* 函数返回值时会产生一个临时变量作为函数返回值的副本,而返回引用时不会产生值的副本 */
int & fun() {
int *p = new int(5);
std::cout << "p's address is: " << p << std::endl;
return *p;
}
int main() {
int & a = fun(); //声明引用类型,接收返回的引用
std::cout << "a is: " << a << std::endl;
std::cout << "a's address is: " << &a << std::endl;
delete &a;
std::cout << "------------------------\n";
int b = fun(); // 声明 int 类型,接受返回 int 类型的引用, 也可以编译通过
std::cout << "b is: " << b << std::endl;
std::cout << "b's address is: " << &b << std::endl;
return 0;
}
输出:
p's address is: 0x651708
a is: 5
a's address is: 0x651708
------------------------
p's address is: 0x651708
b is: 5
b's address is: 0x28ff40
右值引用
右值引用 (Rvalue Referene) 是 C++ 新标准 (C++11, 11 代表 2011 年 ) 中引入的新特性 , 它实现了转移语义 (Move Sementics) 和精确传递 (Perfect Forwarding)。详细:C++11 标准新特性: 右值引用与转移语义,它的主要目的有两个方面:
- 消除两个对象交互时不必要的对象拷贝,节省运算存储资源,提高效率;
- 能够更简洁明确地定义泛型函数;
右值引用形式:类型 && a= 被引用的对象。与左值的区别在于:右值是临时变量,如函数返回值,且不变。右值引用可以理解为右值的引用,右值初始化后临时变量消失。
#include <iostream>
using namespace std;
int glo=10;
void process(int && a){
glo+=a;
}
void process(int &a){
glo-=a;
}
int get_return(){
int b=3;
return b;
}
int main() {
int a = 10;
process(8);
cout<<glo<<endl;
process(a);
cout<<glo<<endl;
int && k = get_return();
cout<<k<<endl;
return 0;
}
右值引用的意义
直观意义:为临时变量续命,也就是为右值续命,因为右值在表达式结束后就消亡了,如果想继续使用右值,那就会动用昂贵的拷贝构造函数。(关于这部分,推荐一本书《深入理解C++11》)
右值引用是用来支持转移语义的。转移语义可以将资源 ( 堆,系统对象等 ) 从一个对象转移到另一个对象,这样能够减少不必要的临时对象的创建、拷贝以及销毁,能够大幅度提高 C++ 应用程序的性能。临时对象的维护 ( 创建和销毁 ) 对性能有严重影响。
转移语义是和拷贝语义相对的,可以类比文件的剪切与拷贝,当我们将文件从一个目录拷贝到另一个目录时,速度比剪切慢很多。
通过转移语义,临时对象中的资源能够转移其它的对象里。
在现有的 C++ 机制中,我们可以定义拷贝构造函数和赋值函数。要实现转移语义,需要定义转移构造函数,还可以定义转移赋值操作符。对于右值的拷贝和赋值会调用转移构造函数和转移赋值操作符。如果转移构造函数和转移拷贝操作符没有定义,那么就遵循现有的机制,拷贝构造函数和赋值操作符会被调用。
普通的函数和操作符也可以利用右值引用操作符实现转移语义。
无论是声明一个左值引用还是右值引用,都必须立即进行初始化。而其原因可以理解为是引用类型本身自己并不拥有所绑定对象的内存,只是该对象的一个别名。左值引用是具名变量值的别名,而右值引用则是不具名(匿名)变量的别名。
https://www.zhihu.com/question/22111546