30. 设计模式与内存管理:对象池模式,引用计数,写时拷贝(COW)

内存管理这事儿,说简单也简单,说复杂能让你调一整天。我这些年做C++项目,最头疼的往往不是业务逻辑,而是对象什么时候创建、什么时候销毁、怎么避免拷贝。今天咱们聊聊三个跟内存管理强相关的设计模式:对象池、引用计数、写时拷贝。它们解决的是同一个问题——别让内存成为性能瓶颈

对象池模式:别频繁new/delete

先说说对象池。我在做游戏服务器时遇到过这么个场景:玩家每发一个技能,就要创建几十个子弹对象。如果每次都用new,打一场团战下来,内存分配和释放的次数能让你CPU冒烟。

对象池的思路很简单——提前创建好一批对象,用的时候拿,用完还回去。说白了就是复用。

核心思想:避免频繁的动态内存分配和释放,通过复用对象来降低开销。

来看一个简单的对象池实现:

template<typename T>
class ObjectPool {
private:
    std::vector<T*> pool;
    std::vector<bool> in_use;
    std::mutex mtx;

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

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

    T* acquire() {
        std::lock_guard<std::mutex> lock(mtx);
        for (size_t i = 0; i < pool.size(); ++i) {
            if (!in_use[i]) {
                in_use[i] = true;
                return pool[i];
            }
        }
        // 池子不够用了,可以扩容
        pool.push_back(new T());
        in_use.push_back(true);
        return pool.back();
    }

    void release(T* obj) {
        std::lock_guard<std::mutex> lock(mtx);
        for (size_t i = 0; i < pool.size(); ++i) {
            if (pool[i] == obj) {
                in_use[i] = false;
                break;
            }
        }
    }
};

嗯,这里要注意:对象池不是万能的。如果你的对象构造/析构成本很低,或者对象大小差异很大,那池化反而可能浪费内存。我建议在以下场景考虑用对象池:

  • 对象创建/销毁频繁(比如每帧几十上百次)
  • 对象构造开销大(比如要初始化一堆资源)
  • 对象数量可控,不会无限增长

我的习惯:对象池的大小最好做成可配置的。我曾经在项目里写死了池大小,结果上线后流量暴增,池子不够用,频繁扩容反而比直接new还慢。后来改成动态扩容+上限限制,才稳下来。

引用计数:谁在用这个对象?

引用计数,说白了就是给对象装一个计数器。每多一个人用,计数加1;少一个人用,计数减1。计数归零时,对象自动销毁。

这个模式在C++里最典型的应用就是std::shared_ptr。但我想说的是,别滥用shared_ptr。我见过一个项目,所有指针都用shared_ptr,结果循环引用导致内存泄漏,查了两天才找到问题。

引用计数的核心实现:

class RefCounted {
private:
    int ref_count_ = 0;

public:
    void addRef() {
        ++ref_count_;
    }

    void release() {
        if (--ref_count_ == 0) {
            delete this;  // 注意:对象必须是堆分配的
        }
    }
};

// 使用示例
class Texture : public RefCounted {
    // 纹理数据...
};

你想想看,引用计数最大的好处是什么?是自动管理生命周期。你不用操心什么时候delete,只要保证引用关系正确就行。

但坑也不少:

  • 循环引用:A引用B,B引用A,两个计数永远不为0。解决方案是用weak_ptr打破循环。
  • 线程安全:多线程下加减计数需要原子操作,否则计数会乱。std::shared_ptr的引用计数是线程安全的,但对象本身不是。
  • 性能开销:每次拷贝都要操作原子变量,在高频场景下不可忽视。

我曾经踩过的坑:在一个实时渲染引擎里,每帧要拷贝几千次shared_ptr,结果原子操作成了瓶颈。后来改成unique_ptr配合对象池,性能提升了30%。所以,引用计数虽好,但别迷信。

写时拷贝(COW):延迟拷贝,能省则省

写时拷贝,英文叫Copy-On-Write。它的逻辑很简单:多个对象共享同一份数据,只有当某个对象要修改数据时,才真正拷贝一份

这个模式在C++的std::string里曾经用过(早期实现),后来因为线程安全问题被移除了。但COW的思想在很多场景下依然很有价值。

来看一个COW的实现思路:

class CowBuffer {
private:
    struct Data {
        char* buffer;
        size_t size;
        int ref_count;

        Data(size_t s) : size(s), ref_count(1) {
            buffer = new char[s];
        }

        ~Data() {
            delete[] buffer;
        }
    };

    Data* data_;

    void detach() {
        if (data_->ref_count > 1) {
            // 有人共享,需要拷贝
            Data* new_data = new Data(data_->size);
            memcpy(new_data->buffer, data_->buffer, data_->size);
            new_data->ref_count = 1;
            
            // 减少原数据的引用
            if (--data_->ref_count == 0) {
                delete data_;
            }
            
            data_ = new_data;
        }
    }

public:
    CowBuffer(size_t size) : data_(new Data(size)) {}

    CowBuffer(const CowBuffer& other) : data_(other.data_) {
        ++data_->ref_count;
    }

    ~CowBuffer() {
        if (--data_->ref_count == 0) {
            delete data_;
        }
    }

    // 读操作不需要拷贝
    const char* read() const {
        return data_->buffer;
    }

    // 写操作需要先分离
    char* write() {
        detach();
        return data_->buffer;
    }
};

COW的精髓在于:读操作不拷贝,写操作才拷贝。如果你的场景里读多写少,COW能省下大量内存和拷贝时间。

但COW也有适用条件:

场景 适合COW? 原因
大量只读共享 ✅ 非常适合 几乎零拷贝开销
频繁写入 ❌ 不适合 每次写都要拷贝,反而更慢
多线程环境 ⚠️ 需要小心 引用计数和拷贝操作需要加锁
对象体积大 ✅ 适合 拷贝成本高,延迟拷贝收益大

我个人的建议:COW在现代C++里用得少了,因为移动语义和右值引用已经能解决大部分拷贝问题。但如果你在做嵌入式开发或者资源受限的系统,COW依然是个好工具。

三种模式的对比与选择

这三种模式解决的是不同层面的内存问题。我画了一张图帮你理清思路:

内存管理三模式对比 对象池模式 核心:复用对象 适用:频繁创建/销毁 优点:减少内存碎片 缺点:内存占用固定 典型应用: 游戏子弹、数据库连接 线程池 引用计数 核心:自动生命周期 适用:共享所有权 优点:自动释放内存 缺点:循环引用风险 典型应用: shared_ptr、COM对象 垃圾回收辅助 写时拷贝(COW) 核心:延迟拷贝 适用:读多写少 优点:节省内存拷贝 缺点:写操作有开销 典型应用: std::string(早期) Linux fork() 共同目标:减少不必要的内存分配和拷贝,提升性能

这三种模式,说白了都是在跟内存分配较劲。对象池是空间换时间,提前分配好;引用计数是自动管理,省去手动释放的麻烦;COW是懒加载,能拖就拖。

在实际项目中,我经常把它们组合使用。比如:对象池里的对象用引用计数管理生命周期,数据共享用COW减少拷贝。但记住一点:没有银弹。每种模式都有适用场景,选对了是利器,选错了是累赘。

面试官可能会问:「如果让你设计一个高性能的内存管理系统,你会怎么组合这些模式?」我的回答思路是:先分析业务场景的读写比例、对象生命周期、并发程度,然后针对性地选择模式。没有标准答案,但要有分析过程。

好了,关于内存管理的三个模式就聊到这儿。记住:懂模式是基础,懂场景才是高手

专注资料整理