简介:FastMM4是Delphi生态中广受欢迎的开源内存管理库,该压缩包提供4.97版完整组件,面向需要做内存泄漏检测、双重释放及越界排查的Delphi/C++ Builder开发者。包内共89个文件,以FastMM4.pas源代码、FastMM4Options.inc配置、多语言消息文件、Demo演示工程和预编译DLL为主,整体仅799KB,方便直接集成或按需调整配置。资源附有FastMM4_Readme.txt说明与FAQ,并包含C++ Builder支持文件,适用于Win32平台下的内存管理替换与诊断。目前已有270人学习下载,适合希望在项目中快速启用FastMM4的初、中级开发者。通过该包可掌握在Delphi中启用FullDebugMode、获取详细泄漏报告、自定义分配策略等要点,减少内存隐患,提升程序稳定性。
1. 为什么 FastMM4 是 Delphi 内存管理的默认答案
Delphi 程序在长时间运行后会突然变得迟钝,内存占用一点点攀升,最终触发系统虚拟内存告警。多数人第一反应是代码里有对象没有释放,但逐行审查后却发现每个 Create 都有对应的 Free。问题往往出在默认内存管理器上:它分配的碎片较多,且无法告诉你某一块内存到底是谁分配、谁释放的。FastMM4 打破了这种困境,它既是分配器,也是内存泄漏检测工具,4.97 版本在稳定性和功能之间取得了很好的平衡。FastMM497.zip 包内包含 FastMM4.pas 源码、FastMM4Options.inc 配置头、FastMM4Messages.pas 多语言消息文件、FullDebugMode 资源、C++ Builder 支持文件、Demos 以及替换 BorlndMM.dll 的方案,几乎覆盖了 Delphi 7 到现代 RAD Studio 的常见场景。如果你在做的程序涉及长期驻留或频繁分配小对象,下面的内容能帮你把 FastMM4 真正用起来,包括怎么接入、怎么配置、怎么读懂报告,以及哪些参数在生产环境里不能开。
2. FastMM4 的接入机制与核心配置项
2.1 为什么必须放在 uses 的第一位
FastMM4 能接管 Delphi 内存管理,核心在于它实现了 System.pas 中声明的GetMem、FreeMem、ReallocMem等例程。Delphi 的初始化顺序决定了第一个 uses 单元会最先执行初始化代码,FastMM4 在初始化时会调用SetMemoryManager将自身的实现注册到 RTL 的内存管理者表里。如果它没有排在第一,前面已经有其他单元通过旧分配器分配了内存,后面又用 FastMM4 释放,就会导致堆块信息错乱。因此工程主文件的 uses 子句里,FastMM4 必须放在最前面。
program OrderManager; uses FastMM4, // 绝对第一个 FastMM4Messages, // 多语言消息,可放在后续位置 Forms, uMainForm in 'uMainForm.pas' {MainForm}; {$R *.res} begin Application.Initialize; Application.CreateForm(TMainForm, MainForm); Application.Run; end.这段代码里,FastMM4 之前不能出现任何其他 uses 单元,连 System 和 SysUtils 都不能在它前面。编译后可以调用GetMemoryManagerState来验证 FastMM4 是否生效:
var State: TMemoryManagerState; begin GetMemoryManagerState(State); WriteLn('Small blocks: ', State.SmallBlockTypeStates.Count); end;如果编译通过,说明 FastMM4 已经接入,因为GetMemoryManagerState是 FastMM4 提供的扩展函数,默认 RTL 没有。对于没有控制台窗口的 GUI 程序,可以在窗体显示标题时输出,或者写到日志文件。这一步不需要额外配置,却往往是新手最先卡住的地方。
2.2 FastMM4Options.inc:编译期决定一切
FastMM4 的行为在编译期锁死,运行期只能读取报告。FastMM4Options.inc里有一系列{$define}指令,修改后需要 clean build。下面是接入时最常调整的开关,我用表格列出:
| 指令 | 默认状态 | 作用 | 我的建议 |
|---|---|---|---|
| EnableMemoryLeakReporting | 开启 | 在程序退出时输出泄漏报告 | 保持开启,即使生产环境 |
| FullDebugMode | 关闭 | 启用全套诊断能力,如越界检测、双重释放 | 只在 debug 和测试环境开启 |
| UseReleaseMem | 关闭 | 释放时将内存填充为特定字节,方便查看释放后访问 | 调试时开,生产务必关 |
| CatchUseAfterFreedMem | 关闭 | 捕获已释放块的再次读取或写入 | 在 FullDebugMode 下有效 |
| LogErrorsToFile | 关闭 | 将错误细节追加写入文件 | 配合控制台或无 UI 场景 |
| FastMM4_NoMessageBoxes | 关闭 | 禁止弹窗报错,改用日志 | 生产环境建议开启 |
比如,在排查 C++ Builder 插件导致的内存越界时,我会打开 FullDebugMode 和 UseReleaseMem,这样每个内存块释放后都会被写成$E5,一旦访问到这个值,调用栈就能对应到释放位置。代价是分配大小变大、速度下降,测试完成必须关闭。需要注意,FullDebugMode下所有检测功能都按最保守的方式运行,千万不要仅仅因为少了一个Free就打开它,它带来的性能损失可能让本来极快的程序慢到一个数量级。
2.3 多语言消息与 DLL 场景
包里的 FastMM4Messages.pas 提供了包括简体中文在内的十余种翻译。在FastMM4Options.inc中启用ChineseSimplified后,报告和错误提示会变成中文。需要注意,如果程序是 DLL,消息单元必须随 DLL 一起编译,而且 DLL 与主 EXE 的 FastMM4 版本要一致,否则两边消息编号对不上,报告会显示乱码。我在处理第三方 DLL 时,曾遇到过 EXE 里 FastMM4 4.97 和 DLL 里 FastMM4 4.84 混用的情况,结果泄漏报告出现缺失行,后来统一版本才正常。
另外,NeverUninstall这个指令对 DLL 尤其重要。DLL 被卸载时,如果 FastMM4 同时卸载,而 EXE 里的已分配块还引用了 DLL 的管理器,程序退出时会崩溃。通常建议在 DLL 中开启NeverUninstall,让分配器的生命周期跟随进程,而不是跟随加载计数。这个选项会让 DLL 基址在卸载时无法释放内存,看上去像泄漏,但实际上是为了避免更严重的错误。
2.4 最小接入配置与编译脚本
如果只是给一个已有工程加上内存检测,我会把 FastMM4 相关的三个文件复制到工程目录:FastMM4.pas、FastMM4Options.inc、FastMM4Messages.pas。然后修改 dpr 的 uses。为了在不同配置里启用或禁用 FullDebugMode,我习惯在项目文件顶部放一组编译指令:
{$IFDEF DEBUG} {$DEFINE FullDebugMode} {$DEFINE EnableMemoryLeakReporting} {$ENDIF}这个指令必须出现在uses FastMM4;之前,并且FastMM4Options.inc中由用户定义的指令会覆盖。在用命令行编译时,设置--conditionalsymbol即可。这样接入后,无论是 IDE 还是 CI 环境,都能快速切换。对于使用 Community Edition 的开发者,FastMM4 同样适用,因为它不依赖任何商业组件。对于刚入门 Delphi 的人,把这三行放到工程里就是最省心的一步,它不会改变任何业务逻辑,却能在退出时给出第一份内存报告。
3. FullDebugMode 下的内存泄漏检测与错误报告
3.1 打开 FullDebugMode 的完整流程
只用替换内存管理器只能影响性能,真正的内存检测能力由FullDebugMode提供。修改FastMM4Options.inc,取消{$define FullDebugMode}的注释,同时需要把FastMM_FullDebugMode.res链接进工程。可以直接在 dpr 中加一行:
{$R FastMM_FullDebugMode.res}这个资源文件是 FastMM4 在报告调用栈时用来把内存地址解析成方法名的关键。如果没有链接,运行时会提示 “FastMM compiled with FullDebugMode but without the required resource file”。RAD Studio 的 dproj 工程可以把FastMM_FullDebugMode.dpr直接编译成资源,也可以把 FastMM4 的 demos 项目用作模板。这里最重要的是理解为什么需要资源:FastMM4 在运行期通过内存中返回地址定位调用函数,却没有符号表,只能依靠资源段里预存的代码偏移,否则报告只能显示十六进制地址,对定位问题毫无帮助。
FullDebugMode 下 FastMM4 会在每个内存块前后插入护检字节,记录分配和释放的调用栈。一次越界写入通常能在系统崩溃之前被 FastMM4 拦下,并报告类似FastMM has detected a block overwritten at address X。这比事后分析转储文件高效得多,因为报告里已经带着当前线程的调用栈,直接点进去就能看到是哪个单元哪一行越界。
3.2 常见错误类型与报告关键字
在 FastMM4 的日志中,不同错误类型有不同特征,我整理了一份对照,方便快速定位。
| 错误类型 | 报告关键字 | 常见原因 |
|---|---|---|
| 内存越界写 | Block overwritten | 数组下标越界、字符串操作越界 |
| 重复释放 | Double free | 同一个对象被两个所有者释放 |
| 释放非法指针 | Invalid pointer | 指向栈上数据或已释放块 |
| 分配泄漏 | Memory leak | 只分配未释放的对象 |
3.3 制造一个泄漏并解读报告
写一个最简的泄漏程序:
program LeakCheck; uses FastMM4, Classes, SysUtils; var List: TList; begin List := TList.Create; List.Add(TObject.Create); // 对象未释放 // List.Free; end.以 FullDebugMode 编译运行,退出时 FastMM4 会在 stderr 输出类似内容:
FastMM has detected a memory leak: The memory block is 12 bytes long. Allocated by Thread 0x00001C80. This block was allocated in the unit: LeakCheck.dpr, line 10. Call stack (return addresses): 00405F10 [LeakCheck.dpr] 00406B47 ...报告中的 12 bytes 是对象实际占用的堆空间。如果看到两个相邻的内存块,一个 12 字节一个 16 字节,说明两个对象都泄漏了。这里容易被忽略的是,FastMM4 只统计退出时仍然存在的块。如果全局对象在 finalization 阶段才释放,FastMM4 可能在释放之前扫描,从而误报。解决方式是设置ReportMemoryLeaksOnShutdown,让它延迟到 finalization 完成后再报告,或者配合NeverUninstall使用。
3.4 用 Usage Tracker 追踪分配现场
包内 Demos 里的 Usage Tracker 思路很实用:在每次分配时记录 Pascal 源码位置,释放时移除。这样即使泄漏发生在复杂的并发路径中,也能锁定具体调用栈。我一般在 FullDebugMode 之上再叠加一个LogErrorsToFile定义,把样本日志写到文件,然后用脚本统计相同调用栈的出现次数。如果某条路径出现上千次且没有对应释放,基本可以断定是循环里Create后漏了Free。这个技巧对于定位“对象池容量不断膨胀”这类问题非常有效。
3.5 常见误用:release 配置中保留 FullDebugMode
有些开发者图省事,一直保持 FullDebugMode 编译生产版本,结果程序在客户机器上内存占用比原来高出一倍多,追问后才意识到是护检字节和调用栈记录的开销。正确做法是用编译指令区分:
{$IFDEF RELEASE} // 不定义 FullDebugMode {$ELSE} {$DEFINE FullDebugMode} {$ENDIF}然后在项目配置中为 Release 设置一个RELEASE条件符号。这样一项小改动就能避免客户现场出现性能劣化,同时还能保留EnableMemoryLeakReporting用于离线分析。我见过不少团队最终把 FastMM4 完全移出 release,结果真的出现问题后又来逐个查代码,其实保留泄漏报告并不影响运行,只是多一个文本文件而已。
4. 替换 BorlndMM.dll 与 C++ Builder 共存
4.1 为什么必须替换 BorlndMM.dll
Delphi 和 C++ Builder 共享运行时早期版本时,内存管理器被独立到一个名为 BorlndMM.dll 的模块中。问题在于,C++ 库和 Delphi 库都依赖这个 DLL,而 DLL 自身对内存块的记录方式与 FastMM4 不同。如果 Delphi 分配的内存由 C++ 代码释放,或者反过来,系统堆可能直接崩溃。FastMM497.zip 里专门给出了 Replacement BorlndMM DLL 和 FastMM4BCB.cpp,目的就是把所有模块的内存请求统一到 FastMM4 这一套实现上。
替换步骤可以整理成下面的对照:
| 步骤 | 操作 | 验证 |
|---|---|---|
| 1 | 备份原始 borlndmm.dll | 保留原文件,便于回滚 |
| 2 | 从 FastMM4 预编译目录复制 FastMM4 生成的 DLL 并重命名 | 文件存在于程序目录 |
| 3 | 确认 EXE 和 DLL 均以小写 borlndmm.dll 命名,并位于同一目录 | 使用 Process Explorer 查看加载的模块路径 |
| 4 | 测试典型的跨模块函数调用 | 确定无 Access Violation |
这里要强调:替换 DLL 不等于在 EXE 里把 FastMM4 去掉。EXE 本身仍需要将 FastMM4 放在 uses 第一,并且 DLL 内部也必须链接 FastMM4。否则 DLL 使用的分配器与 EXE 的 FastMM4 是两套实体,仍然会跨模块。我见到过有人只替换了 DLL 没有接入 FastMM4 源码,结果程序卡在启动早期,原因正是两个管理器互相争夺堆块记录。
4.2 C++ Builder 工程接入 FastMM4BCB.cpp
包内的 FastMM4BCB.cpp 是 C++ Builder 的桥接文件。在 C++ 工程中,把 FastMM4.pas 和 FastMM4BCB.cpp 一起加入项目,然后在主 cpp 文件的最前面包含桥接头文件。注意包含顺序也必须是所有自定义头文件之前,因为 C++ Builder 的全局对象构造也会分配内存。
#include <vcl.h> #pragma hdrstop #include "FastMM4BCB.hpp" // 必须在其他模块之前 #include "MainForm.h"编译并运行后,可以在任意事件里调用GetMemoryManagerState来确认接入,如果 FastMM4BCB.hpp 没有暴露这个函数,就用 FastMM4 单元中导出的 C 函数。C++ Builder 工程要注意调用约定,默认是__fastcall,而 FastMM4BCB.cpp 中导出的函数通常已经处理好了,不需要再手动修饰。在连接器设置里,最好把 FastMM4 的 obj 放在依赖列表最前面,避免被其他运行时库抢先链接。
4.3 动态加载 DLL 场景的内存归属
FastMM4 的 Demos 里有一个 Dynamically Loaded DLL 演示,展示在运行时 LoadLibrary 后,EXE 和 DLL 的内存如何协同。最稳妥的规定是:内存应该由分配它的模块负责释放,模块边界不要传递裸指针。例如 DLL 导出下面这样的函数:
extern "C" __declspec(dllexport) const char* GetText();返回的const char*若由 DLL 的 FastMM4 分配,调用方必须调用 DLL 内导出的释放函数,或者直接约定使用 COM/OLE 的BSTR。如果在 EXE 中直接使用 FastMM4 的FreeMem去释放这个指针,开发期可能不报错,因为两块 FastMM4 配置恰好相同,换一台机器就可能因 DLL 的 FastMM4 实例不同而崩溃。为了避免这种隐患,我会在 DLL 接口层把数据拷贝到调用方分配好的缓冲区,并且把缓冲区长度作为参数。
4.4 替换 DLL 后的验证技巧
替换 borlndmm.dll 后,最直接的验证方式是使用 Sysinternals Process Explorer,在程序运行起来后查看加载模块列表,确认borlndmm.dll的路径来自 FastMM4 生成目录,而不是系统目录。如果出现两个 borlndmm.dll 副本,说明某个 DLL 被固定写到自己的子目录,这会导致加载顺序混乱。此时可以用 API Monitor 记录 LoadLibrary 调用,找出加载第二个副本的模块,再从配置上统一输出目录。另外,如果 FastMM4 报告中有 “This module was compiled with a different version of FastMM” 这类提示,说明 EXE 与 DLL 的版本不一致,需要重新编译。
5. 多线程内存池:越用越快的分配参数
5.1 线程局部缓存的原理与代价
FastMM4 在多线程下的核心优化是每线程空闲列表。每个线程释放的小内存块先进入自己的缓存,不需要加锁。线程再次分配时直接复用,极大减少了竞争。代价是这些缓存不会立即归还操作系统,导致任务管理器中的内存占用偏高。如果设定期限过了也没有新分配,FastMM4 才会把空闲块回收到全局池。你可以通过 FastMM4Options.inc 中的MaximumCachedMemorySize之类的参数限制缓存总量。具体数值和选项名请在拿到手的版本里确认,不同版本略有差异。我一般会将上限设为每线程 2MB,过小会失去缓存优势,过大则容易让内存水线被撑高,在云主机上表现尤其明显。
5.2 用代码验证内存池是否生效
运行压力测试时,使用如下代码观察工作集:
var PMC: TProcessMemoryCounters; WorkingSet: Cardinal; begin PMC.cb := SizeOf(PMC); if GetProcessMemoryInfo(GetCurrentProcess, @PMC, SizeOf(PMC)) then WorkingSet := PMC.WorkingSetSize; end;如果线程池缓存过大,WorkingSetSize会在一批对象释放后仍然很高,但不会持续增长,说明是缓存;如果持续上升且不回落,才是真泄漏。区分这两点是多年开发最容易混淆的地方,FastMM4 提供的GetMemoryManagerState可以查看当前各尺寸类别的可用块数量。当 AvailableBlocks 始终不为零、但 TotalAllocated 不再增长时,可以确认是缓存回收滞后,而不是业务泄漏。
5.3 真实案例:线程缓存与泄漏的辨别
曾经有一个后台服务,每秒钟创建并释放上千个短生命周期的对象。关闭 FullDebugMode 后内存依然缓慢增长,最后怀疑泄漏。通过上面的工作集检测,发现每轮 GC 后内存会回落但基线抬高。用 Usage Tracker 锁定了某个 TObjectList 在异常分支中漏了 Clear。FastMM4 报告里显示是 16 字节小对象,如果不结合调用栈,很难想到是集合容器没有释放。因此,不要只盯着报告第一行,要看分配栈的顶层业务函数。另外,不要一看到内存占用高就断定是 FastMM4 缓存造成,先对比同一稳定性版本下开关 FullDebugMode 前后的数据,才能把问题范围缩小。
提示:生产环境中不要关闭 EnableMemoryLeakReporting,但要将 FastMM4_NoMessageBoxes 打开,防止报错弹窗阻塞无人值守的服务器。
本文还有配套的精品资源,点击获取