说明:下面的内容仅仅属于个人观点。
对Object Pascal编译器给类对象分配堆内存细节的一种大胆猜
mahongxi (烤鸡翅膀)(色摸)
对Object Pascal编译器给类对象分配堆内存细节的一种大胆猜
CSDN烤鸡翅膀
读过我以前写的文章的网友,都知道我是一个喜欢"刨根问底"、"死钻牛角尖"的家伙。最近由于工作需要转学DELPHI,在接触Object Pascal之后,果然领会到了它的整洁和优美,怪不得连《程序设计语言:设计与实现》一书的作者也称赞pascal是"一种极优美的语言"。但在学习过程中遇到了好多问题,特别是对于像我这样由C++转至OP[对Object Pascal的简称]学习的人,由于两种语言风格不同,问号就会更多了。其中,OP和C++语言的一个很大的区别就是:类对象[或称之为类实例]的内存分配机制不同。其中有两方面要说:
一、什么时候分配?
在C++中,定义了对象,那么马上分配其内存,之后调用其构造函数,这个内存可能在堆中,也可能在栈内,也可能在全程数据区内。但OP却截然不同,定义一对象,如:obj : TObject;只是为其分配了4字节的一个指针空间,而真正的对象空间还没有分配,那怎么用?在使用前当然要给对象分配空间,不然就会造成访问内存出错,给对象分配空间的办法也很简单:
obj := Tobject.Create;
就OK,这个对象空间是分配在堆内的,大家知道,栈内空间可以在使用期过后自动回收,但堆内存需要程序员自己管理,所以在使用完类对象之后,别忘了 obj.Free [真正实现析构的是obj.Destroy,但obj.free是一种更安全的方式]。
"什么时候分配"这个问题在OP和C++上的答案确实不同,但还不至于让我"疑惑"。知道了OP类对象是通过调用这样的语句(构造函数):obj := Tobject.Create;来得到堆内存的,但在这个处理细节上,编译器在内部是如何实现分配堆内存的呢?
请看下一个问题:
二、OP编译器是如何分配的内存?
首先要感谢Lippman的《Inside C++ Ojbect Model》,这是一本不可多得的好书,她告诉了你对于C++编译器实现的一些你最迷惑、也是最想关心的细节,但不知DELPHI业界内有没有这样一本书,可以让我清楚的了解到OP编译器具体[具体到每个细节]是如何给一个类对象分配堆内存的 [如果有这样的书,您一定要通知我:coder@vip.sina.com] ?
我大胆的做了猜测!
一些小动作都是在Tobject类内部事先已经定义好的!下面让我们来关注一下这几个Tobject类方法(Tobject定义于System.pas):
TObject = class
……
constructor Create;
procedure Free;
class function InitInstance(Instance: Pointer): TObject;
procedure CleanupInstance;
class function InstanceSize: Longint;
class function NewInstance: TObject; virtual;
procedure FreeInstance; virtual;
destructor Destroy; virtual;
end;
从方法的名称上我们能隐约的感觉到:NewInstance和FreeInstance肯定和类对象的构造和析构有些关联!
先来分析一下NewInstance:
class function TObject.NewInstance: TObject;
begin
Result := InitInstance(_GetMem(InstanceSize));
end;
只有一句代码,但却调用了三个其它方法:
1、
class function TObject.InstanceSize: Longint;
begin
Result := PInteger(Integer(Self) + vmtInstanceSize)^;
end;
这个方法是OP类实现RTTI的一个重要方法,它能返回类对象所需要占用堆内存的大小,注意它并非是类对象所占有内存大小,因为类对象是一指针,那么在32位环境下,指针永远是4字节!
大家可能对这句代码比较疑惑Result := PInteger(Integer(Self) + vmtInstanceSize)^;下面我定义一个OP类:
TBase = class(TObject)
x : Integer;
y : Double;
constructor Create;
end;
然后分配内存:
b : Tbase ;
b := TBase.Create;
我设想分配后的内存布局应是这样的[按C++对象的内存考虑联想的]:
[此处为图片,显示不出,请参考我的专栏的文章]
再来看这句:Result := PInteger(Integer(Self) + vmtInstanceSize)^;它的目的是取到VMT中Index = -40[注意:常量vmtInstanceSize = -40]的格子中的内容。大家看这里的
Self变量是什么值呢?是b的值也就是VPTR的ADDRESS吗?绝对不是!因为程序在执行到TObject.InstanceSize时只是想通过调用它知道得划分多少堆内存,但还没有正式分配堆内存,也就是说,VPTR、X、Y还不存在[但VMT是和类一同建立起来的,它包含了和类有关的一些信息,如类实例需要请求的堆内存的大小等等],当然这个Self也就不能是b的值了,我猜测它的内容是VMT中index = 0的格子的Address,只有这样,这里的代码和下面要讲的代码才能被正常解释,但,Self是怎么被Assigned为这个值的,我想是编译器所做的处理吧。
这样,Result := PInteger(Integer(Self) + vmtInstanceSize)^自然得到了类对象所需要堆内存大小的信息!
为了证明我上面的猜测是正确的,大家可以实验以下代码:
var
b :Tbase;
size_b : Integer;
begin
b := TBase.Create;
ShowMessage(Format('InitanceSize of TBase : %d',[b.InstanceSize]));
size_b := PInteger(PInteger(b)^ - 40)^;
ShowMessage(Format('InitanceSize of TBase : %d',[size_b]));
……
end;
大家可以看到,两种方法得到的是同一个值!
好,现在我们回过头来讲解TObject.NewInstance中要调用的第二个函数。
2、function _GetMem(Size: Integer): Pointer;
它在System.pas 中的定义如下:
function _GetMem(Size: Integer): Pointer;
{$IF Defined(DEBUG) and Defined(LINUX)}
var
Signature: PLongInt;
{$IFEND}
begin
if Size > 0 then
begin
{$IF Defined(DEBUG) and Defined(LINUX)}
Signature := PLongInt(MemoryManager.GetMem(Size + 4));
if Signature = nil then
Error(reOutOfMemory);
Signature^ := 0;
Result := Pointer(LongInt(Signature) + 4);
{$ELSE}
Result := MemoryManager.GetMem(Size);
if Result = nil then
Error(reOutOfMemory);
{$IFEND}
end
else
Result := nil;
end;
具体代码就不分析了,但我们终于看到了OP中分配堆内存的具体函数,原来是OP是通过一个内存管理器MemoryManager来管理类对象所取得的堆内存空间的!
TObject.NewInstance中第三个调用的方法:
3、
class function TObject.InitInstance(Instance: Pointer): TObject;
{$IFDEF PUREPASCAL}
var
IntfTable: PInterfaceTable;
ClassPtr: TClass;
I: Integer;
begin
FillChar(Instance^, InstanceSize, 0);
PInteger(Instance)^ := Integer(Self);
ClassPtr := Self;
while ClassPtr <> nil do
begin
IntfTable := ClassPtr.GetInterfaceTable;
if IntfTable <> nil then
for I := 0 to IntfTable.EntryCount-1 do
with IntfTable.Entries[I] do
begin
if VTable <> nil then
PInteger(@PChar(Instance)[IOffset])^ := Integer(VTable);
end;
ClassPtr := ClassPtr.ClassParent;
end;
Result := Instance;
end;
{$ELSE}
asm
PUSH EBX
PUSH ESI
PUSH EDI
MOV EBX,EAX
MOV EDI,EDX
STOSD
MOV ECX,[EBX].vmtInstanceSize
XOR EAX,EAX
PUSH ECX
SHR ECX,2
DEC ECX
REP STOSD
POP ECX
AND ECX,3
REP STOSB
MOV EAX,EDX
MOV EDX,ESP
@@0: MOV ECX,[EBX].vmtIntfTable
TEST ECX,ECX
JE @@1
PUSH ECX
@@1: MOV EBX,[EBX].vmtParent
TEST EBX,EBX
JE @@2
MOV EBX,[EBX]
JMP @@0
@@2: CMP ESP,EDX
JE @@5
@@3: POP EBX
MOV ECX,[EBX].TInterfaceTable.EntryCount
ADD EBX,4
@@4: MOV ESI,[EBX].TInterfaceEntry.VTable
TEST ESI,ESI
JE @@4a
MOV EDI,[EBX].TInterfaceEntry.IOffset
MOV [EAX+EDI],ESI
@@4a: ADD EBX,TYPE TInterfaceEntry
DEC ECX
JNE @@4
CMP ESP,EDX
JNE @@3
@@5: POP EDI
POP ESI
POP EBX
end;
{$ENDIF}
刚才知道_GetMem已经得到了堆内存空间,而我们现在要讨论的这个方法是进行一些必须的初始化。其它代码不管,只看这两句:
FillChar(Instance^, InstanceSize, 0);
PInteger(Instance)^ := Integer(Self);
第一就是给类对象清零,现在我们知道为什么OP的类实例的字段会自动被初始化为零了吧[String就为空,指针就为nil]!
第二条语句,是让VTPR指针指向VMT表的0号格子[读者请参考结构图自行分析,此处也证明上面我对Self值的猜测的正确性]。
到了这里,你也许会说,说了半天,都是猜测,或许,OP编译器根本就不会调用那个TObject.NewInstance方法呢!
问得好,再做实验!
还是以上面的那个Tbase类为例,重载TObject.NewInstance方法,如下:
TBase = class(TObject)
x : Integer;
y : Double;
class function NewInstance: TObject; override;
procedure FreeInstance; override;
constructor Create;
end;
{实现}
constructor TBase.Create;
begin
self.x := 2;
self.y := 3.14;
end;
procedure TBase.FreeInstance;
begin
inherited;
ShowMessage(Format('Call %s.FreeInstance!!!',[self.ClassName]));
end;
class function TBase.NewInstance: TObject;
begin
ShowMessage(Format('call %s.NewInstance',[self.ClassName]));
result := inherited NewInstance;
end;
之后进行简单的声明对象:
var
b : Tbase;
begin
b := Tbase.Create; ß在这里设断点!
b.Free;
end;
通过对代码进行跟踪果然在一进入Create就马上调用NewInstance方法。
[说明:一定要重载它才能跟踪到它,在断点处,观察CPU,从反汇编后的代码中可以发现,是先调用一个_ClassCreate,然后才调用NewInstance]
用同样的方法可以分析出b.Free会最终调用到FreeInstance;来释放对象。
我想基本上大的问题已经说请了,Object Pascal为了实现分配堆内存,在你调用构造器的时候:
b := Tbase.Create;
在构造方法内你的代码前,安插了代码调用NewInstance方法,析构时,则在析构函数中你的代码后,调用FreeInstance函数。
那么,现在再来看这种情况:派生
TBase = class(TObject)
x : Integer;
y : Double;
class function NewInstance: TObject; override;
procedure FreeInstance; override;
constructor Create;
end;
TSub = class (TBase)
m : Integer;
n : Double;
constructor Create;
end;
{实现}
constructor TBase.Create;
begin
self.x := 2;
self.y := 3.14;
end;
procedure TBase.FreeInstance;
begin
inherited;
ShowMessage(Format('Call %s.FreeInstance!!!',[self.ClassName]));
end;
class function TBase.NewInstance: TObject;
begin
ShowMessage(Format('call %s.NewInstance',[self.ClassName]));
result := inherited NewInstance;
end;
{ TSub }
constructor TSub.Create;
begin
inherited Create; ß注意这里!
self.m := 4;
self.n := 12.32;
end;
我们已经知道,
var
s : Tsub;
s := Tsub.Create;
时,在进入Tsub.Create内部马上得到了它想要的内存[这里是32字节],那么当:
inherited Create;时,在Tbase.Create内部,还有内存分配的动作吗?我们可以通过三点证明:这里,Tbase.Create只是完成程序员给出的初始化代码,没有进行内存分配的动作。
第一点,ReturnValue := inherited Create;所得到的返回地址和调用Tsub.Create所得到的返回地址相同。
第二点,如果在Tbase.Create内部又分配新的内存,那么
self.x := 2;
self.y := 3.14;
只是针对新的内存操作,而原来的S对象中从TBASE中继承来的X,Y不会变,还是0,但我们发现,S中的X,Y已经改变,所以也可以证明Tbase.Create没有分配新的内存,只是对原有内存中的X,Y进行设置。
第三点,跟踪。这是最简单,最一目了然的方法,看看inherited Create;到底有没有调用NewInstance,实验证明,跟本没有调用。
但是,如果把Tsub.Create中的inherited Create;改为Tbase.Create;情况则大不同了,用上面三种方式发现,它又分配了新的堆内存,这样不但没有达到程序员初始化数据的目的,反而造成了内存泄漏,而这样的BUG是很难找到的。
也就是说,编译器发现如果是通过类来调用构造函数,就会当成是新的类对象进行构造、分配堆内存,如果是在构造器内部inherited Create;只是按常规的处理 类方法 的方式进行处理。我想,对于Anders Hejlsberg[DELPHI设计者],想在编译器中实现这样的功能并非一件难事[实际上,我们通过查看汇编代码也能分析出个中原由,有兴趣者请注意其中的TEST d1,d1指令和其下的跳转指令]。
PS:刚才被网友告知有本书叫《delphi的原子世界》,我很想得到它,如果您手上有它的E-BOOK版,希望您能发给我: coder@vip.sina.com
Constructor是一个类函数,也就是说,在调用之前他已经存在了,Delphi中全部的用到的类的定义都会存放在内存中,Create的时候分配实例内存,分配的内存大小,类定义的位置-40字节的地方,是一个整数类型,调用Create的时候编译器自动的调用了Object的分配内存的类方法,如果不依赖通过调用类的Create方法构造实例,那么可以通过NewInstance的类方法分配内存,然后调用实例的Create来实现初始化。
对于一个类方法而言,Self隐含参数指出的是类定义的基地址,但是是有变化的,如果通过类来调用类方法,Self指出的是类定义的基地址,但是通过实例调用类方法,那么Self指出的是实例的基地址。
通过以上讨论,可以看出,在一个Create当中不使用inherrited Create是而通过Base类不符合构造逻辑的,因为,Base的构造的含义是重新生成一个Base类实例。实际上,在调用类的Constructor的时候已经为类的实例分配好了内存,inherrited Create的含义不是分配内存,而是让基类有机会初始化他的内部变量,你不掉用,符合你需要的类的大小的内存也已经分配了。TComponent就没有调用inherrited Create,因为他的祖先类根本不需要初始化私有变量,就是说没有需要分配内存的私有变量。在Constructor当中的self指出的是类的实例的基地址,不是类定义的基地址,因为编译器生成了创建实例,然后再调用实例的Create的代码。
调用类的Constructor编译器生成的代码,还包括一个try except的外壳,如果新的实例在他的Create当中引发了异常,那么新的实例内存就会被回收,没有任何内存会被分配。
其实说穿了,你把Tsub.Create中的inherited Create改为Tbase.Create,并不是在"重载构造函数"而是"在类中创建类",请看:
constructor TSub.Create;
begin
Tbase.Create; // 注意这里!在类的构造函数里继续创建==非法的对象引用
self.m := 4;
self.n := 12.32;
end;
这样就很容易理解了。
在DELPHI中:
'类引用'会调用类的NewInstance方法以获得类实例
'对象引用'不会调用类的NewInstance方法
总结:
先引用一段"DELPHI技术手册"上的一段话:
"构造函数是一种特殊的方法。如果通过类的引用调用一个构造函数,DELPHI会创建该类的一个实例,初始化实例,并适当调用构造函数。如果通过对象的引用来调用构造函数,DELPHI会把构造函数当作一个普通的函数来调用。
DELPHI规则使得构造函数可以调用同一个类的构造函数或者一个继承下来的构造函数。这个调用使用一个对象引用(即SELFT),所以构造函数就被当作一个普通的方法来调用。
通过类的引用来调用构造函数时,DELPHI将调用NewInstance方法创建实例。TObject.NewInstance给新对象要配内存并把这些内存全部置零。"
inherited关键字的意义:'派生类中的方法可以通过在方法调用前面使用inherited关键字代替对象引用来调用它的基类的一个方法。'
constructor TSub.Create;
begin
Tbase.Create; //这里显然是一个类引用,但是它是一个没有分配引用指针的类引用,会引起内存泄漏,那么它为什么会分配堆内存呢,因为'通过类的引用来调用构造函数时,DELPHI将调用NewInstance方法创建实例。TObject.NewInstance给新对象要配内存并把这些内存全部置零。'
self.m := 4;
self.n := 12.32;
end;
constructor TSub.Create;
begin
inherited Create; //inherited关键字是代替对象引用来调用它的基类的一个方法,那么它是一个对象引用,DELPHI的VCL后台程序规则,也就是DELPHI的VCL程序执行规则决定了即然是对象引用,那么'如果通过对象的引用来调用构造函数,DELPHI会把构造函数当作一个普通的函数来调用。'
self.m := 4;
self.n := 12.32;
end;
木石三,纠正你上面的一段话。
作为一个进程而言,不管它退出的时候是否释放了他申请的内存,windows都会无条件的收回,所以你关于进程在退出前回收内存的说法是不恰当的,通常,进程退出就直接完成了,不会去回收内存。你所说的情况,只会是在Windows95以前的版本中会出现,在进程共享堆的时候,他有义务回收,但是在win98以后,堆不再是共享的,而是进程隔离的,这时候进程没有义务回收。同样的情况适用于进程使用的系统对象,进程退出就会释放对象句柄,不过,对象句柄存在引用技术,进程间共享的对象,在引用计数达到0才释放,这种情况适合进程间共享内存,不过不管是否释放,进程退出的时候windows都会清理他用过的对象,引用计数减一。