C/C++ 八股文之内存

Author:Once Day Date:2026年1月13日

漫漫长路,才刚刚开始…

全系列文章请查看专栏: C语言_Once-Day的博客-CSDN博客

参考文档:

1. 内存本质

内存是计算机中的存储空间,本质上是一个连续的字节数组。程序运行时,所有的指令、数据和状态都存储在内存中。从这个角度看,编程的本质就是操控内存中的数据:读取、修改、传递这些数据,最终完成特定的计算任务。

内存中的每个字节都有一个唯一的地址(address),这个过程称为内存编址。地址本质上是一个非负整数,用于标识某个字节在内存中的位置。

在32位系统中,地址用32位表示,理论上可以访问 2^32 = 4GB 的内存空间;

64位系统使用64位地址,理论寻址空间达到 2^64 字节(实际受硬件和操作系统限制)。

地址空间(address space)是程序可以使用的所有有效地址的集合。现代操作系统为每个进程提供虚拟地址空间,这是一个抽象层,让每个进程都认为自己独占整个内存。

典型的进程地址空间布局(以Linux为例):

  • 代码段(text):存储可执行指令。
  • 数据段(data/bss):存储全局变量、静态变量。
  • 堆(heap):动态分配的内存,由低地址向高地址增长。
  • 栈(stack):存储局部变量、函数调用信息,由高地址向低地址增长。

变量是对一块内存区域的抽象命名。编译器为每个变量分配特定大小的内存空间,变量名在编译后会被替换为对应的内存地址。

变量的三个关键属性:

  1. 地址: 变量在内存中的位置。
  2. 大小: 变量占用的字节数。
  3. 类型: 决定如何解释这些字节。

当一个多字节数据(如int、long)存储到内存时,其各字节的存储顺序称为字节序。主要有两种:

  • 大端序(Big-Endian):高位字节存储在低地址。
  • 小端序(Little-Endian):低位字节存储在低地址。
int num = 0x12345678;

// 在小端系统(如x86)中,内存布局:
// 地址:  0x1000  0x1001  0x1002  0x1003
// 内容:   0x78    0x56    0x34    0x12

// 在大端系统中,内存布局:
// 地址:  0x1000  0x1001  0x1002  0x1003
// 内容:   0x12    0x34    0x56    0x78
2. 内存分区

程序在运行时,操作系统会将进程的虚拟地址空间划分为多个逻辑区域,每个区域有不同的用途和特性。理解内存分区对于编写高效、安全的C/C++程序至关重要。典型的内存分区包括:代码区、全局/静态存储区、栈区、堆区和常量区

(1)代码区存储程序的可执行指令,即编译后的机器码:

  • 只读属性:防止程序意外修改自身指令。
  • 可共享:多个进程可以共享同一份代码(如动态链接库)。
  • 固定大小:在程序编译时确定,运行时不变。

(2)全局/静态存储区 (Data Segment),存储全局变量和静态变量:

  • 初始化数据段 (.data),存储已初始化的全局变量和静态变量。
  • 未初始化数据段 (.bss),存储未初始化或初始化为0的全局变量和静态变量。BSS段在可执行文件中不占用空间,只记录大小,程序加载时由操作系统自动清零。

(3)栈区 (Stack),用于存储函数调用时的局部变量、函数参数、返回地址等信息:

  • 自动管理:变量离开作用域时自动释放,无需手动管理。

  • 有限大小:通常为几MB(Linux默认8MB),可通过ulimit -s查看。

  • 生长方向:通常从高地址向低地址生长。

  • 速度快:仅需移动栈指针即可分配/释放内存。

(4)堆区用于程序运行时的动态内存分配,由程序员通过malloc/calloc/realloc(C)或new(C++)显式分配:

  • 手动管理:必须显式释放,否则造成内存泄漏。

  • 灵活大小:可以在运行时决定分配多少内存。

  • 生长方向:通常从低地址向高地址生长。

  • 速度较慢:涉及复杂的内存管理算法。

  • 碎片化:频繁分配释放可能导致内存碎片。

(5)常量区存储程序中的常量数据,主要包括字符串字面量和const修饰的全局常量:

  • 只读:试图修改会导致段错误。

  • 可共享:相同的字符串字面量可能共享同一份存储。

  • 生命周期:整个程序运行期间。

典型的32位Linux进程内存布局(从低地址到高地址):

高地址
+------------------+
|   内核空间       |  (不可访问)
+------------------+
|   栈区 ()      |  (向下生长)
|                  |
||
|                  |
|   ...            |
|                  |
||
|                  |
|   堆区 ()      |  (向上生长)
+------------------+
|   .bss段        |  (未初始化数据)
+------------------+
|   .data段       |  (已初始化数据)
+------------------+
|   常量区         |  (只读数据)
+------------------+
|   代码区         |  (只读代码)
+------------------+
低地址
3. 指针与引用的区别

指针和引用在 C++ 中都用于间接访问变量,但它们有一些区别。

  • 指针是一个变量,它保存了另一个变量的内存地址;引用是另一个变量的别名,与原变量共享内存地址。
  • 指针(除指针常量)可以被重新赋值,指向不同的变量;引用在初始化后不能更改,始终指向同一个变量。
  • 指针可以为 nullptr,表示不指向任何变量;引用必须绑定到一个变量,不能为 nullptr。
  • 使用指针需要对其进行解引用以获取或修改其指向的变量的值;引用可以直接使用,无需解引用。

从底层实现看,引用通常被C++编译器当做const指针来操作,但在语法层面,引用更像是原变量的透明别名。

特性 指针 引用
本质 存储地址的变量 变量的别名
可否重新赋值 可以(非const指针) 不可以
可否为空 可以(nullptr 不可以
初始化要求 可以不初始化 必须初始化
使用语法 需要*解引用 直接使用
自身占用内存 是(有地址) 否(语义上)
底层实现 直接表示 通常为const指针
4. 指针传递、值传递、引用传递

值传递(Pass by Value)是将实参的副本传递给形参。函数接收的是实参值的一个拷贝,二者在内存中占据不同的存储空间:

  • 形参是实参的独立副本。
  • 函数内对形参的修改不影响实参。
  • 适用于基本数据类型和小型对象。

引用传递(Pass by Reference)是将实参的引用(别名)传递给形参。引用本质上是实参的另一个名字,与实参共享同一块内存地址:

  • 形参是实参的别名,二者指向同一内存地址。

  • 函数内对形参的修改直接作用于实参。

  • C++特有机制(C语言不支持引用)。

  • 语法简洁,无需解引用操作。

指针传递(Pass by Pointer)实际上是值传递的特殊形式,传递的值是实参的地址。虽然指针本身是按值传递的,但通过解引用可以访问和修改实参:

  • 形参接收实参的地址值(指针的副本)。
  • 通过解引用可以修改实参指向的数据。
  • C/C++都支持。
  • 需要显式的取地址(&)和解引用(*)操作。

三种参数传递方式的对比:

传递方式 能否修改实参 语法复杂度 适用场景
值传递 简单 小型数据、不需修改实参
引用传递 简单 需修改实参、大型对象
指针传递 是* 中等 C语言、需要空指针判断
5. 资源获取即初始化

RAII(Resource Acquisition Is Initialization,资源获取即初始化)是C++中一种关键的资源管理技术,其核心思想是:将资源的生命周期与对象的生命周期绑定

  • 在对象构造时获取资源。

  • 在对象析构时释放资源。

  • 利用C++的自动对象生命周期管理机制,确保资源安全释放。

RAII适用于所有需要显式管理的有限资源:

  • 内存资源:堆内存分配。
  • 文件资源:文件句柄、文件流。
  • 网络资源:套接字连接。
  • 同步资源:互斥锁、信号量。
  • 系统资源:线程、数据库连接、磁盘空间。

下面是使用 RAII 技术来管理文件句柄的代码示例:

class FileHandler {
private:
    FILE* file;
    
public:
    // 构造函数:获取资源
    FileHandler(const char* filename, const char* mode) {
        file = fopen(filename, mode);
        if (!file) {
            throw std::runtime_error("Failed to open file");
        }
    }
    
    // 析构函数:释放资源
    ~FileHandler() {
        if (file) {
            fclose(file);
        }
    }
    
    // 禁止拷贝(避免双重释放)
    FileHandler(const FileHandler&) = delete;
    FileHandler& operator=(const FileHandler&) = delete;
    
    FILE* get() { return file; }
};

// 使用示例
void processFile() {
    FileHandler fh("data.txt", "r");  // 自动获取资源
    // 使用文件...
    // 无需手动关闭,离开作用域时自动释放
}

RAII确保资源按照获取的相反顺序释放。

RAII的关键优势在于自动处理异常情况,即使发生异常也能正确释放资源(异常栈回溯时C++会自动销毁当前作用域内的所有局部对象)。

6. malloc-free 内存分配原理

mallocfree 是 C 语言中用于动态内存分配和释放内存的两个函数。它们是 C 语言标准库的一部分,用于在程序运行期间请求和释放堆内存。

malloc 根据申请内存的大小采用不同的分配策略:

  • 小块内存分配(< 128KB,典型阈值为 MMAP_THRESHOLD),使用 brk()/sbrk() 系统调用扩展进程的堆区(data segment),通过移动程序间断点(program break)来增加堆的大小,分配的内存来自连续的堆空间。
  • 大块内存分配(≥ 128KB),使用 mmap() 系统调用创建匿名内存映射,直接从虚拟地址空间映射独立的内存区域,每次分配独立的内存段,不影响堆区。

malloc 维护一个内存池(arena),通过空闲链表(free list)管理已分配和空闲的内存块:

  • 搜索空闲链表,寻找合适大小的空闲块(首次适配、最佳适配等策略)。
  • 如果找到足够大的块,分割并返回。
  • 如果没有合适的块,通过 brk()mmap() 向系统申请新内存。

为什么不全部使用 mmap?

  • 系统调用开销,每次 mmap 都是系统调用,涉及用户态到内核态的切换(约 100-1000 CPU 周期)、页表的创建和管理、VMA(虚拟内存区域)结构的维护,对于频繁的小内存分配,开销不可接受。

  • 虚拟地址空间碎片,每次 mmap 创建独立的 VMA,大量小块分配会导致虚拟地址空间碎片化,页表项数量激增,内核 VMA 管理结构占用过多内存。

  • 最小分配单位限制,mmap 以页为单位分配(通常 4KB),小于一页的请求也会占用整页,浪费严重。

为什么不全部使用 brk?

  • 大块内存释放困难,brk 分配的内存位于堆的连续区域,只能通过降低 program break 来释放。如果堆顶部的内存未释放,下方的空闲内存无法归还系统。
  • 内存归还效率低,通过 brk 降低堆顶需要满足严格条件,实际中很多内存长期无法归还,导致进程虚拟内存占用持续增长。
  • 大块内存的独立管理需求,大块内存通常生命周期独立,使用 mmap 可以释放时立即归还系统(通过 munmap),避免影响堆区的碎片状态,实现更精确的内存控制。

free 如何确定释放空间大小?

malloc 在返回给用户的指针之前存储元数据,记录块的大小信息。

7. malloc和new的区别

malloc/free 是 C 语言标准库函数,定义在 <stdlib.h> 中,纯粹的内存分配工具,不感知对象类型,函数调用开销,可被替换实现。

new/delete 是 C++ 语言内置运算符(operator),面向对象的内存管理机制,编译器内建支持,可重载但不可替换其核心语义。

malloc从堆(heap)分配,堆是操作系统层面的概念,通过 brk()/mmap() 等系统调用管理。

new从自由存储区(free store)分配,自由存储区是 C++ 的抽象概念,默认实现可能使用堆,但不强制要求,可通过重载 operator new 改变分配策略(如内存池、栈分配等)。

malloc仅分配原始内存,需要手动计算大小,返回 void *,需要强制转换类型。

new分配内存 + 构造对象,编译器自动计算大小,返回强类型指针(类型安全)。

malloc在分配内存失败时,返回 NULL,需要手动判断进行处理。

new在分配内存失败时,抛出 std::bad_alloc 异常,无需进行 nullptr 判断。

在 C++ 中应优先使用 new/delete(或更好的智能指针),它们提供了类型安全、自动对象管理和异常安全等关键特性。仅在与 C 代码交互或特定优化场景下才考虑 malloc

特性 malloc new
类型 C 函数 C++ 运算符
内存来源 堆(heap) 自由存储区(free store)
返回类型 void* 对象类型指针
大小指定 手动计算 编译器自动
构造函数 不调用 自动调用
析构函数 不调用 delete 时调用
失败处理 返回 NULL 抛出 bad_alloc
可重载 否(仅可替换实现) 是(支持多种重载)
数组释放 free(p) delete[] p
类型安全 弱(需转换) 强(编译期检查)
8. 内存泄漏/野指针/空悬指针

内存泄漏(Memory Leak),是指程序动态分配的内存在不再需要时未被释放,导致该内存无法被重新使用。随着程序运行,可用内存逐渐减少,最终可能耗尽系统资源。

野指针,是未初始化的指针,其值是随机的,指向未知的内存地址。可能在声明时未初始化,或者指针变量本身存储在栈上的随机数据。

空悬指针,是指向已释放内存的指针。该内存地址曾经有效,但现在指向的区域可能被回收、重新分配或包含无效数据。

C++ 标准(C++11 §5.3.5/2)明确规定:删除空指针是合法且无操作的

四种指针问题综合对比:

类型 指针状态 产生原因 典型症状 检测难度
野指针 未初始化 声明未赋值 随机崩溃或数据损坏 中(可能偶然指向有效内存)
空悬指针 指向已释放内存 释放后未置空 延迟崩溃或数据损坏 高(内存可能暂时有效)
空指针 nullptr/NULL 显式赋值 解引用时立即崩溃 低(行为确定)
内存泄漏 内存未释放 忘记 delete 内存耗尽 高(需工具检测)
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐