C++八股 | Day3 | 智能指针 / 内存管理 / 内存分区 / 内存对齐

发布于:2025-06-09 ⋅ 阅读:(17) ⋅ 点赞:(0)

C++内存管理

一、堆和栈的区别

栈和堆都是用于存储程序数据的内存区域。

1. 栈(stack)
  • 生命周期:局部变量的生命周期 由函数的执行周期决定,即函数调用开始分配,函数返回自动释放。
  • 管理方式:由编译器自动分配和回收
  • 优点:分配速度快,效率高。
  • 存储内容:局部变量、函数参数&返回地址等函数调用信息。
2. 堆(heap)
  • 生命周期:变量的生命周期由程序员显式控制,可以通过 new/deletemalloc/free 来分配和释放。
  • 管理方式:程序员手动管理,一旦忘记释放会造成内存泄漏。
  • 缺点:分配和释放较慢,因为需要手动操作&操作系统参与。
  • 存储内容:程序运行时动态分配的数据。

二、内存分区

程序运行时,内存大致分为以下几个区域(从低地址到高地址),每个区域负责不同的任务:

1. 代码区(text segment)
  1. 存储程序的机器码(指令)。
  2. 只读,防止代码被修改。
2. 常量区(rodata)
  1. 存放如字符串常量 “ABCD” 等不可修改的数据。
  2. 属于静态存储区的一部分。
3. 全局/静态区(.data 和 .bss)
  1. .data:存储已初始化的全局变量和静态变量。
  2. .bss:存储未初始化的全局变量和静态变量。
  3. 生命周期:程序整个运行期间都存在。
4. 堆区(heap)
  1. 用于动态分配的内存(如 newmalloc)。
  2. 由程序员手动分配与释放内存。
  3. 高地址方向增长。
5. 栈区(stack)
  1. 存储函数的局部变量、参数、返回值等。
  2. 由编译器自动分配与释放
  3. 低地址方向增长。

三、内存泄漏 & 如何避免

1. 什么是内存泄漏
  1. 定义:内存泄漏是指程序在申请内存后,由于程序设计上的疏忽或错误,忘记释放这块内存,导致这段内存一直被占用但再也不会被使用。
  2. 本质:不是内存本身丢失,而是程序对这段内存的控制权丢失了,结果就是这块内存无法再使用,造成了浪费。
  3. 检测工具:常用的内存泄漏检测工具包括:Valgrind、mtrace 等。
2. 内存泄漏的分类
  1. 堆内存泄露(Heap Leak)
    • 常见于使用 mallocnew 等动态分配内存后,没有及时使用 freedelete 释放。如果这段内存不再被引用,也没有被释放,就会造成堆内存泄漏。
  2. 系统资源泄漏(Resource Leak)
    • 程序使用系统分配的资源比如文件句柄(FILE*)、socket 连接、图像对象(Bitmap)、窗口句柄等系统资源没有被显式释放,也会导致系统资源泄漏,使系统运行效率降低,甚至变得不稳定。
  3. 未将析构函数声明为虚函数造成的泄漏
    • 如果使用多态(即父类指针指向子类对象),但父类没有将析构函数定义为 virtual,则在通过父类指针删除对象时,子类的析构函数不会被调用,导致子类分配的资源没有被释放。
    • 如果一个类要作为父类被继承,必须把析构函数声明为 virtual,否则通过父类指针 delete 子类对象时,只会调用父类析构函数,造成资源泄漏。 例如:
class Base {
  ~Base() { ... } // 析构函数,不是 virtual
};

class Derived : public Base {
  ~Derived() { ... } // 析构函数,释放子类资源
};

Base* b = new Derived();
delete b; // 问题出在这里

如果 Base 的析构函数 不是 virtual,就会发生如下情况:

Base* b = new Derived();
delete b; // ❌ 只调用 ~Base(),不会调用 ~Derived()

结果就是:

  • 子类中自己分配的内存(比如 new 出来的数组)不会被释放。
  • 程序就出现了所谓的内存泄漏。

如何解决?将父类的析构函数设为 virtual!

class Base {
public:
  virtual ~Base() { ... } // ✅ virtual
};

class Derived : public Base {
public:
  ~Derived() { ... } // 子类自己的析构逻辑
};

这样就不会造成内存泄露了。

Base* b = new Derived();
delete b; // ✅ 会调用 ~Derived() 然后再调用 ~Base()

总结:

是否需要写 virtual 说明
父类的析构函数 必须写 virtual,否则通过父类指针 delete 子类会内存泄漏
子类的析构函数 可以不写 virtual,因为继承自父类就已经是虚函数了
3. 什么操作容易导致内存泄漏
  • 指针错误:指针被改写或丢失。
  • 忘记释放:申请的内存在不再使用时没有调用对应释放函数。
  • 异常退出:有时函数提前 return 或发生异常,中间资源释放代码没有执行。
4. 如何防止内存泄漏
  1. 配对使用:谁申请的内存,谁负责释放(new/delete,malloc/free)。
  2. 构造/析构管理:在构造函数申请资源,在析构函数中统一释放资源。
  3. 智能指针(如 std::unique_ptr, std::shared_ptr):自动管理内存,生命周期自动绑定到对象上。
5. 构造函数,析构函数要设为虚函数吗,为什么?
  1. 析构函数:
    析构函数需要。 当派生类对象中有内存需要回收时,如果析构函数不是虚函数,不会触发动态绑定,只会调用基类析构函数,导致派生类资源无法释放,造成内存泄漏。
  2. 构造函数:
    构造函数不需要。 虚函数调用是在部分信息下完成工作的机制,允许我们只知道接口而不知道对象的确切类型。 要创建⼀个对象,你需要知道对象的完整信息(包含对象的确切类型)。

四、智能指针分类

智能指针就是一个封装了普通指针的类,自动帮你管理内存资源,在不用时自动释放,防止忘记 delete 造成内存泄漏,防止多次释放同一内存。它们定义在 C++11 的头文件 < memory > 中。

1. std::unique_ptr 独占指针
  1. 特点:
    • 独占所有权,一个对象只能被一个 unique_ptr 拥有。
    • 无法复制,只能移动(通过 std::move()
    • 自动释放所指向的内存。
#include <memory>

std::unique_ptr<int> ptr = std::make_unique<int>(42);
  1. make_unique 创建,语法简洁安全。
  2. 一般用于:
    • 独占资源
    • 局部使用
    • 保证对象不会被多个指针共享

独占指针举例:

#include <iostream>
#include <memory>

class Dog {
public:
    Dog() { std::cout << "Dog created\n"; }
    ~Dog() { std::cout << "Dog destroyed\n"; }
    void bark() { std::cout << "Woof!\n"; }
};

int main() {
    std::unique_ptr<Dog> dog1 = std::make_unique<Dog>(); // 创建
    dog1->bark();

    // std::unique_ptr<Dog> dog2 = dog1; // ❌ 错误:不能复制

    std::unique_ptr<Dog> dog2 = std::move(dog1); // ✅ 可以移动
    if (!dog1) std::cout << "dog1 is null\n";

    dog2->bark(); // 现在 dog2 拥有资源
}

输出:

Dog created
Woof!
dog1 is null
Woof!
Dog destroyed

在 main() 函数结束时,dog2 离开作用域:C++ 会自动调用 dog2 的析构函数,而 unique_ptr 的析构函数内部会调用 delete 去释放它管理的对象。这句输出:Dog destroyed 说明:dog2 在主函数结束时被销毁,自动释放了堆上的 Dog 对象。

**unique_ptr 的好处就在于 不需要你手动 delete,它会在生命周期结束时自动释放所拥有的资源,避免内存泄漏。**但注意:
如果你在函数中 new 了对象但没有用智能指针接管它的生命周期,那就需要你自己 delete,否则就内存泄漏了.

PS:
. (变量类型为对象)和 -> (变量类型为指针) 用于访问对象的成员,和是否是 class / struct 无关; ::` 用于访问作用域中的静态成员、类型名、常量等。class 和 struct 都可以用。

2. std::shared_ptr 共享指针
  1. 特点:
    • 引用计数机制,多指针可以共享同一对象。
    • 每多一个 shared_ptr 拷贝,引用计数 +1;每销毁一个,引用计数 -1;引用计数为 0 时,资源释放。
    • 易用但要注意循环引用问题(见 weak_ptr)。
std::shared_ptr<int> ptr1 = std::make_shared<int>(42);
std::shared_ptr<int> ptr2 = ptr1; // 引用计数 +1

共享指针举例:

#include <iostream>
#include <memory>

class Cat {
public:
    Cat() { std::cout << "Cat created\n"; }
    ~Cat() { std::cout << "Cat destroyed\n"; }
    void meow() { std::cout << "Meow!\n"; }
};

int main() {
    std::shared_ptr<Cat> cat1 = std::make_shared<Cat>(); // 创建对象,count = 1
    {
        std::shared_ptr<Cat> cat2 = cat1; // count = 2
        std::cout << "Use count: " << cat1.use_count() << '\n';
        cat2->meow();
    } // cat2 出作用域,引用计数 -1,count = 1

    std::cout << "Use count after cat2: " << cat1.use_count() << '\n';
    cat1->meow();
    // cat1 要出作用域,count 从 1 减到 0,此时调用 delete -> 析构
}

输出:

Cat created
Use count: 2
Meow!
Use count after cat2: 1
Meow!
Cat destroyed
3. std::weak_ptr 弱引用指针
  1. 特点:
    • 用于解决 shared_ptr 循环引用的问题
    • 不增加引用计数,不会影响资源释放。
    • 用于观察 shared_ptr 是否还有效,通过 .lock() 转换成 shared_ptr 使用。
std::shared_ptr<int> sharedPtr = std::make_shared<int>(42);
std::weak_ptr<int> weakPtr = sharedPtr; // 不增加引用计数

使用:

if (auto sp = weakPtr.lock()) {
    // 成功获得 shared_ptr,可以使用资源
}

弱引用指针举例:

#include <iostream>
#include <memory>

class Person {
public:
    std::shared_ptr<Person> friendPtr;
    ~Person() { std::cout << "Person destroyed\n"; }
};

int main() {
    std::shared_ptr<Person> p1 = std::make_shared<Person>();
    std::shared_ptr<Person> p2 = std::make_shared<Person>();

    // 错误方式:会导致循环引用
    // p1->friendPtr = p2;
    // p2->friendPtr = p1;

    // 正确方式:用 weak_ptr 打破循环
    std::weak_ptr<Person> weak = p1;
    if (auto sp = weak.lock()) { // 尝试获得 shared_ptr
        std::cout << "Accessed person through weak_ptr\n";
    }

    return 0;
}

输出:

Accessed person through weak_ptr
Person destroyed
Person destroyed

什么是循环引用
如果两个 shared_ptr 互相持有对方,就会造成引用计数永远不为 0,资源永远无法释放 → 内存泄漏

总结:

类型 所有权 引用计数 可复制 适用场景
unique_ptr 独占 独占资源
shared_ptr 共享 多个共享拥有者
weak_ptr 观察者 ✅(不+1) 避免循环引用,观察 shared_ptr

五、如何避免野指针

1. 什么是野指针

野指针指的是:指向一块已经释放或者无效的内存地址的指针。 这种指针如果被错误使用,可能会导致:程序崩溃(如 segmentation fault),数据损坏,不可预测的行为(Undefined Behavior)。

2. 野指针产生原因 & 避免方法
  1. 释放后没有置空指针:
int* ptr = new int;
delete ptr;
// 这时 ptr 成为野指针,仍然指向已释放的内存

虽然原来指向的地址内存已经释放,但指针 ptr 仍然指向原来的地址,这就可能误操作一块无效内存。

避免方法:

delete ptr;
ptr = nullptr; // 将指针设为 nullptr,表示不再指向任何有效内存
  1. 返回局部变量的地址:
int* createInt() {
    int x = 10;
    return &x; // 错误!x 是局部变量,函数结束后内存被销毁
}

x 是局部变量,函数结束后其内存在栈上被回收,但指针返回了这块无效地址。

避免方法:

  • 不返回局部变量的地址。
  • 使用 new 在堆上申请内存后返回,或者使用引用传参。
  1. 函数参数的指针被释放:
void foo(int* ptr) {
    delete ptr; // 在这里释放
}

int main() {
    int* ptr = new int;
    foo(ptr); // 调用函数释放 ptr
    // 这里 ptr 成为了野指针,仍然可以访问已释放的内存
}

虽然在 foo() 中释放了内存,但 main() 中的 ptr 并不知道这件事,还在使用这块已释放的内存。

避免方法:

  • 函数不要释放由调用者传入的内存。
  • 或者释放后及时在调用处也将指针设为 nullptr。
3. 避免野指针的原则
  1. delete 后一定记得 ptr = nullptr
  2. 不返回局部变量的地址。
  3. 避免函数内部释放外部传入的指针,除非有明确的管理约定。
  4. 可考虑使用智能指针(如 std::unique_ptr, std::shared_ptr)来自动管理生命周期。
4. delete ptr 中到底发生了什么

假设我们有下面这段代码:

int* ptr = new int(42);  // 第一步
delete ptr;              // 第二步

第一步:int* ptr = new int(42)

这个语句分两件事:

  1. 在堆区分配一个 int,值是 42,
    假设分配到了地址 0x1000,可以理解为:
堆内存地址:0x1000
存放的值:  42
  1. 在栈区上创建一个变量 ptr,它是一个“指针变量”,
    假设 ptr 本身的地址是 0x2000,但它存放的是 0x1000,即堆内存的地址:
栈内存地址:0x2000
变量名:    ptr
内容:      0x1000

第二步:delete ptr;
这个语句只做一件事:

让系统回收 ptr 指向的那块堆内存(即地址 0x100042)。也就是说,堆上的值 42 被释放掉了(该内存地址会被标记为“可用”,原来的值 42 一般还在,但是你不能保证它还在,也不能再用它),这块内存以后就不能再安全访问了。

但是重要的一点:ptr 这个变量本身还在,它还保留着 0x1000 这个地址,也就是说:堆内存的那块空间(0x1000)变成“废地”,但 ptr 这个“钥匙”还握在你手里,如果你继续用 *ptr = 100; 就相当于你在废地上盖房子 —— 没人知道会发生什么!

六、野指针 vs 悬浮指针

1. 定义对比:
名称 定义说明
野指针 指的是指向已经被释放无效地址的指针。常见于 delete 后,指针本身未置为 nullptr,仍然指向原地址。
悬浮指针(悬浮引用) 指的是引用(或指针)指向一个局部变量,当函数返回后,该局部变量已被销毁,引用仍然存在,就称为悬浮引用。
2. 核心区别:
分类维度 野指针 悬浮指针(引用)
所涉类型 指针类型 引用类型(也可能是指针)
本质问题 内存释放后指针未清空,仍然访问原地址 局部变量生命周期结束,引用仍在使用它
风险表现 访问无效内存:程序崩溃、数据损坏 访问已销毁对象,行为未定义(数据异常)

悬浮指针例子:

int& foo() {
    int x = 10;
    return x; // ❌ 错误:返回了局部变量的引用
}
int main() {
    int& ref = foo(); // ref 是悬浮引用
    cout << ref << endl; // ❌ 错误:访问已销毁变量
}
3. 产生原因:
类型 原因
野指针 由于没有正确管理指针生命周期,特别是释放后未将指针置为 nullptr
悬浮指针 函数返回了对局部变量的引用或指针,超出了变量的作用域
4. 如何避免悬浮指针:
  1. 不要返回局部变量的引用或指针:若要返回引用或指针,应使用动态分配的内存(如 new)或将变量声明为静态
  2. 若要返回引用或指针,应使用动态分配的内存(如 new)或将变量声明为静态
// 正确示例(静态变量)
int& foo() {
    static int x = 10;
    return x; // 把该变量作为引用返回,因为函数的返回类型是 int&。
}
5. 总结

野指针是“指向已释放内存的指针”,悬浮指针/引用是“引用了生命周期已结束的变量”。两者都会导致未定义行为,但来源不同,解决方式也不同。

七、内存对齐

1. 定义

内存对齐是指:数据在内存中的起始地址必须是某个特定“整数倍”地址,这个“整数”叫对齐值(alignment)。

举例:

如果一个 int 是 4 字节,很多 CPU 要求这个 int 的起始地址必须是 4 的倍数,比如 0x10000x10040x1008,而不能从 0x1001 或 0x1003 开始。

这种规则叫“自然对齐”:一个数据的对齐值一般是它的大小。

为什么有这种限制:

因为 CPU 读取内存不是一个字节一个字节读的,它是一段一段读取,如果你的数据没有放在“合适”的边界上,CPU 要么:

  • 读两次(性能下降)
  • 报错(有些系统,如 SPARC)
  • 甚至程序崩溃(极端情况)
2. 为什么需要内存对齐

本质原因是 提高 CPU 访问内存的效率。

  • 大多数 CPU 访问内存是按“对齐边界”进行的
    • 不对齐意味着每次都要额外读取和合并数据 → 读两次 → 慢
  • CPU 缓存(cache line)也是按对齐方式加载的
    • 不对齐会让 CPU 缓存系统效率变差,甚至多次加载 → 增加开销
  • 高位地址对齐优化访问顺序
    • 高对齐要求能更快定位数据 → 地址计算更高效
  • 某些平台要求严格对齐(如 SPARC、ARM)
    • 否则程序会直接 crash!