浅析内存分配

源码面前,了无秘密 ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ
ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ-- 侯捷

今天打算来总结一下C++中的内存分配的一些事情,几乎我们写的每一程序都离不开内存分配这个话题,而不同的程序对内存分配的需求又有不同,尤其在一些嵌入式开发当中,常常需要程序员自定义内存分配的细节,所以今天的话题就从C++中的newdelete开始讲起。

1. new 和 delete

你可能会听说过newnew operatordelete以及delete operator,其实当你听到这些概念的时候,说的就是newdelete,他们表示的都是C++中的操作符,C++中通常使用new表达式去为对象分配内存,他们不允许被重载

  • 当我们使用new在堆上为对象分配一块空间时,如下
struct Complex {
  Complex() = default; // C++11用法,让编译器帮我们生成默认构造函数(ctor)
  Complex(double real, double imag) : real_{ real }, imag_{ imag } {}

private:
  double real_;
  double imag_;
};

Complex* complex = new Complex(1.0, 2.0);
Complex* array = new Complex[10];  // 如果没有默认ctor,这里编译器会出错

实际上C++默默执行了下面三步操作

  • 首先调用全局命名空间的operator new(或 operator new[])函数来分配一块原始内存,注意这块内存并未初始化,关于operator new的细节我们下一小节再来讨论;
  • 转型,将原始指针转化为对象类型;
  • 调用构造函数,分配空间,返回指向该对象的指针。

上面的new表达式就被编译转化为类似下面的形式:

// new 先分配内存,在调用构造
void* mem = operator new(sizeof(Complex));
complex = static_cast(mem);
complex->Complex::Complex(1.0, 2.0);   // 这里是不能够直接调用构造函数,这里只是演示,但是可以借用其它的手法调用,后面第3节我们会说到

当我们使用delete来释放堆上分配的空间时,实际上执行了下面两步操作

  • 先调用析构函数;
  • 调用全局命名空间的operator delete(或 operator new[])函数来释放内存。
delete complex;
delete[] array;

实际上他们被编译器转化为:

Complex::~Complex(complex);  //  调用析构函数(dtor)
operator delete(complex);

2. operator new 和 operator delete

newdelete不同,这两个是C++标准定义的两个全局函数,可以被重载(C++11标准中分别给了6个重载的版本),用来定制特定的内存分配机制。

  • 破冰

前面我们说到了newdelete表达式调用了operator newoperator delete来申请内存和释放内存,其实这两个函数底层调用的就是我们熟知的mallocfree两个函数。

  • 伊始

再开始重载我们自己的operator newoperator delete函数之前,先带大家看一下他们在标准库中的接口形式

void* operator new (std::size_t size);
void* operator new (std::size_t size, const std::nothrow_t& nothrow_value) noexcept;
void* operator new (std::size_t size, void* ptr) noexcept; // placemen
void* operator new[] (std::size_t size);
void* operator new[] (std::size_t size, const std::nothrow_t& nothrow_value) noexcept;
void* operator new[] (std::size_t size, void* ptr) noexcept; // placement

void operator delete (void* ptr) noexcept;
void operator delete (void* ptr, const std::nothrow_t& nothrow_constant) noexcept;
void operator delete (void* ptr, void* voidptr2) noexcept; // placement
void operator delete (void* ptr) noexcept;
void operator delete (void* ptr, const std::nothrow_t& nothrow_constant) noexcept;
void operator delete (void* ptr, void* voidptr2) noexcept; // placement

// C++14 以后 operator delete 多引入了下面四种形式的重载
void operator delete (void* ptr, std::size_t size) noexcept; // with size
void operator delete (void* ptr, std::size_t size, const std::nothrow_t& nothrow_constant) noexcept; // nothrow with size
void operator delete[] (void* ptr, std::size_t size) noexcept;
void operator delete[] (void* ptr, std::size_t size, const std::nothrow_t& nothrow_constant) noexcept;

当我们重载了自己的成员operator newoperator delete之后,我们就可以定制自己的内存分配行为了,我们一般都是重载成员operator newoperator delete,千万要特别小心重载全局命名空间的和operator newoperator delete函数,这将影响到所有的newdelete行为,一般不建议这么做。

  • 一个简单的内存池实现

我们通过一个简单的内存池实现来看看如何重载这些函数。

  • 接口
// allocator.h
struct Allocator {
  void* allocate(size_t size);
  void deallocate(void* head);

private:
  struct obj {
    obj* next;    // embedded pointer
  };

  obj* free_head_;
  const size_t chunk_ {20};
};
  • 实现
void* Allocator::allocate(size_t size) {
    obj* temp;

    if (free_head_ == nullptr) {
        free_head_ = static_cast(malloc(size * chunk_));

        temp = free_head_;
        for (int i = 0; i < chunk_ -1; ++i) {
            temp->next = (obj*)((char*)temp + size);
            temp = temp->next;
        }
        temp->next = nullptr;
    }

    temp = free_head_;
    free_head_ = temp->next;
    return temp;
}

void Allocator::deallocate(void *head) {
    obj* temp = static_cast(head);
    temp->next = free_head_;
    free_head_ = temp;
}

上面我们定义了一个Allocator的类,将分配的内存块通过链表级联在一起,默认一次申请20个对象的大小的块,这个值根据不同情况你可以修改,或者在构造的时候传入都行,根据实际情况。
实现了对一块大的内存的自我管理,申请的时候将free_head_ 指向的内存给用户,释放的时候将内存插入到链表头部。当内存不足的时候又会重新申请20个对象大小的内存块。

  • 优点

    1. 得到的每一大块的内存都是连续的,减少了malloc函数内存的浪费,malloc函数在申请内存的时候会在返回给用户的指针前面和后面插入一些额外的cookie信息,为了free的时候可以知道释放多大的内存;想要更深入的了解可以参考effective C++第三版的条款50和51;
    2. 减少了malloc的调用次数,不过带来的性能不会特别大,malloc的效率其实非常高。
  • 缺点

上面的实现一个很大的不足就是,我们将从操作系统申请的内存一直握在自己的手里,虽然没有发生内存泄漏,但是没能将内存再次还给操作系统。

  • 使用实例
// word.h
struct Word {
  Word () = default;
  Word (int size, int data) : size_(size), data_(data) {}

  static Allocator allocator;
  static void* operator new(size_t size) { return allocator.allocate(size); }
  static void* operator new[](size_t size) { return allocator.allocate(size); }
  static void operator delete(void* pointer) { return allocator.deallocate(pointer); }
  static void operator delete[](void* pointer) { return allocator.deallocate(pointer); }
  static void* operator new(size_t size, void* start) { return start; }  // 这是一个placement new

private:
  int size_;
  int data_;
};
Allocator Word::allocator;

我们可以重载很多个class member operator new(),前提是每一个重载的版本第一参数必须为size_t类型。

3. placement new 和 placement delete

  • 一个简单的例子

第1节我们留下了一个问题,我们说下面的代码是不能够直接调用构造函数的

complex->Complex::Complex(1.0, 2.0);

我们将它稍微改写一下,变成下面的形式就可以调用构造函数了,其实下面的形式,就是我们这一节要说的placement new,又叫定点new或者定位new

new(complex)Complex(1.0, 2.0);
  • 作用

它用于在给定的内存中初始化对象(不会分配内存),对于 operator new 分配的内存空间来说我们无法使用构造函数来构造对象。这个时候我们可以使用placement new形式来构造对象。
另外placement new允许我们在一个特定的、预先分配的内存地址上构造对象,这个地址不仅仅是堆上的内存(如上所示),也可以是栈上分配的空间,如下

std::string str[3];
for (int i = 0; i < 3; ++i) {
  new(str+i)std::string("num is " + std::to_string(i));
  std::cout << *(str+i) << std::endl;
}

// output:
num is 0
num is 1
num is 2
  • 参考

关于placement new的详细部分可以参考effective C++ 第三版的条款52以及C++ Primer 第五版的19.1.2小节。

4. new_handler 和 set_new_handler

operator new分配内存失败的时候,会抛出一个std::bad_alloc异常。在一些老的编译器可能不会抛出异常,而是返回零,不过你可以显示让编译器不抛出异常

new(std::nothrow)int[10];
// 称为nothrow形式
  • 形式

C++平台在抛出异常之前,会先调用一个函数,而且不止一次,这个函数可以由client指定的handler,下面我们看看new_handler的形式和设定方法

typedef void(*new_handler)();
new_handler set_new_handler(new_handler p) throw(); // C++98
new_handler set_new_handler(new_handler p) noexcept(); // C++11

说明一下,set_new_handler尾端声明的throw(),表示该函数不抛出异常,不过在C++11的时候被标记为废弃,改为noexcept,到C++17的时候throw()这种用法已经彻底被删除了。
C++平台这样的设计是为了给用户一个机会,在内存不足的时候调用用户自己设定的handler,也就是由你来决定这个时候该如何抉择。

  • 设计选择

好的new_handler设计,一般有两个选择。

1. 想法设法让更多的内存可用,释放系统当前可以释放的空闲资源;
2. 调用`abort()`或`exit()`来终止程序。

5 . 补充

C++2.0之后引入两个新特性,一个是= delete,另一个是get_new_handler,分别简单介绍一下。

  • = delete

我们可以在operator newoperator delete函数尾部加上= delete,用来表示删除这个函数,不允许使用者调用。

// word.h
struct Word {
  Word () = default;
  static void* operator new(size_t size) = delete;
  static void* operator new[](size_t size) = delete;
  static void operator delete(void* pointer) = delete;
  static void operator delete[](void* pointer) = delete;
};

// 下面四条语句都会compile error
Word* word = new Word();
Word* words = new Word[3];
delete word;
delete[] words;
  • get_new_handler

用来获取new-handler函数,如果用户没有设定的话或者被重置,将返回一个nullptr

new_handler get_new_handler() noexcept;

你可能感兴趣的