C++ 内存模型
本小节内容参考: https://www.bilibili.com/video/BV1et411b73Z/?p=84
C++ 程序执行时,将内存划分为4个区域
- 代码区:存放函数体的二进制代码,有操作系统进行管理
- 全局区:存放全局变量和静态变量以及常量
- 栈区:由编译器自动分配释放,存放函数的参数值,局部变量等
- 堆区:有程序员分配和释放,若程序员不释放,程序结束时由操作系统回收
程序运行前
在程序编译后,生成了exe可执行程序,未执行该程序前分为两个区
代码区:
- 存放CPU执行的机器指令
- 代码区是共享的,共享的目的是对于频繁被执行的程序,只需要在内存中有一份代码即可
- 代码区是只读的,使其只读的原因是防止程序意外地修改了它的指令
全局区:
- 全局变量和静态变量存放在此。
- 全局区还包含了常量区,字符串常量和其他常量也存放在此
- 该区域的数据在程序结束后由操作系统释放
示例
#include <iostream>
using namespace std;
//全局变量
int g_a = 10;
int g_b = 10;
//全局常量
const int c_g_a = 10;
const int c_g_b = 10;
int main()
{
//局部变量
int a = 10;
int b = 10;
//打印地址
cout << "局部变量a地址:" << (int)&a << endl;
cout << "局部变量b地址:" << (int)&b << endl;
cout << "全局变量g_a地址:" << (int)&g_a << endl;
cout << "全局变量g_b地址:" << (int)&g_b << endl;
//静态变量
static int s_a = 10;
static int s_b = 10;
cout << "静态变量s_a地址:" << (int)&s_a << endl;
cout << "静态变量s_b地址:" << (int)&s_b << endl;
cout << "字符串常量地址:" << (int)&"hello world" << endl;
cout << "字符串常量地址:" << (int)&"hello world1" << endl;
cout << "全局常量c_g_a地址:" << (int)&c_g_a << endl;
cout << "全局常量c_g_b地址:" << (int)&c_g_b << endl;
const int c_l_a = 10;
const int c_l_b = 10;
cout << "局部常量c_l_a地址:" << (int)&c_l_a << endl;
cout << "局部常量c_l_b地址:" << (int)&c_l_b << endl;
system("pause");
return 0;
}
程序运行后
栈区:
由编译器自动分配释放,存放函数的参数值,局部变量等
注意事项:不要返回局部变量的地址,栈区开辟的数据由编译器自动释放
示例:
#include <iostream>
using namespace std;
int* func()
{
int a = 10;
return &a;
}
int main()
{
int* p = func();
cout << *p << endl; //打印10,编译器对改地址的数据做了一次保留
cout << *p << endl; //打印无效数据,栈区数据已释放
system("pause");
return 0;
}
堆区:
由程序员分配释放,若程序员不释放,程序结束时有操作系统回收
在C++中主要利用 new
在堆区开辟内存,用 delete
释放内存
示例:
#include <iostream>
using namespace std;
int* func()
{
int* a = new int(10);
return a;
}
int main()
{
int* p = func();
cout << *p << endl;
cout << *p << endl;
delete p;
//cout << *p << endl; //报错,p已释放无法访问
system("pause");
return 0;
}
注: 开辟和释放数组
int* arr = new int[10];
delete[] arr;
1. 动态内存与智能指针
在 C++ 中,动态内存的管理是通过一对运算符来完成的: new
,在动态内存中为对象分配空间并返回一个指向改对象的指针,我们可以选择对对象进行初始化;delete
,接受一个动态对象的指针,销毁该对象,并释放与之关联的内存。
这两个运算符对内存的管理都是手动实现的,C++11 提供了两种智能指针类型来管理动态对象。shared_ptr
允许多个指针指向同一个对象;unique_ptr
则独占所指向的对象。标准库还定义了一个名为 weak_ptr
的伴随类。这三种类型都定义在 memory
头文件中。
1.1 shared_ptr 类
智能指针是模板类,定义的时候必须指定类型
shared_ptr<string> p1; // 指向 string
shared_ptr<list<int>> p2; // 指向 int的 list
下表列出了 shared_ptr
和 unique_ptr
的操作
make_shared 函数
make_shared
标准库函数在动态内存中分配一个对象并初始化它,返回指向此对象的shared_ptr。
// 指向一个值为42 的 int 的shared_ptr
shared_ptr<int> p3 = make_shared<int>(42);
// p4 指向一个值为 "9999999999" 的string
shared_ptr<string> p4 = make_shared<string>(10, '9');
// p5 指向一个值初始化的int, 即值为0
shared_ptr<int> p5 = make_shared<int>();
// 使用 auto
auto p6 = make_shared<vector<string>>();
shared_ptr 的拷贝和赋值
当进行拷贝或赋值操作时,每个 shared_ptr 都会记录有多少个其他 shared_ptr 指向相同的对象
auto p = make_shared<int>(42); //p 指向的对象只有p一个引用者
auto q(p); // p 和 q 指向相同对象,此对象有两个引用者
我们可以认为每个 shared_ptr 都有一个关联的计数器。通常称其为引用计数 。无论何时我们拷贝一个 shared_ptr, 计数都会递增。
auto r = make_shared<int>(42); // r 指向的int只有一个引用者
r = q; // 给r赋值,令它指向另一个地址
// 递增 q 指向的对象的引用计数
// 递减r 原来的对象的引用计数
// r 原来的对象没有引用者,会自动释放
shared_ptr 自动销毁所管理的对象……
shared_ptr 的自动销毁机制,是当引用计数变为0,自动的调用对象的析构函数。并释放它们所占用的内存。
…… shared_ptr 还会自动释放相关联的内存
例如一个返回 shared_ptr 的函数
// factory 返回一个 shared_ptr, 指向一个动态分配的对象
shared_ptr<Foo> factory(T arg)
{
// 处理 arg
// shared_ptr 负责释放内存
return make_shared<Foo>(arg);
}
上面函数返回一个 shared_ptr ,它可以在在恰当的时刻被自动释放。但下面的函数将返回的shared_ptr 保存在局部变量中:
void use_factory(T arg)
{
shared_ptr<Foo> p = factory(arg);
// 使用 p
} // p 离开了作用于,它指向的内存会被自动释放掉
但如果有其他 shared_ptr 也指向这块内存,它就不会释放掉
void use_factory(T arg)
{
shared_ptr<Foo> p = factory(arg);
// 使用 p
return p; // 当我们返回p 时,引用计数进行了递增操作
} // p 离开了作用域,但它指向的内存不会被释放掉
如果将 shared_ptr 存放于一个容器中,而后不在需要全部元素,而只用其中一部分,要记得用 erase 删除不在需要的那些元素。
程序使用动态内存出于以下三种原因之一:
- 程序不知道自己需要使用多少对象
- 程序知道所需对象的准确类型
- 程序需要在多个对象间共享数据
1.2 直接管理内存
C++ 语言定义了两个运算符来分配和释放动态内存,new
和 delete
.
使用 new 动态分配和初始化对象
int *pi = new int; // pi 指向一个动态分配的,未初始化的无名对象
string *ps = new string; // 初始化为空 string
默认情况下,动态分配的对象是默认初始化的,这意味着内置类型或组合类型的对象的值是未定义的,而类类型对象将调用默认构造函数初始化。
使用直接初始化方式来初始化一个动态分配的对象
int *pi = new int(42); // pi 指向的对象的值为42
string *ps = new string(10, '9'); // *ps 为 "9999999999"
vector<int> *pv = new vecetor<int>{1,2, 3, 4};
使用 auto
自动推断类型
auto p1 = new auto(obj); // p 指向一个与 obj类型相同的对象
// 该对象用 obj 进行初始化
auto p2 = new auto{a, b, c}; // 错误: 括号中只能有单个初始化器
动态分配的 const 对象,我们也可以使用new动态分配const对象,只是在分配时必须初始化
const int *pci = new const int(42);
const string *pcs = new const string; // string 有构造函数,可以不用写初始化值
内存耗尽
当计算机中没有足够的内存时,使用 new动态分配空间时会抛出 bad_alloc
。我们可以定义new 分配内存,定位new表达式允许我们向new传递额外的参数,比如 nothrow
. 如果没有空间,不会有异常,而是返回一个空指针
int *p1 = new int; // 如果分配失败,new 抛出 std::bad_alloc
int *p2 = new (nothrow) int; // 如果分配失败,new 返回一个空指针
释放动态内存
动态内存的释放使用 delete
delete p; // p 必须指向一个动态分配的对象或一个空指针
delete 的指针必须指向动态分配的内存或式空指针,而且一个动态分配的内存不能被释放多次
int i, *pi1 = &i, *pi2 = nullptr;
double *pb = new double(33), *pd2 = pd;
delete i; // 错误
delete pi1; // 未定义: pi1 指向一个局部变量
delete pd; // 正确
delete pd2; // 未定义:重复释放
delete pi2; // 正确
动态对象的生存期直到被释放时为止
不论是在函数内部还是其他任何地方,动态分配的对象没有之前都是存在的
// factory 返回一个指针,指向一个动态分配的对象
Foo* factory(T arg)
{
return new Foo(arg); // 调用者负责释放
}
void use_factory(T arg)
{
Foo *p = factory(arg);
// 使用p 但不delete 它
} // p 离开了它的作用域,但它所指向的内存没有被释放
上面这段程序中,使用者在离开指针作用域之前没有释放掉动态分配的内存,这个部分的内存一直存在着,这就会导致内存泄露的风险。
delete 之后重置指针值,这只是提供了有限的保护
当我们delete一个指针后,指针值变为无效了。虽然指针无效了,但在很多机器上指针仍然保存着(已释放了的)动态内存的地址。在delete之后,指针就变成了人们所说的空悬指针,即,指向一块曾经保存数据对象但现在已经无效的内存的指针。
int *p(new int(42));
auto q = p;
delete p;
p = nullptr;
上面的程序我们在释放指针 p 后将其置空,但是q还是指向那块已经被释放了的内存。
1.3 shared_ptr 和 new 结合使用
使用 new 返回的指针来初始化智能指针
shared_ptr<double> p1(new int(42)); // 正确
shared_ptr<double> p2 = new int(42); // 错误
第二条语句错误的原因是接受指针参数的智能指针构造函数是 explicit
的,即不能被隐式调用,需要显示调用。基于这个原因下面的用法也是错误的
shared_ptr<int> clone(int p)
{
return new int(p); // 错误
//return shared_ptr<int>(new int(p)); // 正确
}
不用混用普通指针和智能指针
void process(shared_ptr<int> ptr)
{
// 使用 ptr
} // ptr 离开作用域,被销毁
使用此函数的正确方式是传递给它一个shared_ptr
shared_ptr<int> p(new int(2)); // 引用计数为1
process(p); // 拷贝p会递增它的引用计数;在process中引用计数值为2
int i = *p; // 正确:引用计数值为1
导致错误的用法
int *x(new int(42)); // x是一个普通指针
process(x); // 错误: 不能将int* 转换为 shared_ptr<int>
process(shared_ptr<int>(x)); // 合法,但内存会被释放
int j = *x; // 未定义的:x是一个空悬指针
上面的调用中,我们将一个临时 shared_ptr 传递给 process 。当这个调用表达式结束,这个临时对象被销毁,引用计数就变为0。但 x 继续指向(已释放的)内存,从而变成一个空悬指针。
使用一个内置指针来访问一个智能指针所负责的对象是很危险的,因为我们无法知道对象何时会销毁。
也不要使用 get 初始化另一个智能指针或为智能指针赋值
智能指针类型定义了一个名为 get
的函数,它返回一个内置指针,指向智能指针管理的对象。此函数的是为了这样一种功能设计的:我们需要向不能使用智能指针的代码传递一个内置指针。使用 get 返回的指针的代码不能delete 此指针。
shared_ptr<int> p(new int(42)); // 引用计数为1
int *q = p.get(); // 正确:但使用q时要注意,不要让它管理的指针被释放
{ // 新程序块
// 未定义:两个独立的shared_ptr 指向相同的内存
shared_ptr<int>(q);
}// 程序块结束,q 被销毁,它指向的内存被释放
int foo = *p; // 未定义: p指向的内存已经释放了
两个智能指针指向同一块内存,但是q被delete了之后,p依旧指向那块已经被释放的内存。
其他 shared_ptr 操作
我们可以用 reset
将一个新的指针赋予一个 shared_ptr:
// p的类型 shared_ptr<int>
p = new int(42); // 错误: 不能将一个指针赋予 shared_ptr
p = reset(new int(42)); // 正确: p指向一个新对象
reset
经常域 unique
一起使用, 返回一个bool 值,如果智能指针的引用计数为1返回true,否则返回false,注意,该方法在 C++20 已被移除。
if(!p.unique())
p.reset(new string(*p)); // 我们不是唯一用户,分配新的拷贝
*p += newVal; //现在我们知道自己是唯一的用户,可以改变对象的值
1.4 智能指针和异常
使用智能指针即使发生异常也能正常销毁内存,而直接管理的内存不能自动释放
void f()
{
shared_ptr<int> sp(new int(42));
// 这段代码抛出一个异常,且在f中未被捕获
} // 在函数结束时shared_ptr 自动释放内存
void f()
{
int *ip = new int(42); // 动态分配一个对象
// 这段代码抛出一个异常,且在f中未被捕获
delete ip; // 在退出之前释放内存
}
上面第二个函数由于在delete之前发生异常,内存永远无法被释放。
智能指针和哑类
对于没有良好定义析构函数的类,同样存在内存泄漏的风险,下面是一个网络库的部分代码
struct destination; // 表示我们正在连接什么
struct connection; // 使用连接所需的信息
connection connect(destination*); // 打开连接
void disconnect(connection); // 关闭给定的连接
void f(destination &d /* 其他参数 */)
{
// 获得一个连接;记住使用完后要关闭它
connection c = connect(&d);
// 使用连接
// 如果我们在 f 退出前忘记调用 disconnect, 就无法关闭c了
}
如果 connection 有一个析构函数,就可以在f结束时由析构函数自动关闭连接。
使用我们自己的释放操作
void end_connection(connection *p) {disconnect(*P);}
void f(destination &d /* 其他参数 */)
{
connection c = connect(&d);
shared_ptr<connection> p(&c, end_connection);
}
1.5 unique_ptr
一个 unique_ptr
拥有它所指向的对象。与 shared_ptr 不同,某个时刻只能有一个 unique_ptr 指向一个给定对象。当unique_ptr 被销毁时,它所指向的对象也被销毁。下标列出了 unique_ptr 特有的操作
定义unique_ptr,它没有类似 make_shared 的标准库函数返回一个unique_ptr
unique_ptr<double> p1; // 可以指向一个 double 的unique_ptr
unique_ptr<int> p2(new int(42)); // p2指向一个值为42的int
由于unique_ptr拥有它指向的对象,因此 unique_ptr 不支持普通的拷贝或赋值操作
unique_ptr<string> p1(new string("Stegosairiw"));
unique_ptr<string> p2(p1); // 错误:nuique_ptr 不支持拷贝
unique_ptr<string> p3;
p3 = p2; // 错误: unique_ptr 不支持赋值
虽然我们不能拷贝或赋值,但可以通过 release
或 reset
将指针的所有权从一个(非const)unique_ptr 转移给另一个 unique:
unique_ptr<string> p2(p1.release()); // release 将p1 置空
unique_ptr<string> p3(new string("Trex"));
p2.reset(p3.release()); // reset 释放了p2原来指向的内存
release
只是取消智能指针和内存的关联,并不会释放内存,所有我需要接收它的返回值,并手动管理这块内存
p2.release(); // 错误, p2 不会释放内存,而且我们丢失了指针
auto p = p2.release(); // 正确,但我们必须记得 delete(p)
传递 unique_ptr 参数和返回 unique_ptr
不能拷贝 unique_ptr 的规则有一个例外: 我们可以拷贝或赋值一个将要被销毁的 unique_ptr,例如函数返回一个 unique_ptr
unique_ptr<int> clone(int p)
{
// 正确: 从 int* 创建一个 unique_ptr
return unique_ptr<int>(new int(p));
}
还可以返回一个局部对象的拷贝:
unique_ptr<int> clone(int p)
{
unique_ptr<int> ret(new int(p));
// ....
return ret;
}
上面这两段代码,编译器会执行一种特殊的“拷贝”,叫做移动构造函数(参考13.6)。
向 unique_ptr 传递一个删除器
// 语法格式
unique_ptr<objT, delT> p(new objT, fcn);
// 重写连接程序
void f(destination &d /* 其他参数 */)
{
connection c = connect(&d);
// 当 p 被销毁时,连接将会关闭
unique_ptr<connection, decltype(end_connection)*>
p(&c, end_connection);
// 使用连接
// 当 f 退出时,(即使由于异常而退出),connection 会被正确关闭
}
1.6 weak_ptr
weak_ptr
是一种不控制所指向对象生存期的智能指针,它指向由一个 shared_ptr 管理的对象。将一个 weak_ptr 绑定到一个 shared_ptr 不会改变 shared_ptr 的引用计数。一旦最后一个指向对象的shared_ptr 被销毁,对象就会被释放。
当我们创建一个 weak_ptr 时,要用一个shared_ptr 来初始化它:
auto p = make_shared<int>(42);
weak_ptr<int> wp(p); // wp 弱共享p;p的引用计数未改变
由于对象可能不存在,我们不能使用 weak_ptr 直接访问对象,而必须调用 lock
. 此函数检查 weak_ptr 指向的对象是否存在。如果存在,lock 返回一个指向共享对象的shared_ptr
if(shared_ptr<int> np = wp.lock()) // 如果np不为ks则条件成立
{
// 在 if 中, np与p共享对象
}
2. 动态数组
2.1 new 和数组
为了让 new 分配一个对象数组,我们要在类型名之后跟一对方括号
int *pia = new int[get_size()]; // pia 指向第一个int
方括号从的大小必须时整型,但不必是常量。
通过类型别名分配一个数组
typedef int arrT[42];
int *p = new arrT;
// 等价表达式
int *p = new int[42];
分配一个数组会得到一个元素类型的指针
使用 new T[]
分配的内存为动态数组,但是我们并未得到一个数组类型的对象,而是得到一个数组类型的指针。因此不能对此动态数组调用 begin
或 end
,同样也不能用范围 for
语句来处理。
初始化动态分配对象的数组
int *pia = new int[10]; // 10 个未初始化的int
int *pia2 = new int[10](); // 10 个值初始化为0的int
string *psa = new string[10]; // 10 个空string
string *psa2 = new string[10](); // 10个空string
int *pia3 = new int[10]{0, 1, 2, 3, ,4 , 5, 6, 7, 8, 9}; // 列表初始化
动态分配一个空数组是合法的
可以用任意表达式来确定要分配的对象的数目:
size_t n = get_size(); // get_size 返回需要的元素的数目
int *p = new int[n];
for(int *q = p; q != p + n; ++q) {
// ....
}
// 动态分配一个空数组是合法的
char arr[0]; // 错误
char *cp = new char[0]; // 正确,但cp不能解引用
释放动态数组
delete p; // 释放一个动态分配的对象或空
delete [] pa; // 释放一个动态分配的数组或空
智能指针和动态数组
标准库提供了一个可以管理 new 分配的数组的 unique_ptr版本。为了用一个 uniqu_ptr 管理动态数组,我们必须在对象类型后面跟一对空方括号
unique_ptr<int[]> up(new int[10]);
up.release(); //自动用 delete[] 销毁
shared_ptr 不直接支持管理动态数组,如果希望使用 shared_ptr 管理一个动态数组,必须提供自己定义的删除器:
shared_ptr<int> sp(new int[10], [](int *p) { delete[] p; });
sp.reset();
上面的代码中的删除器是由一个 lambda表达式提供的。
2.2 allocator 类
new 操作是将内存分配和对象构造组合在一起的,有时候我们只是需要一块很大的内存,而对象的构造可能在稍后完成。而且这种绑定的操作对于没有默认构造函数的类不能动态分配数组。
allocator 类
标准库 allocator
定义在头文件 memory 中,它帮助我们将内存分配和对象构造分离开来。它提供一种类型感知的内存分配方法,它分配的内存是原始的、未构造的。
allocator<string> alloc; // 可以分配 string 的 allocator 对象
auto const p = alloc.allocate(n); // 分配n个未初始化的 string
这个 allocate 调用申请了能够容纳n个string的动态内存。
allocator 分配未构造的内存
使用 construct
成员函数在给定为止构造对象
auto q = p; // q 指向最后构造的元素之后的位置
alloc.construct(q++); // *q 为空字符串
alloc.construct(q++,10, 'c'); // *q 为 cccccccc
alloc.construct(q++, "hi"); // *q ww hi
还未构造对象的情况下就使用原始内存是错误的:
cout << *p << endl; // 正确
cout << *q << endl; // 灾难:q指向未构造的内存
调用 destroy
销毁构造的对象
while(q != p)
alloc.destroy(--q);
对象被销毁了,但是内存还存在,我们可以继续使用这部分内存。
释放内存通过调用deallocate
来完成:
alloc.deallocate(p, n); // n 需要和分配时的大小一样
拷贝和填充未初始化内存的算法
标准库还为 allocator 类定义了两个伴随算法,可以在未初始化内存中创建对象。
假定有一个 int 的vector ,希望将其内容拷贝到动态内存中。我们将分配一块比 vector 中元素所占空间大一倍的动态内存
// 分配比vi中元素所占空间大一倍的动态内存
auto p = alloc.allocate(vi.size() * 2);
// 通过拷贝 vi 中的元素来构造从p开始的元素
auto q = uninitialized_copy(vi.begin(), vi.end(), p);
// 将剩余元素初始化为 42
uninitialized_fill_n(q, vi.size(), 42);
3. 使用标准库: 文本查询程序
- TODO