C++漫游指南——字符串篇与内存分配篇
目录
字符串篇
char []、char*和string的区别
1. char [](字符数组)
2. char*(字符指针)
char arr[]与char* ptr可修改性的辨析
3、std::string(C++ 标准库的字符串)
const char *、char const *和char * const的区别
1. const char * ptr(或 char const * ptr)
2. char * const ptr
3. const char * const ptr
内存分配篇
malloc的用法
sbrk 和 brk
mmap
混合使用的策略
new的用法
new与malloc的异同
placement new
探究new与delete的隐藏空间
new与malloc
new[]与delete []
彩蛋——变量存储空间大全:
从new分配的视角看各种内存分配
变量分配位置与实际内存存储位置辨析
如何保证类的对象只能被开辟在heap上
如何保证类的对象只能被开辟在stack上
从手动内存分配到RAII
weak_ptr的分析
unique_ptr的分析
内存布局对比
异常安全性
智能指针的安全性
字符串篇
char []、char*和string的区别
1. char []
(字符数组)
char arr[] = "hello";
发生了什么事?
-
"hello"
是一个 字符串字面量,存在程序的.rodata
只读数据段。 -
编译器在生成代码时会把这段字符串的内容
"h" "e" "l" "l" "o" "\0"
拷贝到栈上的arr
数组中。 -
所以:
-
.rodata
中会保留原始"hello"
。 -
arr
中是它的一份 可修改的副本。
-
✅ 也就是说:你写了一个字符串字面量,它必然存在于
.rodata
中;但如果你用它初始化一个数组,它还会被拷贝一次到别处(比如栈上)。
特点
-
char[]
是一个固定长度的字符数组,存储在栈上(如果是局部变量)。 -
char arr[] = "hello";
等价于char arr[] = {'h', 'e', 'l', 'l', 'o', '\0'};
,结尾自动包含\0
(空字符),表示字符串结束。 -
由于数组大小固定,因此不能直接扩展数组的大小。
可变性
-
你可以修改数组中的内容:
arr[0] = 'H'; // 修改后 arr 变成 "Hello"
-
但不能直接用
=
赋值新的字符串:arr = "world"; // ❌ 错误,数组名是常量,不能重新赋值
适用场景
-
适用于固定大小的字符串,不需要动态分配内存的情况。
-
适用于函数内部定义的临时字符串。
2. char*
(字符指针)
char* ptr = "hello";
const char* ptr = "hello"; // 明确告诉编译器:这个字符串不能改 下面这种写法更好
特点
-
char*
是一个指针,指向字符串的首地址,字符串本身存储在只读数据区(只适用于字符串字面量)。 -
"hello"
是字符串字面量,默认存储在只读数据段,ptr
只是指向它的指针。
Tips:这种写法的重点应该是被指向的常量字符串,相当于我们原本就有了个常量字符串“Hello”,我们用ptr只是定位和访问这个字符串,而没有资格去修改这个常量字符串。By the way,C++中字符串字面量(String Literal)和字符串常量(String Constant)可以大致视为同一个意思,都是指字符串不可被修改的特性。
可变性
-
不能修改字符串内容:
ptr[0] = 'H'; // ❌ 未定义行为,可能导致段错误(Segmentation Fault)
-
但可以让
ptr
指向其他字符串:ptr = "world"; // ✅ 正确,ptr 只是指针,指向新的字符串
适用场景
-
适用于只读字符串(如字符串字面量)。
-
适用于字符串存储在动态分配的堆上(用
new
或malloc
)。
动态分配示例
如果要让 char*
存储可修改的字符串,应该使用动态分配:
char* ptr = new char[6]; // 分配 6 个字节存储 "hello"
strcpy(ptr, "hello"); // 复制字符串到堆内存
ptr[0] = 'H'; // ✅ 可以修改
delete[] ptr; // 释放内存
char arr[]与char* ptr可修改性的辨析
char arr[] = "hello"与char* ptr = "hello"
char arr[] = "hello"; // ✅ 这是一个数组,不是常量!
arr[0] = 'H'; // ✅ 可以修改
arr="world" //❌ 未定义行为
关键点:数组的名字 arr
不是变量,而是一个不可修改的地址常量(constant)
arr
不是一个普通变量,它是 数组在栈上的固定地址。所以arr不可以再被绑定到其他位置上,我们只能在arr包含的区域内修改字符。
所以你不能说 arr = ...
,这就像你试图改变常量一样。
char* ptr = "hello"; // 合法,但危险(未定义行为)
const char* ptr2 = "hello"; // 安全,推荐用法
ptr[0]='H' //❌ 未定义行为
关键点:我们可以把*ptr看作为自由人,想指向哪儿就指向哪儿(前提是合法区域),但是“hello”是固定好的常量。我们不能修改“hello”这个常量,只能去访问它。当然,我们可以把“hello”复制到栈区,这样就可以修改这个复制后的字符串了。
3、std::string
(C++ 标准库的字符串)
好用!爱用!无需多言
#include <string>
std::string str = "hello";
这行代码做了什么事?
-
"hello"
是一个字符串字面量 → 存在 只读数据段.rodata
; -
std::string str = "hello";
会调用构造函数,把"hello"
拷贝进去; -
拷贝的内容会被存在
std::string
的内部缓冲区(可能在栈,也可能在堆,取决于实现和字符串长度);
符串太短,直接存在 str
对象内部。会在堆上分配内存(new)来保存内容。
string的本质
class string {char* data_; // 指向字符串数据(可能是栈或堆)size_t size_; // 长度size_t capacity_; // 容量
};
SSO(Small String Optimization)
小字符串优化 是为了避免频繁的堆内存分配而引入的优化技术。
std::string s = "hi"; // 很短!只2个字符
对于这么短的字符串,很多编译器(如 GCC、Clang)会直接把字符串内容放在 std::string
对象内部的缓冲区中 —— 不用堆!
也就是说:
-
整个
std::string
对象就完全在栈上; -
字符数据也在栈上(SSO buffer);
-
没有堆分配(不调用
new
)!
💡 一般 SSO 的长度限制是 15 或 22 字节左右(取决于实现和平台)
const char *、char const *和char * const的区别
const char *
、char const *
和 char * const
这三种 const
修饰方式在 C++ 中是常见的,让人容易混淆。理解它们的核心在于 "const 修饰的是谁"这个规律:
const
作用的是它左边的元素(如果左边没有,就作用右边)。从右往左读,这样更符合 C++ 的语法结构。
详细分析
1. const char * ptr
(或 char const * ptr
)
📌 指针可以修改,但指向的值不能改 🔹 常量数据,指针可变
const char *ptr = "hello"; // ✅ OK
char const *ptr2 = "world"; // ✅ OK,与上面等价
ptr = "new string"; // ✅ OK,可以修改指针指向
ptr[0] = 'H'; // ❌ ERROR,不能修改指向的内容
📌 解析:
-
我们可以把(*ptr)看做是一个整体,这个整体指向一个字符串字面量,const则是修饰这个字符串整体。所以这个字符串这个整体不可以被修改。
-
但是
ptr
本身 不是const
,相当于是个自由人,可以指向其他合法地址。所以ptr
可以指向别的地址。
✅ 等价写法:
char const *ptr = "hello"; // 与 const char *ptr 等价
2. char * const ptr
📌 指针本身是常量,不能修改指向,但指向的值可以改 🔹 变量数据,指针不可变
char buffer[] = "hello";
char * const ptr = buffer; // ✅ OK
ptr[0] = 'H'; // ✅ OK,可以修改内容
ptr = "new string"; // ❌ ERROR,不可以修改指针指向
📌 解析:
-
const
在*
右边,所以修饰ptr
,意味着ptr
不可变,指针不能修改指向。 -
但
ptr
指向的char
没有const
修饰,所以内容可以修改。 -
需要注意的一点是,这里我们先用
buffer[]
来复制一份“hello”这个字符串字面量。然后在用ptr指向buffer
这个数组的首地址,这样ptr如果修改“hello”这个字符串也是修改buffer[]
的内容。 -
切记,
.rodata
只读区的内容是不可以修改的!
3. const char * const ptr
📌 指针和指向的内容都不可改 🔹 常量数据,指针也不可变
const char * const ptr = "hello"; // ✅ OK
ptr = "new string"; // ❌ ERROR,不能修改指针指向
ptr[0] = 'H'; // ❌ ERROR,不能修改指向的内容
📌 解析:
-
const char *
使得 指向的内容不可变。 -
* const
使得 指针本身不可变。 -
因此,指针指向的地址和内容都不能修改。
内存分配篇
谈到C++的内存分配,核心就是辨析malloc、new的用法。下面就来具体讲讲这两者到底有什么区别
+-------------------------+ | Stack | ⬆️ 高地址 +-------------------------+ | 共享库的内存映射区域 | <----匿名 `mmap()` 分配的地址通常在这里 +-------------------------+ | Heap | +-------------------------+ | BSS (未初始化数据) | +-------------------------+ | Data (已初始化数据) | +-------------------------+ | Text (代码段) | ⬇️ 低地址 +-------------------------+
malloc的用法
malloc
是 C 语言中用于动态内存分配的函数,它在运行时从内存中分配指定字节数的连续内存块,并返回该内存块的指针。malloc
不会初始化分配的内存内容,初始值是未定义的。
使用 malloc
后,程序员需要手动释放分配的内存(使用 free
函数),否则会造成内存泄漏。此外,若分配失败,malloc
会返回 NULL
,因此调用后应检查返回值是否为 NULL
以确保分配成功。
malloc
的底层是由sbrk/brk与mmap协同实现的。当用户分配的内存小于128K时,系统会通过brk()
这个系统调用从heap中分配内存;当用户分配的内存大于128K时,系统则会调用mmap()
在文件映射区(共享库文件映射区)来分配内存。
sbrk 和 brk
简介
-
sbrk()
和brk()
是 底层系统调用,用于管理进程的堆区(Heap)。 -
堆区是从低地址向高地址扩展的,
brk
指定了堆区的末尾。 -
sbrk
会在堆区尾部申请或释放内存。
原理
-
brk()
:直接设置堆的结束位置。 -
sbrk()
:在当前brk
值的基础上移动,调整堆的大小。
示例
#include <unistd.h>
#include <stdio.h>
int main() {void *initial_brk = sbrk(0); // 获取当前 brkprintf("Initial break: %p\n", initial_brk);
void *new_brk = sbrk(1024); // 分配 1KB 内存printf("New break: %p\n", new_brk);
// 释放内存sbrk(-1024);printf("Break after releasing: %p\n", sbrk(0));
}
缺点:
brk
主要用于扩展和管理堆区,但是它有以下限制:
-
堆区连续性要求
-
brk
将堆区的结束位置向高地址移动或缩小,分配的内存必须是连续的虚拟内存地址。 -
如果堆区后方存在已分配的内存空间(如共享库、线程栈等),就无法继续扩展堆区。
-
-
难以释放内存
-
brk
只能通过收缩brk
指针来释放内存,且必须按分配的顺序释放。 -
如果中间某个区域仍在使用,就无法回收前面的空闲内存,导致内存碎片化。
-
-
无法映射文件
-
brk
仅仅提供堆区内存,无法直接映射文件到内存。 -
对于文件 I/O 和共享内存场景,
mmap
更加合适。
-
-
不支持大内存分配
-
大量分配内存时,
brk
的效率低下。现代操作系统通常使用mmap
分配超过 128KB 的内存。
-
brk 和 sbrk 的系统调用流程
假设程序中调用了以下代码:
sbrk(1024); // 分配 1KB 内存
调用过程如下:
用户空间调用:
用户空间的
sbrk
函数会通过glibc
触发系统调用。
glibc
使用syscall
指令将控制权转交给内核。进入内核空间:
系统调用号会存入寄存器中,内核根据该号找到对应的
sys_brk()
。执行内核代码:
sys_brk()
会检查是否有足够的虚拟地址空间。调用
do_brk()
在堆区后分配物理内存,并更新进程的堆结束地址。返回用户空间:
分配成功后,内核将结果返回给用户空间。
mmap
这里笔者主要是分析用mmap进行匿名空间分配的过程,对于文件映射等操作则是一带而过,为的是不让读者陷入到细节陷阱里面去。如果对于mmap想要有全面的认识,可以自行搜索mmap的完整用法。
简介
-
mmap()
是一个更高级的内存映射函数。它不仅用于内存分配,还能直接映射文件到内存。 -
常用于大块内存的分配、文件 I/O 优化、共享内存等场景。
-
一般malloc调用的mmap就是匿名映射。
原理
-
mmap()
直接在虚拟地址空间中分配内存页。 -
使用虚拟内存管理机制,绕过了堆区管理,更加灵活。
示例
#include <sys/mman.h>
#include <stdio.h>
int main() {size_t size = 4096; // 4KBvoid *ptr = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);if (ptr == MAP_FAILED) {perror("mmap failed");return 1;}
printf("Memory allocated at: %p\n", ptr);
// 使用内存int *arr = (int*)ptr;arr[0] = 42;printf("Value at ptr[0]: %d\n", arr[0]);
// 释放内存munmap(ptr, size);
}
参数解析
-
NULL
:由系统选择内存地址 -
size
:映射的内存大小 -
PROT_READ | PROT_WRITE
:可读可写 -
MAP_ANONYMOUS
:匿名映射,不映射文件 -
MAP_PRIVATE
:私有映射,不会共享
缺点
-
性能开销更高
-
mmap
需要调用内核,涉及内核空间和用户空间的切换,开销较高。 -
对于小内存分配,如几百字节的内存,
brk
的效率更高。
内存管理复杂
-
使用
mmap
分配的内存不归属于堆区,管理和追踪这些区域更加复杂。 -
malloc
中使用brk
分配小块内存,再通过内部管理机制(如 free list)优化分配效率。
地址空间浪费
-
mmap
分配的内存通常按页(4KB)对齐,会有一定的内存对齐浪费。 -
使用
brk
可以更加精细地管理小块内存,减少浪费。
-
mmap的系统调用流程
假设程序中调用了以下代码:
mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0))
调用过程如下:
用户空间调用**:
用户程序调用
mmap
,底层通过glibc
库。
glibc
最终执行syscall
指令,请求内核执行系统调用mmap
。进入内核空间:
syscall
传递系统调用号和参数到内核。内核根据调用号找到
sys_mmap()
或sys_mmap_pgoff()
函数。执行内核逻辑:
sys_mmap
检查参数是否合法,比如长度、保护标志、映射类型等。调用内部的
do_mmap()
,在进程的虚拟地址空间找到合适位置插入新的 VMA(虚拟内存区域,vm_area_struct
)。配置页表项,映射虚拟地址到物理地址。设置页属性(只读、可写、共享等)。
如果是
MAP_ANONYMOUS
,不会关联任何文件,只是映射一段纯粹的物理内存页(可能还伴随懒分配,即按需分配)。可能需要更新页表(或延迟到实际访问时建立页表项)。
返回用户空间:
返回新映射区域的首地址,供用户程序使用。
混合使用的策略
现代内存分配器(如 glibc
中的 ptmalloc
)会根据内存分配的大小,灵活选择 brk
或 mmap
:
-
小内存分配(<= 128KB): 使用
brk
分配堆区内存,通过内部的空闲链表管理小块内存,效率更高。 -
大内存分配(> 128KB): 使用
mmap
直接映射内存页,减少内存碎片化,同时便于回收。 -
内存释放:
-
小块内存通过
free
归还到堆区,供后续复用。 -
大块内存直接通过
munmap
释放,归还给操作系统。
-
new的用法
new
是 C++ 中用于动态内存分配的运算符,它不仅在堆上分配内存,还会自动调用构造函数初始化对象,适用于类和复杂数据结构的创建。它返回的是类型安全的指针,避免了类型转换的问题。
与 C 语言中的 malloc
相比,new
更加符合 C++ 的面向对象特性,并在分配失败时抛出 std::bad_alloc
异常(除非使用 nothrow
)。释放时必须配对使用 delete
或 delete[]
,否则可能会导致内存泄漏或未定义行为。
C++的new其实就是一个操作符,我们可以自行对它进行重载
void* operator new(std::size_t size) {return malloc(size); // C++ 默认 `operator new` 使用 `malloc`
}
这里 size
大小参数是编译器自动推导的。
new与malloc的异同
举个例子:
MyClass* obj = new MyClass();
实际上 new
做了两件事:
-
调用
malloc
(或operator new
):为MyClass
对象分配足够的内存。 -
调用
MyClass
的构造函数:在分配的内存上构造MyClass
对象。
而 malloc
由于不会主动调用类的构造函数,所以我们在使用 malloc
进行内存分配后,还需要手动调用构造函数。例如
MyClass* obj = (MyClass*)malloc(sizeof(MyClass));
只分配了一块内存,但不会调用 MyClass
的构造函数。
obj
只是一个未初始化的内存块,使用前必须手动构造:
new(obj) MyClass(); // 使用 placement new 调用构造函数
这里placement new
的用法乍一看十分奇怪,实际上确实是有点怪,下一节将进行具体的分析。
#include<iostream>
using namespace std;
struct MyStruct {int a;double b;MyStruct(int x,int y):a(x),b(y){cout<<"构造函数被调用"<<endl;}
};
int main(){MyStruct *s1=new MyStruct(1,2);MyStruct *s2=(MyStruct*)malloc(sizeof(MyStruct)); // 生成一块地址并声明类型// new(s2) MyStruct(3,4); // placement new来调用构造函数cout<<s2->a<<" "<<s2->b<<endl; return 0;
}
输出结果:
构造函数被调用
0 0(随机值)
new[]
如何用malloc
进行等价替换
MyClass* arr = new MyClass[3]; // 自动调用 3 次构造函数
delete[] arr; // 自动调用 3 次析构函数
如果用malloc
来写就显得复杂不少了
MyStruct *s4 = (MyStruct*)malloc(sizeof(MyStruct) * 3);
// 在每个位置 placement new
for (int i = 0; i < 3; ++i) {new (&s4[i]) MyStruct(); // 这里一定要取地址,因为new是对一块地址空间进行操作// new(s4+i) MyStruct(); // 这种写法也是可以的
}
// 使用 s4[0], s4[1], s4[2]
// 手动析构
for (int i = 0; i < 3; ++i) {s4[i].~MyStruct();
}
free(s4);
placement new
placement new 是 C++ 中一种特殊形式的 new
,它允许在一块已分配好的内存上构造对象,而不是分配新内存。用法是:
new (p) MyClass(args...);
其中 p
是一块有效内存地址。placement new 不会申请新内存,只是在 p
所指的位置上调用对象的构造函数,因此需要手动管理内存释放和对象析构。常用于内存池、性能优化等场景。
下面是内存池的实例代码
#include <iostream>
#include <new> // std::nothrow, placement new
#include <cstddef> // std::size_t
#include <cassert> // assert
template<typename T, std::size_t N> // 内存池只能分配N个T类型的元素
class MemoryPool {
private:alignas(T) unsigned char buffer[N * sizeof(T)]; // 原始大内存块bool used[N] = {}; // 使用标志
public:MemoryPool() = default;~MemoryPool() {// 析构已构造的对象for (std::size_t i = 0; i < N; ++i) {if (used[i]) {T* ptr = reinterpret_cast<T*>(buffer + i * sizeof(T));ptr->~T();}}}
template<typename... Args> // 模板参数包:可以接受任意数量、任意类型的参数。T* allocate(Args&&... args) { // 每一个参数都是转发引用(万能引用)for (std::size_t i = 0; i < N; ++i) {if (!used[i]) {used[i] = true;void* place = static_cast<void*>(buffer + i * sizeof(T));// 在指定位置构造对象return new (place) T(std::forward<Args>(args)...);}}return nullptr; // 没有空闲位置}
void deallocate(T* ptr) {if (!ptr) return;std::size_t index = (reinterpret_cast<unsigned char*>(ptr) - buffer) / sizeof(T);assert(index < N && used[index]); // 确保指针在池内
ptr->~T(); // 手动析构对象used[index] = false;}
};
/
struct MyStruct {int x;MyStruct(int val) : x(val) {std::cout << "MyStruct constructed with x=" << x << std::endl;}~MyStruct() {std::cout << "MyStruct destroyed with x=" << x << std::endl;}
};
int main() {MemoryPool<MyStruct, 5> pool; // 一个存5个MyStruct对象的内存池
MyStruct* a = pool.allocate(1);MyStruct* b = pool.allocate(2);MyStruct* c = pool.allocate(3);
pool.deallocate(b);pool.deallocate(a);pool.deallocate(c);
return 0;
}
这里buffer
是一大块连续内存(对齐到 T 类型),预分配好。
allocate()
:
-
找到一个空闲槽。
-
用
placement new
在那块内存上原地构造一个T
类型对象。
deallocate()
:
-
手动调用对象的析构函数。
-
把那一槽标记为可用。
探究new
与delete
的隐藏空间
new与malloc
delete
的底层原理
delete
其实也可以看做是一个封装了free
的包装函数
void operator delete(void* ptr) noexcept {free(ptr);
}
调用过程也是两步走:
1、调用析构函数(如果是对象的话)
-
如果
p
指向一个对象(例如MyClass* p
),则首先调用p->~MyClass()
,手动执行析构函数,完成资源清理。
2、释放内存(调用 operator delete
)
-
调用全局或重载版本的
operator delete(ptr)
来把内存释放回堆(通常是归还给 malloc 管理器或操作系统)。
如果是如果是单独的 new
/delete
,例如
MyStruct* p = new MyStruct();
delete p;
通常:
-
没有额外隐藏数据(也不需要记录个数)。
-
直接调用析构函数,释放这块内存。
因为单个对象,delete
只要析构自己,不需要知道"个数"。
所以,隐藏空间这个行为,主要是 new[]
/delete[]
专有的。
new[]与delete []
当我们写
MyStruct* arr = new MyStruct[10];
实际上,new[]
分配的内存块结构大致是这样的:
[隐藏的元信息] [对象0] [对象1] [对象2] ... [对象9]
隐藏的元信息一般是一个整数(比如 size_t
类型,通常是 8字节或16字节,在 64位机器上经常是16字节对齐),记录数组中有多少个元素(这里是 10
)。然后返回「对象们的起始位置」给我们用(跳过头部)。
低地址
+-----------------+
| 数组元素个数 size|
+-----------------+ <- 真正malloc返回的p
| obj[0] |
| obj[1] |
| obj[2] |
| ... |
+-----------------+
高地址
这样,当我们调用 delete[] arr
时,编译器/运行时就知道:
-
有 10 个元素需要循环调用析构函数。
这块隐藏信息位于用户拿到的指针(arr
)之前的一小段空间。
而delete[]
先得到我们传的指针(比如 arr
)。
往前偏移固定的字节数(比如 16 字节)或根据对齐要求,找到隐藏的数组大小信息。
读取出元素数量,比如 10
。
循环调用每个元素的析构函数。
最后,整体释放内存(通常调用 operator delete[]
→ free
)。就像我们刚才手动用malloc模拟new[]那样。
彩蛋——变量存储空间大全:
先来看进程的典型内存布局(从低地址到高地址,忽略了首尾部分)
低地址
+-------------------------+
| 起始部分 |
+-------------------------+ 0x40000000 (4MB,纪念8086处理器与i386处理器的内存分水岭)
| Text 段 | → 存放代码(函数)
+-------------------------+
| Rodata 段 | → 只读数据(如 const 字符串)
+-------------------------+
| Data 段 | → 初始化的全局 / static 变量
+-------------------------+
| BSS 段 | → 未初始化的全局 / static 变量
+-------------------------+
| Heap 堆 | → 动态分配内存(如 new/malloc)
+-------------------------+
| ... |
| Stack 栈 | → 局部变量、函数调用栈帧
+-------------------------+
| 内核代码和数据 |
|------------------------| → 对每个进程都一样
| 物理内存 |
+-------------------------+
| 与进程相关的数据结构 |
| (页表、内核栈信息等等) | → 每个进程都不相同
+-------------------------+
高地址
变量类型 | 是否初始化 | 存储区域 | 举例 |
---|---|---|---|
全局变量 | 初始化 | .data 段 | int g = 10; |
全局变量 | 未初始化 | .bss 段 | int g; |
static 局部变量 | 初始化 | .data 段 | static int x = 5; |
static 局部变量 | 未初始化 | .bss 段 | static int x; |
局部变量(非 static) | 任意 | 栈(Stack) | int a = 1; |
const 全局变量 | 初始化 | .rodata 段 | const int x = 42; |
const 局部变量 | 任意 | 栈(Stack) | const int y = 5; |
const static 局部变量 | 初始化 | .rodata 段或.data 段 | static const int x = 100; |
new/malloc 分配 | 动态 | 堆(Heap) | int* p = new int(5); |
好好记,这可是要考的。
从new
分配的视角看各种内存分配
看完了上述new
与malloc
的各种辨析,能读到这里的相比也是半步内存分配高手了。下面就来讲讲C++在用到new时的各种情况底层分析。
变量分配位置与实际内存存储位置辨析
C++ 变量的分配位置(栈 or 堆)和实际存储内容的位置(栈 or 堆)并不总是一致,有时变量本身在栈上,而它指向的数据却在堆上。
1、变量在栈上,数据在堆上
#include <iostream>
using namespace std;
int main() {int* p = new int(42); // `p` 在栈上,但 `*p` 存在堆上cout << "p: " << &p << " (栈上)" << endl;cout << "*p: " << p << " (堆上)" << endl;delete p;
}
p: 0x7ffc5b5c3a00 (栈上)
*p: 0x56324b0c4eb0 (堆上)
2、STL 容器(如 std::vector
)
#include <vector>
#include <iostream>
int main() {std::vector<int> vec = {1, 2, 3, 4}; // `vec` 在栈上,但存储数据在堆上std::cout << "vec: " << &vec << " (栈上)" << std::endl;std::cout << "vec data: " << vec.data() << " (堆上)" << std::endl;
}
如何保证类的对象只能被开辟在heap上
这属于是经典问题了,现在咱们就从底层内存分配的视角来理解这个问题的本质以及具体的解决方案。
方法一:将构造函数设为 private
或 protected
原理:通过将构造函数和析构函数设为 private
或 protected
,禁止外部直接删除对象。只能通过类内部提供的 delete
来释放内存。
限制:对象不能在栈上创建,因为栈上的对象会在离开作用域时自动调用析构函数。
class HeapOnly {
public:static HeapOnly* Create() {return new HeapOnly();}
void Destroy() {delete this; // 👈 只有成员函数内部能访问 private 的析构}
private:HeapOnly() = default;~HeapOnly() = default;
};
int main(){HeapOnly* obj = HeapOnly::Create();// 使用 obj ...obj->Destroy(); // 👈 正确销毁return 0;
}
方法二:智能指针
原理:强制通过智能指针返回对象,确保对象在堆上分配,同时避免手动管理内存。
#include <memory>
class HeapOnly {
public:static std::unique_ptr<HeapOnly> Create() {return std::unique_ptr<HeapOnly>(new HeapOnly());}
private:HeapOnly() = default;~HeapOnly() = default;
};
总的来说,上述两种方法本质都是通过控制new
方法来达到将对象分配至栈上的目的。因为智能指针的本质其实也是包装new
,下面我们就开始探究智能指针的本质了。
如何保证类的对象只能被开辟在stack上
也是从new
着手——重载new
操作符就可以了。
void* operator new(std::size_t size) {cout<<" Do nothing"<<endl; // C++ 默认 `operator new` 使用 `malloc`
}
从手动内存分配到RAII
博主博主,你的
new
确实更加强大,但还是太吃手动释放了。有没有更加简单又强势的内存管理推荐一下吗?有的兄弟,有的。这么强的内存管理方式当然不止一个了,还有三个。都是当前版本t0的内存分配方式。
C++ 的智能指针(Smart Pointer)是 RAII(Resource Acquisition Is Initialization) 的实现之一,用于自动管理动态分配的内存,防止内存泄漏和悬空指针问题。智能指针位于 <memory>
头文件中,主要包括以下三种类型:
-
std::unique_ptr
- 独占所有权 -
std::shared_ptr
- 共享所有权 -
std::weak_ptr
- 弱引用,防止循环引用 -
std::auto_ptr
- 复制时会转移所有权( 在C++17中被标记为deprecated,不建议使用 开除智能指针籍 )
由于本次内容侧重理解智能指针的底层内存分配逻辑,所以对于智能指针的具体用法就不详细说明了。
shared_ptr
的分析
std::shared_ptr
允许多个 shared_ptr
实例共享同一个对象。当所有 shared_ptr
都被销毁时,资源才会释放。
#include <iostream>
#include <memory>
struct Bar {Bar(int x) : x_(x) { std::cout << "Bar(" << x_ << ") constructed\n"; }~Bar() { std::cout << "Bar(" << x_ << ") destroyed\n"; }int x_;
};
int main() {std::shared_ptr<Bar> ptr1 = std::make_shared<Bar>(20); // 推荐使用 std::make_sharedstd::shared_ptr<Bar> ptr2 = ptr1; // 共享所有权std::cout << "ptr1->x_ = " << ptr1->x_ << ", use_count = " << ptr1.use_count() << '\n';
ptr1.reset(); // ptr1 释放资源,但 ptr2 仍然持有std::cout << "After ptr1.reset(), use_count = " << ptr2.use_count() << '\n';
return 0;
}
关键点:
-
std::make_shared<T>(args...)
也是推荐的创建方式(更高效,减少额外的内存分配)。 -
共享对象的
use_count()
记录当前shared_ptr
的引用计数。 -
只有当
use_count()
变为0
时,资源才会释放。
shared_ptr
、weak_ptr
的内部结构一般都包含两大部分:
-
对象本体(Object)
-
被管理的实际对象,通常是
new T(...)
生成的堆上内存。
-
控制块(Control Block)
-
存放引用计数(包括强引用和弱引用)及其他元信息。
-
是智能指针生命周期和线程安全的关键。
控制块的内部结构
控制块是一个 由标准库实现并隐藏 的结构,里面通常包含:
struct ControlBlock {size_t strong_count; // shared_ptr 的数量size_t weak_count; // weak_ptr 的数量(注意:weak_count 初始值就是1)void (*deleter)(void*); // 删除资源的函数指针void* ptr; // 指向实际对象
};
-
strong_count
:表示当前有多少个shared_ptr<T>
共享这个资源。 -
weak_count
:表示当前有多少个weak_ptr<T>
+ 1。这个+1
是为了维持control block
本身的生命周期,即使strong_count == 0
也不立刻释放控制块。 -
deleter
:用于删除对象,默认是delete
,但你也可以自定义。 -
ptr
:就是shared_ptr
指向的那个堆对象。
多个 shared_ptr<T>
指针可以共享同一个控制块,并增加共享引用计数(strong_count
)。
而 weak_ptr<T>
也是指向同一个控制块,但不会增加 strong_count
,只是增加weak_ptr
。
weak_ptr
的分析
std::weak_ptr
是 std::shared_ptr
的一种非拥有型指针,不会增加引用计数。它用于解决 shared_ptr
之间的 循环引用问题。
#include <iostream>
#include <memory>
struct Widget {Widget(int x) : x_(x) { std::cout << "Widget(" << x_ << ") constructed\n"; }~Widget() { std::cout << "Widget(" << x_ << ") destroyed\n"; }int x_;
};
int main() {std::shared_ptr<Widget> sp = std::make_shared<Widget>(30);std::weak_ptr<Widget> wp = sp; // 不影响 use_count
std::cout << "use_count = " << sp.use_count() << '\n';
if (auto spt = wp.lock()) { // 检查 weak_ptr 是否仍然有效std::cout << "spt->x_ = " << spt->x_ << '\n';}
sp.reset(); // 释放资源
if (wp.expired()) {std::cout << "wp expired\n";}
return 0;
}
关键点:
-
std::weak_ptr
不能直接访问资源,需要lock()
获取std::shared_ptr
。 -
expired()
用于检查对象是否已经被释放。
本质:
-
共享控制块
-
只增加
weak_count
,不会影响资源的释放 -
通过
lock()
升级为shared_ptr
,如果资源还活着
unique_ptr
的分析
std::unique_ptr
是独占所有权的智能指针,即同一时间只能有一个 unique_ptr
指向某个对象。一旦 unique_ptr
被销毁,所管理的对象也会被释放。如果你不知道应该用哪种智能指针,就优先使用unique_ptr
防止出现内存泄漏。
unique_ptr只负责一个指针的生命周期:
-
析构时,直接调用
delete
或delete[]
-
所以它的行为更类似于一个
RAII
封装的裸指针
#include <iostream>
#include <memory>
struct Foo {Foo(int x) : x_(x) { std::cout << "Foo(" << x_ << ") constructed\n"; }~Foo() { std::cout << "Foo(" << x_ << ") destroyed\n"; }int x_;
};
int main() {std::unique_ptr<Foo> ptr1 = std::make_unique<Foo>(10); // 推荐使用 std::make_uniquestd::cout << "ptr1->x_ = " << ptr1->x_ << '\n';
// std::unique_ptr 不能被复制// std::unique_ptr<Foo> ptr2 = ptr1; // ❌ 编译错误
// 但可以通过 std::move 转移所有权std::unique_ptr<Foo> ptr2 = std::move(ptr1);if (!ptr1) {std::cout << "ptr1 is now empty\n";}return 0;
}
控制块信息:
std::unique_ptr<T>
是独占所有权的智能指针,不允许多个指针同时拥有同一个对象。因此 它不需要记录引用计数,也不需要维护额外的共享控制信息。
虽然unique_ptr
没有控制块,但它也有deleter
指针,所以一个unique_ptr
大小也是16,和shared_ptr
一样
struct testSize{public:int a_;double b_=0;double c_=222;testSize(int a):a_(a){};
};
int main() {auto test1=std::make_shared<testSize>(10);std::cout<<"size1: "<<sizeof(test1)<<std::endl; // 这里输出的是智能指针中:对象指针+控制块的大小auto test2=std::shared_ptr<testSize>(new testSize(20));std::cout<<"size2: "<<sizeof(test2)<<std::endl;
cout<<"test1"<<(*test1).a_<<endl; // 这里用.访问的事test本身,用->才是指向的对象
auto test3=new testSize(30);std::cout<<"size3: "<<sizeof(test3)<<" "<<sizeof(*test3)<<std::endl; // 存在内存对齐
auto test4=std::make_unique<testSize>(40);std::cout<<"size4: "<<sizeof(test1)<<std::endl; // deleter指针+对象指针std::cout << test4->a_ << std::endl; // 推荐方式return 0;
}
输出如下:size1: 16
size2: 16
test110
size3: 8 24
size4: 16
40
new
和 make_unique/make_shared
的区别
了解了智能指针的本质,现在就可以进一步探讨new
和make_xxx
的区别了。
内存布局对比
std::shared_ptr<Foo> sptr1(new Foo(10)); // 两次分配
Heap Memory:
[ Foo (对象) ] <- new 分配
[ 控制块 ] <- shared_ptr 额外分配
std::shared_ptr<Foo> sptr = std::make_shared<Foo>(10); // 一次分配
Heap Memory:
[ 控制块 + Foo (对象) ]
std::make_shared
直接分配了一个连续的内存块,提高了缓存效率和访问速度。
异常安全性
std::shared_ptr<Foo> sptr1(new Foo(10)); // 两次分配
sp1
可能未构造完成,导致 new Foo(20)
分配的内存泄漏。
std::shared_ptr<Foo> sp1 = std::make_shared<Foo>(10);
构造 shared_ptr
和 Foo
作为一个原子操作,不会出现 new
时分配的内存泄漏问题。
无法使用make_shared
的情况
1. 使用自定义删除器时
std::make_shared<T>
无法指定自定义删除器,而 std::shared_ptr<T>
构造函数 std::shared_ptr<T>(new T, deleter)
允许自定义删除方式。
#include <iostream>
#include <memory>
void customDeleter(int* ptr) {std::cout << "Custom Deleter: deleting int\n";delete ptr;
}
int main() {// 不能使用 std::make_shared<int>() 指定删除器std::shared_ptr<int> ptr(new int(10), customDeleter);
}
✅适用场景:
-
需要 自定义释放逻辑(例如关闭文件、释放数据库连接)。
-
需要 非
delete
释放(如free()
、CloseHandle()
、fclose()
)。
为什么 std::make_shared<T>()
不行?
-
std::make_shared<T>()
总是使用delete
释放对象,无法指定自定义删除器。
2. 使用 std::shared_ptr<T>
管理栈对象
不能用 std::make_shared<T>
或 std::shared_ptr<T>
直接管理栈上的对象,否则会导致重复释放(未定义行为)。
#include <memory>
int main() {int x = 10;std::shared_ptr<int> ptr(&x); // ❌ 不能用 shared_ptr 管理栈对象!
}
❌ 错误原因:
-
栈对象
x
会在main()
结束时被销毁,而shared_ptr<int>
也会尝试释放它,导致二次释放(double free)。
✅ 正确做法:
-
让
shared_ptr<T>
只管理堆对象,用std::make_shared<int>(10)
创建。
3. 需要创建 std::shared_ptr<T[]>
(数组)
std::make_shared<T>()
不支持动态数组,因为它不提供 std::shared_ptr<T[]>
版本。必须使用 std::shared_ptr<T>(new T[N])
。
#include <iostream>
#include <memory>
int main() {// std::make_shared<int[]>(10); // ❌ 编译错误!
std::shared_ptr<int[]> arr(new int[10]); // ✅ 正确arr[0] = 42;std::cout << arr[0] << std::endl;
}
✅ 适用场景:
-
需要使用
std::shared_ptr<T[]>
管理数组。
❌ 为什么 std::make_shared<T>()
不行?
-
std::make_shared<T>()
不支持数组,只能管理单个对象。
4.当构造函数是保护或者私有的时
当类的构造函数是 private
或 protected
时,std::make_shared<T>()
无法访问构造函数,因此不能使用 std::make_shared<T>()
创建对象。
std::make_shared<T>(...)
需要直接调用 T
的构造函数,但如果 T
的构造函数是 private
或 protected
,编译器会报错:
-
std::make_shared<T>()
不能访问private
/protected
构造函数。 -
只有
T
的友元(friend)函数或类可以访问private
/protected
构造函数。
解决方案
使用 std::shared_ptr<T>(new T(...))
如果 T
的构造函数是 private
/ protected
,可以在 T
的静态工厂方法中使用 new
:
#include <memory>
#include <iostream>
class MyClass {
private:MyClass() { std::cout << "MyClass Constructor\n"; } // ✅ 私有构造
public:static std::shared_ptr<MyClass> CreateInstance() {return std::shared_ptr<MyClass>(new MyClass()); // ✅ 手动 new}
};
int main() {auto obj = MyClass::CreateInstance();
}
✅ 正确:
-
CreateInstance()
可以访问private
构造函数。 -
使用
new MyClass()
生成对象。
智能指针的安全性
std::shared_ptr
的引用计数(use_count
)是原子的,多个线程可以安全地共享和管理 shared_ptr
本身的生命周期。
但是 shared_ptr
指向的对象(管理的资源)并不一定是线程安全的,多个线程同时访问或修改它可能会导致数据竞争。
1. shared_ptr
本身是线程安全的
std::shared_ptr
的引用计数是线程安全的,因为它内部使用了原子操作来管理 use_count
。 这意味着:
-
你可以在多个线程中拷贝
shared_ptr
,它的use_count
会被安全地增加。 -
你可以在多个线程中销毁
shared_ptr
,当use_count
变为 0 时,对象会被安全释放。
示例:多个线程共享 shared_ptr
,但不会修改资源
#include <iostream>
#include <memory>
#include <thread>
void threadFunc(std::shared_ptr<int> sp) {std::cout << "Thread use_count: " << sp.use_count() << std::endl;
}
int main() {auto sp = std::make_shared<int>(42);
std::thread t1(threadFunc, sp);std::thread t2(threadFunc, sp);
t1.join();t2.join();
std::cout << "Main use_count: " << sp.use_count() << std::endl;
}
输出示例:Thread use_count: 3
Thread use_count: 3
Main use_count: 3
解释:
-
shared_ptr<int>
被多个线程共享,每个线程增加use_count
,但不会修改int
,所以是安全的。
2. 但 shared_ptr
指向的对象不一定是线程安全的
虽然 shared_ptr
本身是线程安全的,但它管理的对象(资源)可能不是线程安全的。 如果多个线程同时访问和修改资源,就会发生数据竞争(Race Condition)。
示例:多个线程修改 shared_ptr
指向的对象(数据竞争)
#include <iostream>
#include <memory>
#include <thread>
void threadFunc(std::shared_ptr<int> sp) {(*sp)++; // ❌ 多个线程修改同一个 int,导致数据竞争
}
int main() {auto sp = std::make_shared<int>(0);
std::thread t1(threadFunc, sp);std::thread t2(threadFunc, sp);
t1.join();t2.join();
std::cout << "Final value: " << *sp << std::endl; // ❓ 可能是 1 或 2,不确定
}
-
sp
被多个线程共享,但*sp
(int
值)是非线程安全的。 -
(*sp)++
不是原子操作,可能会导致数据竞争,最终结果不确定。
关于内存分配的大致总结就是这么多了,个人能力有限,如果有纰漏或者错误的地方还请指正。有一说一把typora的内容直接复制过来有些字体就变得很怪了,而且也没看到CSDN有啥改字体的地方。如果需要原本的md文件可以在评论区留言~