一篇带你学会C++:new/delete的使用和内层管理的底层逻辑!

1.C++的内存分布

在现代操作系统(Linux / Windows / macOS)和常见 64 位/32 位程序模型下,应用的虚拟地址空间通常可以被划分为若干段(从低地址到高地址,实际地址范围由 OS + ASLR 决定):

1.1总体运行时的内存分布

// 
低地址
┌─────────────────────────────┐
│ 程序代码区(text)          │    ← 只读,存放机器指令
├─────────────────────────────┤
│ 只读常量/只读数据(rodata) │    ← 字符串字面量、const 全局等
├─────────────────────────────┤
│ 初始化数据段(.data)       │    ← 已初始化的全局/静态变量
├─────────────────────────────┤
│ 未初始化数据段(.bss)      │    ← 未初始化或为 0 的全局/静态变量
├─────────────────────────────┤
│ 堆(heap) ↑                │    ← 动态分配(malloc/new),向高地址扩张(通常)
│                             │
│      (动态分配区)         │
│                             │
├─────────────────────────────┤
│  [ 映射区:mmap 文件/共享库 ]│    ← 通常在堆上方或在高地址部分映射
├─────────────────────────────┤
│  栈(stack) ↓              │    ← 每个线程通常有自己的栈,向低地址扩张(通常)
└─────────────────────────────┘


1.2分区详解

1.2.1代码区

存放:可执行文件的机器代码(函数、指令)

权限:通常 可执行、只读(避免被修改)。

生命周期:程序加载到内存后一直存在直到进程结束。
说明:多个同程序进程可共享该段(节省物理内存)。JIT 或自修改代码会有特殊处理(需要可写可执行页面,通常不推荐)。

1.2.2只读常量区

存放字符串字面量(“hello”)、const 全局常量

权限:只读(写入会导致段错误)。

生命周期:程序整个生命周期。

示例

// const char* s = "hello"; // "hello" 存在 

1.2.3数据段与 BSS 段

存放:
.data(已初始化数据段):存放带初始值的全局/静态变量,例如 int g = 5;

.bss(未初始化/零初始化):存放未显式初始化的全局/静态变量,或初始化为 0 的变量(节约可执行文件大小)

生命周期:程序启动→结束

访问:和代码区类似,地址固定,方便调试与外部工具查看(如 readelf、objdump)

1.2.4堆(Heap / 动态区)

存放动态分配的内存(malloc / calloc / realloc / new)。

生命周期:从分配到释放(程序员或库负责释放),否则为内存泄漏直到进程结束。

增长方向(典型):向高地址增长(brk/sbrk 调整程序 break),但现代 allocator 也会使用 mmap 在任意位置分配内存(尤其是大块或线程/共享内存)。

分配器:glibc 的 malloc、tcmalloc、jemalloc、mimalloc 等实现不同策略(bins、slab、ptmalloc),影响性能与碎片。

特点

  1. 比栈慢(有锁、元数据处理)
  2. 大对象通常通过匿名 mmap 分配(便于释放回操作系统)
  3. 存在碎片(内存被分成很多小块导致无法连续使用)

示例:

// 
int* p = new int(10);         // C++ heap(调用 operator new)
void* q = malloc(1024);       // C heap

1.2.5栈(Stack)

存放:函数局部变量函数参数(按 ABI)、返回地址保存的寄存器(callee/caller-saved)、帧指针(可选)等。

生命周期:进入函数时分配,离开函数时自动释放。

增长方向(典型):向低地址增长(某些体系可反向)。

特点

  1. 分配/释放快速(在指针上增加/减少)
  2. 空间有限(线程栈大小通常是几百 KB 到几 MB),递归或大局部数组易导致栈溢出(stack overflow)
  3. 栈帧(stack frame)布局受 ABI 和编译器优化影响(有无 frame pointer)

示例:

// 
void foo() {
    int a = 10;        // 存放在栈
    int arr[1000];     // 大数组也在栈(易溢出)
}

1.3例题🌰

// 
#include <stdio.h>
#include <stdlib.h>

int g_val = 10;
static int g_static = 20;

void Func()
{
    static int s_val = 30;
    int a = 40;
    int arr[5] = {1,2,3,4,5};
    char str1[] = "hello";
    const char* str2 = "world";
    int* p1 = (int*)malloc(sizeof(int));
    int* p2 = new int(50);
}
选项:
A. 栈
B. 堆
C. 数据段(静态区)
D. 代码段(常量区)

g_val 在哪里?        ____
g_static 在哪里?     ____
s_val 在哪里?        ____
a 在哪里?            ____
arr 在哪里?          ____
str1 在哪里?         ____
*str1 在哪里?        ____
str2 在哪里?         ____
*str2 在哪里?        ____
p1 在哪里?           ____
*p1 在哪里?          ____
p2 在哪里?           ____
*p2 在哪里?          ____

答案

g_val        → C  数据段(静态区)
g_static     → C  数据段(静态区)
s_val        → C  数据段(静态区)
a            → A  栈
arr          → A  栈
str1         → A  栈
*str1        → A  栈
str2         → A  栈
*str2        → D  代码段(常量区)
p1           → A  栈
*p1          → B  堆
p2           → A  栈
*p2          → B  堆

2.C语言中动态内存管理方式:malloc/calloc/realloc/free

在 C 语言中,动态内存分配发生在“堆区”,由程序员手动申请、手动释放。
主要通过以下 4 个函数完成:

函数作用
malloc只申请内存
calloc申请并初始化
realloc调整已申请内存的大小
free释放内存

需要包含头文件:

#include <stdlib.h>

3.C++中内存管理方式

C语言内存管理方式在C++中可以继续使用,但有些地方就无能为力,而且使用起来比较麻烦,因此C++又提出了自己的内存管理方式:通过new和delete操作符进行动态内存管理。
在 C++ 中,new 和 delete 既可以用于内置类型(int、double、char 等),也可以用于 自定义类型(类、结构体)。两者在“内存分配阶段”是相同的,但在“对象构造与析构阶段”存在本质区别,这正是很多初学者容易混淆的地方。

3.1new/delete操作的内置类型

// 
int* p = new int(10);
delete p;

这行代码在底层实际做了两件事:
第一步,通过 operator new 在堆上申请一块 sizeof(int) 大小的内存;
第二步,把值 10 直接写入这块内存中。

由于 int 属于内置类型,它没有构造函数和析构函数,因此:
new int(10) 只做“分配内存 + 简单初始化”
delete p 只做“释放内存”,不会执行任何析构逻辑

同理,数组形式也是如此:

int* arr = new int[5]{1,2,3,4,5};
delete[] arr;

这里 new[] 连续申请 5 个 int 的空间并初始化,delete[]只是一次性释放这片堆内存。整个过程中 没有任何构造与析构行为,只是纯粹的内存管理。

3.2new/delete操作的自定义类型

接下来是 自定义类型(类对象) 的情况,这是 new/delete 真正体现 C++ 面向对象特性的地方。

//
class Test
{
public:
    Test()
    {
        cout << "构造函数" << endl;
        p = new int(10);
    }

    ~Test()
    {
        cout << "析构函数" << endl;
        delete p;
    }

private:
    int* p;
};

当你执行:

Test* t = new Test;

此时 new 做了三件事:

第一步:调用 operator new(sizeof(Test)) 在堆上申请一块足够大的原始内存;
第二步:在这块内存上调用 Test 的构造函数,对对象进行初始化;
第三步:返回这块内存的首地址,并转为 Test* 类型。

当你执行:

delete t;

delete 同样会依次完成两件关键步骤:

第一步:先调用 t->~Test(),执行析构函数,释放对象内部申请的资源(例如 p 指向的内存);
第二步:再调用 operator delete(t),真正把这块堆内存归还给系统。

这正是 自定义类型与内置类型使用 new/delete 的本质区别

  1. 内置类型:只有“分配内存 + 释放内存”
  2. 自定义类型:在此基础上 额外包含构造和析构过程

如果是对象数组,差异会更加明显:

Test* arr = new Test[3];
delete[] arr;

执行流程是:

  1. new Test[3]:先申请一块能容纳 3 个Test对象的连续内存,然后 依次调用 3 次构造函数
  2. delete[] arr:先 依次调用 3 次析构函数,再统一释放整块内存

如果错误的写成:

delete arr;   // 错误写法

就会导致:

  1. 只调用第 1 个元素的析构函数
  2. 其余对象的资源无法释放
  3. 形成内存泄漏甚至程序崩溃

因此有一条必须牢记的铁律:
new 对应 delete,new[] 对应 delete[],永远不能混用。

再从“底层本质”来看,不论你是对内置类型还是自定义类型使用 new,最终都会落到两个函数上:

void* operator new(size_t size);
void  operator delete(void* p);

区别仅在于:

  1. 内置类型:new 之后 不会触发构造函数
  2. 自定义类型:new 之后 一定会调用构造函数
  3. 内置类型:delete 时 不会触发析构函数
  4. 自定义类型:delete 时 一定会调用析构函数

3.3malloc/free和new/delete的区别

也正因为这个特性,malloc/free 无法替代 new/delete 来创建类对象。比如:

Test* t = (Test*)malloc(sizeof(Test));
free(t);

这种写法:

  1. 不会调用构造函数
  2. 不会调用析构函数
  3. 对于内部有动态资源管理的类来说是严重错误用法

而:

Test* t = new Test;
delete t;

则完全符合 C++ 的对象生命周期管理模型,这也是 C++ 必须引入new/delete的根本原因。

malloc/freenew/delete的共同点是:都是从堆上申请空间,并且需要用户手动释放。

不同的地方是:

  1. malloc和free是函数,new和delete是操作符
  2. malloc申请的空间不会初始化,new初始化
  3. malloc申请空间时,需要手动计算空间大小并传递,new只需在其后跟上空间的类型即可, 如果是多个对象,[]中指定对象个数即可
  4. malloc的返回值为void*, 在使用时必须强转,new不需要,因为new后跟的是空间的类型
  5. malloc申请空间失败时,返回的是NULL,因此使用时必须判空,new不需要,但是new需要捕获异常
  6. 申请自定义类型对象时,malloc/free只会开辟空间,不会调用构造函数与析构函数,而new在申请空间后会调用构造函数完成对象的初始化,delete在释放空间前会调用析构函数完成空间中资源的清理释放

4.定位new表达式(placement-new)

4.1定位new语法

定位new的语法如下:

new (address) Type(arguments);

address:指向已经分配好的内存地址。
Type:要构造的类型。
arguments:构造函数参数。

举例🌰:

#include <iostream>
#include <new>  // placement new 需要包含这个头文件

struct MyClass {
    int x;
    MyClass(int val) : x(val) { std::cout << "构造函数调用\n"; }
    ~MyClass() { std::cout << "析构函数调用\n"; }
};

int main() {
    char buffer[sizeof(MyClass)];  // 在栈上预留一块内存
    MyClass* p = new (buffer) MyClass(10);  // 在 buffer 上构造对象

    std::cout << "p->x = " << p->x << std::endl;

    p->~MyClass();  // 手动调用析构函数

    return 0;
}

输出:

构造函数调用
p->x = 10
析构函数调用

关键点说明

  1. 不会分配内存
    定位 new 并不分配内存,它只是在给定的内存地址上调用构造函数。如果内存不足或地址无效,可能导致严重错误。

  2. 手动调用析构函数
    因为对象没有通过普通 new 分配,delete 不能释放它,必须手动调用析构函数:

p->~MyClass();
  1. 常用于自定义内存管理
    对象池(Object Pool)
    内存对齐优化
    嵌入式系统或性能敏感场景

  2. 注意对齐要求
    放置对象的内存必须满足该类型的对齐要求,否则行为未定义。可使用 alignas 保证对齐:

alignas(MyClass) char buffer[sizeof(MyClass)];

4.2与普通new的区别

特性普通 new定位 new
内存分配自动在堆上分配使用用户提供的内存
构造函数调用自动调用同样会调用构造函数
析构函数调用自动调用(delete)需手动调用
适用场景常规动态对象自定义内存管理、高性能需求

小结
定位 new 的核心作用是:在已有内存上构造对象,而不是分配新内存。
它常用于高性能场景或特殊内存管理需求。
使用时需注意手动调用析构函数,并保证内存对齐正确。

🌈本篇关于C++内层管理的讲解到此结束!我们下篇再见👋👋👋

评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值