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

C++ 内存管理高级技巧

2021-06-227.1k 阅读

智能指针与资源管理

在 C++ 中,内存管理是一个关键方面,而智能指针则是现代 C++ 实现自动内存管理的强大工具。智能指针本质上是一个类,它模拟指针的行为,同时在其生命周期结束时自动释放所指向的内存。这大大减少了手动内存管理带来的错误,例如内存泄漏和悬空指针。

1. std::unique_ptr

std::unique_ptr 是一种独占所有权的智能指针。这意味着同一时间只有一个 std::unique_ptr 可以指向给定的对象。当 std::unique_ptr 被销毁时(例如超出作用域),它所指向的对象也会被自动销毁。

#include <iostream>
#include <memory>

class MyClass {
public:
    MyClass() { std::cout << "MyClass constructed" << std::endl; }
    ~MyClass() { std::cout << "MyClass destructed" << std::endl; }
};

void demonstrateUniquePtr() {
    std::unique_ptr<MyClass> ptr = std::make_unique<MyClass>();
    // 这里可以通过 ptr 访问 MyClass 对象
    // 当 ptr 超出作用域时,MyClass 对象会自动被销毁
}

int main() {
    demonstrateUniquePtr();
    return 0;
}

在上述代码中,std::make_unique<MyClass>() 创建了一个 MyClass 对象,并返回一个指向它的 std::unique_ptr。当 demonstrateUniquePtr 函数结束时,ptr 超出作用域,MyClass 对象被自动销毁。

std::unique_ptr 不支持拷贝构造和赋值运算符,因为它的设计初衷就是独占所有权。但是,它支持移动语义。

std::unique_ptr<MyClass> moveUniquePtr() {
    std::unique_ptr<MyClass> temp = std::make_unique<MyClass>();
    return temp;
}

void receiveUniquePtr(std::unique_ptr<MyClass> ptr) {
    // 在这里处理 ptr
}

int main() {
    std::unique_ptr<MyClass> movedPtr = moveUniquePtr();
    receiveUniquePtr(std::move(movedPtr));
    // movedPtr 现在不再拥有对象,为空指针
    return 0;
}

moveUniquePtr 函数中,temp 被返回时,所有权被移动到了 movedPtr。然后,movedPtr 通过 std::move 被移动到 receiveUniquePtr 函数中的 ptr 中。

2. std::shared_ptr

std::shared_ptr 允许多个指针共享对一个对象的所有权。它通过引用计数来管理对象的生命周期。每当一个新的 std::shared_ptr 指向该对象时,引用计数增加;当一个 std::shared_ptr 被销毁时,引用计数减少。当引用计数变为 0 时,对象被自动销毁。

#include <iostream>
#include <memory>

class MySharedClass {
public:
    MySharedClass() { std::cout << "MySharedClass constructed" << std::endl; }
    ~MySharedClass() { std::cout << "MySharedClass destructed" << std::endl; }
};

void demonstrateSharedPtr() {
    std::shared_ptr<MySharedClass> ptr1 = std::make_shared<MySharedClass>();
    std::shared_ptr<MySharedClass> ptr2 = ptr1;
    std::cout << "Use count of ptr1: " << ptr1.use_count() << std::endl;
    std::cout << "Use count of ptr2: " << ptr2.use_count() << std::endl;
}

int main() {
    demonstrateSharedPtr();
    return 0;
}

在上述代码中,ptr1ptr2 都指向同一个 MySharedClass 对象。use_count 成员函数用于获取当前对象的引用计数。在这里,ptr1ptr2 的引用计数都为 2。当 demonstrateSharedPtr 函数结束时,ptr1ptr2 都超出作用域,引用计数减为 0,MySharedClass 对象被销毁。

3. std::weak_ptr

std::weak_ptr 是一种弱引用,它指向由 std::shared_ptr 管理的对象,但不会增加对象的引用计数。std::weak_ptr 主要用于解决 std::shared_ptr 之间的循环引用问题。

#include <iostream>
#include <memory>

class B;

class A {
public:
    std::weak_ptr<B> weakRefToB;
    ~A() { std::cout << "A destructed" << std::endl; }
};

class B {
public:
    std::shared_ptr<A> sharedRefToA;
    ~B() { std::cout << "B destructed" << std::endl; }
};

void demonstrateWeakPtr() {
    std::shared_ptr<A> a = std::make_shared<A>();
    std::shared_ptr<B> b = std::make_shared<B>();
    a->weakRefToB = b;
    b->sharedRefToA = a;
}

int main() {
    demonstrateWeakPtr();
    // 当函数结束时,a 和 b 超出作用域,由于 weakRefToB 不会增加引用计数,
    // A 和 B 的对象都能被正确销毁,避免了循环引用导致的内存泄漏
    return 0;
}

在上述代码中,如果 A 中的 weakRefToB 也是 std::shared_ptr,就会形成循环引用,导致 AB 的对象永远不会被销毁。而使用 std::weak_ptr 则解决了这个问题。

自定义内存分配器

C++ 允许开发者自定义内存分配器,以满足特定的内存管理需求。自定义内存分配器可以优化内存使用、提高性能或实现特殊的内存布局。

1. 简单自定义内存分配器示例

下面是一个简单的自定义内存分配器示例,它只是在标准库分配器的基础上增加了一些打印信息。

#include <iostream>
#include <memory>

template <typename T>
class MyAllocator {
public:
    using value_type = T;
    MyAllocator() noexcept = default;
    template <typename U>
    MyAllocator(const MyAllocator<U>&) noexcept {}
    ~MyAllocator() noexcept = default;

    T* allocate(std::size_t n) {
        std::cout << "Allocating " << n << " bytes for type " << typeid(T).name() << std::endl;
        return static_cast<T*>(::operator new(n * sizeof(T)));
    }

    void deallocate(T* p, std::size_t n) {
        std::cout << "Deallocating " << n << " bytes for type " << typeid(T).name() << std::endl;
        ::operator delete(p);
    }
};

template <typename T, typename U>
bool operator==(const MyAllocator<T>&, const MyAllocator<U>&) noexcept {
    return true;
}

template <typename T, typename U>
bool operator!=(const MyAllocator<T>&, const MyAllocator<U>&) noexcept {
    return false;
}

int main() {
    std::vector<int, MyAllocator<int>> vec;
    vec.push_back(10);
    return 0;
}

在上述代码中,MyAllocator 类实现了 allocatedeallocate 方法,分别用于内存分配和释放。std::vector<int, MyAllocator<int>> 使用了这个自定义分配器。

2. 内存池分配器

内存池是一种常见的优化内存分配的技术。它预先分配一块较大的内存,然后从这块内存中分配小块内存给程序使用,当小块内存不再使用时,将其归还到内存池,而不是直接释放回操作系统。

#include <iostream>
#include <vector>
#include <cstddef>

class MemoryPool {
private:
    std::vector<char> pool;
    std::size_t currentIndex;

public:
    MemoryPool(std::size_t size) : pool(size), currentIndex(0) {}

    void* allocate(std::size_t n) {
        if (currentIndex + n > pool.size()) {
            std::cerr << "Out of memory in pool" << std::endl;
            return nullptr;
        }
        void* result = &pool[currentIndex];
        currentIndex += n;
        return result;
    }

    void deallocate(void*, std::size_t) {
        // 简单的内存池不实现真正的释放,只是重置索引
        currentIndex = 0;
    }
};

template <typename T, std::size_t PoolSize>
class PoolAllocator {
private:
    static MemoryPool pool;
public:
    using value_type = T;
    PoolAllocator() noexcept = default;
    template <typename U>
    PoolAllocator(const PoolAllocator<U, PoolSize>&) noexcept {}
    ~PoolAllocator() noexcept = default;

    T* allocate(std::size_t n) {
        return static_cast<T*>(pool.allocate(n * sizeof(T)));
    }

    void deallocate(T* p, std::size_t n) {
        pool.deallocate(p, n * sizeof(T));
    }
};

template <typename T, std::size_t PoolSize>
MemoryPool PoolAllocator<T, PoolSize>::pool(PoolSize);

template <typename T, typename U, std::size_t PoolSize>
bool operator==(const PoolAllocator<T, PoolSize>&, const PoolAllocator<U, PoolSize>&) noexcept {
    return true;
}

template <typename T, typename U, std::size_t PoolSize>
bool operator!=(const PoolAllocator<T, PoolSize>&, const PoolAllocator<U, PoolSize>&) noexcept {
    return false;
}

int main() {
    std::vector<int, PoolAllocator<int, 1024>> vec;
    vec.push_back(10);
    return 0;
}

在上述代码中,MemoryPool 类实现了一个简单的内存池。PoolAllocator 则是基于这个内存池的分配器。std::vector<int, PoolAllocator<int, 1024>> 使用了这个内存池分配器,从 1024 字节大小的内存池中分配内存。

栈内存与堆内存的高效利用

在 C++ 中,理解栈内存和堆内存的特性并高效利用它们对于优化程序性能至关重要。

1. 栈内存的优势与使用场景

栈内存是自动分配和释放的,其分配和释放速度非常快。局部变量存储在栈上,当函数调用时,栈帧被创建,局部变量在栈帧内分配内存,函数返回时,栈帧被销毁,局部变量的内存被自动释放。

void stackUsageExample() {
    int localVar = 10;
    // localVar 存储在栈上,函数结束时自动释放
}

栈内存适用于生命周期较短、大小固定且已知的对象。例如,简单的数值类型、小型结构体等。由于栈内存的分配和释放不需要额外的函数调用(如 mallocfree),因此效率极高。

2. 堆内存的特点与优化

堆内存用于动态分配内存,即大小在编译时不确定的对象。使用 newdelete 运算符(或 mallocfree 函数)在堆上分配和释放内存。

void heapUsageExample() {
    int* heapVar = new int(20);
    // heapVar 指向堆上分配的内存
    delete heapVar;
}

堆内存的分配相对较慢,因为它涉及到系统调用(如 sbrkmmap)来从操作系统获取内存。为了优化堆内存的使用,可以尽量减少频繁的小内存分配。例如,可以使用对象池技术,预先分配一定数量的对象,需要时从对象池中获取,使用完毕后归还到对象池,而不是每次都进行新的堆内存分配。

#include <iostream>
#include <vector>

class Object {
public:
    Object() { std::cout << "Object constructed" << std::endl; }
    ~Object() { std::cout << "Object destructed" << std::endl; }
};

class ObjectPool {
private:
    std::vector<Object*> pool;
    std::vector<bool> inUse;

public:
    ObjectPool(std::size_t size) {
        for (std::size_t i = 0; i < size; ++i) {
            pool.push_back(new Object());
            inUse.push_back(false);
        }
    }

    Object* getObject() {
        for (std::size_t i = 0; i < pool.size(); ++i) {
            if (!inUse[i]) {
                inUse[i] = true;
                return pool[i];
            }
        }
        return nullptr;
    }

    void returnObject(Object* obj) {
        for (std::size_t i = 0; i < pool.size(); ++i) {
            if (pool[i] == obj) {
                inUse[i] = false;
                return;
            }
        }
    }

    ~ObjectPool() {
        for (Object* obj : pool) {
            delete obj;
        }
    }
};

int main() {
    ObjectPool pool(10);
    Object* obj1 = pool.getObject();
    Object* obj2 = pool.getObject();
    pool.returnObject(obj1);
    Object* obj3 = pool.getObject();
    return 0;
}

在上述代码中,ObjectPool 预先分配了 10 个 Object 对象。getObject 方法从对象池中获取一个未使用的对象,returnObject 方法将对象归还到对象池。这样可以减少堆内存的频繁分配和释放,提高性能。

内存对齐与布局优化

内存对齐是指数据在内存中的存储地址是其自身大小的整数倍。合理的内存对齐可以提高内存访问效率,同时也与某些硬件平台的要求相关。

1. 内存对齐的原理

在 C++ 中,结构体和类的成员变量会按照一定的规则进行内存对齐。例如,下面的结构体:

struct MyStruct {
    char a;
    int b;
    short c;
};

假设 char 占 1 字节,int 占 4 字节,short 占 2 字节。如果不进行内存对齐,MyStruct 的大小理论上是 1 + 4 + 2 = 7 字节。但实际上,由于内存对齐,a 之后会填充 3 字节,使得 b 的地址是 4 的倍数。所以 MyStruct 的实际大小是 8 字节。

2. 控制内存对齐

开发者可以通过 #pragma pack 指令来控制结构体的内存对齐方式。

#pragma pack(push, 1)
struct PackedStruct {
    char a;
    int b;
    short c;
};
#pragma pack(pop)

在上述代码中,#pragma pack(push, 1) 将对齐方式设置为 1 字节对齐,这样 PackedStruct 的大小就是 1 + 4 + 2 = 7 字节。#pragma pack(pop) 恢复之前的对齐设置。

另外,C++11 引入了 alignas 关键字,用于指定变量或类型的对齐要求。

alignas(16) double alignedDouble;

上述代码将 alignedDouble 的对齐要求设置为 16 字节,这在处理 SIMD 指令等对内存对齐有严格要求的场景中非常有用。

内存泄漏检测与调试

内存泄漏是 C++ 程序中常见的问题之一,它会导致程序随着运行时间的增加不断消耗内存,最终可能导致系统资源耗尽。

1. 使用工具检测内存泄漏

  • Valgrind:Valgrind 是一款功能强大的内存调试工具,尤其在 Linux 系统上广泛使用。它可以检测内存泄漏、未初始化内存访问、越界访问等多种内存相关问题。
#include <iostream>
#include <stdlib.h>

int main() {
    int* ptr = new int(10);
    // 这里忘记释放 ptr
    return 0;
}

使用 Valgrind 检测上述代码:

valgrind --leak-check=full./a.out

Valgrind 会输出详细的内存泄漏信息,包括泄漏的内存块大小、分配的位置等。

  • Microsoft Visual Studio 内存诊断工具:在 Windows 平台上,Microsoft Visual Studio 提供了内存诊断工具。在调试模式下运行程序时,可以使用这些工具来检测内存泄漏。通过在 #include <crtdbg.h> 后添加 _CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);,程序结束时会输出内存泄漏信息。

2. 手动检测与预防

除了使用工具,开发者也可以通过一些手动方法来检测和预防内存泄漏。例如,在类的构造函数和析构函数中添加日志信息,以确保对象被正确创建和销毁。

class MyLeakProneClass {
public:
    MyLeakProneClass() { std::cout << "MyLeakProneClass constructed" << std::endl; }
    ~MyLeakProneClass() { std::cout << "MyLeakProneClass destructed" << std::endl; }
};

void potentialLeakFunction() {
    MyLeakProneClass* obj = new MyLeakProneClass();
    // 这里忘记释放 obj
}

通过查看日志,可以发现对象没有被销毁,从而定位潜在的内存泄漏。另外,使用智能指针可以有效预防大部分内存泄漏问题,因为智能指针会自动管理对象的生命周期。

总结

C++ 内存管理是一个复杂而关键的领域。掌握智能指针、自定义内存分配器、栈堆内存的高效利用、内存对齐以及内存泄漏检测等高级技巧,对于编写高效、健壮的 C++ 程序至关重要。通过合理运用这些技术,开发者可以优化程序性能,减少内存相关的错误,提高代码的可维护性。在实际开发中,应根据具体的需求和场景选择合适的内存管理策略,以充分发挥 C++ 的性能优势。