HotPDF Delphi Component 在文档关闭或重新加载时会释放该文档拥有的每一个 PDF 对象:THotPDF.CloseIndirectObjects 遍历对象注册表,把每条拥有边收进一个指针集合,断开所有这些边,然后才把每个唯一节点和每份流载荷各释放一次。正是这个三阶段顺序,让共享的子对象、所有权环、重复注册以及 wrapper 与 body 互为别名这些情况都能安然拆掉,既不 double free 也不留下任何东西。在 v2.752.4 之前,同一个例程做的事简单得多、也糟糕得多:它释放惰性文件流源,对 IndirectObjects 列表调用 Clear,释放列表容器,然后把每一个实际的 PDF 对象留给进程退出去回收。当时那段代码里的注释对此也很诚实。逐个释放这些对象会导致 access violation,所以「安全的做法」就是干脆不释放。本文要讲的是为什么逐个释放真的会崩,以及在一门手工管理内存的语言里,一个真正能用的拆卸该长什么样
为什么不能对每个注册过的对象直接 Free?
因为这些对象类的析构函数对谁拥有谁这件事意见不一致,而注册表里同时装着同一条所有权链上好几个层级的条目。于是遍历列表并对每个条目调用 Free,会视哪些类恰好挨在一起而把一部分内存释放两次、另一部分永远不释放
HPDFObjs.pas 和 HPDFDoc.pas 里的三个不对称造成了这个问题。THPDFDictionaryObject.Destroy 遍历自己的 Items,只在 IsIndirect 为 False 时释放值,它的假设是间接子对象归注册表所有、会在那里释放。THPDFArrayObject.Destroy 不做这个区分,释放它持有的每一项。而 THPDFIndirectObject.Destroy——那个携带对象号的 wrapper——会释放它的 InternalObject body。现在设想一个注册表里装着一个间接字典、一个在某个槽里列着同一个字典的数组,以及一个 body 也被单独注册成根的 wrapper,而解析器在真实文件上产出的正是这种局面。先释放数组,字典在注册表走到它之前就没了。释放 wrapper 和 body,不管哪个在前,第二次调用都会在一个悬空指针上跑析构函数。只释放字典,它跳过的任何间接子对象就永远留着。注册表的任何顺序都修不好这一点,因为注册表是一个平铺列表,而所有权关系是一张图,唯一的出路就是去推理这张图
在 PDF 对象图里什么才算一条拥有边?
拥有边是一个指针,它的目标由指向方负责销毁;引用是别的一切,而拆卸必须沿着前一种走、忽略后一种。在 HotPDF 里这正好给出四种边:THPDFDictionaryObject 的 Items、THPDFArrayObject 的 Items、THPDFIndirectObject 背后的 InternalObject,以及 THPDFStreamObject 的两半,它的 Dictionary 和它的 Stream 载荷。引用的那几类同样要紧,因为沿着它们走会把一次图遍历变成死循环或者 use-after-free。一个 THPDFLink 持有一个对象号和 generation,这正是 ISO 32000-1 §7.3.10 对间接引用的定义:一个住在别处的对象的名字,而不是对象本身。通过注册表解析那个号,得到的节点是别的边已经拥有过的,所以 CloseIndirectObjects 根本不解引用链接。字典和数组保留的 FParent 反向指针在另一个方向上讲的是同一个故事;父对象已经拥有子对象,沿指针向上只会重访一个已经走过的节点。两者都不碰,源码里的注释用一行说明了这一点:链接和父指针是引用,不是拥有边
三阶段拆卸是怎么运作的?
第一阶段是广度优先收集。例程用 IndirectObjects 的每个条目播种一个工作列表,然后对每个节点把它各条拥有边的目标追加进去,遇到已经见过的就跳过。seen 集合是一个用 HPDFFastCacheHashInt64 对指针值做哈希的开放寻址原始指针数组,线性探测,装到一半时 GrowSeen 翻倍。这个结构里没有任何东西按节点分配内存,当一份文档带着几十万个对象时这一点很重要。流载荷进的是单独的 Streams 列表,因为它们是 TStream 派生类而不是 THPDFObject 节点,要走自己那一趟释放
procedure Collect(Value: TObject; Payload: boolean);
var
Slot: Integer;
begin
if Value = nil then Exit;
if (SeenCount + 1) * 2 >= Length(Seen) then GrowSeen;
Slot := PointerSlot(Pointer(Value), Length(Seen));
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);
Inc(I);
end;
第二阶段是让析构函数可以安全运行的那一环:在任何析构函数执行之前,把每一条拥有边都设成 nil。wrapper 得到 MarkAsFreed,它清掉 FInternalObject 并设置析构函数首先检查的那个标志。流对象的 Dictionary 和 Stream 被赋 nil。每个字典项的 Item^.Value 被清掉,每个数组槽被写回 nil。这一趟之后图里不再有任何边,所以当第三阶段对 Nodes 里每个节点调用 Free、再对 Streams 里每份载荷同样调用时,每个析构函数都找不到可递归进去的东西,只销毁它自己
// 第二阶段:在释放任何东西之前断开每一条拥有边
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);
看看这个拆分买到了什么。一个被两个流对象共享的字典只被收集一次、从两边断开、释放一次。一个数组列着自己父字典的环会终止,因为 seen 集合拒绝了第二次访问。wrapper 和它的 body 都注册成根时,集合里是两个不同的指针,所以两个都被释放,而 wrapper 的析构函数不再试图释放 body,因为 MarkAsFreed 已经把那条边拿走了。一个 TMemoryStream 被赋成两个流对象的载荷时,它在 Streams 里只出现一次。这些情况没有一个需要特殊处理,而这正是模型对了的标志
怎么区分泄漏和分配器保留?
去看内存管理器的活分配计数是否随负载移动,而不只是看它保留的占用。Delphi 内存管理器会把释放掉的大块留着复用,所以一个在关闭文档后停在 400 MiB 的进程未必泄漏了;一个每跑一轮活块计数就按每页加一的进程才是。推动这个修法的探针故意做得很小:一个 THotPDF 写者产出一页,然后三个读取器加载它。四个都释放之后,堆报告正好显示四个活的 512 KiB 分配,每个实例一个,那就是它们各自拥有却从未释放的内容流载荷。把规模放大,同一个模式就再明白不过了。把并行渲染管线跑两遍,大块已分配数字从 384 MiB 走到 640 MiB,这个增量与页数成正比,分配器保留解释不了。重写之后,在一页的诊断里,实例消失后报告的大块已分配字节和保留字节都是零。如果你想在自己的进程里追同一类增长,带保留字节数的对象依赖图会告诉你文档打开期间哪些对象占着内存;本文讲的是文档关闭时这些对象的释放行为
内存阈值做出来的回归测试很脆,所以实际发布的测试改为统计析构函数调用次数。一个夹具手工搭出那张病态图:一个共享字典挂在两个流下面,一个数组同时装着那个共享字典和它自己的根,一份载荷赋给两个流,根被注册两次,还有一个 body 被单独注册的 wrapper,然后释放文档并断言每个唯一对象恰好析构一次:一份载荷、两个流、两个字典、一个数组、一个 wrapper、一个数字。在旧代码下三个生命周期测试全都报告零次析构,这是对「留给进程退出处理」这句话最直接的陈述
在图被拆掉之前必须先发生什么?
任何从图里借用对象的后台工作都必须先停下,任何持有由这些对象编译出的显示列表或位图的缓存都必须丢掉,否则工作线程或缓存引用会读到已释放的内存。所以 CloseIndirectObjects 开头先调用 CancelLoadedPagePrefetch,然后在碰注册表之前让渲染页面缓存失效。LoadFromFile 和 LoadFromStream 里的重新加载路径以及组件析构函数都经过它,所以不管你是在替换文档还是销毁实例,顺序都一样;在一个 THotPDF 上跨文档复用的规则就靠这个保证撑着。那段前置处理里有两个细节只有跑测试才浮出来。第一,析构函数在关闭图的时候,渲染缓存和显示列表缓存背后的频率草图已经被销毁了,所以这里的失效处理要用这些字段非 nil 作为守卫,而不是无条件调用。第二,InvalidateRenderedPageCache 正是那个以页索引 -1 触发 OnLoadedDocumentModified 的例程,而一个重新加载文件的调用方不该收到旧文档内部拆卸的编辑通知。处理程序被保存下来,在调用前后设成 nil,并在 finally 里恢复,而重新加载的回归测试断言第二次 LoadFromStream 之后通知计数为零。一个悄悄改变事件契约的内存修复,是一次包装得更好看的回归,所以它值得有自己的断言。如果你对着某份文档跑并行渲染管线然后再重新加载它,取消这一步正是让工作线程池不去和拆卸抢跑的东西
在你自己的 Delphi 代码里复用这个模式
这套技术并不专属于 PDF。任何 Delphi 对象模型,只要析构函数对子对象的所有权不一致、同一个子对象能从好几个父对象到达,或者反向指针与前向指针共存,逐个对象朴素地 Free 就会崩或者泄漏。修法永远是同一个形状:判定哪些指针字段是拥有、哪些是引用,用一个容忍重访的指针集合收集拥有边的闭包,剪断每一条边,然后销毁那个平铺列表。剪边这一步是人们会跳过的那一步,也正是它让既有的析构函数可以安全复用,而不必被迫重写模型里的每一个类。不过边界值得明说。指针集合用对象地址作身份,所以一个已经被释放、地址又被新分配复用的对象会无法分辨;顺序保证了收集期间没有任何析构函数运行,这才排除了那种情形。这次遍历只看它认识的那四种边,所以一个新类如果通过遍历不检查的字段拥有子对象,那个子对象就会一直泄漏,直到遍历被告知这件事。而因为链接是通过注册表解析而不是沿着走的,一个只被链接引用、从未注册过的对象根本不在这次拆卸的触达范围里;在 HotPDF 里解析器保证了注册,但手工搭的图必须遵守同一条规则
这一切都在组件内部,所以对应用来说可见的效果只是关闭或重新加载文档会归还它占的内存,API 没有任何变化。HotPDF 是面向 Delphi 和 C++Builder 的原生 VCL PDF 库,带完整源码;API 参考和试用版在 HotPDF Delphi PDF 组件页上