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

C++ 动态内存分配实践指南

2021-05-227.7k 阅读

C++ 动态内存分配概述

在 C++ 编程中,内存管理是一项至关重要的任务。程序中的数据需要存储在内存中,而合理地分配和释放内存对于程序的性能、稳定性以及资源利用效率都有着深远的影响。动态内存分配允许程序在运行时根据实际需求分配和释放内存,这与静态内存分配(例如在栈上分配局部变量)形成鲜明对比。

动态内存分配的必要性

静态内存分配适用于在编译时就确定大小的对象,例如局部变量和数组。然而,在许多实际场景中,对象的大小在运行时才能确定。例如,编写一个处理用户输入数据的程序,用户输入的数据量是不确定的。在这种情况下,就需要动态内存分配,以便在运行时根据实际输入数据的大小来分配合适的内存空间。

另一个常见的场景是处理复杂的数据结构,如链表、树和图。这些数据结构的节点数量和结构在运行时可能会发生变化,因此动态内存分配是实现它们的基础。

动态内存分配的基本机制

C++ 提供了两种主要的动态内存分配方式:使用 newdelete 运算符,以及使用标准库中的内存管理函数,如 mallocfree。虽然 mallocfree 是从 C 语言继承而来的函数,但 newdelete 是 C++ 特有的运算符,它们在功能上与 mallocfree 类似,但在使用方式和行为上有一些重要的区别。

使用 newdelete 运算符

new 运算符用于在堆上分配内存并构造对象,而 delete 运算符则用于释放由 new 分配的内存并销毁对象。

基本语法

  1. 分配单个对象
// 分配一个 int 类型的对象
int* ptr = new int;
// 为分配的对象初始化值
*ptr = 42;
// 释放内存
delete ptr;

在上述代码中,new int 分配了一个 int 类型的内存空间,并返回一个指向该空间的指针 ptr。然后通过指针给这个内存空间赋值,最后使用 delete 释放该内存。

  1. 分配数组
// 分配一个包含 5 个 int 类型元素的数组
int* arr = new int[5];
for (int i = 0; i < 5; i++) {
    arr[i] = i * 2;
}
// 释放数组内存
delete[] arr;

当分配数组时,使用 new[] 语法,释放时对应的要使用 delete[]。注意,对于数组,delete 操作符只会释放内存,不会调用数组中每个对象的析构函数,如果数组元素是自定义类型,这可能会导致资源泄漏。

异常处理

new 运算符在内存分配失败时会抛出 std::bad_alloc 异常。这使得我们可以通过异常处理机制优雅地处理内存分配失败的情况。

try {
    int* bigArray = new int[1000000000];
    // 使用 bigArray
    delete[] bigArray;
} catch (const std::bad_alloc& e) {
    std::cerr << "内存分配失败: " << e.what() << std::endl;
}

在上述代码中,当尝试分配一个非常大的数组时,如果内存不足,new 会抛出 std::bad_alloc 异常,我们可以在 catch 块中捕获并处理这个异常,向用户输出错误信息。

自定义类型的动态内存分配

当动态分配自定义类型的对象时,new 不仅分配内存,还会调用对象的构造函数,而 delete 会调用对象的析构函数。

class MyClass {
public:
    MyClass() {
        std::cout << "MyClass 构造函数" << std::endl;
    }
    ~MyClass() {
        std::cout << "MyClass 析构函数" << std::endl;
    }
};

int main() {
    MyClass* obj = new MyClass;
    delete obj;
    return 0;
}

上述代码中,创建 MyClass 对象时,构造函数被调用,输出 “MyClass 构造函数”,当释放对象时,析构函数被调用,输出 “MyClass 析构函数”。

使用 mallocfree

mallocfree 是 C 语言提供的内存管理函数,在 C++ 中仍然可用。malloc 用于分配指定大小的内存块,free 用于释放由 malloccallocrealloc 分配的内存。

基本用法

#include <stdio.h>
#include <stdlib.h>

int main() {
    // 分配 10 个 int 类型的内存空间
    int* arr = (int*)malloc(10 * sizeof(int));
    if (arr == NULL) {
        fprintf(stderr, "内存分配失败\n");
        return 1;
    }
    for (int i = 0; i < 10; i++) {
        arr[i] = i;
    }
    // 使用完后释放内存
    free(arr);
    return 0;
}

在上述代码中,malloc 分配了足够存储 10 个 int 类型数据的内存空间,并返回一个 void* 类型的指针,需要将其强制转换为 int* 类型。malloc 不初始化分配的内存,因此需要手动初始化数组元素。free 函数用于释放分配的内存。

newdelete 的区别

  1. 对象构造与析构newdelete 会调用对象的构造函数和析构函数,而 mallocfree 不会。这意味着如果要动态分配自定义类型的对象,使用 newdelete 更为合适,否则可能会导致对象初始化和清理不完整。
  2. 返回值new 在分配失败时抛出异常,而 malloc 返回 NULL。因此,使用 malloc 时需要显式检查返回值是否为 NULL 来判断内存分配是否成功。
  3. 类型安全new 是类型相关的运算符,它会根据对象类型分配正确大小的内存。而 malloc 只接受一个表示字节数的参数,需要手动计算对象大小并进行类型转换,这在一定程度上增加了出错的可能性。

智能指针与动态内存管理

虽然 newdelete 提供了基本的动态内存分配功能,但手动管理内存容易导致内存泄漏、悬空指针等问题。为了解决这些问题,C++ 引入了智能指针。

智能指针的概念

智能指针是一种类模板,它提供了一种自动管理动态分配内存的机制。智能指针在其生命周期结束时,会自动释放其所指向的内存,从而避免了手动调用 delete 可能带来的错误。

智能指针的类型

  1. std::unique_ptrstd::unique_ptr 是一种独占所有权的智能指针。一个 std::unique_ptr 只能指向一个对象,当 std::unique_ptr 被销毁时,它所指向的对象也会被销毁。
#include <memory>

int main() {
    std::unique_ptr<int> ptr(new int(42));
    // 使用 ptr
    return 0;
}

在上述代码中,当 ptr 超出作用域时,它所指向的 int 对象会被自动释放。std::unique_ptr 不能被复制,但可以被移动。

std::unique_ptr<int> ptr1(new int(42));
std::unique_ptr<int> ptr2 = std::move(ptr1);

这里通过 std::moveptr1 的所有权转移给了 ptr2,此时 ptr1 不再指向任何对象。

  1. std::shared_ptrstd::shared_ptr 允许多个智能指针共享同一个对象的所有权。它使用引用计数来跟踪有多少个 std::shared_ptr 指向同一个对象。当引用计数降为 0 时,对象会被自动释放。
#include <memory>
#include <iostream>

int main() {
    std::shared_ptr<int> ptr1(new int(42));
    std::shared_ptr<int> ptr2 = ptr1;
    std::cout << "引用计数: " << ptr1.use_count() << std::endl;
    return 0;
}

在上述代码中,ptr1ptr2 共享同一个 int 对象的所有权,通过 use_count 方法可以获取当前的引用计数。

  1. std::weak_ptrstd::weak_ptr 是一种弱引用,它指向由 std::shared_ptr 管理的对象,但不增加对象的引用计数。std::weak_ptr 主要用于解决 std::shared_ptr 可能出现的循环引用问题。
#include <memory>
#include <iostream>

class B;
class A {
public:
    std::shared_ptr<B> ptrB;
    ~A() {
        std::cout << "A 析构" << std::endl;
    }
};
class B {
public:
    std::weak_ptr<A> ptrA;
    ~B() {
        std::cout << "B 析构" << std::endl;
    }
};

int main() {
    std::shared_ptr<A> a = std::shared_ptr<A>(new A);
    std::shared_ptr<B> b = std::shared_ptr<B>(new B);
    a->ptrB = b;
    b->ptrA = a;
    return 0;
}

在上述代码中,如果 B 中的 ptrAstd::shared_ptr 类型,就会形成循环引用,导致 ab 所指向的对象无法被正确释放。使用 std::weak_ptr 可以避免这种情况。

动态内存分配的常见问题与解决方案

内存泄漏

内存泄漏是指程序分配了内存,但在不再需要时没有释放它。这会导致可用内存逐渐减少,最终可能导致程序崩溃或系统性能下降。

  1. 原因

    • 忘记调用 deletefree
    • 异常发生时没有正确释放已分配的内存。
    • 存在循环引用(在使用 std::shared_ptr 时)。
  2. 解决方案

    • 使用智能指针,让其自动管理内存释放。
    • 在异常处理中确保释放已分配的内存。例如:
void someFunction() {
    int* ptr = new int;
    try {
        // 可能抛出异常的代码
        throw std::runtime_error("发生异常");
    } catch (...) {
        delete ptr;
        throw;
    }
    delete ptr;
}
- 避免在 `std::shared_ptr` 之间形成循环引用,如使用 `std::weak_ptr` 来打破循环。

悬空指针

悬空指针是指指向已释放内存的指针。当使用悬空指针访问内存时,会导致未定义行为。

  1. 原因

    • 释放内存后没有将指针设置为 NULL
    • 函数返回一个指向局部变量(已在栈上分配)的指针,当函数结束时,局部变量被销毁,指针成为悬空指针。
  2. 解决方案

    • 释放内存后立即将指针设置为 NULL
int* ptr = new int;
delete ptr;
ptr = NULL;
- 避免返回指向局部变量的指针。如果需要返回动态分配的对象,使用智能指针来管理其生命周期。

内存碎片

内存碎片是指在连续的内存空间中存在许多小块的空闲内存,这些小块内存由于不连续,无法满足较大内存分配的需求。

  1. 原因: 频繁地分配和释放大小不同的内存块,导致内存空间碎片化。

  2. 解决方案

    • 尽量一次性分配较大的内存块,并在需要时从中划分小的内存块。例如,使用内存池技术,预先分配一块较大的内存,然后在程序运行过程中从这个内存池中分配和释放小块内存。
    • 优化内存分配策略,根据对象的生命周期和使用模式,合理安排内存分配和释放的时机,减少不必要的内存分配和释放操作。

动态内存分配的性能优化

减少内存分配次数

频繁的内存分配和释放操作会带来额外的开销,包括系统调用开销和内存管理数据结构的维护开销。因此,尽量减少内存分配次数可以提高程序性能。

  1. 对象复用:对于一些需要频繁创建和销毁的对象,可以考虑复用已有的对象,而不是每次都重新分配内存。例如,在实现一个对象池时,可以预先创建一定数量的对象,当需要使用时从对象池中获取,使用完后再放回对象池。
class Object {
public:
    // 对象的方法
};

class ObjectPool {
private:
    std::vector<std::unique_ptr<Object>> pool;
    std::queue<int> availableIndices;
public:
    ObjectPool(int size) {
        for (int i = 0; i < size; i++) {
            pool.emplace_back(new Object);
            availableIndices.push(i);
        }
    }
    Object* getObject() {
        if (availableIndices.empty()) {
            return nullptr;
        }
        int index = availableIndices.front();
        availableIndices.pop();
        return pool[index].get();
    }
    void returnObject(Object* obj) {
        for (int i = 0; i < pool.size(); i++) {
            if (pool[i].get() == obj) {
                availableIndices.push(i);
                break;
            }
        }
    }
};
  1. 批量分配:在需要分配多个相同类型的对象时,可以一次性分配一块较大的内存,然后在这块内存上构造对象。例如,在处理大量的小对象时,可以使用 std::vectorreserve 方法预先分配足够的空间,避免在插入元素时频繁重新分配内存。
std::vector<int> vec;
vec.reserve(1000);
for (int i = 0; i < 1000; i++) {
    vec.push_back(i);
}

选择合适的内存分配策略

不同的内存分配策略适用于不同的场景,选择合适的策略可以提高内存使用效率和程序性能。

  1. 栈内存与堆内存:栈内存分配和释放速度快,但大小有限,适用于局部变量和小型对象。堆内存大小几乎不受限制,但分配和释放开销较大,适用于动态大小的对象和大型数据结构。尽量将小型对象分配在栈上,将大型对象或动态大小的对象分配在堆上。
  2. 内存对齐:内存对齐是指数据在内存中的存储地址是其自身大小的整数倍。合理的内存对齐可以提高内存访问效率,因为现代处理器在读取内存时通常以特定的字节数(如 4 字节、8 字节等)为单位。C++ 编译器会自动对结构体和类进行内存对齐,但有时也可以通过 #pragma pack 等指令手动调整对齐方式。
struct MyStruct {
    char a;
    int b;
};
// 这里 MyStruct 的大小可能不是 sizeof(char) + sizeof(int),而是进行了内存对齐后的大小

内存分配器的优化

C++ 标准库提供了默认的内存分配器,但在一些特定场景下,可以通过自定义内存分配器来优化内存管理。

  1. 自定义内存分配器:自定义内存分配器可以根据应用程序的需求,实现更高效的内存分配和释放策略。例如,对于特定类型的对象,可以实现一个专门的内存分配器,减少内存碎片和提高分配效率。
template <typename T>
class MyAllocator {
public:
    using value_type = T;
    MyAllocator() = default;
    template <typename U>
    MyAllocator(const MyAllocator<U>&) {}
    T* allocate(std::size_t n) {
        return static_cast<T*>(::operator new(n * sizeof(T)));
    }
    void deallocate(T* p, std::size_t n) {
        ::operator delete(p);
    }
};
  1. 使用第三方内存分配器:一些第三方内存分配器,如 tcmallocjemalloc 等,在性能和内存管理效率方面表现出色。可以在项目中引入这些第三方分配器来替代默认的内存分配器,以提高程序的整体性能。

动态内存分配在不同场景下的应用

数据结构实现

  1. 链表:链表是一种常用的数据结构,其节点在运行时动态分配内存。每个节点包含数据和指向下一个节点的指针。
struct ListNode {
    int data;
    ListNode* next;
    ListNode(int value) : data(value), next(nullptr) {}
};

class LinkedList {
private:
    ListNode* head;
public:
    LinkedList() : head(nullptr) {}
    void addNode(int value) {
        ListNode* newNode = new ListNode(value);
        if (head == nullptr) {
            head = newNode;
        } else {
            ListNode* current = head;
            while (current->next != nullptr) {
                current = current->next;
            }
            current->next = newNode;
        }
    }
    ~LinkedList() {
        ListNode* current = head;
        ListNode* next;
        while (current != nullptr) {
            next = current->next;
            delete current;
            current = next;
        }
    }
};
  1. :树结构如二叉树、AVL 树等,其节点也需要动态分配内存。以二叉树为例:
struct TreeNode {
    int data;
    TreeNode* left;
    TreeNode* right;
    TreeNode(int value) : data(value), left(nullptr), right(nullptr) {}
};

class BinaryTree {
private:
    TreeNode* root;
public:
    BinaryTree() : root(nullptr) {}
    void insert(int value) {
        TreeNode* newNode = new TreeNode(value);
        if (root == nullptr) {
            root = newNode;
        } else {
            TreeNode* current = root;
            while (true) {
                if (value < current->data) {
                    if (current->left == nullptr) {
                        current->left = newNode;
                        break;
                    } else {
                        current = current->left;
                    }
                } else {
                    if (current->right == nullptr) {
                        current->right = newNode;
                        break;
                    } else {
                        current = current->right;
                    }
                }
            }
        }
    }
    ~BinaryTree() {
        // 这里省略了完整的树节点释放代码,可通过递归方式释放所有节点
    }
};

图形处理

在图形处理中,经常需要动态分配内存来存储图像数据、顶点数据等。例如,在处理二维图像时,可能需要分配一个二维数组来存储像素值。

#include <iostream>
#include <memory>

class Image {
private:
    std::unique_ptr<std::unique_ptr<int[]>[]> pixels;
    int width;
    int height;
public:
    Image(int w, int h) : width(w), height(h) {
        pixels.reset(new std::unique_ptr<int[]>[width]);
        for (int i = 0; i < width; i++) {
            pixels[i].reset(new int[height]);
        }
    }
    ~Image() {
        for (int i = 0; i < width; i++) {
            pixels[i].reset();
        }
        pixels.reset();
    }
    void setPixel(int x, int y, int value) {
        if (x >= 0 && x < width && y >= 0 && y < height) {
            pixels[x][y] = value;
        }
    }
    int getPixel(int x, int y) const {
        if (x >= 0 && x < width && y >= 0 && y < height) {
            return pixels[x][y];
        }
        return 0;
    }
};

网络编程

在网络编程中,动态内存分配用于处理网络数据包。网络数据包的大小和内容在运行时是不确定的,因此需要根据实际情况分配内存。

#include <iostream>
#include <string>
#include <memory>

class NetworkPacket {
private:
    std::unique_ptr<char[]> data;
    size_t size;
public:
    NetworkPacket(size_t s) : size(s) {
        data.reset(new char[size]);
    }
    void setData(const char* src, size_t len) {
        if (len <= size) {
            std::copy(src, src + len, data.get());
        }
    }
    const char* getData() const {
        return data.get();
    }
    size_t getSize() const {
        return size;
    }
};

通过深入理解 C++ 的动态内存分配机制,并在实际编程中合理运用,我们可以编写出高效、稳定且资源利用合理的程序。无论是简单的应用程序还是复杂的大型项目,正确的内存管理都是成功的关键之一。同时,不断优化动态内存分配策略,根据不同的场景选择合适的内存管理方式,将有助于进一步提升程序的性能和可维护性。