C++内存管理变革(2):最袖珍的垃圾回收器 - AutoFreeAlloc

概述

C/C++最被人诟病的,可能是没有一个内存垃圾回收器(确切是说没有一个标准的垃圾回收器)。本文讨论的内容要点是,在C/C++中实现一个最袖珍的、功能受限的垃圾回收器。这个垃圾回收器区别于其他垃圾回收器的主要特征是:

  1. 袖珍但具实用性。整个垃圾回收器代码行数100行左右(不含空白行),相当小巧。相对而言,它的功能也受到一定的限制。但是它在很多关键的场合恰恰非常有用。该垃圾回收器以实用作为首要目标,已经成为我和身边一些同事编程的重要工具。 
  2. 高性能。区别于其他垃圾回收器的是这个袖珍的垃圾回收器非但不会导致性能的下降,反而提高了程序的时间性能(分配的速度加快)和空间性能(所占内存空间比正常的malloc/new少)。而这也是实用的重要指标。

本文算法并不复杂。技术上的东西,很多点明了就没有什么了,也许重要的意义是在于其首创性。其实,boost1提供的pool组件也在试图提供类似功能的自动内存回收能力。但是实现相对复杂且低效(基于经典的mempool技术2)。

现在,你也许急着想看看,这个垃圾回收器长什么样了。闲话少叙,那就让我们就开始一步步把谜底揭开吧。

思路

理解该垃圾回收器的关键点在于,是在于理解它的目标:为一个复杂的局部过程(算法)提供自动内存回收的能力。

所谓局部过程(算法),是指那些算法复杂性较高,但在程序运行期所占的时间又比较短暂的过程3。例如:搜索引擎的搜索过程、读盘/存盘过程、显示(绘制)过程等等。通常这些过程可能需要申请很多内存,而且内存分配操作的入口点很多(就是调用new的地方很多),如果每调用一次new就要考虑应该在什么地方delete就徒然浪费我们宝贵的脑力,使得我们无法把全力精力集中在算法本身的设计上。也许就是在这种情形下,C/C++程序员特别羡慕那些具备垃圾回收器的语言。相对而言,如果算法复杂性不高的话,我们的程序员完全有能力控制好 new/delete的匹配关系。并且,这种“一切皆在我掌控之中”的感觉给了我们安全感4和满足感。 

因此,这个垃圾回收器的重心并不是要提供一个理论上功能完备的内存自动回收机制。它只是针对复杂性较高的局部过程(算法),为他们提供最实效的内存管理手段。从局部过程的一开始,你就只管去申请、使用内存,等到整个算法完成之后,这个过程申请的大部分内存(需要作为算法结果保留的例外),无论它是在算法的那个步骤申请的,均在这个结束点上由垃圾回收器自动销毁。我们画个示意图:

1.gif

图 1

规格

我们将该垃圾回收器命名为 AutoAlloc。它的接口很简单,仅涉及两个概念:allocate、clear。

typedef void (*FnDestructor)(void* pThis);
 
class AutoAlloc
{
public:
    ~AutoAlloc();                                  // 析构函数。自动调用Clear释放内存
    void* allocate(size_t cb);                     // 类似于malloc(cb)
    void* allocate(size_t cb, FnDestructor fn);    // 申请内存并指定析构函数
    void clear();                                  // 析构并释放所有分配的对象
};

为了方便,提供辅助的 New 操作5上一篇中已经简单介绍实现了),大体如下:

template <class Type, class AllocType>
Type* New(AllocType& alloc);                    // 类似于new Type
 
template <class Type, class ArgType1, class AllocType>
Type* New(ArgType1 arg1, AllocType& alloc); // 类似于new Type(arg1)
 
template <class Type, class AllocType>
Type* NewArray(size_t count, AllocType& alloc);// 类似于new Type[count]

使用样例:

AutoAlloc alloc;
 
int* intArray = (int*)alloc.Alloc(sizeof(int)*count);
int* intArray2 = NewArray<int>(count, alloc);
 
MyClass* obj = New<MyClass>(alloc);
MyClass* objWithArg = New<MyClass>(arg1, alloc);
MyClass* objArray = NewArray<MyClass>(count, alloc);
 
alloc.Clear();
 
// …
// 现在,不能再访问intArray, obj, objWithArg, objArray等数据了。

内存管理机制

class AutoAlloc
{
public:
    enum { BlockSize = 2048 };
private:
    struct _MemBlock
    {
        _MemBlock* pPrev;
        char buffer[BlockSize];
    };
    enum { HeaderSize = sizeof(_MemBlock) - BlockSize };
 
    char* m_begin;
    char* m_end;
};

AutoAlloc类与内存管理相关的变量只有两个:m_begin、m_end。单从变量定义来看,基本上很难看明白。但是有了下面这张示意图就容易理解多了:

2.png

图 2

整个AutoAlloc申请的内存,通过_MemBlock构成链表。只要获得了链表的头,就可以遍历整个内存链,释放所有申请的内存了。而链表的头(图中标为_ChainHeader),可以通过m_begin计算得到:

_MemBlock* AutoAlloc::_ChainHeader() const
{
    return (_MemBlock*)(m_begin - HeaderSize);
}

为了使得_ChainHeader初始值为null,构造函数我们这样写:

AutoAlloc::AutoAlloc()
{
    m_begin = m_end = (char*)HeaderSize;
}

内存分配(Alloc)过程

下面我们考虑内存分配(Alloc)过程。Alloc过程主要会有三种情况,具体代码为:

void* AutoAlloc::Alloc(size_t cb)
{
    if (m_endm_begin < cb)
    {
        if (cb >= BlockSize)
        {
                _MemBlock* pHeader = _ChainHeader();
                _MemBlock* pNew = (_MemBlock*)m_alloc.allocate(HeaderSize + cb);
                if (pHeader)
                {
                  pNew->pPrev = pHeader->pPrev;
                  pHeader->pPrev = pNew;
                }
                else
                {
                  m_end = m_begin = pNew->buffer;
                  pNew->pPrev = NULL;
                }
                return pNew->buffer;        }
        else
        {
            _MemBlock* pNew = (_MemBlock*)malloc(sizeof(_MemBlock));
            pNew->pPrev = _ChainHeader();
            m_begin = pNew->buffer;
            m_end = m_begin + BlockSize;
        }
    }
    return m_end -= cb;
}

【情形1】 也就是最简单的情况,当前_MemBlock还有足够的自由内存(free memory),即:

    m_endm_begin >= cb

此时,只需要将m_end前移cb字节就可以了。我们画个示意图如下:
3.png

图 3

【情形2】 在当前的_MemBlock的自由内存(free memory)不足的情况下,我们就需要申请一个新的_MemBlock以供使用6。申请新的_MemBlock,我们又会遇到两种情况:

【情形2.1】 申请的字节数(即cb)小于一个_MemBlock所能够提供的内存(即BlockSize)。

这种情况下,我们只需要将该_MemBlock作为新的当前_MemBlock挂到链表中,剩下的工作就和情形1完全类似。示意图如下:

4.png

图 4

【情形2.2】 而在内存申请的字节数(即cb)大于或等于一个Block的字节数时,我们需要申请可使用内存超过正常长度(BlockSize)的_MemBlock。这个新生成的_MemBlock全部内存被用户申请。故此,我们只需要修改_ChainHeader的pPrev指针,改为指向这一块新申请的_MemBlock即可。m_begin、m_end保持不变(当前的_MemBlock还是当前的_MemBlock)。如图:

5.png

图 5

内存释放(Clear)过程

下面我们考虑内存释放(Clear)过程。这个过程就是遍历_MemBlock释放所有的_MemBlock的过程,非常简单。代码如下:

void AutoAlloc::Clear()
{
    _MemBlock* pHeader = _ChainHeader();
    while (pHeader)
    {
        _MemBlock* pTemp = pHeader->pPrev;
        free(pHeader);
        pHeader = pTemp;
    }
    m_begin = m_end = (char*)HeaderSize;
}

自动析构过程

我们知道,C++以及其他面向对象语言为对象引入了构造、析构过程。这是一个了不起的发明。因为只有这样,才能够保证对象从一开始产生以来(刚new出来),到对象销毁这整个过程,它的数据都处于完备状态,是自洽的。

我们知道,C++以及其他面向对象语言为对象引入了构造、析构过程。这是一个了不起的发明。因为只有这样,才能够保证对象从一开始产生以来(刚new出来),到对象销毁这整个过程,它的数据都处于完备状态,是自洽的。

由于垃圾回收器负责对象的回收,它自然不止需要关注对象申请的内存的释放,同时也需要保证,在对象销毁之前它的析构过程被调用。上文我们为了关注内存管理过程,把自动析构过程需要的代码均去除了。为了支持自动析构,AutoAlloc类增加了以下成员:

class AutoAlloc
{
    struct _DestroyNode
    {
        _DestroyNode* pPrev;
        FnDestructor fnDestroy;
    };
    _DestroyNode* m_destroyChain;
};

如果一个类存在析构,则它需要在Alloc内存的同时指定析构函数。代码如下:

void* AutoAlloc::Alloc(size_t cb, FnDestructor fn)
{
    _DestroyNode* pNode = (_DestroyNode*)Alloc(sizeof(_DestroyNode) + cb);
    pNode->fnDestroy = fn;
    pNode->pPrev = m_destroyChain;
    m_destroyChain = pNode;
    return pNode + 1;
}

只要通过该Alloc函数申请的内存,我们在Clear中就可以调用相应的析构。当然,Clear函数需要补充自动析构相关的代码:

void AutoAlloc::Clear()
{
    while (m_destroyChain)
    {
        m_destroyChain->fnDestroy(m_destroyChain + 1);
        m_destroyChain = m_destroyChain->pPrev;
    }
    // 以下是原先正常的内存释放过程…
}

时间性能分析

void* AutoAlloc::Alloc(size_t cb);

OOP技术带来一个内存上的问题是,对象粒度越来越细了,对象基本上都是小对象。这就对内存管理的性能提出了很高的要求。 

如果我们以对象大小平均为32字节计算的话,每2048/32 = 64操作中,只有一次操作满足m_end – m_begin < cb的条件。也就是说,在通常情况(63/64 = 98.4%的概率)下,Alloc操作只需要一个减法操作就完成内存分配。

我说这是世界上最快速的内存分配算法,也许你对此仍然抱有怀疑态度。但是可以肯定的一点是,要突破它的性能极限我觉得已经很难很难了。 

void AutoAlloc::Clear();

一般内存管理器通常一次内存分配操作就需调用相应的一次Free操作。但是AutoAlloc不针对每一个Alloc进行释放,而是针对每一个_MemBlock。仍假设对象平均大小为32字节的话,也就是相当于把64次Alloc操作合并,为其提供一次相应的Free过程。

★ 结论:AutoAlloc在时间上的性能,大约比普通的malloc/free的快64倍。

空间性能分析

我们知道,一般内存管理器为了将用户申请的内存块管理起来,除了用户需要的cb字节内存外,通常额外还提供一个内存块的头结构,通过这个头结构将内存串连成为一个链表。一般来讲,这个头结构至少有两项(可能还不止),示意如下:

struct MemHeader
{
    MemHeader* pPrev;
    size_t cb;
};

仍然假设平均Alloc一次的内存为32字节。则一次malloc分配过程,就会浪费8/32 = 25%的内存。并且由于大量的小对象存在,整个内存中的碎片(指那些自由但无法被使用的内存)将特别严重。

而AutoAlloc的Alloc没有如何额外开销。整个AutoAlloc,只有在将_MemBlock串为链表的有一个额外的 pPrev指针,加上_MemBlock是malloc出来的,有额外的8字节开销。总计浪费(4+8)/2048 = 0.6%的内存,几乎可以忽略不计。 

后记

AutoAlloc于2004-5-21开发,只有100行的代码量。但是,这个组件获得了空前的成功,它的应用范围逐步扩大,超过了我最初实现这个组件时的预计。

我渐渐冷静下来,考虑这其中蕴涵的道理。我逐步领会到了,它的成功之处,不是它在时间、空间性能的高效,而是在于它帮助C++程序员解决了最大的难题——内存管理。虽然,这个解决方案并不是完整的。

AutoAlloc是一个切入点,从它身上,让我明白了C++的new/delete的不合理;STL引入的allocator是一个切入点,从它身上,让我明白了内存管理有很强的区域性,在不同的区域(局部过程)中对allocator的需求却又不尽相同。

我们前文也提到了一个例子:一个文档打开,编辑,直到文档被最终关闭,这个完成算不算局部过程呢?在AutoAlloc解决的问题域来看,显然我们无法认为它是一个局部过程。但是,从其他allocator角度来讲,是否就有可能把它作为一个局部过程了呢?

正是考虑到AutoAlloc的缺陷,我们需要一个功能更强的垃圾回收器。这就是我们下一次需要讨论的组件了。

最后,仍然需要明确的一点是,我们很难也不需要实现一个象Java、C#那样的垃圾回收器。提供一个具备特定的内存管理能力的allocator才是正道。

附加说明

本文所描述的AutoAlloc组件,完整代码可在WINX库中找到。你也可以通过以下链接在线浏览:

另外, 这篇文章写的时间较早,其规格虽然与现在的AutoAlloc一样,但成员函数名改了:

Alloc -> allocate
Clear -> clear

之所以这样,是因为AutoAlloc被纳入stdext库(这个库可独立于winx界面库,是winx界面库的基础)。stdext库的命名风格尽量与STL的命名习惯一致。

相关文章:《C++内存管理变革(1): GC Allocator

关于本文

本文写于 2005-7-17,第一次公开发表是我的CSDN博客上:

Unless otherwise stated, the content of this page is licensed under Creative Commons Attribution-ShareAlike 3.0 License

Subscription expired — please renew

Pro account upgrade has expired for this site and the site is now locked. If you are the master administrator for this site, please renew your subscription or delete your outstanding sites or stored files, so that your account fits in the free plan.