说到内存管理,首先要明确内存管理的目的是什么,当然是为了节省运行时间和内存空间了。可能很多人会觉得有些过时了,他们的观点是:一方面现在的内存动辄几个G或者十几G,不在乎那一点点的内存分配的多少,大不了花钱加内存条;另一方面现在的高级语言(例如Java,C#)很多都有自动内存回收机制,不需要用户自己手动分配和释放内存,系统GC机制会自动释放内存。是的,确实现在的开发者可以把更多的精力放在处理真正的业务逻辑上,快速迭代软件开放周期,毕竟在移动互联网时代,时间就是金钱和资源。但是,现在也是大数据和人工智能时代,内存多了数据也更大了;还有,垃圾回收机制尽管可以自动回收内存,相应的,程序员也控制不了回收的时机,在一定程度上失去了对程序的可控权。如果要写出更加优秀的程序,明白内存分配的原理,做到心中自有丘壑,应用程序可以有更好的用户体验,尤其是C++开发者。
那么,C++中的内存管理需要了解那些知识呢?我觉得最起码需要了解:
- 标准库
std::allocator
new
new[]
new()
,operator new()
与对应的delete
版本malloc
和free
机制。
就像候捷老师的PPT说的内存分配的途径:
上面三个分配内存的路径是我们程序员可以控制的,也是可以直接调用的。操作系统调用的HeapAlloc是不可移植的,但是上面的三种分配内存的方式最终都是调用操作系统的方法。下面我们从最简单的new
delete
开始说起内存分配的那些事儿。
new / delete 里里外外
new / delete 小试牛刀
c++ 中 new
相信大家都非常熟悉,就是在堆内存上替一个对象分配内存并返回指向该对象的指针。基本代码就是 A* p = new A()
, 与new
对应的就是delete
, 释放该指针指向的内存, 用法 delete p
。
这是大家都知道的用法,不过这里有几点值得注意:
new
delete
必须成对使用,不然会内存泄漏。new
delete
不是函数(c语言中的malloc 与 free是函数),而是表达式(expression)。- 数组内存分配用 array new,即
new[]
, 对应释放用delete[]
,也要成对使用。
new 三重门
小试牛刀是基本内容,相信大家都明白,也很好理解。但是new
和 malloc
到底有啥不同呢?我们知道malloc
分配的单位是字节,而且是一块没有初始化的内存。但是new
除了给对象分配内存外,还在该内存上初始化了对象,即调用对象的构造函数,然后返回指针给用户。
1. throw or nothrow operator new
假设我们有这样的代码:
A* p = new A();
编译器会把上面的语句理解为:
A* p = nullptr;
void *mem = operator new(sizeof(A)); // 1 alloc 调用对应的重载版本
p = static_cast<A*>(mem); // 2 cast
p->A:A(); // 3 construct
注意上面的代码 最后一行用户是不能执行的(本机测试在vs2015可以,但是gnu 5.4不行,C++标准是不允许的,所以也不建议使用),但是可以理解成编译器是可以内部调用的。所以很清楚new
表达式分成三步执行:
- 分配内存(placement new 除外)
- 转换指针类型
- 执行构造函数
其中第二步是简单的指针类型转换,没什么可细说的,第一步operator new
是 new
运算符,它和+
-
等运算符一样是可以被重载的,C++ 默认重载了三种。上面重载的版本内部默认调用了 c 语言的 malloc
分配内存。
void *mem = operator new(sizeof(A)); // 1 alloc
上面的代码的默认实现如下:
// throwing allocation
void* operator new(size_t size) throw (std::bad_alloc)
{
void *p;
while((p = malloc(size)) == 0)
{
if(new_handler_call != nullptr)
{
new_handler_call();
}
else
{
throw std::bad_alloc();
}
}
return p;
}
如果是抑制抛出异常的版本:
A* p = new(std::nothrow) A();
编译器会把上面的语句理解为:
A* p = nullptr;
void *mem = operator new(sizeof(A), std::nothrow)(sizeof(A)); // 1 alloc 调用对应版本
p = static_cast<A*>(mem); // 2 cast
p->A:A(); // 3 construct
其中第二句分配内存的代码:
void *mem = operator new(sizeof(A), std::nothrow)(sizeof(A)); // 1 alloc
默认实现的如下:
// nothrow allocation
void* operator new (std::size_t size, const std::nothrow_t& nothrow_value) throw()
{
void *p;
while((p = malloc(size)) == 0)
{
if(new_handler_call != nullptr)
{
new_handler_call();
}
else
{
return nullptr;
}
}
return p;
}
上面的代码出现了类型std::new_handler
回调函数,其实质是一个无参返回void的函数指针类型,即 typedef void (*new_handler)()
类型,new_handler_call
是其一个实例, 当内存分配失败时则会调用,用户可以通过全局函数set_new_hanlder
设置, 后面会详细说明。
2. placement new
这里我们有必要总结一下:
operator new 是可以重载的运算符。默认的重载有三种:
//throwing (1)
void* operator new (std::size_t size) throw (std::bad_alloc);
//nothrow (2)
void* operator new (std::size_t size, const std::nothrow_t& nothrow_value) throw();
//placement (3)
void* operator new (std::size_t size, void* ptr) throw();
第一种 throwing 和第二种 nothrow 上面刚刚说过。第三种是placement new
它的默认实现是:
void* operator new (std::size_t size, void* ptr) throw()
{
return ptr;
}
对!什么也没做,直接返回指针,所以如果我们 执行语句:
A a;
//... 这里改变a的值
A* p = new(&a) A(); //
则编译器同样会把上面的代码new表达式翻译成:
A* p = nullptr;
void *mem = operator new(sizeof(A) ,&a); // 1 直接返回指针
p = static_cast<A*>(mem); // 2 cast
p->A:A(); // 3 construct
需要注意,1. placement new
的内存可以是栈空间, 2.没有分配新内存,只是在a的地址上重新构造函数
具体可访问 cplusplus
3. custom new (自定义 new)
当然我们也可以重载自己的 operator new
运算符,但是要满足条件就是: 第一个参数必须是 std::size_t 。例如,我们重载自己的 operator new
原型如下:
void* operator new(std::size_t size, int extra);
这时,再假设我们有这样的语句:
A* p = new(1) A(2);
编译器会把上面的语句理解为:
A* p = nullptr;
void *mem = operator new(sizeof(A) , 1); // 1 alloc 调用对应的重载版本
p = static_cast<A*>(mem); // 2 cast
p->A:A(2); // 3 construct
注意, 上面的第二句就会执行我们重载的operator new
代码来分配内存,第四句构造函数也有相应的参数了。当然我们可以重载更多的自定义operator new
,例如 :
void* operator new(std::size_t size, ***);//***标识其他类型
如果要想调用自己的operator new
就需要有对应的 new(***) Type()
。
至此,默认的三种operator new
和 我们自己重载的版本都讲了。啰嗦了这么多,总之一句话,! new expression
(new
表达式)会被编译器理解成三个语句,其中第一个语句operator new
是可以被重载和重写的,调用哪个版本的operator new
函数由new
表达式的形式决定。 即:
A* p = new(***) A(xxx)
被编译器翻译成:
// 第二行***与第四行xxx与new表达式对应
A* p = nullptr;
void *mem = operator new(sizeof(A) , ***);
p = static_cast<A*>(mem);
p->A:A(xxx);
array new
上面给出了new
表达式的三个默认版本和一个自定义版本,以及对应的operator new
函数,可我们也知道new
也可以分配数组内存,称之为array new
, 即 new[]
, array new
表达式首先是分配一大块可以容纳所有数组的内存,然后在相应内存上调用每个对象的构造函数。与上面的new
对应,假设我们有下面的代码:
A* p = new A[10];
则编译器会理解为:
A* p = nullptr;
void *mem = operator new[] (sizeof(A) * 10);
p = static_cast<A*>(mem);
A* tp = p;
for(int i = 0; i < 10; ++i)
{
tp->A:A();
tp = tp + 1;
}
如同我们刚刚所说的,首先是通过operator new
分配一大块内存,然后移动指针在内存块上初始化数组对象,注意这是编译器内部对new
表达式的实现,我们不可以重新实现,但是我们可以重载上面的第二个语句operator new[]
。 和上面的new
一样,operator new[]
也有四种重载方式:
//throwing (1)
void* operator new[] (std::size_t size) throw (std::bad_alloc);
//nothrow (2)
void* operator new[] (std::size_t size, const std::nothrow_t& nothrow_value) throw();
//placement (3)
void* operator new[] (std::size_t size, void* ptr) throw();
//costom (4)
void* operator new[] (std::size_t size, user-defined-args...);
调用它们也需要new[]
表达式有相应的格式:
//throwing (1)
A* p = new A[10];
//nothrow (2)
A* p = new(nothrow) A[10];
//placement (3)
A* p = new(&a) A[10];
//costom (4)
void* operator(2) new[10];
符合的规则和new
一样:
A* p = new(***) A[n]
调用对应的operator new[]
:
void* operator new[] (std::size_t size, ***);
但是有一个不同之处是,new[]
只能对每个对象执行默认构造函数。例如,new A[10](2)
是错误的。
delete / delete[]
前面我们已经知道,new expression
执行三个步骤 ( A* p = new A() ):
- 分配内存(placement new 除外)
- 转换指针类型
- 执行构造函数
编译器理解为:
A* p = nullptr;
void *mem = operator new(sizeof(A)); // 1 alloc 调用对应的重载版本
p = static_cast<A*>(mem); // 2 cast
p->A:A(); // 3 construct
同样,delete expression
执行二个步骤 ( delete p ):
- 执行析构函数
- 回收内存(placement new 除外)
可以看到,delete
表达式执行的操作和 new
表达式顺序刚好相反,即先调用析构函数,后释放内存。同样delete p
可以被编译器理解为:
p->~A();
operator delete(p);
其中,第二行的operator delete
负责释放内存,它是可以被重载的,同operator new
一样,有四种重载方式:
//ordinary (1)
void operator delete (void* ptr) throw ();
//nothrow (2)
void operator delete (void* ptr, const std::nothrow_t& nothrow_value) throw();
//placement (3)
void operator delete (void* ptr, void* place) throw();
//costom (4)
void operator delete (void* ptr, user-defined-args...) throw();
但是怎么指定调用哪一个重载函数呢?这里不能像new一样根据new expression
原型来选择相应的版本,因为delete的形式是固定的,只能是delete p
,所以当我们执行 delete p
时默认的释放内存的执行语句总是 void operator delete (void* ptr) throw();
,那什么时候调用下面的三种重载方法呢? 答案是,当用 new(xxx) A() 表达式创建对象时,如果构造函数抛出异常,此时对象的内存已经分配,所以会调用与new相匹配的operator delete释放此对象内存。如下面代码:
#include <stdexcept>
#include <iostream>
struct X {
X() { throw std::runtime_error(""); }
// custom placement new
static void* operator new(std::size_t sz, bool b) {
std::cout << "custom placement new called, b = " << b << '\n';
return ::operator new(sz);
}
// custom placement delete
static void operator delete(void* ptr, bool b)
{
std::cout << "custom placement delete called, b = " << b << '\n';
::operator delete(ptr);
}
// ordinary delete
static void operator delete(void* ptr)
{
std::cout << "ordinary delete called" << '\n';
::operator delete(ptr);
}
};
int main() {
try {
X* p1 = new (true) X;
//delete p1;
} catch(const std::exception&) { }
}
因为 new (true) X
在构造函数中抛出异常,所以要调用相应的 operator delete(void* ptr, bool b);
,但是若在构造函数中除去异常,并手动delete p1
则会调用 operator delete(void* ptr);
具体说明大家可以参考cppreference 最后一个代码示例。c++这样设计的初衷就是为了对象构造失败(异常)的时候也能正确释放内存,具体说明可以查看《effective c++》: Item52 “写了placement new也要写placement delete”。下面我们再来看看operator delete[]
, 即 delete array
:
//ordinary (1)
void operator delete[] (void* ptr) throw();
//nothrow (2)
void operator delete[] (void* ptr, const std::nothrow_t& nothrow_value) throw();
//placement (3)
void operator delete[] (void* ptr, void* place) throw();
//costom (4)
void operator delete[] (void* ptr, user-defined-args...) throw();
和array new
一样,也有四个重载形式,前三个是默认实现了的,值得注意的是,所以的形式都是不能抛出异常的,即释放内存是不要抛出异常,前面的operator new[]
也是一样。同样delete[] p
也可以被编译器理解为:
A* tp = p;
for(int i = 0; i < 10; ++i)
{
tp->~A();
tp = tp + 1;
}
operator delete[](p);
可以看出,delete[] p
先多次调用析构函数,然后一次释放内存。这里大家可能比较疑惑的是,传入一个指针p是怎么知道调用多少次析构函数的?如果用delete p
而不是 delete[] p
释放array new
一定会内存泄漏吗? 这就涉及到下一个话题malloc
和 free
。请听下回分解!