C++基础与深度解析 | 动态内存管理 | 智能指针


《C++基础与深度解析》传送门:


一、动态内存基础

  在 C++ 中,动态内存管理是程序设计中的一个核心概念。主要有两种类型的内存分配:栈内存(Stack Memory)和堆内存(Heap Memory)。

image-20240519113132775

  • 栈内存

    栈内存用于存储局部变量,包括函数的参数和局部对象。

    栈内存的特点更好的局部性,对象自动销毁

    • 自动管理编译器自动管理栈内存的分配和释放。当函数被调用时,其局部变量被自动推入(pushed)到栈中;当函数返回时,这些变量被自动弹出(popped)。
    • 有限大小:栈的大小是有限的,由操作系统和平台决定,通常比堆内存小。
    • 快速访问:栈内存提供快速的访问速度,因为它遵循后进先出(LIFO)的原则,有较好的局部性。
    • 作用域限制:栈内存中的对象的作用域仅限于定义它们的块(例如,函数内部)。
    • 对象自动销毁:当控制流离开对象的作用域时,对象自动被销毁,析构函数被调用(如果对象是类实例)。
  • 堆内存

    堆内存用于动态内存分配,可以通过 newdelete 操作符或内存分配函数(如 mallocfree)来管理。

    堆内存的特点运行期动态扩展,需要显式释放

    • 动态扩展堆内存的大小可以在程序运行时动态变化,它通常比栈内存大得多。
    • 手动管理:程序员需要手动分配和释放堆内存。使用 new 表达式分配内存时,必须使用 delete 来释放它。
    • 碎片和泄漏:如果不正确管理,堆内存可能导致碎片化和内存泄漏。必须小心管理堆内存,避免内存泄漏和野指针。
    • 生命周期控制:堆内存中的对象的生命周期不受限于作用域,需要显式管理其生命周期。
    • 运行期分配:可以在程序的任何地方根据需要分配堆内存。

1.new与delete

  在 C++ 中,使用 newdelete 操作符来动态地构造和销毁对象是一个常见的做法。这个过程确实涉及两个主要步骤:内存分配和对象构造(对于构造)以及对象析构和内存释放(对于销毁)。

对象构造

  • 分配内存new 操作符首先为对象分配所需的内存。这部分内存的大小基于对象的数据成员和继承结构。

  • 构造对象:一旦内存被分配,构造函数被调用以初始化对象。构造函数负责设置对象的初始状态,包括初始化数据成员、分配任何动态内存等。

对象销毁

  • 析构对象:当使用 delete 操作符时,首先调用对象的析构函数。析构函数负责清理对象,包括释放任何动态分配的内存、关闭文件句柄等。

  • 释放内存:析构函数执行完成后,delete 操作符释放之前 new 分配的内存。

注意事项

  • 匹配 newdelete:每次使用 new 分配内存时,必须有对应的 delete 来释放内存,否则会造成内存泄漏。
  • 构造和析构的顺序:对于对象数组,构造函数和析构函数的调用顺序是相反的。构造时,从第一个元素到最后一个元素;析构时,从最后一个元素到第一个元素。

示例:

#include <iostream>

class Example {
public:
    int value;
    Example() {
        value = 0; // 构造函数初始化
    }
    ~Example() {
        
    }
};

int main() {
    Example* obj = new Example(); // 分配内存并构造对象
    obj->value = 10;
    std::cout << obj->value << std::endl;
    delete obj; // 销毁对象并释放内存
    return 0;
}

new的几种常见形式

  • 构造单一对象

    这是最基本的 new 用法,用于在堆上构造一个单一对象。

    int* p = new int;	//构造对象时,缺省初始化
    
  • 构造对象数组

    用于在堆上构造对象的数组。

    int* pArray = new int[5];  //指针指向对象数组首元素
    
    int* pArray = new int[5]{1,2,3,4,5}; //列表初始化 
    
    //删除对象数组的时候比较特殊
    delete[] pArray;
    
  • nothrow new

    C++11 引入了 std::nothrow 作为 new 的一个修饰符,当内存不足时,nothrow new 不会抛出异常,而是返回 nullptr。若不加该修饰符,当内存不足分配失败时,会抛出异常。

    #include <new>
    int* pNothrow = new(std::nothrow) int;
    

    因此,常见写法是错误的

    int* pArray = new int[5];
    //若内存申请失败会直接跳到异常处理部分
    if (pArray == nullptr)
    {
    	//...
    }
    
  • placement new:内存的分配与内存的构造显示分离

    placement new 是一种特殊的 new,它允许在已经分配的内存上构造对象。这通常用于初始化已经分配的内存(可以是堆内存也可以是栈内存),如在 std::vectorstd::string 的内部。使用 placement new 时,必须确保提供的内存是足够大的,并且正确对齐。

    #include <iostream>
    
    int main() {
        char* buffer = new char[sizeof(int)]; // 分配内存
        int* p = new (buffer) int(4); // 在buffer上构造int对象
        std::cout << *p;  //4
        return 0;
    }
    
  • new auto(C++20)

    C++20 引入了 new auto,这是一个使用自动类型推断的 new 表达式。它允许编译器根据初始化器来推断对象的类型。

    auto obj = new auto(new Example("constructor argument"));
    

    在这个例子中,obj 的类型将被推断为 Example*,因为 new Example("constructor argument") 返回的是 Example* 类型。

无论使用哪种形式的 new,都应该匹配合适的 delete(或 delete[])来避免内存泄漏。

new与对象对齐

  在 C++ 中,new 操作符用于动态分配内存并构造对象。默认情况下,new 分配的内存会自动对齐以满足对象的对齐要求。这意味着分配的内存地址将是对象大小的整数倍,这是为了满足特定硬件平台的内存对齐要求,以提高访问对象的效率。

示例:

#include <iostream>
#include <new> // 包含 new 操作符的定义
struct alignas(16) Str{};  //手动指定对齐要求

int main() {
    Str* obj = new Str;
    // obj 指向的内存将自动满足 Example 类的对齐要求
    std::cout << "Memory address of object: " << obj << std::endl;
    delete obj; // 记得使用 delete 释放内存
    return 0;
}

多次运行:

Memory address of object: 0x55fd9cf6de70
Memory address of object: 0x558ff6c58e70

最后一位都为0,都是16的整数倍。

对象对齐要求

  • 对象对齐是指对象的内存地址应该是其大小的倍数。这是为了确保对象的任何非静态数据成员都能够在内存中正确对齐。
  • C++ 标准规定了对象的默认对齐要求,但编译器实现可能会有不同的对齐方式。

注意事项

  • 在某些情况下,你可能需要手动指定对齐要求,这可以通过使用 alignas 关键字或编译器特定的扩展来实现。
  • 使用 new 分配的内存时,不需要手动处理对齐,因为 new 会自动处理

delete的常见用法

  在 C++ 中,delete 操作符用于释放之前使用 new 分配的内存,并销毁对象。以下是 delete 的常见用法:

  • 销毁单一对象

    当使用 new 分配一个单一对象时,必须使用 delete 来释放内存并销毁对象。

    int* p = new int;
    *p = 10;
    delete p; // 销毁 int 对象并释放内存
    p = nullptr; // 将指针设置为 nullptr 是一个好习惯
    
  • 销毁对象数组

    如果使用 new[] 分配了一个对象数组,需要使用 delete[] 来释放整个数组的内存并销毁所有对象。

    int* pArray = new int[10];
    delete[] pArray; // 销毁 int 数组并释放内存
    pArray = nullptr; // 同样,将指针设置为 nullptr
    
  • placement delete

    Placement new 是一种特殊的 new,它允许在已经分配的内存上构造对象。与之对应的是 placement delete,它用于在特定内存上销毁对象,但不释放内存。

    char* buffer = new char[sizeof(int)]; // 分配内存
    int* p = new (buffer) int; // 在buffer上构造int对象
    
    // 使用对象...
    
    // 使用 placement delete 销毁对象,但不释放内存
    p->~int(); // 调用 int 类型的析构函数
    // 注意:不要再次使用 delete buffer,因为内存并未释放
    

使用new与delete的注意事项

  • 根据分配的是单一对象还是数组,采用相应的方式销毁

    • 对于使用 new 分配的单个对象,必须使用 delete 来释放内存。
    • 对于使用 new[] 分配的对象数组,必须使用 delete[] 来释放内存。
  • delete nullptr

    delete 一个 nullptr 是安全的,不会导致错误。这是从 C++11 开始的语言规定。

  • 避免删除非 new 返回的内存

    不要使用 delete 来释放不是通过 newnew[] 分配的内存。例如,不要用 delete 释放使用 malloccalloc 分配的内存。

  • 避免多次删除同一块内存:

    一旦使用 delete 释放了内存,不要再次删除同一块内存。多次删除同一内存是未定义行为,可能导致程序崩溃。

  • 设置指针为 nullptr

    在调用 delete 释放内存后,将指针设置为 nullptr 是一个好习惯。这有助于避免悬垂指针(dangling pointer)问题。

  • 注意对象的构造函数和析构函数:

    确保对象的构造函数和析构函数正确实现,以便于资源的获取和释放。

  • 内存泄漏:

    未能正确释放动态分配的内存会导致内存泄漏。使用工具如 Valgrind 或内存检测器来帮助识别内存泄漏。

  • RAII(资源获取即初始化):

    尽可能使用 RAII 机制管理资源。这意味着使用对象来封装资源,如智能指针(std::unique_ptrstd::shared_ptr)。考虑使用智能指针代替原始指针进行内存管理,智能指针会自动释放它们所管理的内存。

使用 new 与 delete 的问题内存所有权不清晰,容易产生不销毁,多销毁的情况

  在 C++ 中,使用原始指针和 new/delete 进行内存管理确实可能导致内存所有权不清晰的问题。这种情况通常发生在多个指针可能指向同一块动态分配的内存时,如果不正确管理这些指针,就可能产生内存泄漏(不销毁)或多次销毁(多销毁)的问题。

  • 内存泄露

    当一个动态分配的对象被所有引用它的指针释放后,如果没有适当的销毁和内存释放,该对象所占用的内存就无法被再次利用,从而导致内存泄漏。

  • 多次销毁

    如果同一块动态分配的内存被多个指针共享,且这些指针分别调用 delete 去释放这块内存,就会发生多次销毁的问题。这通常会导致程序崩溃或未定义行为。

针对这种问题,C++ 的解决方案:智能指针。利用 std::unique_ptrstd::shared_ptr 等智能指针来自动管理内存。这些智能指针会在适当的时候自动释放它们所管理的对象。

调用系统自身的new/delete行为(不要轻易使用)

  在 C++ 中,调整系统自身的 newdelete 行为通常涉及到替换全局的 newdelete 操作符,或者使用自定义的内存管理策略。但这通常不是推荐的做法,因为它可能会带来复杂性和风险。相反,应该优先考虑使用标准库提供的工具和机制,如智能指针和自定义分配器,来满足特殊的内存管理需求。

二、智能指针

  在 C++ 中,智能指针提供了一种自动管理动态分配内存的方法,它们在 C++11 标准中被引入,并在后续版本中得到了增强和改进。智能指针自动处理对象的生命周期,包括它们的构造、析构和内存释放,从而减少了内存泄漏和其他内存管理错误的风险。

  智能指针是现代 C++ 中管理动态内存的首选方式。它们提供了比传统 new/delete 更安全和方便的内存管理机制。std::unique_ptr 适用于需要独占所有权的场景,而 std::shared_ptr 适用于需要共享所有权的场景std::weak_ptr 作为补充,用于解决 std::shared_ptr 可能引起的循环引用问题。随着 std::auto_ptr 在 C++17 中被删除,推荐使用 std::unique_ptrstd::shared_ptr 作为主要的智能指针选择。

std::auto_ptr ( C++17 删除)

  std::auto_ptr 是 C++11 之前使用的智能指针,它通过转移所有权的方式来管理内存。当 std::auto_ptr 被复制时,原始指针的所有权会被转移给复制后的指针,原始指针将不再指向有效的对象。

1.shared_ptr

  在 C++ 中,std::shared_ptr 是一种智能指针,用于实现基于引用计数的共享内存管理。这意味着多个 std::shared_ptr 实例可以指向同一个动态分配的对象,对象的生命周期将由引用计数控制,当最后一个指向对象的 std::shared_ptr 被销毁时,对象将自动被销毁。

基本实现

  std::shared_ptr 通过构造函数接受一个原始指针,并在内部维护该对象的引用计数。

#include <memory>

std::shared_ptr<Example> ptr1(new Example); //1
std::shared_ptr<Example> ptr2 = ptr1; // 引用计数增加--2

析构时,调用shared_ptr的析构函数,会先将引用计数的值减1,然后判断引用计数的值是否为0,如果为0,则调用delete来删除之前分配的内存。

reset / get方法

  • reset方法

    reset 方法用于更改 std::shared_ptr 所指向的对象。如果 shared_ptr 已经指向一个对象,其引用计数会减少,并在计数为零时销毁对象。

    ptr1.reset(new Example); // 释放原来的对象,指向新的 Example 对象
    
  • get方法

    get 方法返回 std::shared_ptr 所管理对象的原始指针。

    Example* rawPtr = ptr1.get();
    

指定内存回收逻辑

  可以通过 std::shared_ptr 的自定义删除器来指定释放内存时的逻辑。例如调用以下构造函数

template< class Y, class Deleter >
shared_ptr( Y* ptr, Deleter d );

其他类似构造函数可参考:https://en.cppreference.com/w/cpp/memory/shared_ptr/shared_ptr

std::shared_ptr<Example> ptr(new Example, [](Example* p) { /* 自定义删除逻辑 */ });  //这里使用了Lambda函数,也可以定义一个函数传入函数名称

std::make_shared

  std::make_shared 是推荐的方式,用于创建 std::shared_ptr 管理的对象。它比使用 newshared_ptr 构造函数更高效,因为它只需一次内存分配。系统将用来保存对象的内存与用来保存引用计数的内存开辟的尽量的近。

std::shared_ptr<Example> ptr = std::make_shared<Example>();

参考网址:https://en.cppreference.com/w/cpp/memory/shared_ptr/make_shared

支持数组

  从 C++17 开始,std::shared_ptr 支持对数组的管理,C++20 进一步支持使用 std::make_shared 分配数组。

// C++17 支持 shared_ptr<T[]>
std::shared_ptr<int[]> arr(new int[10]);

// C++20 支持 make_shared 分配数组
std::shared_ptr<int[]> arr2 = std::make_shared<int[]>(10);

注意事项

  • 不要对 std::shared_ptr 管理的对象调用 delete:由于 std::shared_ptr 会自动处理内存释放,手动调用 delete 会导致未定义行为。
  • 循环引用问题:如果多个 std::shared_ptr 相互引用,可能会导致循环引用问题,此时应使用 std::weak_ptr 来打破循环。
  • 性能考虑:虽然 std::shared_ptr 很方便,但在性能敏感的代码中,引用计数的维护可能会带来额外的开销。
  • 线程安全std::shared_ptr 的复制和销毁是线程安全的,但同时修改多个 std::shared_ptr 指向同一个对象的引用计数可能需要额外的同步机制。

2.uniuqe_ptr

  在 C++ 中,std::unique_ptr 是一种智能指针,它提供了独占内存的解决方案。这意味着,与 std::shared_ptr 不同,std::unique_ptr 严格保证同一时间只有一个 std::unique_ptr 实例拥有对象的所有权。

参考网址:https://en.cppreference.com/w/cpp/memory/unique_ptr

基本用法

  std::unique_ptr 通过构造函数接受一个原始指针来管理动态分配的内存。

#include <memory>

std::unique_ptr<Example> uniquePtr(new Example);

unique_ptr 不支持复制,但可以移动

  std::unique_ptr 不支持复制操作,这意味着你不能直接复制一个 std::unique_ptr 的实例。这避免了多个指针指向同一内存的问题。不过,std::unique_ptr 支持移动操作,可以在不同 std::unique_ptr 实例之间转移所有权。

std::unique_ptr<Example> uniquePtr2 = std::move(uniquePtr); // 转移所有权
// uniquePtr 现在没有所有权,uniquePtr2 拥有 Example 对象

示例:

#include <memory>
#include <iostream>

class Example {
public:
    Example() { std::cout << "Example created." << std::endl; }
    ~Example() { std::cout << "Example destroyed." << std::endl; }
};

int main() {
    // 创建 unique_ptr
    std::unique_ptr<Example> uniquePtr(new Example);
    
    // unique_ptr 不支持复制,尝试复制将导致编译错误
    // std::unique_ptr<Example> anotherPtr = uniquePtr; // 错误

    // 移动 unique_ptr
    std::unique_ptr<Example> anotherPtr;
    anotherPtr = std::move(uniquePtr);
    // 此时 anotherPtr 拥有 Example 对象的所有权,uniquePtr 不再拥有

    // 当 anotherPtr 离开作用域时,Example 对象被销毁
    // Example destroyed.

    return 0;
}

为 unique_ptr 指定内存回收逻辑

  std::unique_ptr 允许你通过自定义删除器来指定释放内存时的逻辑。

template<
    class T,
    class Deleter = std::default_delete<T>
> class unique_ptr;

template <
    class T,
    class Deleter
> class unique_ptr<T[], Deleter>;

示例:

#include <iostream>
#include <memory>

class Example {
public:
    int value;
    Example() {
        value = 0; // 构造函数初始化
    }
    ~Example() {
        
    }
};

void fun(Example* ptr)
{
    std::cout << "Func is called\n";
    delete ptr;
}

int main() {
    std::unique_ptr<Example, decltype(&fun)> uniquePtr(new Example, fun);

    return 0;
}

3.weak_ptr

  在 C++ 中,std::weak_ptr 是一种智能指针,它用来辅助 std::shared_ptr 解决循环引用问题。std::weak_ptr 本身不拥有它所观察的资源,而是观察由 std::shared_ptr 管理的资源的引用计数。它提供了一种机制来观察资源的生命周期,而不增加其引用计数。使用 std::weak_ptr 时,应当小心处理可能的空指针情况,并在适当的时候检查 lock() 方法的返回值。

基本用法

  std::weak_ptr 通常基于一个 std::shared_ptr 实例来构造,它持有一个对 std::shared_ptr 管理资源的弱引用。

#include <memory>

std::shared_ptr<Example> shared(new Example);
std::weak_ptr<Example> weak(shared);

lock方法

  std::weak_ptr 提供了一个 lock() 方法,该方法尝试获取一个 std::shared_ptr。如果 std::weak_ptr 指向的资源已经被释放(即没有任何 std::shared_ptr 指向它),lock() 方法将返回一个空的 std::shared_ptr

std::shared_ptr<Example> lockedPtr = weak.lock();
if (lockedPtr) {
    // 安全地使用 lockedPtr
} else {
    // 指向的资源已经被释放
}

防止循环引用产生的内存泄露

  std::weak_ptr 主要用于解决 std::shared_ptr 可能产生的循环引用问题。当两个对象相互拥有对方的 std::shared_ptr 时,它们将无法被销毁,因为它们的引用计数永远不会降到零。通过将其中一个 std::shared_ptr 替换为 std::weak_ptr,可以打破这种循环引用。

示例:

#include <memory>
#include <iostream>

class Node {
public:
    std::weak_ptr<Node> node;
    ~Node()
    {
        std::cout << "fun is called\n";
    }
};

int main() {
    // 由于 Node 内部使用了 weak_ptr,所以循环引用不会发生
    std::shared_ptr<Node> x = std::make_shared<Node>();
    std::shared_ptr<Node> y = std::make_shared<Node>();
    x->node = y;
    y->node = x;
}

三、动态内存的相关问题

sizeof 不会返回动态分配的内存大小

  sizeof 运算符返回的是变量或类型的存储空间大小。当使用 new 分配内存或使用std::vector<>容器时,sizeof返回指针本身的大小,而不是分配的内存块的大小

int* p = new int[10];
std::cout << sizeof(p) << std::endl; // 输出指针的大小,而不是数组的大小

动态分配是在运行期完成的,而sizeof是在编译期完成的

使用分配器( allocator )来分配内存

  C++ 标准库提供了分配器(std::allocator)模板类,它可以用来分配和构造任意类型的内存块。相比下面两种分配内存方式,在C++中推荐使用这种。

参考网址:https://en.cppreference.com/w/cpp/memory/allocator

std::allocator<int> alloc;
int* p = alloc.allocate(10);  //分配内存并返回内存,并不调用构造函数
alloc.construct(p, 1); // 构造一个 int 对象
// 使用完毕后,需要手动析构和释放内存
alloc.destroy(p);
alloc.deallocate(p, 10);

使用 malloc / free 来管理内存(只能管理内存,并不关注对象的构造与销毁)

  C 语言的 mallocfree 函数也可用于 C++ 程序中来分配和释放内存。

int* p = (int*)malloc(10 * sizeof(int));
if (p) {
    // 使用内存
    
}
free(p);

malloc分配的内存的重大缺陷:不能要求满足一定的对齐特性。

使用 aligned_alloc 来分配对齐内存

  C++17 引入了 std::aligned_alloc 函数,用于分配指定对齐的内存。

#include <cstdio>
#include <cstdlib>
 
int main()
{
    int* p1 = static_cast<int*>(std::malloc(10 * sizeof *p1));
    std::printf("default-aligned address:   %p\n", static_cast<void*>(p1));
    std::free(p1);
 
    int* p2 = static_cast<int*>(std::aligned_alloc(1024, 1024));
    std::printf("1024-byte aligned address: %p\n", static_cast<void*>(p2));
    std::free(p2);
}

运行结果:

default-aligned address:   0x2221c20
1024-byte aligned address: 0x2222400

动态内存与异常安全

  在 C++ 中,异常安全性是一个重要的考虑因素。当使用动态内存时,需要确保在发生异常时资源得到正确的释放。

  • 使用智能指针(如 std::unique_ptrstd::shared_ptr)可以提供异常安全保证,因为它们在异常发生时会自动释放资源。
  • 对于手动管理的内存,需要在异常处理中明确释放内存。

C++ 对于垃圾回收的支持

  C++ 不提供内置垃圾回收机制,它依赖于确定性的资源管理策略,如 RAII(Resource Acquisition Is Initialization)和智能指针。然而,社区提供了一些第三方垃圾回收库,可以在 C++ 程序中使用。

内置垃圾回收机制需要另开一个线程来进行维护,影响程序的性能。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值