谈Delphi中LocalObject内存分配的优化
众所周知,Delphi中,所有的object都是开辟在堆里面的。实际上,Delphi是不存在C++中的LocalObject的概念的。但是LocalObject内存分配的高效实在有目共睹,本文主要就是讲如何把Delphi的Object申请到Stack上面去。机制其实很简单,大部分的情况都可以用这种方式来优化(如果有优化必要的话)。小弟在此抛砖引玉,愿与达者交流切磋。
对于LocalObject的使用,基本就是一个临时对象的作用, Delphi里面和C++里面的写法分别是这个样子的。
sample.cpp
/// ......
int test()
{
SampleClass a;
return a.execute(1, 2);
}
/// sample.pas
function test(): Integer;
var
a: TSampleClass;
begin
a := TSampleClass.Create();
try
result := a.execute(1, 2);
finally
a.Free();
end;
end;
单以形式而论,这两个函数的几乎是一模一样。可是他们的效率,却有着很大的差别,有心人可以自行测试一下,stack object的效率明显比heap object的效率高不少。尤其是,这样的小函数被调用次数非常多的时候,比如我在实际工程里遇到的一个几何函数里面的相交判断,被调用的次数达百万的级别,且有些地方直接影响到界面的刷新,效率要求比较高些。出于能快些就快些的极端主义思想,这里的内存分配已经不容小觑了,所以需要懂些手脚,令人庆幸的是,动这个手脚还一点也不麻烦。
我的方法很简单,就是把内存分配到系统栈上去。我们知道,Delphi里面有一个record结构(对应于c/c++里面的struct),虽然record并不如c里面的好用,可是他的内存是分配在栈上这是一样的。(其实还可以利用静态数组,一样可以做到这一点,本质上是一样的。我这里还是采用了一个record, 这样应该好理解一些。)
我先把修改过的代码贴上,接下来再解释原理。
/// sample.pas
type
TSampleClass = class(TObject)
public
procedure Finalize(); 负责释放引用类型的成员变量
end;
TSampleClassRec = record
fcontent: array [0..const_TSampleClass_Size-1] of byte;
end;
/// ....
function test(): Integer;
var
aRec: TSampleClassRec; /// 一个大小和TSampleClass一样的record
a: TSampleClass;
begin
a := TSampleClass(TSampleClass.InitInstance(@aRec)); 注意,这里不同了
a.Create();
try
result := a.execute(1, 2);
finally
Finalize();
a.CleanupInstance(); 注意,这里不同了
end;
end;
下面,我们来解释原因。
在Delphi里面,object的创建,是通过TObject.Create来完成的。下面我们来看看TObject.Create里面到底做了些什么。
我们可以到System.pas单元里去看看constructor TObject.Create这个函数,会比较惊讶的发现,空的,什么都没有!!!什么都没做!!!不错,确实什么都没有,但绝对不是什么都没做,我们说还是做了不少事情的,人家是田螺姑娘的作风,喜欢偷偷的干活,不愿被别人看到。那么,到底是怎么完成的呢?这里可以建议一本书,李维先生的《inside VCL》比较详细的阐述了这个过程。不过,不喜欢看书的兄弟,也没有关系,可以看我写的,大约100来字,倒也可以把过程讲明白(喜欢深刻的兄弟还是建议看一下,实际上是调用了System.pas里面的_ClassCreate函数,一堆汇编,暂时先供起来)。
我们可以看Delphi提供的一个实例,他把整个过程,分割开来了,堪称模板型的代码。请看VCL源码里面Forms.pas的procedure TApplication.CreateForm(InstanceClass: TComponentClass; var Reference);函数。
procedure TApplication.CreateForm(InstanceClass: TComponentClass; var Reference);
var
Instance: TComponent;
begin
Instance := TComponent(InstanceClass.NewInstance); 首先,创建实例,分配内存,VMT等
TComponent(Reference) := Instance;
try
Instance.Create(Self); 现在,调用初始化函数,初始化userdata
except
TComponent(Reference) := nil;
raise;
end;
if (FMainForm = nil) and (Instance is TForm) then
begin
TForm(Instance).HandleNeeded;
FMainForm := TForm(Instance);
end;
end;
不错,TObject.Create 实际就是这两个步骤完成的。首先NewInstance, 然后Create。这里,大家会发现一个疑问,这里是通过一个实例来调用Create,而不是正常的通过Class来调用。这两种方式的区别就在于,是否需要第一个NewInstance过程,开辟内存。这点可以在function _ClassCreate(AClass: TClass; Alloc: Boolean): TObject;看出来,参数Alloc: Boolean和JL @@noAlloc这一行就是根据调用方式的不同,是否需要分配内存的两条路径。
到这里,我们的任务就是,看看NewInstance作了什么。发现以下的结果
class function TObject.NewInstance: TObject;
begin
Result := InitInstance(_GetMem(InstanceSize));
end;
原来是通过_GetMem来实现的,那么问题就简单了,什么神秘的面向对象的一大套的复杂招式,在独孤九剑下面,看他的本质,还是一块内存,而且是毫无二致,普通得不能再普通的内存块而已。
到此为止,我们的这个LocalObject的机制已经毫无遮掩了,完完全全,彻彻底底,赤裸裸展现在眼前了。我的任务也完成了,充当了一回外科医生的角色,应该说“报告队长,我已经切好了,请验收”“好啊,可以缝回去了”“收到”。
要缝回去,还需要注意几个地方,要不然缝错了,搞得五官不正,也太对不起医生的称号了,鲁迅先生对庸医深恶痛绝,我们追随先贤遗烈,万万不可轻易启衅。
第一个问题,TSampleClass的实际大小如何获得。这个很不幸,我们无法用sizeof来得到一个object的大小。有两种办法,一个是正道的方式,不过很痛苦,麻烦;一个是邪一点,不过很有效,最重要的是很简单。正道的方式,自然是直接分析数据,看实际占多少内存,但这个需要很深的功力才能做到,而且即使功力真的很深,难免犯错,容易走火入魔,稍不留意,前功尽弃。另一个方式,本人比较推荐的方式,把程序运行以下,进调试界面,或者写message,调用instancesize函数,看一下那个数字就可以了。任务完成。不过,作为一个有良心的医生,虽然不能根治毛病,最好给一个防御措施。强烈建议写上, Assert(TSampleClass.InstanceSize() = SizeOf(TSampleClassRec));
第二个问题,千万要注意,绝对不能对这种Stack Object调用Free, 或者 destroy什么的不需要了。但是,这也会带来一个问题,对于成员的释放就有问题了. 象Integer, Char, Double等是没有关系,可以直接不理,但是对于string, 动态数组等,最好还是释放一下先。于是,需要一个Finalize()函数,负责释放引用类型的成员,在finally段里面记得调用。
第三个问题,CleanupInstance.还是需要调用的,这叫做断后。大丈夫光明磊落,来得清楚,去得明白。
另外,这种方式的优化,在于调用次数比较多的时候,以及分配内存的时间和算法本身差别不大的时候,效果比较明显。之所以有这样的效果,就是靠了stack上面分配内存只需修改esp值即可,都不用访问内存。而getmem,我们说至少通过一个系统的调用。windows下是怎么实现的倒是不清楚,当初在学操作系统的时候,老师讲过一个buddy的内存分配算法,相当的巧妙,也相当的繁复。与栈上分配花的时间不可同日而语。因为他是一种通用的内存分配算法,需要考虑内存的释放,回收,还要考虑一个内存碎片的处理等。所以,基于这种想法,我们实际上除了对LocalObject使用本文描述的机制来优化之外,对于那些确实需要创建在堆里的object,专门写一个内存池的分配方式。比如一开始就开辟一个TSampleClassRec的大数组,每次创建,扔出来一份,释放则扔回去一份。这个机制,比通用的分配机制在碎片的处理上,效率可以高很多。我们可以另外探讨一下。(对此,effectice c++一书中, 有一点提及, 但也足够了.高手就是高手,几句话就能把本质讲清楚, 使我颇有感悟,才有了这篇文字, 在此略表敬意.)
终于写完了,医生需要休息一下了。希望这个小小技巧可以为你解决一些问题,便是本人无尚荣幸了。抛砖引玉,多多分享。
关于一些实例的临时对象的效率优化,注意这里临时对象的概念。
比如:在我们的CalcFunc里面有些这种形式的函数,我例举一个如下
class function TCSeg2D.IntersectWith(const Line1, Line2: TWXLine; var Pt1,
Pt2: TWPoint; ATol: Double): TIntResult;
var
Seg1, Seg2: TCSeg2D;
begin
Seg1 := TCSeg2D.Create(Line1);
try
Seg2 := TCSeg2D.Create(Line2);
try
Result := Seg1.IntersectWith(Seg2, Pt1, Pt2, ATol);
finally
Seg2.Free;
end;
finally
Seg1.Free;
end;
end;
在我们的实现逻辑里面,可以看到seg1, seg2,除了参与了一下小小的计算,并没有其他的作用,本地创建,
本地释放了。我们把这些东西叫做临时对象。(他实际起到的作用也是一个临时存些数据)
但是,这里实际上有一份不小的开销,他必须在堆上创建两个实例出来,需要通过 _GetMem 来得到他所需要的内存。
这个开销实际上很大,尤其是对于经常调用的函数,简直是随着调用次数级数上升.
所以,我的想法是把这些内存的开辟,放到栈上面去完成。我试了一下, 这样的优化效率还是比较可观的。
比如有这么一个类
TSmall = class(TObject)
private
FContext: Integer;
public
constructor Create;
destructor Destroy; override;
procedure Release;
function sum(I, J: Integer): Integer;
end;
里面放了一个Integer, 模拟数据, 实际上就算包括需函数表, 理论上讲是没有问题的.
sum函数就是我们的业务函数.
根据他的InstanceSize 看到实际大小是 8个字节.
所以我又定义了一个 8字节的record. 作为栈上申请内存用。
TSmallRec = record
FContext: array [0..1] of Integer;
end;
需要注意的地方, 我们再也不能调用destroy函数了,因为它为会去做释放内存的操作,但是我们的内存将会创建在栈上,
所以函数会自己退栈。所以还是需要释放出了内存之外的其他资源,所以定义了一个release 函数。
///
现在看实际上的使用方式
这个是原来的用法
procedure TTestSmall.DoOld;
begin
with TSmall.Create() do
try
sum(1, 3)
finally
Free();
end;
end;
新的用法
procedure TTestSmall.DoNew;
var
rSmall: TSmallRec;
oSmall: TSmall;
begin
oSmall := TSmall(TSmall.InitInstance(@rSmall));
with oSmall.Create() do
try
sum(1, 3);
finally
cleanupinstance();
end;
end;
我对他们分别作了1000次的调用,结果是
old: time used(run 1000 times): 1092
new: time used(run 1000 times): 454
/
我做了一个测试,得出一个结论:
object越大,效果越好。
业务越简单,效果越好。
可以参考一下内存管理,我基本考虑的是buddy算法的机制。
众所周知,Delphi中,所有的object都是开辟在堆里面的。实际上,Delphi是不存在C++中的LocalObject的概念的。但是LocalObject内存分配的高效实在有目共睹,本文主要就是讲如何把Delphi的Object申请到Stack上面去。机制其实很简单,大部分的情况都可以用这种方式来优化(如果有优化必要的话)。小弟在此抛砖引玉,愿与达者交流切磋。
对于LocalObject的使用,基本就是一个临时对象的作用, Delphi里面和C++里面的写法分别是这个样子的。
sample.cpp
/// ......
int test()
{
SampleClass a;
return a.execute(1, 2);
}
/// sample.pas
function test(): Integer;
var
a: TSampleClass;
begin
a := TSampleClass.Create();
try
result := a.execute(1, 2);
finally
a.Free();
end;
end;
单以形式而论,这两个函数的几乎是一模一样。可是他们的效率,却有着很大的差别,有心人可以自行测试一下,stack object的效率明显比heap object的效率高不少。尤其是,这样的小函数被调用次数非常多的时候,比如我在实际工程里遇到的一个几何函数里面的相交判断,被调用的次数达百万的级别,且有些地方直接影响到界面的刷新,效率要求比较高些。出于能快些就快些的极端主义思想,这里的内存分配已经不容小觑了,所以需要懂些手脚,令人庆幸的是,动这个手脚还一点也不麻烦。
我的方法很简单,就是把内存分配到系统栈上去。我们知道,Delphi里面有一个record结构(对应于c/c++里面的struct),虽然record并不如c里面的好用,可是他的内存是分配在栈上这是一样的。(其实还可以利用静态数组,一样可以做到这一点,本质上是一样的。我这里还是采用了一个record, 这样应该好理解一些。)
我先把修改过的代码贴上,接下来再解释原理。
/// sample.pas
type
TSampleClass = class(TObject)
public
procedure Finalize(); 负责释放引用类型的成员变量
end;
TSampleClassRec = record
fcontent: array [0..const_TSampleClass_Size-1] of byte;
end;
/// ....
function test(): Integer;
var
aRec: TSampleClassRec; /// 一个大小和TSampleClass一样的record
a: TSampleClass;
begin
a := TSampleClass(TSampleClass.InitInstance(@aRec)); 注意,这里不同了
a.Create();
try
result := a.execute(1, 2);
finally
Finalize();
a.CleanupInstance(); 注意,这里不同了
end;
end;
下面,我们来解释原因。
在Delphi里面,object的创建,是通过TObject.Create来完成的。下面我们来看看TObject.Create里面到底做了些什么。
我们可以到System.pas单元里去看看constructor TObject.Create这个函数,会比较惊讶的发现,空的,什么都没有!!!什么都没做!!!不错,确实什么都没有,但绝对不是什么都没做,我们说还是做了不少事情的,人家是田螺姑娘的作风,喜欢偷偷的干活,不愿被别人看到。那么,到底是怎么完成的呢?这里可以建议一本书,李维先生的《inside VCL》比较详细的阐述了这个过程。不过,不喜欢看书的兄弟,也没有关系,可以看我写的,大约100来字,倒也可以把过程讲明白(喜欢深刻的兄弟还是建议看一下,实际上是调用了System.pas里面的_ClassCreate函数,一堆汇编,暂时先供起来)。
我们可以看Delphi提供的一个实例,他把整个过程,分割开来了,堪称模板型的代码。请看VCL源码里面Forms.pas的procedure TApplication.CreateForm(InstanceClass: TComponentClass; var Reference);函数。
procedure TApplication.CreateForm(InstanceClass: TComponentClass; var Reference);
var
Instance: TComponent;
begin
Instance := TComponent(InstanceClass.NewInstance); 首先,创建实例,分配内存,VMT等
TComponent(Reference) := Instance;
try
Instance.Create(Self); 现在,调用初始化函数,初始化userdata
except
TComponent(Reference) := nil;
raise;
end;
if (FMainForm = nil) and (Instance is TForm) then
begin
TForm(Instance).HandleNeeded;
FMainForm := TForm(Instance);
end;
end;
不错,TObject.Create 实际就是这两个步骤完成的。首先NewInstance, 然后Create。这里,大家会发现一个疑问,这里是通过一个实例来调用Create,而不是正常的通过Class来调用。这两种方式的区别就在于,是否需要第一个NewInstance过程,开辟内存。这点可以在function _ClassCreate(AClass: TClass; Alloc: Boolean): TObject;看出来,参数Alloc: Boolean和JL @@noAlloc这一行就是根据调用方式的不同,是否需要分配内存的两条路径。
到这里,我们的任务就是,看看NewInstance作了什么。发现以下的结果
class function TObject.NewInstance: TObject;
begin
Result := InitInstance(_GetMem(InstanceSize));
end;
原来是通过_GetMem来实现的,那么问题就简单了,什么神秘的面向对象的一大套的复杂招式,在独孤九剑下面,看他的本质,还是一块内存,而且是毫无二致,普通得不能再普通的内存块而已。
到此为止,我们的这个LocalObject的机制已经毫无遮掩了,完完全全,彻彻底底,赤裸裸展现在眼前了。我的任务也完成了,充当了一回外科医生的角色,应该说“报告队长,我已经切好了,请验收”“好啊,可以缝回去了”“收到”。
要缝回去,还需要注意几个地方,要不然缝错了,搞得五官不正,也太对不起医生的称号了,鲁迅先生对庸医深恶痛绝,我们追随先贤遗烈,万万不可轻易启衅。
第一个问题,TSampleClass的实际大小如何获得。这个很不幸,我们无法用sizeof来得到一个object的大小。有两种办法,一个是正道的方式,不过很痛苦,麻烦;一个是邪一点,不过很有效,最重要的是很简单。正道的方式,自然是直接分析数据,看实际占多少内存,但这个需要很深的功力才能做到,而且即使功力真的很深,难免犯错,容易走火入魔,稍不留意,前功尽弃。另一个方式,本人比较推荐的方式,把程序运行以下,进调试界面,或者写message,调用instancesize函数,看一下那个数字就可以了。任务完成。不过,作为一个有良心的医生,虽然不能根治毛病,最好给一个防御措施。强烈建议写上, Assert(TSampleClass.InstanceSize() = SizeOf(TSampleClassRec));
第二个问题,千万要注意,绝对不能对这种Stack Object调用Free, 或者 destroy什么的不需要了。但是,这也会带来一个问题,对于成员的释放就有问题了. 象Integer, Char, Double等是没有关系,可以直接不理,但是对于string, 动态数组等,最好还是释放一下先。于是,需要一个Finalize()函数,负责释放引用类型的成员,在finally段里面记得调用。
第三个问题,CleanupInstance.还是需要调用的,这叫做断后。大丈夫光明磊落,来得清楚,去得明白。
另外,这种方式的优化,在于调用次数比较多的时候,以及分配内存的时间和算法本身差别不大的时候,效果比较明显。之所以有这样的效果,就是靠了stack上面分配内存只需修改esp值即可,都不用访问内存。而getmem,我们说至少通过一个系统的调用。windows下是怎么实现的倒是不清楚,当初在学操作系统的时候,老师讲过一个buddy的内存分配算法,相当的巧妙,也相当的繁复。与栈上分配花的时间不可同日而语。因为他是一种通用的内存分配算法,需要考虑内存的释放,回收,还要考虑一个内存碎片的处理等。所以,基于这种想法,我们实际上除了对LocalObject使用本文描述的机制来优化之外,对于那些确实需要创建在堆里的object,专门写一个内存池的分配方式。比如一开始就开辟一个TSampleClassRec的大数组,每次创建,扔出来一份,释放则扔回去一份。这个机制,比通用的分配机制在碎片的处理上,效率可以高很多。我们可以另外探讨一下。(对此,effectice c++一书中, 有一点提及, 但也足够了.高手就是高手,几句话就能把本质讲清楚, 使我颇有感悟,才有了这篇文字, 在此略表敬意.)
终于写完了,医生需要休息一下了。希望这个小小技巧可以为你解决一些问题,便是本人无尚荣幸了。抛砖引玉,多多分享。
关于一些实例的临时对象的效率优化,注意这里临时对象的概念。
比如:在我们的CalcFunc里面有些这种形式的函数,我例举一个如下
class function TCSeg2D.IntersectWith(const Line1, Line2: TWXLine; var Pt1,
Pt2: TWPoint; ATol: Double): TIntResult;
var
Seg1, Seg2: TCSeg2D;
begin
Seg1 := TCSeg2D.Create(Line1);
try
Seg2 := TCSeg2D.Create(Line2);
try
Result := Seg1.IntersectWith(Seg2, Pt1, Pt2, ATol);
finally
Seg2.Free;
end;
finally
Seg1.Free;
end;
end;
在我们的实现逻辑里面,可以看到seg1, seg2,除了参与了一下小小的计算,并没有其他的作用,本地创建,
本地释放了。我们把这些东西叫做临时对象。(他实际起到的作用也是一个临时存些数据)
但是,这里实际上有一份不小的开销,他必须在堆上创建两个实例出来,需要通过 _GetMem 来得到他所需要的内存。
这个开销实际上很大,尤其是对于经常调用的函数,简直是随着调用次数级数上升.
所以,我的想法是把这些内存的开辟,放到栈上面去完成。我试了一下, 这样的优化效率还是比较可观的。
比如有这么一个类
TSmall = class(TObject)
private
FContext: Integer;
public
constructor Create;
destructor Destroy; override;
procedure Release;
function sum(I, J: Integer): Integer;
end;
里面放了一个Integer, 模拟数据, 实际上就算包括需函数表, 理论上讲是没有问题的.
sum函数就是我们的业务函数.
根据他的InstanceSize 看到实际大小是 8个字节.
所以我又定义了一个 8字节的record. 作为栈上申请内存用。
TSmallRec = record
FContext: array [0..1] of Integer;
end;
需要注意的地方, 我们再也不能调用destroy函数了,因为它为会去做释放内存的操作,但是我们的内存将会创建在栈上,
所以函数会自己退栈。所以还是需要释放出了内存之外的其他资源,所以定义了一个release 函数。
///
现在看实际上的使用方式
这个是原来的用法
procedure TTestSmall.DoOld;
begin
with TSmall.Create() do
try
sum(1, 3)
finally
Free();
end;
end;
新的用法
procedure TTestSmall.DoNew;
var
rSmall: TSmallRec;
oSmall: TSmall;
begin
oSmall := TSmall(TSmall.InitInstance(@rSmall));
with oSmall.Create() do
try
sum(1, 3);
finally
cleanupinstance();
end;
end;
我对他们分别作了1000次的调用,结果是
old: time used(run 1000 times): 1092
new: time used(run 1000 times): 454
/
我做了一个测试,得出一个结论:
object越大,效果越好。
业务越简单,效果越好。
可以参考一下内存管理,我基本考虑的是buddy算法的机制。