现在的位置: 首页 > 综合 > 正文

谈Delphi中LocalObject内存分配的优化

2013年01月06日 ⁄ 综合 ⁄ 共 6124字 ⁄ 字号 评论关闭
    谈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算法的机制。

抱歉!评论已关闭.