内存管理任何有意义的程序都需要分配和释放内存。随着程序复杂性、大小的增长和性能的提高,内存管理技术变得越来越重要。D 提供了多种管理内存的方式。 D 中三种主要的分配内存的方法是:
静态数据,分配在默认数据段内。 堆栈数据,分配在程序堆栈内。 垃圾收集数据,动态分配于垃圾收集堆上。 本章描述了使用它们的技术,同时还有一些高级话题: 字符串(和数组)的写时复制 实时 平滑操作 自由链表 引用计数 显式类实例分配 标记/释放 RAII (资源获得即初始化) 在堆栈上分配类实例 在堆栈上分配未初始化数组String(和数组)的写时复制考虑将一个数组传递给函数的情况,可能会修改数组的值,并返回修改后的数组。因为数组是通过引用传递的,而不是通过值传递,一个关键的问题是:谁拥有数组的内容?例如,一个将所有字母转换大写的函数:
char[] toupper(char[] s)
{
int i;
for (i = 0; i < s.length; i++)
{
char c = s[i];
if ('a' <= c && c <= 'z')
s[i] = c - (cast(char)'a' - 'A');
}
return s;
}
注意,调用者的那个 s[] 也被修改了。这可能完全违背了你的本意,或者更糟的是,s[] 可能会位于一块只读的内存中。
如果 toupper() 总是复制 s[],就会为已经是全部大写的字符串消耗不必要的时间和内存。
这个解决方案要实现写时复制,这意味着只有在串需要被修改时才会被复制。一些串处理语言将它作为默认的行为,但是这么做代价巨大。串 "abcdeF" 将会在函数中被复制 5 次。如果要使用协议来获得最高的效率,就必须在代码中显示的使用。这里的 toupper() 被重写以用高效的方式来实现写时复制:
char[] toupper(char[] s)
{
int changed;
int i;
changed = 0;
for (i = 0; i < s.length; i++)
{
char c = s[i];
if ('a' <= c && c <= 'z')
{
if (!changed)
{ char[] r = new char[s.length];
r[] = s;
s = r;
changed = 1;
}
s[i] = c - (cast(char)'a' - 'A');
}
}
return s;
}
D Phobos 运行时库的数组处理函数实现了写时复制协议。 实时实时编程意味着程序必须能够保证一个最大延迟,或者说完成操作的最长时间。在大多数内存分配方案中,包括 malloc/free 和垃圾收集,理论上延迟都是无界的。保证延迟的最可靠的方法是预先为对时间要求苛刻的部分分配全部的数据。如果没有分配内存的调用,垃圾收集器将不会运行,则程序运行时间不会超过最大延迟的允许范围。 平滑操作同实时编程相关的是对平滑操作的需求,也就是当垃圾收集程序停止所有其他活动进行垃圾收集时程序不会任意的暂停。这种程序的一个例子是互动射击游戏。如果游戏不规则的暂停,尽管这不是程序的致命错误,也会激怒用户。有几种技术可以用来取出或者减轻这种作用: 在运行那些必须平滑运行的代码之前预先分配所有的数据。 在程序本身暂停的时候手动运行垃圾收集。这种例子可以是当程序刚刚显示了供用户输入的提示符而用户暂时没有响应时。这会减少在需要平滑运行的代码时运行垃圾收集的纪律。 在执行需要平滑运行的代码之前调用 gc.disable() ,在其执行之后调用 gc.enable() 。这会使 gc 乐于分配更多的内存,这样就会减少运行垃圾收集的机会。 自由链表自由链表是加速对频繁分配和抛弃的型别的访问的好方法。它的思想很简单——当释放对象时,并不真正释放它们,而是将它们放到自由链表上。在分配时,直接从自由链表上取用。 class Foo
{
static Foo freelist;// 自由链表头
static Foo allocate()
{Foo f;
if (freelist)
{ f = freelist;
freelist = f.next;
}
else
f = new Foo();
return f;
}
static void deallocate(Foo f)
{
f.next = freelist;
freelist = f;
}
Foo next;// 由 FooFreeList 使用
...
}
void test()
{
Foo f = Foo.allocate();
...
Foo.deallocate(f);
}
这种自由链表方法很高效。 如果用于多线程环境,需要对 allocate() 和 deallocate() 函数进行同步。 当采用 allocate() 从自由链表中分配对象时,Foo 的构造函数不会再次运行,所以分配程序需要重新初始化那些需要初始化成员。 不一定要为它实现 RAII ,因为就算有对象由于抛出异常的原因没有被传递给 deallocate() ,最终也会由 GC 收集。引用计数引用计数要求在每个对象内部保留一个计数域。当有引用指向它时,递增引用计数;当指向它的引用消失时,递减引用计数。当引用计数减为 0 ,就删除该对象。 D 不对引用计数提供任何自动支持,引用计数必须由程序员显式地实现。
在 Win32 COM 编程中 使用成员函数 AddRef() 和 Release() 来维护引用计数。
显式类实例分配D 提供了对定制对象实例的分配程序和释放程序的方法。通常情况下,对象实例应该分配在垃圾收集堆上,当垃圾收集程序运行时释放无用的对象实例。对于那些特殊情况,可以使用 New声明 和 Delete声明 处理。例如,使用 C 运行时库的 malloc 和 free :import std.c.stdlib;
import std.outofmemory;
import std.gc;
class Foo
{
new(uint sz)
{
void* p;
p = std.c.stdlib.malloc(sz);
if (!p)
throw new OutOfMemory();
gc.addRange(p, p + sz);
return p;
}
delete(void* p)
{
if (p)
{ gc.removeRange(p);
std.c.stdlib.free(p);
}
}
}
new() 的关键特征有: new() 不能指定返回类型,但返回类型被定义为 void* 。new() 必须返回 void* 。 如果 new() 无法分配内存,不必返回 null ,但必须抛出异常。 new() 返回的指针必须按照默认的堆齐方式对齐。在 win32 系统中为 8 。 当从 Foo 派生的类调用该分配程序时,需要提供 size 参数,并且应该大于传递给 Foo 的值。 如果不能分配所需的存储空间,不会返回 null ,而应该抛出异常。抛出什么样的异常由程序员决定,前述情况下,抛出 OutOfMemory() 。 当扫描内存收集根指针到垃圾收集堆时,会自动扫描静态数据段和堆栈,但不会扫描 C 的堆。因此,如果 Foo 或它的派生类使用的分配程序含有指向由垃圾收集程序分配的对象的引用的话,就需要以某种方式通知 GC 这个事实。可以使用 gc.addRange() 方法达到这个目的。 不必初始化内存,因为编译器会自动在调用 new() 之后插入代码将类实例的成员设为它们的默认值,然后运行构造函数(如果有的话)。delete() 的关键特征有: 已经对参数 p 调用了析构函数(如果有的话),所以它指向的数据将被视为垃圾。 指针 p 可以为 null 。 如果使用 gc.addRange() 通知了 GC ,就必须在释放程序中调用 gc.removeRange() 。 如果定义了 delete() ,就必须定义对应的 new() 。如果使用类专用的分配程序和释放程序分配内存,就必须小心地编码以避免内存泄露和悬挂引用的出现。如果涉及到异常,还必须实现 RAII 以避免内存泄露。 标记/释放标记/释放等价于在堆栈上分配和释放内存。这会在内存中创建一个‘堆栈’。 对象的分配仅仅需要向下移动堆栈指针。指针被作了“标记”,释放整个内存区域时只需要简单地将堆栈指针复位到标记点处即可。import std.c.stdlib;
import std.outofmemory;
class Foo
{
static void[] buffer;
static int bufindex;
static const int bufsize = 100;
static this()
{void *p;
p = malloc(bufsize);
if (!p)
throw new OutOfMemory;
gc.addRange(p, p + bufsize);
buffer = p[0 .. bufsize];
}
static ~this()
{
if (buffer.length)
{
gc.removeRange(buffer);
free(buffer);
buffer = null;
}
}
new(uint sz)
{ void *p;
p = &buffer[bufindex];
bufindex += sz;
if (bufindex > buffer.length)
throw new OutOfMemory;
return p;
}
delete(void* p)
{
assert(0);
}
static int mark()
{
return bufindex;
}
static void release(int i)
{
bufindex = i;
}
}
void test()
{
int m = Foo.mark();
Foo f1 = new Foo;// 分配
Foo f2 = new Foo;// 分配
...
Foo.release(m);// 释放 f1 和 f2
}
在分配时,buffer[] 被作为一个区域加入 gc ,所以不需要在 Foo.new() 内部再通过一个单独的调用完成此事。 RAII(资源获得即初始化)RAII 技术可用于避免使用显式内存分配和释放时可能的内存泄露问题。给这样的类添加 auto 特征 会解决这种问题。 在堆栈上分配类实例在堆栈上分配类实例可用来分配在函数退出时需要释放的临时对象。当函数因为异常而退出、展开堆栈时,不需要进行特殊的处理。如果想要上述的操作有效,这些对象绝对不能有析构函数,因为上述过程中不会调用对象的析构函数。 如果想要在堆栈上分配带有析构函数的类对象,可以使用 auto 特征 声明对象。尽管目前的实现并不把对象放到堆栈上,但在未来的版本中可能会这样做。
import std.c.stdlib;
class Foo
{
new(uint sz, void *p)
{
return p;
}
delete(void* p)
{
assert(0);
}
}
void test()
{
Foo f = new(std.c.stdlib.alloca(Foo.classinfo.init.length)) Foo;
...
}
不需要检查 alloca() 是否失败并在失败时抛出异常,因为按照定义,如果堆栈溢出了,alloca() 将会生成一个堆栈溢出异常。 不需要调用 gc.addRange() 或者 gc.removeRange() ,因为 gc 会自动扫描堆栈。 那个虚设的 delete() 函数用来保证没有 delete 堆栈对象的企图会成功。在堆栈上分配未初始化的数组D 中的数组总是会被初始化。所以,下面的声明:void foo()
{ byte[1024] buffer;
fillBuffer(buffer);
...
}
不会运行得像你想象的那样快,因为需要初始化 buffer[] 。如果对程序的性能分析表明初始化造成了性能问题,可以通过下面的方法来解决:import std.c.stdlib;
void foo()
{ byte[] buffer = (cast(byte*)std.c.stdlib.alloca(1024))[0 .. 1024];
fillBuffer(buffer);
...
}
一个良好的 D 实现将会识别出 alloca() 的参数为常量,并用位于堆栈上的同样大小的未初始化的数组代替它。这将得到同 C 中的堆栈数组一样的效率。 在使用堆栈上的未初始化的数据前,需要认真考虑下面这些警告:
垃圾收集程序会扫描堆栈上的未初始化数据以查找所有指向已分配内存的饮用。因为未初始化数据中的内容是旧的 D 堆栈帧留下的,所以很有可能其中有些东西会被误认为是指向 gc 堆的引用,这样相应的 gc 内存就不会被释放。这种问题确实会发生,并且相当难以跟踪。 一个函数可能会把一个指向它的堆栈帧内部数据的引用传递到函数外面。当分配新的堆栈帧正好覆盖了旧的数据,并且新的堆栈帧没有初始化时,指向旧数据的引用看起来仍是有效的。那么程序的行为就会错误百出(译注:臭名昭著的“悬挂引用”)。如果所有堆栈帧上的数据都被初始化了的话,将能够极大地提高此类错误重现的概率,这会使定为错误变得容易得多。 就算用法正确,未初始化数据也是 bug 和麻烦之源。D 的设计目标之一是通过限制源码中的未定义行为来提高可靠性和可移植性,而未初始化数据是未定义、不可移植、错误缠身及不可预测的行为的万恶之源。因此,本节给出的做法应该只用在其它提高速度的办法都不够用或者基准测试表明必须加快所有操作执行速度的情况下。