C++的内存管理【由浅入深-C++】

文章目录

  • 前言
  • [第一章:C/C++ 内存分布详解](#第一章:C/C++ 内存分布详解)
    • [1\. 内存分布全景图](#1. 内存分布全景图)
    • [2\. 各区域详细剖析](#2. 各区域详细剖析)
      • [2.1 内核空间](#2.1 内核空间)
      • [2.2 栈区 (Stack)](#2.2 栈区 (Stack))
      • [2.3 共享区 / 文件映射区](#2.3 共享区 / 文件映射区)
      • [2.4 堆区 (Heap)](#2.4 堆区 (Heap))
      • [2.5 数据段 (Data Segment)](#2.5 数据段 (Data Segment))
        • [A. 已初始化数据段 (.data)](#A. 已初始化数据段 (.data))
        • [B. 未初始化数据段 (.bss)](#B. 未初始化数据段 (.bss))
      • [2.6 代码段 (Code Segment / Text Segment)](#2.6 代码段 (Code Segment / Text Segment))
    • [3\. 深度对比:栈 (Stack) vs 堆 (Heap)](#3. 深度对比:栈 (Stack) vs 堆 (Heap))
    • [4\. 实战代码解析](#4. 实战代码解析)
    • 补充:cons声明的变量位置
      • [1\. 局部 `const` 变量](#1. 局部 const 变量)
      • [2\. 全局 `const` 变量](#2. 全局 const 变量)
      • [3\. `static` 修饰的 `const`](#3. static 修饰的 const)
      • [4\. 字符串字面量 (特殊)](#4. 字符串字面量 (特殊))
      • [5\. 被编译器优化了 (常量折叠)](#5. 被编译器优化了 (常量折叠))
      • 总结一张表
  • [第二章:C++ 内存管理详解](#第二章:C++ 内存管理详解)
    • [1\. 内置类型的内存管理](#1. 内置类型的内存管理)
      • [1.1 单个变量的申请与释放](#1.1 单个变量的申请与释放)
      • [1.2 数组(多个变量)的申请与释放](#1.2 数组(多个变量)的申请与释放)
      • [1.3 核心思考:内置类型混用 `delete` 和 `delete[]` 会怎样?](#1.3 核心思考:内置类型混用 deletedelete[] 会怎样?)
    • [2\. 自定义类型的内存管理](#2. 自定义类型的内存管理)
      • [2.1 单个对象的申请与释放](#2.1 单个对象的申请与释放)
      • [2.2 new初始化具体流程:](#2.2 new初始化具体流程:)
      • [2.3 初始化方式](#2.3 初始化方式)
        • [1\. 默认初始化:`new T` (无括号)](#1. 默认初始化:new T (无括号))
        • [2\. 值初始化:`new T()` (空括号)](#2. 值初始化:new T() (空括号))
        • [3\. 直接初始化:`new T(args)` (圆括号传参)](#3. 直接初始化:new T(args) (圆括号传参))
        • [4\. 列表初始化:`new T{args}` (花括号,C++11)](#4. 列表初始化:new T{args} (花括号,C++11))
        • [5\. 数组的初始化](#5. 数组的初始化)
        • 快速一览表
      • [2.4 对象数组的申请与释放](#2.4 对象数组的申请与释放)
        • [2.4.1 为什么必须要有默认构造函数?](#2.4.1 为什么必须要有默认构造函数?)
        • [2.4.2 `delete[]` 的魔力:Cookie 机制](#2.4.2 delete[] 的魔力:Cookie 机制)
    • [补充:Cookie 机制只有在显示写析构情况下才开?](#补充:Cookie 机制只有在显示写析构情况下才开?)
      • [1\. 核心规则:有没有析构函数是关键](#1. 核心规则:有没有析构函数是关键)
        • [情况 A:内置类型(int, float)或 无自定义析构函数的结构体](#情况 A:内置类型(int, float)或 无自定义析构函数的结构体)
        • [情况 B:有自定义析构函数的类](#情况 B:有自定义析构函数的类)
      • [2\. Cookie开的字节数](#2. Cookie开的字节数)
      • [3\. 代码验证:亲眼看看"地址偏移"](#3. 代码验证:亲眼看看“地址偏移”)
      • [4\. 总结图解](#4. 总结图解)
    • [3\. 总结与对比表](#3. 总结与对比表)
  • [第四章:内存分配底层------operator new 与 operator delete](#第四章:内存分配底层——operator new 与 operator delete)
    • [4.1 核心概念区分:Expression vs Function](#4.1 核心概念区分:Expression vs Function)
    • [4.2 全局 operator new 与 operator delete](#4.2 全局 operator new 与 operator delete)
      • [1\. 全局 operator new](#1. 全局 operator new)
      • [2\. 全局 operator delete](#2. 全局 operator delete)
    • [4.3 类专属重载](#4.3 类专属重载)
    • [4.4 数组版本:operator new[] 与 operator delete[]](#4.4 数组版本:operator new[] 与 operator delete[])
    • [4.5 Placement New (定位 new)](#4.5 Placement New (定位 new))
    • [5.5 深入详解:定位 new (Placement New)](#5.5 深入详解:定位 new (Placement New))
    • [4.6 常见面试题与坑](#4.6 常见面试题与坑)
      • [1\. `delete` 和 `free` 的区别?](#1. deletefree 的区别?)
      • [2\. 构造函数抛出异常会发生什么?](#2. 构造函数抛出异常会发生什么?)
    • 第四章总结
  • [第五章:new 和 delete 的底层实现原理](#第五章:new 和 delete 的底层实现原理)

前言

本文介绍C和C++的内存管理相关内容

(【由浅入深】是一个系列文章,它记录了我个人作为一个小白,在学习c++技术开发方向计相关知识过程中的笔记,欢迎各位彭于晏刘亦菲从中指出我的错误并且与我共同学习进步,作为该系列的第三部曲-c++,大部分知识会根据本人所学和我的助手------通义,gimini等以及合并网络上所找到的相关资料进行核实编写,每一篇文章都可能会因为一些错误在后续时间增删改查,因为该系列会按照我在互联网中的学习笔记形式编写,我会使用绝大多数人使用的讲解顺序编写,所以基础框架和大部分内容案例会与他人一样,基础知识不会过于详细讲述)


第一章:C/C++ 内存分布详解

在 C/C++ 程序设计中,我们讨论的"内存"通常指的是虚拟地址空间(Virtual Address Space)。操作系统为每一个正在运行的进程提供了一个独立的、连续的虚拟地址空间。

以 32 位系统为例,寻址空间为 2 32 2^{32} 232,即 4GB。这 4GB 空间被严格划分为几个特定的区域,从低地址高地址依次分布。

1. 内存分布全景图

我们将内存划分为以下几个核心区域(按地址从低到高排列):

  1. 代码段 (Code Segment / Text Segment)
  2. 数据段 (Data Segment / Initialized Data)
  3. BSS 段 (BSS Segment / Uninitialized Data)
  4. 堆区 (Heap)
  5. 共享区/文件映射区 (Memory Mapping Segment)
  6. 栈区 (Stack)
  7. 内核空间 (Kernel Space)
text 复制代码
  内存地址 (Address)                  内存区域 (Memory Segment)               
  ==================                  =========================               
  
  0xFFFFFFFF (4GB)        +-----------------------------------------+ 
                          |                                         | 
           高地址         |               内核空间                  |  ⛔️ 用户代码不可访问
           (High)         |            (Kernel Space)               |     (操作系统的地盘)
                          |                                         | 
  0xC0000000 (3GB)        +-----------------------------------------+ <--- 用户空间分界线
                          |                                         | 
                          |                栈区                     |  📥 局部变量、函数参数
                          |               (Stack)                   |     (编译器自动管理)
                          |                  |                      | 
                          |                  v                      | 
                          |              (向下生长)                 | 
                          |                                         | 
                          |         [文件映射区 / 共享库]           |  📚 动态链接库(.so/.dll)
                          |      (Memory Mapping Segment)           | 
                          |                                         | 
                          |              (向上生长)                 | 
                          |                  ^                      | 
                          |                  |                      | 
                          |                堆区                     |  🏗️ new/malloc 分配
                          |               (Heap)                    |     (程序员手动管理)
                          |                                         | 
                          +-----------------------------------------+ 
                          |         未初始化数据段 (.bss)           |  📦 全局/静态变量 (无初值)
                          +-----------------------------------------+ 
                          |        已初始化数据段 (.data)           |  📦 全局/静态变量 (有初值)
                          +-----------------------------------------+ 
                          |               代码段                    |  📜 二进制指令
                          |           (Text Segment)                |  🔒 只读常量 ("hello")
                          +-----------------------------------------+ 
           低地址         |            受保护/保留区                |  ☠️ 空指针(nullptr)指向这里
           (Low)          |              (Reserved)                 |     (访问即崩溃)
  0x00000000              +-----------------------------------------+ 

2. 各区域详细剖析

2.1 内核空间

  • 位置:最高地址部分(在 32 位系统中,通常是顶部的 1GB)。
  • 作用:也就是操作系统内核代码运行的地方。包含系统调用表、页表、内核栈等。
  • 权限用户代码无法直接读写该区域。如果你尝试访问(例如空指针解引用通常指向低地址,但野指针可能指向这里),操作系统会抛出异常,导致程序崩溃。

2.2 栈区 (Stack)

  • 位置 :用户空间的较高地址处,向下增长(向低地址方向延伸)。
  • 存储内容
    • 非静态局部变量:函数内部定义的普通变量。
    • 函数参数:调用函数时传递的参数。
    • 函数调用信息:栈帧(Stack Frame),包括返回地址、寄存器状态等。
  • 管理方式自动管理。由编译器自动分配和释放。函数进入时压栈,函数退出时弹栈。
  • 特点
    • 效率极高 :基于 CPU 的 push/pop 指令,分配内存仅仅是移动栈指针(ESP/RSP)。
    • 空间有限 :栈的大小通常较小(Linux 下默认通常是 8MB,Windows 下默认 1MB)。这也是为什么递归过深在栈上开辟超大数组 会导致 Stack Overflow(栈溢出)。

2.3 共享区 / 文件映射区

  • 位置:位于堆和栈之间。
  • 作用
    • 加载动态链接库(.dll / .so)。
    • 用于 mmap 系统调用实现的共享内存。
    • 高效的文件读写映射。

2.4 堆区 (Heap)

  • 位置 :位于数据段之上,向上增长(向高地址方向延伸)。
  • 存储内容 :程序运行期间动态分配 的内存。
    • C 语言:malloc, calloc, realloc
    • C++:new
  • 管理方式手动管理 。必须由程序员显式释放(free/delete),否则会导致内存泄漏 (Memory Leak)
  • 特点
    • 空间巨大:受限于虚拟内存大小,可以申请很大的空间。
    • 效率较低 :分配时需要在堆管理器中查找合适的空闲块,可能涉及系统调用,容易产生内存碎片

2.5 数据段 (Data Segment)

这一区域通常包含两个部分,用于存储全局变量静态变量 (static) 。它们的生命周期是整个程序运行期间

A. 已初始化数据段 (.data)
  • 存储内容已初始化初值不为 0 的全局变量和静态变量。
  • 示例int g_val = 10;static int s_val = 5;
B. 未初始化数据段 (.bss)
  • 全称:Block Started by Symbol(历史遗留术语)。
  • 存储内容未初始化 ,或初始化为 0 的全局变量和静态变量。
  • 特点 :在可执行文件中,.bss 段不占用实际磁盘空间(只需要记录大小即可),程序加载运行时,操作系统会将这块内存清零。
  • 示例int g_val; (默认为0)

2.6 代码段 (Code Segment / Text Segment)

  • 位置:最底层的用户空间地址。
  • 存储内容
    1. 二进制代码:编译器生成的机器指令(函数的执行逻辑)。
    2. 只读常量 :例如字符串字面量 "Hello World"const 修饰的全局变量(视编译器优化而定)。
  • 权限只读 (Read-Only) 。防止程序在运行时意外修改指令。如果尝试修改字符串字面量(例如 char* p = "abc"; p[0] = 'x';),会触发段错误。

3. 深度对比:栈 (Stack) vs 堆 (Heap)

这是面试和实际开发中最核心的区别:

特性 栈 (Stack) 堆 (Heap)
分配方式 编译器自动分配释放 程序员手动分配释放 (new/delete)
生长方向 向低地址生长 (Down) 向高地址生长 (Up)
空间大小 小 (MB 级别) 大 (GB 级别,受虚存限制)
分配效率 极高 (寄存器操作) 较低 (算法查找,可能涉及系统调用)
碎片问题 无 (先进后出) 有 (频繁分配释放导致外部碎片)
内容 局部变量、函数上下文 动态对象、大型数据结构

4. 实战代码解析

下面这段代码清晰地展示了变量在内存中的实际分布位置:

cpp 复制代码
#include <stdio.h>
#include <stdlib.h> // 提供 malloc 和 free

// 1. 全局区 (Global/Static)
int g_val = 100;          // .data (已初始化数据段)
int g_uninit_val;         // .bss  (未初始化数据段,自动清零)
static int s_val = 200;   // .data (静态变量,生命周期伴随整个程序)

void func() {
    // 2. 栈区 (Stack) -> 局部变量
    int a = 10;
    
    // 3. 堆区 (Heap) -> 动态分配
    // 区别点:C语言使用 malloc 申请内存,返回 void* 需要强转
    // 注意:ptr 这个指针变量本身在栈上,它存的值是堆内存的地址
    int* ptr = (int*)malloc(sizeof(int));
    if (ptr != NULL) {
        *ptr = 30; // 给堆上的内存赋值
    }

    // 4. 代码段/常量区 (Text/RO Data)
    // str_ptr 是一个局部指针(在栈上),指向常量区只读字符串 "Hello"
    const char* str_ptr = "Hello"; 

    // 【对比特例】栈上的字符数组
    // str_arr 在栈上分配空间,并将 "World" 从常量区**拷贝**过来
    // 所以这里的地址是栈地址,且内容可修改
    char str_arr[] = "World";

    // 5. 打印地址验证 (通常顺序:代码段 < 数据段 < 堆 < ... < 栈)
    printf("=== 内存地址分布 (由低到高) ===\n");
    printf("[代码段] 函数地址:       %p\n", func);
    printf("[常量区] 字符串字面量:   %p (指向 \"Hello\")\n", str_ptr);
    printf("[数据段] 全局变量 g_val: %p\n", &g_val);
    printf("[数据段] 静态变量 s_val: %p\n", &s_val);
    printf("[BSS段 ] 未初始化全局:   %p\n", &g_uninit_val);
    printf("[堆区  ] malloc分配:     %p (ptr指向的实体)\n", ptr);
    printf("\n--- 巨大跨度 (未映射区域) ---\n\n");
    printf("[栈区  ] 局部变量 a:     %p\n", &a);
    printf("[栈区  ] 字符数组:       %p (str_arr本身)\n", str_arr);
    printf("[栈区  ] 指针变量 ptr:   %p (ptr本身)\n", &ptr);

    // 释放堆内存
    free(ptr);
}

int main() {
    func();
    return 0;
}

预期输出分析 (地址大小趋势)

运行上述代码,你通常会观察到地址数值的大小关系如下:
代码区 < 常量区 < 数据段 < 堆区 < ...巨大跨度... < 栈区

关键点解析

  1. 指针与实体的区别 int* ptr = (int*)malloc(sizeof(int));
    • ptr 是一个变量,存储在上。
    • (int*)malloc(sizeof(int)) 分配的内存块,位于上。
    • ptr 内部存的值,就是堆上那块内存的地址。
  2. const char*const char* str = "abc";
    • 字符串 "abc" 存储在代码段(常量区),它是只读的。
    • 如果写成 char str[] = "abc";,那么 "abc" 会被拷贝到数组中,此时它是可修改的。(即"abc"本来是在常量区,但会被拷贝栈区中str所在位置)

补充:cons声明的变量位置

const不能决定存储位置。const 只是一个编译期的类型限定符(Type Qualifier),它主要约束代码层面的"不可修改性",而不能直接决定变量在内存中的物理存储位置。变量存储在哪里,主要由它的生命周期和作用域决定,而不是 const。

这个问题没有唯一的答案。const 只是给变量打上了一个"只读 "的标签,但它并不决定变量存在哪里。

决定变量存储位置的,依然是它的生命周期(是全局的还是局部的)。

下面是详细的分布情况:

1. 局部 const 变量

📍 位置:栈区 (Stack)

当你写在函数内部时:

c 复制代码
void func() {
    const int a = 10; 
}
  • 本质 :它和普通的 int a = 10 一模一样,都存储在栈上。
  • 区别const 在这里只是给编译器 看的。编译器会帮你检查,如果你写了 a = 20,编译时就会报错。
  • 破解:因为在栈上(栈内存默认是可读写的),如果你用指针强制转换去改它,在 C 语言里往往能改成功(但这属于流氓行为)。

2. 全局 const 变量

📍 位置:常量区 (.rodata)

当你写在函数外部时:

c 复制代码
const int g_a = 100;

int main() { ... }
  • 本质 :由于是全局的,且承诺不修改,编译器会将它放入只读数据段 (.rodata)
  • 区别 :这是操作系统/硬件层面 的保护。这块内存页被标记为 Read Only
  • 破解:如果你用指针强制去改它,程序会直接崩溃(Segmentation Fault),因为你试图写入受保护的内存。

3. static 修饰的 const

📍 位置:常量区 (.rodata)

无论是在函数内还是函数外:

c 复制代码
void func() {
    static const int b = 20; 
}
  • 本质static 决定了它存在静态数据区,const 决定了它是只读的。两者结合,通常会放进 .rodata

4. 字符串字面量 (特殊)

📍 位置:常量区 (.rodata)

c 复制代码
const char* str = "Hello";
  • 注意这里有两个东西:
    1. str 指针变量本身 :如果是在函数里,它在上。
    2. "Hello" 字符串实体 :这串字符存储在常量区

5. 被编译器优化了 (常量折叠)

📍 位置:哪里都不在 (符号表/指令立即数)

c 复制代码
const int WIDTH = 1920;
int x = WIDTH + 10;
  • 如果编译器发现你从未对 WIDTH 取地址 (&WIDTH),它可能根本不会为 WIDTH 分配内存。
  • 在生成的机器码里,WIDTH 直接被替换成了数字 1920(立即数),嵌入在 CPU 的指令代码中。

总结一张表

你的代码写法 实际存储位置 物理属性 备注
void f() { const int a = 10; } 栈 (Stack) 可读写 编译器禁止你写,但内存本身没锁。
const int g_a = 10; (全局) 常量区 (.rodata) 只读 硬件保护,强行写会崩溃。
static const int s = 10; 常量区 (.rodata) 只读 同上。
"Hello World" (字符串) 常量区 (.rodata) 只读 试图修改会崩溃。
(未取地址的简单常量) 无 (寄存器/立即数) 变成了指令的一部分。

第二章:C++ 内存管理详解

在上一章中,我们探讨了 C++ 内存分布的基础(栈、堆、数据段等)。在实际开发中,堆(Heap)内存的管理是最为复杂且容易出错的环节。

C++ 的 newdelete 操作符不仅仅是 C 语言中 mallocfree 的语法糖,它们承载了对象生命周期管理的核心职责。本章我们将把目光聚焦在"类型"上,对比内置类型与自定义类型在动态内存分配中的不同表现,特别是针对单个变量与数组的创建细节。

1. 内置类型的内存管理

内置类型主要指 int, char, double, float 以及指针等基础数据类型。对于这些类型,内存管理相对"纯粹",主要关注的是内存的分配与释放,不涉及复杂的构造与析构逻辑。

1.1 单个变量的申请与释放

对于单个内置类型变量,我们需要关注的是初始化问题。

cpp 复制代码
void TestBuiltInSingle() {
    // 1. 申请内存但不初始化
    // 内存中的值是随机值(脏数据)
    int* p1 = new int; 
    
    // 2. 申请内存并初始化
    // 内存中的值为 10
    int* p2 = new int(10);

    // 3. 申请内存并进行默认初始化
    // 内存中的值为 0
    int* p3 = new int(); 

    // 释放
    delete p1;
    delete p2;
    delete p3;
}
  • 关键点new intnew int() 是有区别的。前者在堆上分配内存后不进行处理(随机值),后者会将内存清零。

1.2 数组(多个变量)的申请与释放

当我们需要动态创建一个内置类型的数组时:

cpp 复制代码
void TestBuiltInArray() {
    // 1. 申请 10 个 int 的数组,不初始化
    // 此时 arr[0] ~ arr[9] 都是随机值
    int* arr1 = new int[10];

    // 2. 申请 10 个 int 的数组,并全部初始化为 0
    int* arr2 = new int[10]();

    // 3. (C++11) 列表初始化
    // 前三个为 1, 2, 3,其余为 0
    int* arr3 = new int[10]{1, 2, 3};

    // 释放
    // 严格遵循匹配原则,使用 delete[]
    delete[] arr1;
    delete[] arr2;
    delete[] arr3;
}

1.3 核心思考:内置类型混用 deletedelete[] 会怎样?

这是一个经典的面试题。对于内置类型 (如 int):

cpp 复制代码
int* p = new int[10];
delete p; // 这样写会崩溃吗?
  • 结论 :在大多数现代编译器下,这通常不会崩溃,也不会造成明显的内存泄漏。
  • 原因 :内置类型没有析构函数。当 delete p 执行时,它只是释放了 p 指向的那块内存。因为不需要调用析构函数,编译器不需要知道数组具体的元素个数去遍历清理。
  • 警示 :尽管可能不报错,但这属于Undefined Behavior (未定义行为) ,严禁在生产代码中使用。请始终坚持 new[] 配对 delete[]

2. 自定义类型的内存管理

一旦涉及到类(Class),情况就发生了本质变化。new 不仅仅分配内存,还负责构造delete 不仅仅释放内存,还负责析构

假设我们要使用以下类:

cpp 复制代码
class A {
public:
    int _a;
    A(int a = 0) : _a(a) {
        std::cout << "A() Constructed: " << this << std::endl;
    }
    ~A() {
        std::cout << "~A() Destructed: " << this << std::endl;
    }
};

2.1 单个对象的申请与释放

cpp 复制代码
void TestCustomSingle() {
    // 1. 分配内存 -> 2. 调用构造函数
    A* p = new A(10); 
    
    // 1. 调用析构函数 -> 2. 释放内存
    delete p; 
}
  • 底层动作
    • new A(10) 等价于调用 operator new 分配 sizeof(A) 大小的字节,然后在该内存上调用 A::A(10)
    • delete p 等价于调用 p->~A(),然后调用 operator delete 释放内存。

2.2 new初始化具体流程:

1. 直接初始化:new A(10) (主流)

流程: 堆内存分配 -> 匹配 A(int) -> 原地构造。

结论: 无临时对象,无拷贝。

2. 拷贝初始化:new A = 10 (少见)

这种写法看起来像是"先用 10 构造一个临时 A,再拷贝到堆上"。

C++17 之前:理论上确实存在"临时对象 + 移动构造/拷贝构造"的语义,但编译器通常会优化掉。

C++17 及以后:标准强制规定省略拷贝。此时 10 依然是被直接传递给堆上对象的构造函数。结果和第一种写法完全一样。

2.3 初始化方式

在使用 new 申请堆内存时,有没有括号 以及用什么括号,决定了对象里的成员变量是"随机值"还是"零值"。

主要分为以下几种方式,核心区别在于默认初始化(Default Initialization)与值初始化(Value Initialization)

1. 默认初始化:new T (无括号)

这是最简单的写法,但对于没有构造函数的类型(如 int 或简单 struct),它也是最危险的。

  • 语法T* p = new T;
  • 行为
    • 对于有显式构造函数的类:调用默认构造函数。
    • 对于内置类型(POD)不进行初始化。内存里原来是什么垃圾数据,现在就是什么。
cpp 复制代码
struct A { int x; };         // 简单结构体(POD)
class B { public: B() { y = 10; } int y; }; // 有构造函数

A* p1 = new A;  // p1->x 是随机值(垃圾值)!危险!
B* p2 = new B;  // p2->y 是 10(调用了 B())
int* p3 = new int; // *p3 是随机值
2. 值初始化:new T() (空括号)

加上空括号,表示你希望编译器帮你把内存"打扫干净"。

  • 语法T* p = new T();
  • 行为
    • 对于有显式构造函数的类:调用默认构造函数(效果同上)。
    • 对于内置类型(POD) :进行零初始化(Zero Initialization)。内存会被清零。
cpp 复制代码
A* p1 = new A();  // p1->x 必定是 0 (安全)
B* p2 = new B();  // p2->y 是 10 (同上)
int* p3 = new int(); // *p3 必定是 0

总结 :如果你定义的类没有写构造函数,或者你在 new int/double请务必加上 (),否则你会得到随机值。

3. 直接初始化:new T(args) (圆括号传参)

这是最常见的带参构造方式。

  • 语法T* p = new T(a, b);
  • 行为 :直接寻找匹配参数列表 (a, b) 的构造函数进行调用。
cpp 复制代码
class C {
public:
    C(int a) { val = a; }
    int val;
};

C* p = new C(100); // 调用 C(int),val 为 100
4. 列表初始化:new T{args} (花括号,C++11)

这是 C++11 引入的"统一初始化"语法,通常比圆括号更推荐。

  • 语法T* p = new T{a, b}; (或空花括号 new T{} 等同于 new T()
  • 行为
    • 优先匹配参数为 std::initializer_list 的构造函数。
    • 如果没有,则匹配普通构造函数。
    • 对于聚合类(简单结构体):可以直接按顺序给成员赋值。
    • 安全性禁止窄化转换 (Narrowing Conversion)。例如不允许把 double 隐式转给 int
cpp 复制代码
struct Point { int x, y; };

Point* p1 = new Point{1, 2}; // C++11 特性:聚合初始化,x=1, y=2
// Point* p2 = new Point(1, 2); // 错误!旧语法不支持这样直接初始化结构体,除非你写了构造函数

int* p3 = new int{5};   // OK
// int* p4 = new int{5.5}; // 编译报错!花括号禁止数据截断(double->int)
5. 数组的初始化

对于数组,规则也是类似的,括号决定了是否清零。

  • new T[N] :默认初始化。如果是 int 数组,里面全是随机值。
  • new T[N]()new T[N]{} :值初始化。如果是 int 数组,里面全是 0
  • new T[N]{a, b, c} :(C++11) 前几个元素用 a,b,c 初始化,剩下的元素补 0
cpp 复制代码
int* arr1 = new int[5];     // [随机, 随机, 随机, 随机, 随机]
int* arr2 = new int[5]();   // [0, 0, 0, 0, 0]
int* arr3 = new int[5]{1,2};// [1, 2, 0, 0, 0]

注意:new A[3] = { 1, 2, 3 }; ❌ 错误 new 后面不能接 =

new 运算符被设计为直接在分配的内存上构造对象,不涉及"创建一个临时列表对象再拷贝过去"的概念。因此,它强制使用直接初始化的语法(即不带 = 的大括号)。

快速一览表
写法 class (有构造函数) struct / int (无构造函数)
new T 调用构造函数 随机值 (未初始化) ⚠️
new T() 调用构造函数 清零 (Zero Init)
new T(x) 调用构造函数 T(x) 初始化为 x
new T{x} 调用构造函数 / 聚合初始化 初始化为 x (禁止截断)

建议: 除非你有极端的性能需求且确定后续会立马覆盖内存,否则对于简单类型(int, struct),习惯性加上 (){} 是个好习惯。

2.4 对象数组的申请与释放

这是本章的重难点。

cpp 复制代码
void TestCustomArray() {
    // 申请包含 10 个 A 对象的数组
    // 注意:这里必须要求 A 有默认构造函数!
    A* arr = new A[10];

    // 释放
    delete[] arr;
}
2.4.1 为什么必须要有默认构造函数?

当我们执行 new A[10] 时,编译器需要连续构造 10 个对象。编译器无法给这 10 个对象分别传递不同的参数,因此它默认调用无参构造函数 (或全缺省构造函数)。如果类 A 没有默认构造函数,代码将无法通过编译。

2.4.2 delete[] 的魔力:Cookie 机制

对于自定义类型,严禁 混用 deletedelete[]

cpp 复制代码
A* arr = new A[10];
delete arr; // 极度危险!!程序崩溃或未定义行为

为什么 delete[] 知道要调用 10 次析构函数?

当编译器检测到你要 new 一个有析构函数 的自定义类型数组时,它会在分配的内存块头部多分配几个字节(通常是 4 字节或 8 字节),用来存储数组的元素个数 。这被称为 Cookie

内存布局示意图:

text 复制代码
[ Cookie (记录数量: 10) ] [ 对象 A[0] ] [ 对象 A[1] ] ... [ 对象 A[9] ]
^                         ^
|                         |
真正分配的起始地址           返回给用户的指针 (arr)
  1. 当你调用 delete[] arr

    • 系统会"向后看"(指针偏移),读取 Cookie 中的数字(比如 10)。
    • 循环调用 10 次析构函数。
    • 最后释放整块内存(包含 Cookie)。
  2. 如果你错误地调用 delete arr

    • 系统认为这只是一个单个对象。
    • 它只调用一次 arr[0] 的析构函数(导致剩下 9 个对象资源泄漏)。
    • 然后试图释放内存。但 free 需要的是内存块的首地址 。由于 arr 跳过了 Cookie,传给 free 的地址是不对齐或错误的(不是真正的起始位置),直接导致**Heap Corruption(堆破坏)**或程序崩溃。

补充:Cookie 机制只有在显示写析构情况下才开?

Cookie(记录数组元素个数的额外空间)通常只在"类型拥有非平凡析构函数(就是编译器认为"这个对象死的时候,必须执行一些代码来善后"的情况。)" 时才会存在。 (人话:只有当类既没有显式析构函数,也没有包含带析构函数的成员变量时,才不会多开那 4 个字节。你显式定义了析构函数,哪怕函数体是空的,只要你写了,编译器就认为它是非平凡的。)

下面是详细的底层剖析:

1. 核心规则:有没有析构函数是关键

编译器决定是否"多开几个字节"来存数组长度(Cookie),依据是:delete[] 时是否需要循环调用析构函数

情况 A:内置类型(int, float)或 无自定义析构函数的结构体
  • 行为 :编译器不会多开空间存 Cookie。
  • 原因 :因为没有析构函数需要调用。delete[] p 的任务仅仅是把这块内存归还给操作系统。内存分配器(如 malloc 的底层实现)本身就已经记录了这块内存块的大小(Size),所以 C++ 运行时不需要额外记录"里面有几个 int"。
  • 结果new int[10] 请求多少内存,就分配多少(加上 malloc 自身的头部开销,但用户指针前没有 C++ 的 Cookie)。
情况 B:有自定义析构函数的类
  • 行为 :编译器在数组头部多开空间(Cookie)。
  • 原因delete[] p 时,程序必须知道"我要调用多少次析构函数"。内存分配器只知道这块内存是 100 字节,但它不知道这代表 10 个对象还是 20 个对象。因此,C++ 必须自己找个地方把"对象的个数"记下来。
  • 结果 :返回给你的指针,实际上是偏移过的。

2. Cookie开的字节数

在现代开发中:

  • 32 位系统 (x86) :Cookie 通常是 4 字节 (size_t 的大小)。
  • 64 位系统 (x64) :Cookie 通常是 8 字节(或者是为了内存对齐而更大的填充)。

此外,内存对齐(Alignment) 也会影响这个"多开的空间"具体看起来是多大。编译器为了保证对象的地址是高效对齐的(比如 16 字节对齐),可能会在 Cookie 和第一个对象之间填充 Padding。

3. 代码验证:亲眼看看"地址偏移"

我们可以通过打印指针地址来验证这个机制。

cpp 复制代码
#include <iostream>

using namespace std;

// 类A:没有显式析构函数(编译器认为它是 Trivial 的)
class A {
public:
    int _a;
    A() : _a(0) {}
};

// 类B:有显式析构函数
class B {
public:
    int _b;
    B() : _b(0) {}
    ~B() { cout << "~B" << endl; } // 关键在于这个析构函数
};

int main() {
    // ---- 测试 A (无析构) ----
    A* pA = new A[10];
    // 实际上分配的起始地址 vs 返回的地址
    // 既然没有 Cookie,理论上 new 返回的地址就是内存块的起始地址
    // 这里的验证比较隐晦,我们通常通过看 delete 的行为或者调试器的内存窗口来确认

    // ---- 测试 B (有析构) ----
    B* pB = new B[10];
    
    // 我们做一个危险的实验来寻找 Cookie
    // 获取 pB 之前的那个整数
    size_t* cookie_ptr = (size_t*)pB; 
    cookie_ptr--; // 指针前移一位(在64位下前移8字节,32位下前移4字节)

    cout << "B requested size: 10" << endl;
    cout << "Value in Cookie: " << *cookie_ptr << endl; // 这里应该打印出 10

    delete[] pA;
    delete[] pB;

    return 0;
}

运行结果(在 64 位环境下):

text 复制代码
B requested size: 10
Value in Cookie: 10

你会发现,就在 pB 指针的前面(前移 8 字节),确实藏着一个数字 10。如果你对 pA 做同样的操作,前面通常是乱码或 malloc 的调试信息,而不是对象个数。

4. 总结图解

假设 32 位系统,new T[3]

  1. Tint (无析构):

    text 复制代码
    [ int ][ int ][ int ]
    ^
    p (返回给用户的地址)
  2. TMyClass (有析构):

    text 复制代码
    [  3  ][ MyObj ][ MyObj ][ MyObj ]
    ^      ^
    |      p (返回给用户的地址)
    |
    真正的内存起始地址 (传给 free 的是这里)

这也是为什么有析构函数的数组混用 delete(而不是 delete[])会崩溃 的根本原因:delete p 以为 p 就是起始地址,直接把它传给 free,但实际上 p 是偏移过的,并非通过 malloc 分配出的首地址,导致堆损坏(Heap Corruption)。

3. 总结与对比表

为了方便记忆,我们将本章内容总结如下:

特性 内置类型 (int, double...) 自定义类型 (Class/Struct)
new T 只分配内存,值为随机(除非 new T() 分配内存 + 调用 1 次构造函数
new T[N] 分配 N * sizeof(T) 内存 分配内存 + 调用 N 次默认构造函数
delete ptr 释放内存 调用 1 次析构函数 + 释放内存
delete[] ptr 释放内存 调用 N 次析构函数 + 释放内存
Cookie 机制 通常不需要(取决于编译器,无析构则不需要) 必须有(用于记录 N,以便循环析构)
混用后果 new[] -> delete 通常无害但不规范 new[] -> delete 崩溃或内存泄漏

第四章:内存分配底层------operator new 与 operator delete

4.1 核心概念区分:Expression vs Function

首先必须厘清两个极其容易混淆的概念:

  1. new 表达式 (new expression) :
    这是我们在代码里直接写的 A* p = new A(10);。它是一个不可重载的关键字。
  2. operator new 函数 :
    这是一个可以重载的函数,通常声明为 void* operator new(size_t size)。它的唯一任务就是申请原始内存

它们的关系

当你写下 new A() 时,编译器实际上执行了以下逻辑(伪代码):

cpp 复制代码
// 1. 调用 operator new 申请生肉(内存)
void* raw_mem = operator new(sizeof(A));

// 2. 在生肉上调用构造函数(烹饪)
call_constructor(raw_mem); 

// 3. 返回指针
A* p = static_cast<A*>(raw_mem);

这一章我们重点讨论的是第 1 步:那个负责"找系统要内存"的函数。

4.2 全局 operator new 与 operator delete

C++ 标准库提供了全局的默认实现。如果你不自己写,编译器就会用这些默认版本。

1. 全局 operator new

它的底层实现通常就是调用 C 语言的 malloc

cpp 复制代码
void* operator new(size_t size) {
    if (size == 0) size = 1; // C++标准规定,申请0字节也要返回有效地址
    while (true) {
        // 尝试分配内存
        void* ptr = malloc(size);
        if (ptr) return ptr;

        // 如果分配失败,尝试调用 new_handler 处理(比如释放一点缓存)
        std::new_handler global_handler = std::get_new_handler();
        if (global_handler) {
            global_handler();
        } else {
            // 实在没办法了,抛出异常
            throw std::bad_alloc();
        }
    }
}

2. 全局 operator delete

它的底层实现通常就是调用 free

cpp 复制代码
void operator delete(void* ptr) noexcept {
    if (ptr == nullptr) return; // 保证 delete 空指针是安全的
    free(ptr);
}

4.3 类专属重载

这是这一章的重头戏。你可以为一个特定的类重写这两个函数。
为什么要做这个?

  1. 性能优化 :针对特定大小的对象使用内存池,避免频繁调用系统的 malloc/free
  2. 调试监控:统计某个类到底创建了多少个对象,有没有内存泄漏。
  3. 特殊内存:强制对象分配在共享内存或显存中。

语法规则

这两个函数在类中隐式是静态的 (static) ,写不写 static 关键字都可以。因为调用它们的时候,对象还没产生(或者即将销毁),所以无法依赖 this 指针。

完整示例:编写一个具有内存监控功能的类

cpp 复制代码
#include <iostream>
#include <new>

class Data {
public:
    int m_val;

    Data(int v) : m_val(v) { 
        std::cout << "  [Construct] Data constructed with " << v << std::endl; 
    }
    ~Data() { 
        std::cout << "  [Destruct] Data destroyed" << std::endl; 
    }

    // 重载 operator new
    // 编译器会自动计算类的大小并传给 size
    static void* operator new(size_t size) {
        std::cout << "1. [operator new] Allocating " << size << " bytes" << std::endl;
        // 这里我们可以自定义分配策略,简单起见我们调用全局的 new
        return ::operator new(size); 
    }

    // 重载 operator delete
    static void operator delete(void* p) {
        std::cout << "3. [operator delete] Freeing memory" << std::endl;
        // 释放内存
        ::operator delete(p);
    }
};

int main() {
    std::cout << "=== Start ===" << std::endl;
    
    // 触发 operator new -> 构造函数
    Data* p = new Data(100); 

    std::cout << "=== Using Object ===" << std::endl;
    
    // 触发 析构函数 -> operator delete
    delete p; 

    std::cout << "=== End ===" << std::endl;
    return 0;
}

运行结果:

text 复制代码
=== Start ===
1. [operator new] Allocating 4 bytes
  [Construct] Data constructed with 100
=== Using Object ===
  [Destruct] Data destroyed
3. [operator delete] Freeing memory
=== End ===

4.4 数组版本:operator new[] 与 operator delete[]

当你使用 new Data[10] 时,调用的不是 operator new,而是 operator new[](Vector New)。

  • operator new[]:负责申请"数组总大小 + 可能的额外空间(用于记录数组长度)"。
  • operator delete[]:负责释放这块内存。

注意: 如果你重载了 operator new,通常也应该重载 operator new[],否则创建数组时会回退到使用全局的分配函数,导致你的内存池或监控失效。

cpp 复制代码
static void* operator new[](size_t size) {
    std::cout << "[Array new] Allocating " << size << " bytes" << std::endl;
    return ::operator new(size);
}

static void operator delete[](void* p) {
    std::cout << "[Array delete] Freeing array" << std::endl;
    ::operator delete(p);
}

4.5 Placement New (定位 new)

通常我们在 C++ 中使用 new 时,是"申请内存"和"构造对象"一把抓。而 Placement New 允许我们将这两步完全分离。它的核心作用是:在已经分配好的内存块上,强行调用构造函数初始化对象。

5.5 深入详解:定位 new (Placement New)

1. 核心定义与头文件

普通 new 向操作系统申请空间,而 Placement New 只是在"借尸还魂"------利用已有的空间。

  • 头文件 :必须包含 <new>

    cpp 复制代码
    #include <new>
  • 底层实现
    标准库中它的实现极其简单,就是一个"透传"函数,不做任何内存申请操作:

    cpp 复制代码
    // C++ 标准库源码简化版
    void* operator new(size_t, void* ptr) noexcept {
        return ptr; // 直接返回传入的地址
    }

2. 具体语法规则

基本语法格式如下:

cpp 复制代码
new (address) Type(arguments);
  • address : 一个指针,指向一块已经分配好的、足够容纳该对象的内存(通常是 void*char*)。
  • Type: 要构造的类型。
  • arguments: 传递给构造函数的参数列表。

3. 标准使用流程 (SOP)

使用 Placement New 必须严格遵守 "五步走" 流程,任何一步出错都可能导致内存腐烂或崩溃。

第一步:准备"生肉" (Raw Memory)

你需要先有一块内存。来源可以是栈、堆(malloc/new char[])、或者特殊的硬件地址。

cpp 复制代码
// 例子:在栈上准备一块缓冲区(也可以是堆)
char memory_pool[sizeof(Complex)]; 
第二步:调用 Placement New

在这块内存上构建对象。

cpp 复制代码
Complex* pc = new (memory_pool) Complex(1.5, 2.5);

此时编译器做了什么?

  1. 调用 operator new(sizeof(Complex), memory_pool) -> 返回 memory_pool 地址。
  2. 在这个地址上执行 Complex::Complex(1.5, 2.5)
  3. 将地址赋值给 pc
第三步:使用对象

此时 pc 是一个指向有效对象的指针,可以正常使用。

cpp 复制代码
std::cout << pc->real() << std::endl;
第四步:手动析构 (关键!)

这是与普通 new 最大的不同。因为内存不是 Placement New 分配的,所以不能使用 delete pc必须显式调用析构函数

cpp 复制代码
pc->~Complex(); // 必须手动调用!
第五步:释放"生肉" (如果有必要)

如果第一步的内存是申请来的(比如用 mallocnew char[]),现在才轮到释放它。如果是在栈上(如本例),则无需处理。

4. 完整代码示例

cpp 复制代码
#include <iostream>
#include <new>      // 必须包含
#include <vector>

class User {
public:
    int id;
    User(int i) : id(i) { std::cout << "Construct User " << id << std::endl; }
    ~User() { std::cout << "Destruct User " << id << std::endl; }
};

int main() {
    // 1. 准备一块足够大的内存(比如模拟内存池)
    // 注意:这里分配的是 char 数组,不是 User 对象
    char* buffer = new char[sizeof(User) * 2];

    // 2. 使用 Placement New 在 buffer 的不同位置构造对象
    User* u1 = new (buffer) User(1);                // 在起始位置构造
    User* u2 = new (buffer + sizeof(User)) User(2); // 在偏移位置构造

    // 3. 使用对象
    std::cout << "u1 id: " << u1->id << std::endl;
    std::cout << "u2 id: " << u2->id << std::endl;

    // 4. 析构对象(必须手动调用!)
    // delete u1; // 错误!!!会导致 buffer 被释放,而 buffer 是 char数组,内存布局不同,会崩溃
    u2->~User();
    u1->~User();

    // 5. 释放原始内存
    delete[] buffer; 
    std::cout << "Raw memory freed." << std::endl;

    return 0;
}

5. 常见陷阱与注意事项

  1. 内存对齐 (Alignment)
    如果你提供的 buffer 地址没有对齐(例如 int 通常需要 4 字节对齐),在某些 CPU 架构上会导致崩溃或性能下降。C++11 提供了 alignasstd::aligned_storage 来辅助解决这个问题。
  2. 严禁使用 delete
    如果你对 Placement New 返回的指针调用 delete,编译器会尝试释放这块内存。但如果这块内存是栈内存、静态区内存或者只是大内存块的一部分,程序就会崩溃。
  3. 覆盖风险
    如果在同一个地址重复调用 Placement New 而没有先析构之前的对象,前一个对象持有的资源(如锁、文件句柄)将无法释放,导致资源泄漏。

6. 为什么要这么麻烦?(应用场景)

既然这么危险,为什么还要用?

  1. 构建内存池 (Memory Pool) :这是最高频的用法。一次性申请一大块内存(减少 malloc 开销),然后用 Placement New 快速在池中切割分配对象。游戏引擎几乎都这么做。
  2. 共享内存 (Shared Memory):在进程间共享内存区域构建复杂的 C++ 对象。
  3. STL 容器底层std::vectorpush_back 扩容时,需要在新内存上拷贝构造旧元素,底层就是用的 Placement New。

4.6 常见面试题与坑

1. deletefree 的区别?

  • free 是 C 语言函数,只释放内存,不管析构。
  • delete 是 C++ 操作符,先调用析构函数再调用 operator delete 释放内存。

2. 构造函数抛出异常会发生什么?

如果在 new Data() 的过程中,operator new 成功申请了内存,但随后的构造函数抛出了异常:

  • C++ 运行时会自动捕获这个异常。
  • 它会自动调用匹配的 operator delete 归还刚才申请的内存(防止内存泄漏)。
  • 然后继续向外层抛出异常。
    这就是为什么重载 new 时通常也要重载 delete,即使你不手动调用它,异常处理机制也可能调用它。

第四章总结

  1. 分工明确operator new 只管分地(分配字节),构造函数负责盖楼(初始化),new 表达式是指挥官。
  2. 可定制 :通过在类中重载 operator new/delete,我们可以接管对象的内存管理权(内存池的核心原理)。
  3. 底层原理 :全局的 operator new 基本等于 malloc + 异常处理。
  4. Placement New:是唯一一个不分配内存的 new,允许我们在指定的地址上"原地构造"对象。

第五章:new 和 delete 的底层实现原理

在第四章中,我们知道了 operator newoperator delete 只是类似于 mallocfree 的内存搬运工。

本章我们将揭开 C++ 编译器的面纱,看看当我们写下 new A() 这样简单的代码时,编译器在幕后到底生成了什么样的汇编逻辑。

5.1 new 表达式的完全拆解

当我们写出如下代码:

cpp 复制代码
Complex* pc = new Complex(1, 2);

编译器会将其翻译为大约如下的伪代码(注意:这是编译器内部视角的逻辑,普通 C++ 代码不能直接这样调用构造函数):

编译器背后的三步走:

  1. 内存分配 (Call operator new)

    调用第四章讲过的函数来申请原始内存。

    cpp 复制代码
    void* mem = operator new(sizeof(Complex));
  2. 类型转换 (Type Casting)

    void* 指针转换为目标类型的指针。

    cpp 复制代码
    Complex* pc = static_cast<Complex*>(mem);
  3. 对象构造 (Call Constructor)

    这是最关键的一步。编译器会在 pc 指向的内存地址上,直接调用构造函数。在 C++ 层面这通常通过 Placement New 的机制实现。

    cpp 复制代码
    pc->Complex::Complex(1, 2); // 伪代码:只有编译器能这样直接调用构造函数

总结new 表达式 = operator new (买地) + Placement New (盖楼)。

5.2 delete 表达式的完全拆解

当我们执行:

cpp 复制代码
delete pc;

编译器的操作顺序正好与 new 相反:

编译器背后的两步走:

  1. 对象析构

    首先调用析构函数,清理对象持有的资源(如关闭文件、释放对象内部的指针等)。此时内存本身还是有效的,只是对象逻辑上结束了。

    cpp 复制代码
    if (pc != nullptr) {
        pc->~Complex(); 
    }
  2. 内存释放

    调用库函数释放那块原始内存。

    cpp 复制代码
    operator delete(pc);

5.3 异常安全机制

如果在 new 的过程中,内存分配成功了,但是构造函数抛出了异常,会发生什么?

如果不做处理,这块刚申请的内存就会永久丢失(泄漏),因为指针还没有赋值给变量,用户无法手动 delete。

C++ 编译器为 new 表达式生成了类似这样的保护代码:

cpp 复制代码
// 编译器的各种 try-catch 魔法
void* mem = operator new(sizeof(Complex));

try {
    // 在 mem 上调用构造函数
    new (mem) Complex(1, 2); 
} catch (...) {
    // 哎呀,构造函数抛异常了!
    // 此时 mem 已经分配,必须收回
    operator delete(mem); 
    
    // 继续向外抛出异常通知用户
    throw; 
}

结论 :C++ 的 new 操作符是强异常安全的。如果构造失败,它保证负责清理掉已经申请的生肉。

第五章总结

  1. new 表达式 是一个组合拳:先调 operator new 分配内存,再调构造函数初始化。
  2. delete 表达式 是逆向组合拳:先调析构函数清理逻辑,再调 operator delete 释放内存。
  3. Cookie 机制new[] 数组时,编译器会在头部多分配空间记录元素个数。这是 delete[] 能够正确工作的关键。
  4. 生死攸关 :严禁 new[]delete 混用,否则会导致析构不全和指针地址错误(Crash)。
  5. 异常保护new 内部自带 try-catch,构造失败会自动回滚内存分配。
相关推荐
小小晓.2 小时前
Pinely Round 4 (Div. 1 + Div. 2)
c++·算法
SHOJYS2 小时前
学习离线处理 [CSP-J 2022 山东] 部署
数据结构·c++·学习·算法
steins_甲乙3 小时前
C++并发编程(3)——资源竞争下的安全栈
开发语言·c++·安全
煤球王子3 小时前
学而时习之:C++中的异常处理2
c++
仰泳的熊猫4 小时前
1084 Broken Keyboard
数据结构·c++·算法·pat考试
CSDN_RTKLIB4 小时前
代码指令与属性配置
开发语言·c++
上不如老下不如小4 小时前
2025年第七届全国高校计算机能力挑战赛 决赛 C++组 编程题汇总
开发语言·c++
雍凉明月夜4 小时前
c++ 精学笔记记录Ⅱ
开发语言·c++·笔记·vscode
GHL2842710904 小时前
文件重命名(C++源码)
前端·c++·windows