CnPack Forum » 技术板块灌水区 » [原创] 可能你不知道的内存泄漏


2007-7-20 20:29 stanleyxu2005
[原创] 可能你不知道的内存泄漏

[b][color=#ff0000]原帖在这[/color][/b][url=http://blog.csdn.net/Stanley_Xu/archive/2007/07/20/1699834.aspx][b][color=#ff0000]http://blog.csdn.net/Stanley_Xu/archive/2007/07/20/1699834.aspx[/color][/b][/url]


[b]Delphi 是如何管理 string 的?[/b]
为了提高 string 的读写性能 Delphi 采用了 copy-on-write 机制进行内存管理。简单来说,在复制一个 string 时并不是真的在内存中把原来 string 的内容复制一份到另外一个地址,而是把新的 string 在内存映射表中指向同原 string 相同的位置,并且把那块内存的引用计数加一。这样就省去了复制字符串的时间。只有当 string 的内容发生变化的时候,才真正将改动的内容完整复制一份到新的地址,然后对原地址的引用计数减一,将新地址的引用计数设为一,最后将新 string 在内存映射表中指向这个新的位置。当某个字符串内存块的引用计数为零了,这块内存就可以被其它程序使用了。注意:所有常量 string 会在编译时率先分配内存,其引用计数不会在程序中变化,始终为-1。更详细的介绍,可以参考『Pascal 精要』和『标准C++类std::string的内存共享和Copy-On-Write技术』。


[b]内存泄漏的发现:[/b]
在检查内存泄漏时,无意发现了使用记录过程中产生的内存泄漏。请看如下代码:
type
  TMyRec = record
    S: string;
    I: Integer;
  end;

procedure Test;
var
  ARec: TMyRec;
begin
  FillChar(ARec, SizeOf(ARec), #0);
  ARec.S := 'abcd';
  ARec.I := 1234;
  // ...
  FillChar(ARec, SizeOf(ARec), #0); //<--- A leak!
  // ...
end;

FillChar 的作用是对一个内存块进行连续赋值,内存泄漏出现在第二次调用 FillChar 的时候。经过调试后发现:如果把记录中的 string 字段改成 Pchar??或者删除,就不再有内存泄漏了。


[b]原因分析:[/b]
我们现在先了解一下记录在内存中是如何分配的。记录是个不同数据类型的集合体。记录长度就是每个字段的内存长度之和。注意,该长度在编译之前就已经是确定的。因此那些长度不定的类型 (如 string、对象) 都是以指针形式出现在记录中。我的分析是:由于 FillChar 是低级内存读写操作,它仅仅把记录所占的内存块清掉,但没通知编译器更新字符串的引用计数,因而造成了泄漏。请看如下代码:

function StringStatus(const S: string): string;
begin
  Result :=
     Format('Addr: %p, RefCount: %d, Value: %s',
       [Pointer(S),
        PInteger(Integer(S) - 4)^, // length
        PInteger(Integer(S) - 8)^,
        S]);
end;

procedure BadExample1;
var
  S1: string;
  ARec: TMyRec;
begin
  S1 := Copy('string', 1, 6); // Force allocates memory for the string
  WriteLn(StringStatus(S1));
  ARec.S := S1;
  WriteLn(StringStatus(ARec.S));
  FillChar(ARec, SizeOf(ARec), #0);
  WriteLn(StringStatus(S1));
end;

[color=#0000ff]Addr: 00E249E8, RefCount: 1, Value: string // OK: Allocated as a new string
Addr: 00E249E8, RefCount: 2, Value: string // OK: RefCount increated
Addr: 00E249E8, RefCount: [color=#ff0000]2[/color], Value: string // [/color][color=#ff0000]!!! RefCount UN-changed!!!
[/color]
在执行 FillChar 之前,字符串 S1 的引用计数是2,但是执行 FillChar 之后并没有减1。这段代码验证了我的推测:FillChar 操作可能会破坏字符串的 Copy-On-Write 机制,使用的时候需要倍加小心!


[b]进一步分析:[/b]
文章开头我提到 “所有有常量 string 会在编译时率先分配内存,其引用计数不会在程序中变化,始终为-1。“ 那么如果我们让 S1 和 ARec.S 都赋值为一个常量字符串,那么照理说就不用管引用计数,也就没有泄漏问题了。请接着看下面这个例子:
procedure BadExample2;
var
  S1: string;
  ARec: TMyRec;
begin
  S1 := 'string'; // Assigns S1 to a const (compiler time allocated) string
  WriteLn(StringStatus(S1));
  ARec.S := S1;
  WriteLn(StringStatus(ARec.S));
  FillChar(ARec, SizeOf(ARec), #0);
  WriteLn(StringStatus(S1));
end;

[color=#0000ff]Addr: 0040CCBC, RefCount: -1, Value: string // OK: RefCount UN-changed
Addr: [color=#ff0000]00E24B08[/color], RefCount:??1, Value: string // [color=#ff0000]!!! Allocated as a new string !!!
[/color]Addr: 0040CCBC, RefCount: -1, Value: string // OK: RefCount UN-changed[/color]

结果是不是很吃惊,赋值 ARec.S 的时候,并没有直接将其指向常量字符串,而是重新分配了一个新的字符串。由此可见,记录在对字符串赋值上是有问题的!


[b]解决方法:[/b]
既然知道使用 FillChar 来初始化记录是不安全的,那么我们是不是要回到解放前,手动对记录进行初始化呢?也不用。Delphi 有个保留字 out。它和 var、const 一样,是用来修饰函数参数的。它和 var 的功能相似,不同是,它会对那些以指针形式传入的变量先进行引用计数清理。Delphi 的帮助中解释道:[color=#0000ff]An out parameter, like a variable parameter, is passed by reference. With an out parameter, however, the initial value of the referenced variable is discarded by the routine it is passed to. The out parameter is for output only; that is, it tells the function or procedure where to store output, but doesn't provide any input.[/color]
哈哈,这个不正是 FillChar 想要但又做不到的吗?于是我改造了一个 InitializeRecord 来初始化记录。

procedure InitializeRecord(out ARecord; count: Integer);
begin
  FillChar(ARecord, count, #0);
end;

仅仅是多了一层函数嵌套,内存泄漏问题就解决了。多亏了这个神奇的 out!
[color=#000000][size=10pt][size=10pt]我们来仔细看看加了[/size][size=10pt] out [/size][size=10pt]之后[/size][/size][size=10pt],编译器到底做了什么?[/size][/color]
[color=#000000][size=2][/size][size=10pt][/size][/color]
[size=9pt]mov  edx,[[/size][size=9pt]$[/size][size=9pt]0040[/size][size=9pt]c904]
mov  eax,ebx
call @FinalizeRecord[/size][color=#000000][font=Courier New][size=9pt]  [/size][/font][/color][size=9pt]//<----- cleanup[/size][size=9pt]
mov  edx,[/size][size=9pt]$[/size][size=9pt]0000000[/size][size=9pt]c
call InitializeRecord [/size]
[size=10pt][/size]
[color=#000000][size=10pt]关键就是第三行调用了 [/size][size=10pt]FinalizeRecord[/size][size=10pt]。这是[/size][size=10pt] System.pas [/size][size=10pt]中的一个汇编函数,作用就是对记录做一下清理工作。如果你想探个究竟,可以查看一下这个函数是如何实现的。这里就不作详解了。[/size][/color][size=10pt][/size]



[b]想法总结:[/b]
没想到一个偶然的发现,竟可以带出这么多问题,真是因祸得福。我总价一下几点想法
[list=1][*][size=10pt]FillChar [/size][size=10pt]是低级的内存读写,所以在使用之前你要非常清楚要打算干什么。[/size][*][size=10pt]在记录类型中慎用[/size][size=10pt] string [/size][size=10pt]和[/size][size=10pt] Widestring。如果记录的结构复杂,不妨尝试封装成类,类可以提供更丰富的特性,扩展性更佳。[/size][size=10pt]如果一定要定义带 string 的记录,最好注释一下,以免日后出错。(有时候的确是记录更方便和高效)[/size][*][size=10pt]活用 out 保留字可以解决[/size][size=10pt]接口类型和带[/size][size=10pt] string [/size][size=10pt]的记录类型的引用计数问题。[/size][/list]

[[i] 本帖最后由 stanleyxu2005 于 2007-8-2 12:16 编辑 [/i]]

2007-7-20 20:31 stanleyxu2005
刘总对前一个SafeFillChar的版本的补充:
[quote]拿生成的汇编对比一下更有说服力:

procedure SafeFillChar(var Value: TMyRec);
begin
FillChar(Value, SizeOf(Value), #0);
end;

它的汇编代码实际上为:
Unit1.pas.50: FillChar(Value, SizeOf(Value), #0);
0044C91C 33C9 xor ecx,ecx
0044C91E BA08000000 mov edx,$00000008
0044C923 E8CC61FBFF call @FillChar
Unit1.pas.51: end;
0044C928 C3 ret
比较简单,直接Call FillChar了。

而如果是out的话:
procedure SafeFillChar(out Value: TMyRec);
begin
FillChar(Value, SizeOf(Value), #0);
end;

生成的代码会复杂一点:
Unit1.pas.49: begin
0044C928 55 push ebp
0044C929 8BEC mov ebp,esp
0044C92B 53 push ebx
0044C92C 8BD8 mov ebx,eax
0044C92E 8BC3 mov eax,ebx
0044C930 8B156CC84400 mov edx,[$0044c86c]
0044C936 E8417EFBFF call @InitializeRecord
Unit1.pas.50: FillChar(Value, SizeOf(Value), #0);
0044C93B 8BC3 mov eax,ebx
0044C93D 33C9 xor ecx,ecx
0044C93F BA08000000 mov edx,$00000008
0044C944 E8AB61FBFF call @FillChar
Unit1.pas.51: end;
0044C949 5B pop ebx
0044C94A 5D pop ebp
0044C94B C3 ret

这里的关键就是有个call @InitializeRecord,把传入的记录中的字符串安全清空了。所以不会导致内存泄漏。

总结一句:out修饰符不光是忽视传入参数,还会手工先把传入参数清一把。[/quote]

2007-7-21 00:11 skyjacker
学习ing......

这个世界不缺泄漏,就缺去发现。
期待搂主 more... :lol

2007-7-21 10:45 jAmEs_
我的看法:
這樣的內存泄露說法好像說不過去,只能說record類型帶string本不應該采用FillChar來初始化資源~~。
就好像,無論你的程序多么嚴密,你通過非正常手段故意把內存地址的值改變為空,然后導致程序不再去釋放(假設判斷為空不是否),然后你說內存泄露了。。。
不過SafeFillChar做法倒是一個的技巧。

2007-7-21 21:42 Passion
本文说的正是非正常手段导致的内存泄漏。
这种非正常的手段在有些朋友写的程序中可能会出现。

2007-7-21 23:14 kendling
:lol 今天刚发现这个问题,谁知Passion告诉我这里已经有解决方法了,真好!

2007-7-21 23:23 kendling
再加一个,如果像源作者这样字义只可以初始化一种结构变量TMyRec。
procedure SafeFillChar(out Value: {FILLCHAR_ILLEGAL}TMyRec);
begin
  FillChar(Value, SizeOf(Value), #0);
end;

但是,如果我们像FillChar那样定义就可以初始化所有结构变量。:lol: 不错吧?
procedure SafeFillChar(out Value);
begin
  FillChar(Value, SizeOf(Value), #0);
end;

2007-7-21 23:26 Passion
对楼上的问题:
一、如果out Value是个无类型变量的话,sizeof(Value)会是真正传入的记录的size吗,还是只是一个指针的长度?
二、Delphi是否知道它是record而去调用@InitializeRecord?我觉得不会。

2007-7-21 23:50 kendling
我查过帮助,D本身的FillChar也是这样定义procedure FillChar(var X; Count: Integer; Value: Byte);

2007-7-22 00:26 Passion
FillChar是传入了var X,但它同时传了一个Count。
无类型变量var X作为参数传入是可以的,
但其实际长度如果不传入的话,在函数体内部是无法通过sizeof来得到的。

2007-7-22 09:40 kendling
再改一下:
procedure SafeFillChar(out X; Count: Integer; V: Byte);
begin
  FillChar(X, Count, V);
end;

2007-7-22 21:20 Passion
不对吧两位,楼主和小冬改的SafeFillChar是能完成FillChar的功能,
但没了“因为存在record而由Delphi预先调用InitializeRecord”的机制,
那便真的和FillChar一模一样了。

2007-7-22 21:53 Passion
我说的也不对。虽然没了“因为存在record而由Delphi预先调用InitializeRecord”的机制,但可能存在“因为存在out而由Delphi预先调用FinallizeRecord”的机制,也会自动清除引用计数导致一切正常。
这俩机制虽然不一样,但刚好都被我们碰上了。

2007-7-22 22:19 stanleyxu2005
[quote]原帖由 [i]Passion[/i] 于 2007-7-22 21:53 发表
我说的也不对。虽然没了“因为存在record而由Delphi预先调用InitializeRecord”的机制,但可能存在“因为存在out而由Delphi预先调用FinallizeRecord”的机制,也会自动清除引用计数导致一切正常。
这俩机制虽然不一样,但 ... [/quote]
弄的我虚惊一场。看了一下system.pas里面_InitializeRecord和_FinallizeRecord最终实现也差不多的。实质都是对记录的每个成员进行检查并更新引用计数。

2007-7-22 22:33 Passion
哈哈惭愧惭愧。
现在可以说能定论了。

2007-7-23 09:34 kendling
:lol

2007-8-13 14:13 niaoge
刘总:上面的汇编是从哪里找出来的,记事本打来Exe或dll看的?

2007-8-13 14:33 Passion
我是直接下断点运行,然后断时打开CPU窗口,用CW的复制汇编代码功能复制出来的。

2007-8-31 11:41 niaoge
刚才看dev 代码时,发现一个内存范围出错的地方,就是fillchar,
按照上面的改成var 可是boundschecker还是会报错,
死命地网上在搜了一个上午,找到与fillchar成对的还有一个[b]fillDword,[/b]是4字节一填充,
但是 delphi只有在grids.pas写了一个局部函数[b]fillDword,[/b]
由是再看了一下fastmm4.pas,没想到里面也有一个[b]fillDword[/b][b],[/b]
这两个函数都是用汇编代码,我看不懂,不过意思好像差不多,只能猜fastmm4内提供的会比delphi提供的快,
由是我用fastmm4内的[b]fillDword[/b]替换dev内那个出错fillchar,没想到这一替换boudschecker不报错了
真是奇怪,这么基本的函数delphi怎么不把它放到systems.pas内呢?

2007-10-10 05:12 AdamWu
There is no need to use FillChar to initialize that record.
I think Delphi compiler checks each record field types, and will automatically initialize and finalize any record contains string, object pointer, and interface.

2007-10-29 17:43 stanleyxu2005
[quote]原帖由 [i]AdamWu[/i] 于 2007-10-10 05:12 发表
There is no need to use FillChar to initialize that record.
I think Delphi compiler checks each record field types, and will automatically initialize and finalize any record contains string, object po ... [/quote]
特殊时候需要,比如记录是当作参数传入的.

页: [1]


Powered by Discuz! Archiver 5.0.0  © 2001-2006 Comsenz Inc.