一个镜像已解析 PDF 文件的 Delphi 对象模型,通常长成一份包含每个对象的平铺列表、外加挂在列表上的指针图。遍历那份列表、对每个条目调 Free,程序会中途死在一个析构函数内部的访问违例上——死在一个早先的 Free 已经通过另一个父对象释放过的对象上。把循环换成 List.Clear,崩溃消失,但进程从此每次重载长大约一个文档。两个症状一个根源:列表是平的,所有权是图。能用的拆卸方案是:判定哪些指针拥有自己的目标,把那个闭包收集一次,剪断每条拥有边,然后才把每个节点释放、恰好一次
为什么对每个注册过的对象都 Free 会崩?
HotPDF 里那份平铺列表是文档的对象注册表,而它装着的类对谁销毁什么意见不一。每个析构函数按自己的立场看都合理。把它们放到一个装着同一条所有权链上好几个层级条目的注册表上,逐条目 Free 会把一部分内存释放两次、另一部分永远不释放
HPDFObjs.pas 和 HPDFDoc.pas 里的三个析构函数构成了这个局面。THPDFDictionaryObject.Destroy 遍历自己的 Items,只在 IsIndirect 为 False 时释放值,它的假设是间接子对象归注册表所有。THPDFArrayObject.Destroy 释放它持有的每一项,间接与否都放。THPDFIndirectObject——从对象流(ISO 32000-1 §7.5.7)里取出的对象所用的包装——释放它的 InternalObject body。现在设想一个注册表装着一个间接字典、一个在某个槽里列着同一字典的数组、以及一个 body 也被单独注册成根的 wrapper。先释放数组,字典在注册表走到它之前就没了。释放 wrapper 再释放 body,第二次调用会在一块已不属于任何人的内存上跑析构函数。只释放字典,它跳过的每个间接子对象都留到进程退出
重排注册表救不了它。无论选什么顺序,总有某对条目落在一条拥有边的错误一侧,因为列表没法表达「条目 12 拥有条目 40」
PDF 对象图里的拥有边与引用
拥有边是一个指针,它的目标由指向方负责销毁;其余一切是引用,拆卸只沿第一种走。HotPDF 里恰好有四种拥有边:THPDFDictionaryObject 的 Items、THPDFArrayObject 的 Items、THPDFIndirectObject 背后的 InternalObject,以及 THPDFStreamObject 的两个字段——它的 Dictionary 和它的 Stream 载荷
引用同样值得小心,因为沿引用走会把图遍历变成死循环或读取已释放内存。THPDFLink 持有一个对象号和 generation,正是 ISO 32000-1 §7.3.10 的间接引用:一个指向存放在文件别处的对象的名字。经注册表解析那个号,落点是一个别的边已经拥有的节点,所以拆卸完全不解引用链接。字典和数组保留的 FParent 反向指针朝相反方向跑。父对象已经拥有子对象,爬那个指针只会重访遍历已经走过的节点。两种在整个拆卸期间都不碰
收集、断开、释放:三趟完成的拆卸
HotPDF 在受保护的 THotPDF.CloseIndirectObjects 方法里用三趟释放文档对象,趟序就是全部窍门。第一趟收集。工作列表从每个注册表条目起步,对每个节点,遍历追加它各条拥有边的目标,跳过已经见过的东西。seen 集合是一个开放寻址的原始指针数组,线性探测、装到一半时翻倍,所以没有任何东西按节点分配内存——文档带着几十万个对象时这一点开始要紧。流载荷进第二个列表,因为它们是 TStream 派生类而非 THPDFObject 节点
下面两段代码是组件内部实现的简化摘录,用库自己的对象类型展示这几趟。应用代码从不调用其中任何东西;LoadFromFile、LoadFromStream 和析构函数替你跑它。摘录里地址哈希被缩成一个移位加掩码,seen 表装满时翻倍的那一步也省去了
// 内部拆卸的简化摘录,不是可调用的 API
procedure Collect(Value: TObject; Payload: boolean);
var
Slot: Integer;
begin
if Value = nil then Exit;
// (真实例程在表装到一半时翻倍并重新哈希)
Slot := Integer(NativeUInt(Value) shr 4) and (Length(Seen) - 1);
while Seen[Slot] <> nil do
begin
if Seen[Slot] = Pointer(Value) then Exit; // 已经收集过
Slot := (Slot + 1) and (Length(Seen) - 1);
end;
Seen[Slot] := Pointer(Value);
Inc(SeenCount);
if Payload then Streams.Add(Value) else Nodes.Add(Value);
end;
// 第一趟:用注册表播种,然后只沿拥有边走
for I := 0 to IndirectObjects.Count - 1 do
Collect(TObject(IndirectObjects[I]), False);
I := 0;
while I < Nodes.Count do
begin
Obj := THPDFObject(Nodes[I]);
if Obj is THPDFIndirectObject then
Collect(THPDFIndirectObject(Obj).InternalObject, False)
else if Obj is THPDFStreamObject then
begin
Collect(THPDFStreamObject(Obj).Dictionary, False);
Collect(THPDFStreamObject(Obj).Stream, True);
end
else if Obj is THPDFDictionaryObject then
for J := 0 to THPDFDictionaryObject(Obj).Items.Count - 1 do
Collect(PHPDFDictionaryItem(THPDFDictionaryObject(Obj).Items[J])^.Value, False)
else if Obj is THPDFArrayObject then
for J := 0 to THPDFArrayObject(Obj).Items.Count - 1 do
Collect(TObject(THPDFArrayObject(Obj).Items[J]), False);
// THPDFLink 的值和 FParent 指针从不被跟随
Inc(I);
end;
第二趟剪断。在任何析构函数运行之前,每条拥有边都被设为 nil。wrapper 得到 MarkAsFreed,它清掉 InternalObject 并置上析构函数首先检查的 IsFreed 标志。流对象的 Dictionary 和 Stream 被赋 nil,每个字典项的 Value 被清掉,每个数组槽被覆写为 nil。第三趟清空注册表,释放 Nodes 里的每个节点,然后释放 Streams 里的每份载荷
// 内部拆卸的简化摘录,续;不是可调用的 API
// 第二趟:在释放任何东西之前断开每条拥有边
for I := 0 to Nodes.Count - 1 do
begin
Obj := THPDFObject(Nodes[I]);
if Obj is THPDFIndirectObject then
THPDFIndirectObject(Obj).MarkAsFreed
else if Obj is THPDFStreamObject then
begin
THPDFStreamObject(Obj).Dictionary := nil;
THPDFStreamObject(Obj).Stream := nil;
end
else if Obj is THPDFDictionaryObject then
for J := 0 to THPDFDictionaryObject(Obj).Items.Count - 1 do
PHPDFDictionaryItem(THPDFDictionaryObject(Obj).Items[J])^.Value := nil
else if Obj is THPDFArrayObject then
for J := 0 to THPDFArrayObject(Obj).Items.Count - 1 do
THPDFArrayObject(Obj).Items[J] := nil;
end;
// 第三趟:每个唯一节点与载荷各释放一次
IndirectObjects.Clear;
for I := 0 to Nodes.Count - 1 do TObject(Nodes[I]).Free;
for I := 0 to Streams.Count - 1 do TObject(Streams[I]).Free;
FreeAndNil(IndirectObjects);
剪断让既有析构函数得以原封不动。边没了,每个析构函数找不到可递归进去的东西,只销毁它自己。通往同一结果的另一条路是教会每个析构函数认识注册表,让字典、数组和包装各自询问是否别处有人会释放它们的子对象。那会把图知识摊到四个类和之后新增的每个类上,任何一处答错,double free 就回来。把整个所有权模型保存在一个拆卸例程里是更稳的安排
把别扭的情况挨个过一遍,没有一个需要特殊分支。被两个流对象共享的字典被收集一次、从两边断开、释放一次。数组列着自己父字典的环会终止,因为 seen 集合拒绝第二次访问。wrapper 和它的 body 作为两个根注册,是集合里两个不同的指针,所以两个都被释放,而 wrapper 的析构函数放着 body 不管,因为 MarkAsFreed 已经摘掉了那条边。被赋成两个流对象载荷的一个 TMemoryStream,在 Streams 里恰好出现一次
图被拆掉之前必须先停下什么?
任何从图里借用对象的东西都得先停。HotPDF 能经 PrefetchLoadedPages 在工作线程上渲染页面,并缓存由文档对象编译出的位图和显示列表;活得比图久的 worker 或缓存条目会读到已释放内存。所以拆卸以 CancelLoadedPagePrefetch 开场,并在碰注册表之前让渲染页面缓存失效。LoadFromFile、LoadFromStream 和 THotPDF 析构函数都经过它,所以无论替换文档还是销毁组件,顺序都一样,一个 THotPDF 跨文档复用的规则靠的就是这个
通知契约同样要紧。InvalidateRenderedPageCache 正是那个以 PageIndex = -1 触发 OnLoadedDocumentModified 的方法,而重载文件的查看器没有理由收到旧文档内部清理的编辑通知。拆卸内部把处理器保存下来、在那次调用前后置 nil、在 finally 里恢复。重载仍会产生一次 PageIndex = -1 调用——由加载本身在为新文档重置缓存时触发——那是查看器该重绘的那次。析构函数里,图关闭时缓存已经销毁,所以拆卸在那里完全跳过失效
procedure TViewerForm.FormCreate(Sender: TObject);
begin
FPdf := THotPDF.Create(Self);
FPdf.OnLoadedDocumentModified := PdfModified;
end;
procedure TViewerForm.PdfModified(Sender: TObject; PageIndex: Integer);
begin
if PageIndex = -1 then
RepaintAllPages // 你自己的查看器方法
else
RepaintPage(PageIndex); // 你自己的查看器方法
end;
procedure TViewerForm.OpenDocument(const FileName: string);
begin
// LoadFromFile 在解析之前取消进行中的 prefetch、
// 释放上一个文档的对象;PdfModified 看到一次 -1 调用
FPdf.LoadFromFile(FileName);
FPdf.PrefetchLoadedPages('1-10', 150);
end;
OpenDocument 里不需要显式调 CancelLoadedPagePrefetch,加载自己会做。当你准备在 prefetch 可能还在跑时修改已加载的文档,自己调它仍然是对的;HPDFDoc.pas 里的声明注释要求的正是这个。更大规模的 worker 池渲染,并行渲染管线一文讲了后台工作如何被节流
怎么区分泄漏与分配器保留?
盯着内存管理器的活分配计数跨多次加载的变化,无视进程工作集。Delphi 内存管理器把释放的内存留着复用,所以关闭文档后停在高占用的进程未必泄漏了什么;活的大块计数每次重载都爬升的进程才是。下面的循环在一个实例上把同一份文件加载二十次,每趟之后打印大块数据
procedure ReportLargeBlocks(const Tag: string);
var
State: TMemoryManagerState;
begin
GetMemoryManagerState(State);
Writeln(Tag, ': ', State.AllocatedLargeBlockCount, ' large blocks, ',
State.TotalAllocatedLargeBlockSize, ' bytes');
end;
var
Pdf: THotPDF;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
for I := 1 to 20 do
begin
Pdf.LoadFromFile('sample.pdf');
ReportLargeBlocks('pass ' + IntToStr(I));
end;
finally
Pdf.Free;
end;
ReportLargeBlocks('after Free');
end.
把输出当形状读。第一趟之后稳定的数字是保留或普通的每文档状态。每趟按相同步长上升的数字是泄漏,对 PDF 来说步长通常是较大的流载荷——页面内容和嵌入图像——的大小,因为只有大到进大块统计的分配才算数。用一份十几页的文件,让步长在噪声之上清晰可见。与页数成正比的增长是分配器保留解释不了的。要找文档打开期间哪些对象占着内存,带保留字节数的对象依赖图回答问题的那一侧
在你自己的 Delphi 对象模型里复用这个模式
这里没有任何东西是 PDF 专属的。任何析构函数对子对象所有权不一致、一个子对象能从多个父对象到达、或反向指针与前向指针并存的 Delphi 对象模型,在逐对象 Free 下都会崩或泄漏。修法每次都是同一个形状:把每个指针字段归类为拥有或引用,经一个容忍重访的指针集收集拥有边的闭包,剪断每条边,销毁平铺列表。剪边这一步是人们会跳过的那步。跳过它,每个析构函数最终都得认识其他每一个
照抄之前有几个值得写下来的边界。指针集用对象地址作身份,这只在没有析构函数于收集期间运行时才成立,所以不会有地址在遍历中途被释放又交给新分配。遍历只看它为之编写的那些边;经其他字段拥有子对象的新类必须同时加进收集与断开两趟。而因为链接是解析而不是跟随,一个只被链接引用的对象必须在注册表里才够得着。HotPDF 解析器注册它读到的每个对象,你自己代码里手工搭的图必须守住同一条规则
测试它靠数析构函数调用而不是数字节。手工搭出那张病态图:被两个流共享的字典、一个既装着那个字典又装着自己父对象的数组、一份赋给两个流的载荷、一个注册两次的根、一个数值 body 也被注册的 wrapper。拆掉它,期望每个唯一对象恰好一次销毁——一份载荷、两个流、两个字典、一个数组、一个 wrapper、一个数。你自己的类里,在每个析构函数里自增一个类级计数器就够了(你自己加的插桩钩子),而只清列表的拆卸会报零
HotPDF 里这一切都藏在 LoadFromFile、LoadFromStream 和析构函数背后,应用代码只见结果:关闭或重载文档会归还它的内存。HotPDF 是面向 Delphi 和 C++Builder 的原生 VCL PDF 库、带完整源码,所以本文描述的拆卸可以在 HPDFDoc.pas 里逐行读;API 参考和试用版在 HotPDF PDF component for Delphi 页面