C/C++ 八股文之内存
Author:Once Day Date:2026年1月13日
漫漫长路,才刚刚开始...
全系列文章请查看专栏: C语言_Once-Day的博客-CSDN博客
参考文档:
- 史上最全C/C++面试、C++面经八股文,一文带你彻底搞懂C/C++面试、C++面经!_c++八股-CSDN博客
- 一文搞懂 C/C++ 面试重点知识和常见面试题(2025 年更新) | 编程指北-计算机学习指南
- 快手C++开发一面:new和malloc有什么区别,用new生成的对象,可以用 free释放吗?
文章目录
-
-
- [C/C++ 八股文之内存](#C/C++ 八股文之内存)
-
- [1. 内存本质](#1. 内存本质)
- [2. 内存分区](#2. 内存分区)
- [3. 指针与引用的区别](#3. 指针与引用的区别)
- [4. 指针传递、值传递、引用传递](#4. 指针传递、值传递、引用传递)
- [5. 资源获取即初始化](#5. 资源获取即初始化)
- [6. malloc-free 内存分配原理](#6. malloc-free 内存分配原理)
- [7. malloc和new的区别](#7. malloc和new的区别)
- [8. 内存泄漏/野指针/空悬指针](#8. 内存泄漏/野指针/空悬指针)
-
1. 内存本质
内存是计算机中的存储空间,本质上是一个连续的字节数组 。程序运行时,所有的指令、数据和状态都存储在内存中。从这个角度看,编程的本质就是操控内存中的数据:读取、修改、传递这些数据,最终完成特定的计算任务。
内存中的每个字节都有一个唯一的地址(address),这个过程称为内存编址。地址本质上是一个非负整数,用于标识某个字节在内存中的位置。
在32位系统中,地址用32位表示,理论上可以访问 2^32 = 4GB 的内存空间;
64位系统使用64位地址,理论寻址空间达到 2^64 字节(实际受硬件和操作系统限制)。
地址空间 (address space)是程序可以使用的所有有效地址的集合。现代操作系统为每个进程提供虚拟地址空间,这是一个抽象层,让每个进程都认为自己独占整个内存。
典型的进程地址空间布局(以Linux为例):
- 代码段(text):存储可执行指令。
- 数据段(data/bss):存储全局变量、静态变量。
- 堆(heap):动态分配的内存,由低地址向高地址增长。
- 栈(stack):存储局部变量、函数调用信息,由高地址向低地址增长。
变量是对一块内存区域的抽象命名。编译器为每个变量分配特定大小的内存空间,变量名在编译后会被替换为对应的内存地址。
变量的三个关键属性:
- 地址: 变量在内存中的位置。
- 大小: 变量占用的字节数。
- 类型: 决定如何解释这些字节。
当一个多字节数据(如int、long)存储到内存时,其各字节的存储顺序称为字节序。主要有两种:
- 大端序(Big-Endian):高位字节存储在低地址。
- 小端序(Little-Endian):低位字节存储在低地址。
c
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进程内存布局(从低地址到高地址):
c
高地址
+------------------+
| 内核空间 | (不可访问)
+------------------+
| 栈区 (↓) | (向下生长)
| |
| ↓ |
| |
| ... |
| |
| ↑ |
| |
| 堆区 (↑) | (向上生长)
+------------------+
| .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 技术来管理文件句柄的代码示例:
c++
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 内存分配原理
malloc 和 free 是 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 | 内存耗尽 | 高(需工具检测) |