MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C++动态内存管理与智能指针

2022-04-047.6k 阅读

C++ 动态内存管理

在 C++ 编程中,动态内存管理是一项关键技能。当程序需要在运行时分配和释放内存时,就涉及到动态内存管理。这在处理大小不确定的数据结构(如动态数组、链表、树等)时尤为重要。

1. 堆与栈

在深入探讨动态内存管理之前,先了解一下堆(heap)和栈(stack)这两个概念。

  • :栈是一种自动管理的内存区域,主要用于存储局部变量、函数参数和返回值等。栈上的内存分配和释放由编译器自动完成,速度非常快。例如,当一个函数被调用时,其局部变量会在栈上分配空间,函数结束时,这些变量占用的空间会自动被释放。
  • :堆是一个自由存储区,用于动态内存分配。与栈不同,堆上的内存分配和释放需要程序员手动控制。当程序需要在运行时分配一块内存时,就从堆中获取。由于堆的管理相对复杂,内存分配和释放的速度比栈慢。

2. 动态内存分配函数

在 C++ 中,有两组主要的函数用于动态内存分配和释放:new/delete 操作符和 malloc/free 函数。

  • newdelete 操作符
    • new 操作符用于在堆上分配内存并初始化对象。它会调用对象的构造函数。例如,分配一个 int 类型的动态变量:
int* ptr = new int;
*ptr = 10;
- `new[]` 用于分配一个动态数组:
int* arr = new int[5];
for (int i = 0; i < 5; ++i) {
    arr[i] = i;
}
- `delete` 操作符用于释放由 `new` 分配的单个对象的内存,并调用对象的析构函数:
delete ptr;
- `delete[]` 用于释放由 `new[]` 分配的数组内存,并为数组中的每个对象调用析构函数:
delete[] arr;
  • mallocfree 函数
    • malloc 函数用于在堆上分配指定大小的内存块,但它不会初始化对象,也不会调用构造函数。它返回一个指向分配内存块起始地址的 void* 指针。例如:
int* ptr = (int*)malloc(sizeof(int));
if (ptr != nullptr) {
    *ptr = 10;
}
- `free` 函数用于释放由 `malloc`、`calloc` 或 `realloc` 分配的内存块:
free(ptr);

需要注意的是,malloc/free 主要用于 C 语言风格的内存分配,而 new/delete 是 C++ 中面向对象的内存分配方式。并且 new/delete 会调用构造函数和析构函数,而 malloc/free 不会。

3. 动态内存管理的问题

手动管理动态内存容易出现一些问题,其中最常见的是内存泄漏和悬空指针。

  • 内存泄漏:当动态分配的内存不再被使用,但没有被释放时,就会发生内存泄漏。例如:
void memoryLeak() {
    int* ptr = new int;
    // 忘记调用 delete ptr;
}

随着程序的运行,内存泄漏会导致可用内存逐渐减少,最终可能导致程序崩溃。

  • 悬空指针:当指针所指向的内存被释放后,指针仍然保留着原来的地址,这个指针就成为了悬空指针。如果继续使用悬空指针,可能会导致未定义行为。例如:
int* ptr = new int;
*ptr = 10;
delete ptr;
// ptr 现在是悬空指针
// 如果再次使用 ptr,例如 *ptr = 20; 就会导致未定义行为

智能指针

为了简化动态内存管理并避免上述问题,C++ 引入了智能指针(smart pointers)。智能指针是一种对象,它能够自动管理动态分配的内存,当智能指针超出作用域时,会自动释放其所指向的内存。

1. std::unique_ptr

std::unique_ptr 是 C++11 引入的一种智能指针,它对所指向的对象拥有唯一所有权。这意味着同一时间只能有一个 std::unique_ptr 指向某个对象。当 std::unique_ptr 被销毁(例如超出作用域)时,它会自动调用 delete 释放所指向的对象。

  • 创建 std::unique_ptr
#include <memory>

std::unique_ptr<int> createUniquePtr() {
    return std::unique_ptr<int>(new int(10));
}
  • 使用 std::unique_ptr
void useUniquePtr() {
    std::unique_ptr<int> ptr = createUniquePtr();
    if (ptr) {
        std::cout << "Value: " << *ptr << std::endl;
    }
}

在上述代码中,createUniquePtr 函数返回一个 std::unique_ptr<int>useUniquePtr 函数接收这个智能指针并使用它。当 ptr 超出 useUniquePtr 函数的作用域时,它所指向的 int 对象会自动被释放。

  • std::unique_ptr 数组
std::unique_ptr<int[]> createUniquePtrArray() {
    return std::unique_ptr<int[]>(new int[5]);
}

void useUniquePtrArray() {
    std::unique_ptr<int[]> arr = createUniquePtrArray();
    for (int i = 0; i < 5; ++i) {
        arr[i] = i;
    }
}

std::unique_ptr<int[]> 用于管理动态分配的数组,当 arr 超出作用域时,数组内存会自动释放。

2. std::shared_ptr

std::shared_ptr 也是 C++11 引入的智能指针,它允许多个 std::shared_ptr 指向同一个对象。这些智能指针通过引用计数(reference counting)机制来管理对象的生命周期。当引用计数降为 0 时,对象的内存会自动被释放。

  • 创建 std::shared_ptr
#include <memory>
#include <iostream>

std::shared_ptr<int> createSharedPtr() {
    return std::make_shared<int>(10);
}

这里使用 std::make_shared 函数来创建 std::shared_ptr,这是一种推荐的方式,因为它更高效,会一次性分配对象和引用计数的内存。也可以使用 std::shared_ptr 的构造函数直接从 new 返回的指针创建:

std::shared_ptr<int> ptr(new int(10));
  • 使用 std::shared_ptr
void useSharedPtr() {
    std::shared_ptr<int> ptr1 = createSharedPtr();
    std::shared_ptr<int> ptr2 = ptr1; // ptr2 与 ptr1 指向同一个对象,引用计数增加
    std::cout << "Reference count: " << ptr1.use_count() << std::endl;
    std::cout << "Value: " << *ptr2 << std::endl;
}

在上述代码中,ptr1ptr2 都指向同一个 int 对象,use_count 函数用于获取当前的引用计数。当 ptr1ptr2 都超出作用域时,引用计数降为 0,对象的内存会自动释放。

3. std::weak_ptr

std::weak_ptr 是一种弱引用智能指针,它与 std::shared_ptr 关联,但不会增加引用计数。std::weak_ptr 主要用于解决 std::shared_ptr 可能出现的循环引用问题。

  • 创建 std::weak_ptr
std::shared_ptr<int> sharedPtr = std::make_shared<int>(10);
std::weak_ptr<int> weakPtr = sharedPtr;
  • 使用 std::weak_ptr
void useWeakPtr() {
    std::shared_ptr<int> sharedPtr = std::make_shared<int>(10);
    std::weak_ptr<int> weakPtr = sharedPtr;

    std::shared_ptr<int> lockedPtr = weakPtr.lock();
    if (lockedPtr) {
        std::cout << "Value: " << *lockedPtr << std::endl;
    } else {
        std::cout << "Object has been deleted." << std::endl;
    }
}

lock 函数用于尝试获取一个指向对象的 std::shared_ptr。如果对象仍然存在(即对应的 std::shared_ptr 的引用计数不为 0),则返回一个有效的 std::shared_ptr,否则返回一个空指针。

智能指针的实现原理

1. std::unique_ptr 的实现原理

std::unique_ptr 内部通常包含一个指向动态分配对象的指针。它通过将拷贝构造函数和赋值运算符声明为 delete 来确保其唯一性,不允许对象之间的拷贝。移动构造函数和移动赋值运算符则是允许的,通过转移指针所有权来实现移动语义。例如,一个简单的 std::unique_ptr 实现框架如下:

template <typename T>
class UniquePtr {
private:
    T* ptr;
public:
    UniquePtr(T* p = nullptr) : ptr(p) {}
    ~UniquePtr() {
        if (ptr) {
            delete ptr;
        }
    }
    // 禁止拷贝构造函数和赋值运算符
    UniquePtr(const UniquePtr&) = delete;
    UniquePtr& operator=(const UniquePtr&) = delete;

    // 移动构造函数
    UniquePtr(UniquePtr&& other) noexcept : ptr(other.ptr) {
        other.ptr = nullptr;
    }

    // 移动赋值运算符
    UniquePtr& operator=(UniquePtr&& other) noexcept {
        if (this != &other) {
            delete ptr;
            ptr = other.ptr;
            other.ptr = nullptr;
        }
        return *this;
    }

    T& operator*() const {
        return *ptr;
    }

    T* operator->() const {
        return ptr;
    }
};

2. std::shared_ptr 的实现原理

std::shared_ptr 实现主要依赖引用计数机制。除了包含一个指向动态分配对象的指针外,还包含一个指向引用计数对象的指针。每次创建一个新的 std::shared_ptr 指向同一个对象时,引用计数增加;当一个 std::shared_ptr 被销毁时,引用计数减少。当引用计数降为 0 时,对象的内存被释放。以下是一个简化的 std::shared_ptr 实现框架:

class RefCount {
public:
    RefCount() : count(1) {}
    void increment() {
        ++count;
    }
    void decrement() {
        --count;
        if (count == 0) {
            delete this;
        }
    }
    int getCount() const {
        return count;
    }
private:
    int count;
};

template <typename T>
class SharedPtr {
private:
    T* ptr;
    RefCount* refCount;
public:
    SharedPtr(T* p = nullptr) : ptr(p) {
        if (ptr) {
            refCount = new RefCount();
        } else {
            refCount = nullptr;
        }
    }

    SharedPtr(const SharedPtr& other) : ptr(other.ptr), refCount(other.refCount) {
        if (refCount) {
            refCount->increment();
        }
    }

    ~SharedPtr() {
        if (refCount) {
            refCount->decrement();
            if (refCount->getCount() == 0) {
                delete ptr;
            }
        }
    }

    SharedPtr& operator=(const SharedPtr& other) {
        if (this != &other) {
            if (refCount) {
                refCount->decrement();
                if (refCount->getCount() == 0) {
                    delete ptr;
                }
            }
            ptr = other.ptr;
            refCount = other.refCount;
            if (refCount) {
                refCount->increment();
            }
        }
        return *this;
    }

    T& operator*() const {
        return *ptr;
    }

    T* operator->() const {
        return ptr;
    }

    int use_count() const {
        if (refCount) {
            return refCount->getCount();
        }
        return 0;
    }
};

3. std::weak_ptr 的实现原理

std::weak_ptr 同样包含一个指向对象的指针和一个指向引用计数对象的指针,但它不会增加引用计数。当通过 lock 函数获取 std::shared_ptr 时,会检查引用计数对象是否存在,如果存在则创建一个新的 std::shared_ptr 并增加引用计数。以下是一个简化的 std::weak_ptr 实现框架:

template <typename T>
class WeakPtr {
private:
    T* ptr;
    RefCount* refCount;
public:
    WeakPtr() : ptr(nullptr), refCount(nullptr) {}
    WeakPtr(const SharedPtr<T>& sharedPtr) : ptr(sharedPtr.ptr), refCount(sharedPtr.refCount) {}

    SharedPtr<T> lock() const {
        if (refCount && refCount->getCount() > 0) {
            return SharedPtr<T>(ptr);
        }
        return SharedPtr<T>();
    }
};

智能指针的应用场景

1. 动态分配对象的管理

在大多数需要动态分配对象的场景中,都应该优先使用智能指针。例如,在实现一个链表数据结构时,可以使用 std::unique_ptr 来管理节点对象的生命周期:

struct ListNode {
    int data;
    std::unique_ptr<ListNode> next;
    ListNode(int val) : data(val), next(nullptr) {}
};

class LinkedList {
private:
    std::unique_ptr<ListNode> head;
public:
    void addNode(int val) {
        if (!head) {
            head = std::make_unique<ListNode>(val);
        } else {
            ListNode* current = head.get();
            while (current->next) {
                current = current->next.get();
            }
            current->next = std::make_unique<ListNode>(val);
        }
    }
};

2. 函数参数和返回值

当函数需要接受或返回动态分配的对象时,使用智能指针可以简化内存管理。例如:

std::shared_ptr<int> createObject() {
    return std::make_shared<int>(10);
}

void useObject(std::shared_ptr<int> obj) {
    std::cout << "Value in useObject: " << *obj << std::endl;
}

3. 解决循环引用问题

在对象之间存在相互引用的情况下,使用 std::weak_ptr 可以避免循环引用导致的内存泄漏。例如,在一个双向链表中:

struct Node {
    int data;
    std::shared_ptr<Node> next;
    std::weak_ptr<Node> prev;
    Node(int val) : data(val), next(nullptr) {}
};

在这个双向链表实现中,prev 使用 std::weak_ptr,可以避免 nextprev 之间形成循环引用,确保对象能够正确释放。

动态内存管理与智能指针的性能考虑

1. 智能指针的开销

虽然智能指针大大简化了动态内存管理,但它们也带来了一些性能开销。

  • std::unique_ptrstd::unique_ptr 的性能开销相对较小,因为它只包含一个指针,并且其操作(如移动构造和移动赋值)通常是非常高效的。在现代编译器优化下,std::unique_ptr 的性能几乎与普通指针操作相当。
  • std::shared_ptrstd::shared_ptr 的性能开销主要来自引用计数的维护。每次创建、拷贝、赋值或销毁 std::shared_ptr 时,都需要更新引用计数。这涉及到原子操作(以确保线程安全),在多线程环境下可能会有一定的性能影响。不过,在单线程环境下,这种开销通常是可以接受的,并且现代编译器也会对引用计数操作进行优化。
  • std::weak_ptrstd::weak_ptr 的性能开销主要在于 lock 函数的调用。lock 函数需要检查引用计数并可能创建一个新的 std::shared_ptr,这涉及到一些额外的操作。但如果 std::weak_ptr 只是用于避免循环引用而很少调用 lock 函数,那么其性能影响相对较小。

2. 内存分配策略

在动态内存管理中,内存分配策略也会影响性能。

  • 内存碎片:频繁的动态内存分配和释放可能导致内存碎片,即堆内存中出现许多不连续的小块空闲内存。这会降低内存分配的效率,因为分配较大内存块时可能找不到足够大的连续空闲空间。使用智能指针本身并不能解决内存碎片问题,但良好的内存分配策略(如对象池技术)可以在一定程度上缓解这个问题。
  • 内存对齐:不同的系统和编译器对内存对齐有不同的要求。动态分配的内存需要满足这些对齐要求,否则可能导致性能问题甚至程序错误。new 操作符和智能指针在分配内存时通常会自动处理内存对齐,但了解内存对齐的原理对于优化性能仍然是有帮助的。

3. 优化建议

  • 减少不必要的智能指针操作:避免在性能关键的代码路径中频繁创建、拷贝或销毁智能指针。例如,如果一个函数只需要临时使用一个对象,可以考虑使用 std::unique_ptr 并通过移动语义传递,而不是进行拷贝。
  • 选择合适的智能指针类型:根据实际需求选择 std::unique_ptrstd::shared_ptrstd::weak_ptr。如果对象只需要一个所有者,使用 std::unique_ptr 可以获得最佳性能;如果需要共享对象所有权,且性能要求不是特别高,使用 std::shared_ptr;如果存在循环引用的风险,使用 std::weak_ptr 来打破循环。
  • 结合对象池技术:对于频繁创建和销毁的对象,可以使用对象池技术来减少动态内存分配和释放的次数,从而提高性能并减少内存碎片。

动态内存管理与智能指针在多线程环境下的应用

1. 线程安全问题

在多线程环境下,动态内存管理和智能指针的使用需要特别注意线程安全问题。

  • std::shared_ptrstd::shared_ptr 的引用计数操作是线程安全的,这意味着多个线程可以同时对同一个 std::shared_ptr 进行拷贝、赋值和销毁操作而不会出现数据竞争。然而,当多个线程同时访问 std::shared_ptr 所指向的对象时,如果对象的访问不是线程安全的,仍然需要额外的同步机制(如互斥锁)来保护对象的访问。
  • std::unique_ptr:由于 std::unique_ptr 不支持拷贝,在多线程环境下,只要确保 std::unique_ptr 的所有权转移是线程安全的(例如通过移动语义在不同线程之间传递 std::unique_ptr),通常不会出现线程安全问题。但如果 std::unique_ptr 所指向的对象需要在多个线程中访问,同样需要同步机制来保护对象的访问。
  • std::weak_ptrstd::weak_ptr 的操作本身也是线程安全的,但在使用 lock 函数获取 std::shared_ptr 时,需要注意与其他线程对 std::shared_ptr 的操作同步,以避免出现竞态条件。

2. 同步机制

为了确保多线程环境下动态内存管理和智能指针的安全使用,通常需要使用同步机制。

  • 互斥锁:互斥锁(std::mutex)是最常用的同步机制之一。可以使用互斥锁来保护对智能指针所指向对象的访问。例如:
#include <memory>
#include <mutex>
#include <thread>

std::shared_ptr<int> sharedData;
std::mutex dataMutex;

void threadFunction() {
    std::lock_guard<std::mutex> lock(dataMutex);
    if (!sharedData) {
        sharedData = std::make_shared<int>(10);
    }
    std::cout << "Value in thread: " << *sharedData << std::endl;
}

int main() {
    std::thread t1(threadFunction);
    std::thread t2(threadFunction);
    t1.join();
    t2.join();
    return 0;
}

在上述代码中,std::lock_guard 用于自动管理互斥锁的锁定和解锁,确保在访问 sharedData 时不会出现数据竞争。

  • 读写锁:如果对智能指针所指向对象的操作主要是读操作,可以使用读写锁(std::shared_mutex)来提高性能。读写锁允许多个线程同时进行读操作,但只允许一个线程进行写操作。例如:
#include <memory>
#include <shared_mutex>
#include <thread>

std::shared_ptr<int> sharedData;
std::shared_mutex dataMutex;

void readThreadFunction() {
    std::shared_lock<std::shared_mutex> lock(dataMutex);
    if (sharedData) {
        std::cout << "Value in read thread: " << *sharedData << std::endl;
    }
}

void writeThreadFunction() {
    std::unique_lock<std::shared_mutex> lock(dataMutex);
    sharedData = std::make_shared<int>(20);
}

3. 线程局部存储

在某些情况下,使用线程局部存储(thread_local)可以避免多线程之间对动态内存的竞争。例如,可以将智能指针声明为 thread_local,这样每个线程都有自己独立的智能指针实例,从而避免了同步的开销。

#include <memory>
#include <thread>

thread_local std::unique_ptr<int> localData;

void threadFunction() {
    if (!localData) {
        localData = std::make_unique<int>(10);
    }
    std::cout << "Value in thread: " << *localData << std::endl;
}

int main() {
    std::thread t1(threadFunction);
    std::thread t2(threadFunction);
    t1.join();
    t2.join();
    return 0;
}

在上述代码中,localData 是线程局部的 std::unique_ptr,每个线程都可以独立地管理自己的 localData,无需同步。

动态内存管理与智能指针的常见错误及避免方法

1. 智能指针的误用

  • 错误示例:使用已释放的对象
std::shared_ptr<int> ptr = std::make_shared<int>(10);
std::weak_ptr<int> weakPtr = ptr;
ptr.reset(); // ptr 释放对象
std::shared_ptr<int> newPtr = weakPtr.lock();
if (newPtr) {
    // 这里 newPtr 为空,不应该继续使用
    std::cout << "Value: " << *newPtr << std::endl;
}
  • 避免方法:在使用 std::weak_ptrlock 函数获取 std::shared_ptr 后,始终检查返回的 std::shared_ptr 是否为空。对于 std::unique_ptrstd::shared_ptr,确保在对象可能被释放后不再使用它们。

2. 内存泄漏与悬空指针的残留问题

  • 错误示例:手动释放智能指针管理的内存
std::unique_ptr<int> ptr(new int(10));
delete ptr.get(); // 手动释放内存,导致悬空指针,智能指针析构时会再次释放,产生未定义行为
  • 避免方法:完全依赖智能指针的自动内存管理机制,不要手动释放智能指针所指向的内存。

3. 智能指针类型选择不当

  • 错误示例:在不需要共享所有权的地方使用 std::shared_ptr
void processData() {
    std::shared_ptr<int> ptr = std::make_shared<int>(10);
    // 这里只在函数内部使用,没有共享所有权的需求,使用 std::unique_ptr 更合适
}
  • 避免方法:根据实际需求仔细选择智能指针类型。如果对象只在一个地方使用且不需要共享所有权,优先使用 std::unique_ptr;如果需要共享所有权,再考虑使用 std::shared_ptr;如果存在循环引用风险,使用 std::weak_ptr 来打破循环。

4. 智能指针与数组

  • 错误示例:使用 std::shared_ptr 管理数组时未使用 std::make_shared 的数组版本
std::shared_ptr<int> ptr(new int[5]); // 错误,应该使用 std::make_shared<int[]>(5)
// 这样可能导致内存泄漏,因为 std::shared_ptr 默认使用 delete 而不是 delete[] 来释放内存
  • 避免方法:当使用 std::shared_ptr 管理数组时,使用 std::make_shared<int[]>(size) 来创建智能指针,或者自定义删除器来确保使用 delete[] 释放内存。对于 std::unique_ptr,使用 std::unique_ptr<int[]> 来正确管理数组内存。

通过深入理解动态内存管理与智能指针的原理、应用场景、性能考虑以及常见错误避免方法,C++ 开发者能够更加高效、安全地编写代码,充分发挥 C++ 语言在内存管理方面的强大功能。在实际项目中,合理运用智能指针不仅可以提高代码的可读性和可维护性,还能有效避免内存相关的错误,提升程序的稳定性和性能。无论是开发小型应用程序还是大型系统,掌握动态内存管理与智能指针都是 C++ 编程的关键技能之一。