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_ptrunique_ptr 的操作

image-20221221133226433

image-20221221133235778

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. 程序知道所需对象的准确类型
  3. 程序需要在多个对象间共享数据

1.2 直接管理内存

C++ 语言定义了两个运算符来分配和释放动态内存,newdelete.

使用 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));	// 正确
}

image-20230311221752675

image-20230311222725121

不用混用普通指针和智能指针

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 特有的操作

image-20230312112134446

定义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 不支持赋值

虽然我们不能拷贝或赋值,但可以通过 releasereset 将指针的所有权从一个(非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 被销毁,对象就会被释放。

image-20230312135958589

当我们创建一个 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[] 分配的内存为动态数组,但是我们并未得到一个数组类型的对象,而是得到一个数组类型的指针。因此不能对此动态数组调用 beginend ,同样也不能用范围 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[] 销毁

image-20230312144850878

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 中,它帮助我们将内存分配和对象构造分离开来。它提供一种类型感知的内存分配方法,它分配的内存是原始的、未构造的。

image-20230312151818068

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 类定义了两个伴随算法,可以在未初始化内存中创建对象。

image-20230312153629161

假定有一个 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