鏡像已解析 PDF 檔案的 Delphi 物件模型,最後通常長成「一條裝著所有物件的扁平清單,加上掛在其上的指標圖」。對清單裡每個項目呼叫 Free 走一遍,程式會在中途死掉——某個解構子裡的 access violation,對象是一個早先的 Free 已經經由另一個父節點釋放過的物件。把迴圈換成 List.Clear,當機消失,但處理程序每次重新載入就胖掉大約一份文件的記憶體。兩個症狀同一個病因:清單是扁的,所有權卻是圖。行得通的拆除程序是這樣的:先決定哪些指標「擁有」它們的目標、把這個閉包收集一次、切斷每條擁有邊,然後才釋放每個節點,而且恰好一次
對每個註冊物件都釋放一次,為什麼會當掉?
在 HotPDF 裡,那條扁平清單就是文件的物件註冊表,而它收容的各類別對「誰摧毀誰」各有主張。每個解構子單獨看都合理。把它們放進一個收容同一條所有權鏈多個層級條目的註冊表裡一起跑,逐項 Free 會把某些記憶體釋放兩次、某些一次都不放
HPDFObjs.pas 與 HPDFDoc.pas 裡有三個解構子構成了這個局面。THPDFDictionaryObject.Destroy 走訪它的 Items,只在 IsIndirect 為 False 時釋放值——假設間接子物件歸註冊表管。THPDFArrayObject.Destroy 釋放它持有的每一項,間接與否不論。THPDFIndirectObject——從物件串流(ISO 32000-1 §7.5.7)取出的物件用的包裝器——釋放它的 InternalObject 本體。現在設想註冊表裡有一個間接字典、一個在某個槽位列出同一個字典的陣列,以及一個本體也以自己的名義註冊為根的包裝器。先釋放陣列,註冊表輪到字典時它已經沒了。先釋放包裝器再釋放本體,第二次呼叫是在一塊已經不屬於任何人的記憶體上跑解構子。只釋放字典,它跳過的每個間接子物件就一路配置到處理程序結束
替註冊表重排順序救不了它。不管你挑哪種順序,總有一對條目落在某條所有權邊的錯誤一側,因為清單沒辦法表達「條目 12 擁有條目 40」
PDF 物件圖裡的擁有邊與參照
擁有邊是「來源有責任摧毀其目標」的指標。其餘一切都是參照,拆除程序只跟隨前者。HotPDF 裡恰好有四種擁有邊:THPDFDictionaryObject 的 Items、THPDFArrayObject 的 Items、THPDFIndirectObject 背後的 InternalObject,以及 THPDFStreamObject 的兩個欄位——它的 Dictionary 與 Stream 承載
參照需要同樣的謹慎,因為跟著一條參照走,圖走訪就會變成無窮迴圈或讀到已釋放的記憶體。THPDFLink 持有物件編號與世代號,這正是 ISO 32000-1 §7.3.10 的間接參照:一個「指向檔案裡另一處所存物件」的名字。把那個編號經註冊表解析,會落在另一條邊已經擁有的節點上,所以拆除程序完全不解參照連結。字典與陣列保留的 FParent 回指指標方向相反:父節點本來就擁有子節點,沿它往上爬只會重訪已經走過的節點。整個拆除過程中,這兩種指標都分毫未動
收集、切斷、釋放:三趟完成的拆除
HotPDF 在受保護的 THotPDF.CloseIndirectObjects 方法裡以三趟釋放文件的物件,這三趟的順序就是全部的訣竅。第一趟收集。工作清單以所有註冊表條目起頭,走訪每個節點時把它的擁有邊目標附加進來,跳過已經見過的任何東西。已見集合是一個開放定址、線性探測的原始指標陣列,半滿時加倍,所以每個節點不必配置任何東西——文件帶著幾十萬個物件時這就開始要緊。串流承載進第二條清單,因為它們是 TStream 後代而不是 THPDFObject 節點
下面兩段程式碼是元件內部實作的簡化摘錄,讓你能用函式庫自己的物件型別看清這幾趟。應用程式碼永遠不必呼叫其中任何一個;LoadFromFile、LoadFromStream 與解構子會替你跑。摘錄裡位址雜湊化簡成移位加遮罩,已見集合半滿時加倍重雜湊的步驟也省略了
// 內部拆除程序的簡化摘錄,不是可呼叫的 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。包裝器拿到 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);
正是這個「切」讓既有解構子可以原封不動。邊都沒了,每個解構子找不到任何可以遞迴進去的東西,只毀掉它自己。通往同一結果的另一條路,是讓每個解構子都認識註冊表——字典、陣列與包裝器各自去問「會不會有別條路替我釋放子物件」。那會把圖的知識攤到四個類別和日後新增的每個類別上,任何一處答錯,重複釋放就回來了。把整個所有權模型留在一個拆除常式裡,是比較穩的安排
把那些彆扭案例放進來跑,沒有一個需要特殊分支。兩個串流物件共用的字典被收集一次、從兩邊切斷、釋放一次。陣列出現自己父字典的環,靠已見集合拒絕第二次造訪而終止。包裝器與其本體註冊成兩個根,在集合裡是兩個不同的指標,兩個都被釋放,而包裝器的解構子不再碰本體——MarkAsFreed 已經移除那條邊。同一個 TMemoryStream 被指派為兩個串流物件的承載,在 Streams 裡只出現一次
圖倒下來之前,什麼必須先停下?
任何向圖借物件的東西都必須先停。HotPDF 能透過 PrefetchLoadedPages 在工作執行緒上渲染頁面,也會快取由文件物件編譯出的點陣圖與顯示清單;活得比圖久的 worker 或快取條目,會讀到已釋放的記憶體。所以拆除程序開場先 CancelLoadedPagePrefetch、並在碰註冊表之前把渲染頁面快取設為無效。LoadFromFile、LoadFromStream 與 THotPDF 解構子都走同一條路,所以不論你換文件還是釋放元件,順序都一樣——跨文件重用同一個 THotPDF 的規則正是建立在其上
通知契約同樣要緊。InvalidateRenderedPageCache 正是那個以 PageIndex = -1 觸發 OnLoadedDocumentModified 的方法,而重新載入檔案的檢視器,不該收到一則把舊文件內部清理當成編輯的通知。拆除程序裡,處理器先被存下來、在那次呼叫前後設為 nil、再於 finally 區塊還原。重新載入仍然會產生一次 PageIndex = -1 呼叫——由載入本身在為新文件重設快取時觸發——檢視器應該回應的是那一次。解構子裡,圖關閉時快取早已 disposal,所以拆除程序在那裡整個跳過無效化
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 會取消執行中的預取、並在剖析前釋放前一份
// 文件的物件;PdfModified 會看到一次 -1 呼叫
FPdf.LoadFromFile(FileName);
FPdf.PrefetchLoadedPages('1-10', 150);
end;
OpenDocument 裡不需要明確呼叫 CancelLoadedPagePrefetch,因為載入會做。當你即將修改已載入文件、而預取可能還在跑時,自己叫一下仍是對的;HPDFDoc.pas 的宣告註解要求的正是這個。更大規模的工作池渲染,平行渲染管線一文講了背景工作怎麼被節流
怎麼分辨洩漏與配置器保留?
盯著記憶體管理器的存活配置計數跨多次載入的變化,別看處理程序工作集。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 的剖析器把它讀到的每個物件都註冊,你自己程式碼裡手建的圖也必須守住同一條規則
測試它,靠數解構子呼叫次數而不是數位元組。手動建出那個惡劣圖:兩個串流共用的字典、同時持有那個字典與自己父節點的陣列、指派給兩個串流的同一份承載、註冊兩次的根,以及連數值本體也註冊的包裝器。拆除它,預期每個不重複物件恰好一次摧毀——一份承載、兩個串流、兩個字典、一個陣列、一個包裝器、一個數值。在你自己的類別裡,一個在每個解構子遞增的類別層級計數器就夠了(自己加的埋點掛鉤),而只會清清清單的拆除程序會回報零
在 HotPDF 裡,這一切都藏在 LoadFromFile、LoadFromStream 與解構子背後,應用程式碼只看到結果:關閉或重新載入文件,記憶體就還回來。HotPDF 是給 Delphi 與 C++Builder 的原生 VCL PDF 函式庫,附完整原始碼,所以這裡描述的拆除程序可以在 HPDFDoc.pas 裡逐行閱讀;API 參考與試用建置在 HotPDF PDF component for Delphi 頁面