Valgrind实战:C/C++内存泄漏检测与修复指南 写C/C的老手没被内存问题折腾过几个通宵的应该不多。前几天一个朋友跟我说他们服务端程序跑两天就会慢慢变慢内存从几百M一路涨到几个G最后被系统杀掉。代码翻来覆去看了几遍也没发现哪里有大问题问我有没有什么工具能帮忙查。答案自然是有的而且几乎是绕不开的那一个Valgrind。Valgrind不是新工具在Linux生态里它存在了二十多年直到今天仍然是排查C/C内存泄漏、非法读写、未初始化使用等问题的首选工具之一。它最方便的地方在于不需要改代码、不需要重新编译直接套在你的程序外面跑一次就能给你一份带调用栈的详细报告告诉你哪一行分配的内存没有被释放。这篇文章就是把这个工具从原理到实操讲清楚适合刚开始学C/C、被内存泄漏折磨过、或者想在自己项目里把内存问题系统排查一遍的同学。1. 内存泄漏为什么这么难抓1.1 不立刻崩溃的隐患最可怕大多数小程序的内存泄漏其实是无感的。程序启动时分配一点点内存跑完就退出了操作系统会把所有资源收回去好像什么都没发生过。但一旦程序要常驻运行或者在一个循环里反复做同样的操作泄漏就会像漏水的水龙头一样一滴一滴地积起来。过了一天内存占用翻倍再过一天系统开始swap程序响应变慢最后触发OOM Killer被直接杀掉。最麻烦的是这种问题很难通过代码审查直接看出来。现代C代码里裸指针用得少了但智能指针用错了、容器存了不该存的东西、第三方库内部有隐患都可能在运行时才暴露出内存问题。你盯着代码看几遍逻辑都是对的但内存就是在涨。这时候就需要一个能从“运行时内存行为”切入的工具。1.2 容易踩内存泄漏的三个典型场景第一个典型场景是new和delete不配对。有人写了new[]却用delete去释放或者构造里new了一个对象析构函数却忘了delete。第二个场景是容器使用不当比如把裸指针塞进std::vector容器销毁时指针指向的对象并没有被销毁。第三个场景是异常路径函数里new了一个对象后续某段逻辑抛了异常释放代码被跳过。这些情况在代码里都合法编译器不会报错运行时也不一定立刻崩溃但内存就是在悄悄流失。有些编码规范要求“谁分配谁释放”有些项目强制用std::unique_ptr替代裸指针都是在从源头杜绝这类问题。但规范是理想状态已有的历史代码不会自动变干净。这个时候Valgrind这类检测工具才派上用场。1.3 为什么GDB排查内存问题帮不上大忙GDB是很强大的调试器但它的强项是帮你理解“当前这一刻程序发生了什么”——看某个变量的值、看调用栈、看寄存器状态。内存泄漏是一个跨时间的累计过程你不可能在每个时间点都停下来看一眼堆内存的分配情况。就算你怀疑某个函数有泄漏在GDB里你也没法轻松看到“这个函数一共new了多少次、对应delete了多少次”。Valgrind则是用另一套思路工作的它模拟了一个运行时环境记录下每一次内存分配和释放然后在程序退出时统计哪些内存没有被释放还附带着分配点的调用栈。相当于给程序做了一次“内存记账”这不比人肉翻代码高效得多。2. Valgrind工作原理与上手准备2.1 动态二进制插桩先跑起来再检查Valgrind并不是一个简单的“静态分析”工具它采用动态二进制插桩技术。程序运行起来后不是直接跑在CPU上而是先被Valgrind翻译成中间表示再由它自己的核心执行。在这个过程中每一个内存访问、每一次堆内存分配和释放都会被插入检查逻辑。所以你不需要给源文件加任何特别的东西也不需要重新编译当然带调试信息是强烈推荐的后面会说原因。这套机制带来的最大优势是你可以直接对线上正在用的二进制文件做检测哪怕源码在另一个机器上、哪怕时隔很久只要符号信息还在就能给你定位。代价是运行速度会慢很多典型情况下比原程序慢20到50倍这也在后面的局限部分再说。Valgrind的核心是一个框架不同的检测功能由不同的工具插件实现。我们说的内存泄漏检测用的是它默认的Memcheck。Memcheck负责检查非法读写内存、使用未初始化的值、堆内存泄漏、错误的释放如重复delete、delete不匹配、内存覆盖等。这也是它最常用、最成熟的部分。2.2 安装与基础命令行在常见的Linux发行版上Valgrind基本都可以直接通过包管理器安装。# Debian/Ubuntu sudo apt install valgrind # CentOS/RHEL/Fedora sudo yum install valgrind # 或 sudo dnf install valgrind如果你用的环境比较特殊或者需要特定版本也可以从官网下载源码自己编译。源码编译的过程不复杂wget https://sourceware.org/pub/valgrind/valgrind-3.22.0.tar.bz2 tar xjf valgrind-3.22.0.tar.bz2 cd valgrind-3.22.0 ./configure --prefix/usr/local/valgrind make -j$(nproc) sudo make install这里的--prefix用来指定安装路径如果不加默认装在/usr/local下。源码编译的好处是可以跟随最新版本修复一些老版本的误报问题但日常使用直接装发行版自带的就够了。验证安装是否成功执行valgrind --version。如果能正常输出版本号说明工具链没问题。有一个小细节值得留意建议顺手安装gdb后面做联合调试会用到而且Valgrind报告里有时候会提示你用gdb去查看某个地址的内容。2.3 一个极简的入门实例先准备一个简单的C文件故意制造一个泄漏// leak_demo.cpp #include cstdlib int main() { int* p new int(42); // 忘记 delete p; return 0; }编译并运行检测g -g -o leak_demo leak_demo.cpp valgrind --leak-checkfull ./leak_demo输出里会有一行类似这样的信息12345 LEAK SUMMARY: 12345 definitely lost: 4 bytes in 1 blocks 12345 indirectly lost: 0 bytes in 0 blocks 12345 possibly lost: 0 bytes in 0 blocks 12345 still reachable: 0 bytes in 0 blocks 12345 suppressed: 0 bytes in 0 blocks这个“definitely lost: 4 bytes”说明什么意思是确定有4字节内存泄漏在程序退出时仍然被持有但没有被释放。Valgrind会进一步给出分配位置因为加了-g编译选项它会精确到源文件的行号你可以直接跳过去改。如果没加调试信息它只能给出函数名甚至只有地址定位难度会明显上升。3. 实操一次完整的内存泄漏排查3.1 写一个适合练手的示例程序为了演示完整流程我构造一个稍复杂一点、更贴近实际场景的例子。这个例子模拟一个“用户信息管理模块”里面既有直接的new泄漏也有容器存储不当的泄漏。// user_manager.cpp #include iostream #include string #include vector struct UserInfo { std::string name; int age; }; class UserManager { public: void addUser(const std::string name, int age) { UserInfo* user new UserInfo{name, age}; // 有意泄漏 m_users.push_back(user); } ~UserManager() { // 这里没有释放 m_users 中每个指针指向的对象 } private: std::vectorUserInfo* m_users; }; static void createUsersInLoop() { UserManager manager; for (int i 0; i 10; i) { manager.addUser(user_ std::to_string(i), 20 i); } } // manager 析构但 user 对象全部泄漏 int main() { createUsersInLoop(); char* buffer new char[128]; // 也泄漏 (void)buffer; return 0; }这段代码的问题非常直白UserInfo*存进了vector但vector析构时只释放自身空间不会释放指针指向的堆对象buffer那块128字节的内存也是new了之后没有delete[]。现实中这类问题经常藏得更深但用于演示工具流程已经够了。3.2 编译运行并查看完整报告编译命令和之前一样-g是必须的再加-O0避免编译器优化干扰行号定位。g -g -O0 -o user_manager user_manager.cpp valgrind --leak-checkfull --show-leak-kindsall ./user_manager输出的关键部分如下整理过格式54321 HEAP SUMMARY: 54321 in use at exit: 352 bytes in 12 blocks 54321 total heap usage: 12 allocs, 0 frees, 352 bytes allocated 54321 244 bytes in 10 blocks are definitely lost in loss record 1 of 2 54321 at 0x4C29CF3: operator new(unsigned long) (vg_replace_malloc.c:342) 54321 by 0x4007C3: UserManager::addUser(std::string const, int) (user_manager.cpp:16) 54321 by 0x4007F5: createUsersInLoop() (user_manager.cpp:25) 54321 128 bytes in 1 block are definitely lost in loss record 2 of 2 54321 at 0x4C29CF3: operator new(unsigned long) (vg_replace_malloc.c:342) 54321 by 0x40082C: main (user_manager.cpp:31) 54321 LEAK SUMMARY: 54321 definitely lost: 372 bytes in 11 blocks 54321 indirectly lost: 0 bytes in 0 blocks 54321 possibly lost: 0 bytes in 0 blocks 54321 still reachable: 0 bytes in 0 blocks 54321 suppressed: 0 bytes in 0 blocks报告里的信息量其实不小我们慢慢拆。3.3 解读的关键alloc栈是抓泄漏的核心Valgrind报告里最有用的信息不是“多少bytes lost”这一行而是下面那个调用栈。它告诉你这块内存是在什么代码路径上分配的。栈顶通常是operator new或者malloc往下是你的业务函数一路追溯到入口点。你看栈的时候要关注的是“哪一层业务代码创建了这个对象并且此后一直没有释放”。在这个例子里第一块泄漏的栈非常清晰分配发生在UserManager::addUser的第16行调用点是createUsersInLoop的第25行。也就是说每次调用addUser都会泄漏一个UserInfo对象。维修的方法就是给UserManager加上真正释放元素的析构函数或者直接改用std::unique_ptr的容器。第二块泄漏在main第31行就是那个128字节的buffer。程序退出前没有释放虽然它对运行结果没有影响但属于“definitely lost”习惯上应该清理干净。提示报告里definitely lost是最需要优先处理的类型它代表这块内存的指针已经彻底找不到了连理论上的释放机会都没有。still reachable通常说明程序退出时指针还存在只是你忘了释放这类问题相对没那么紧急但在长期运行的服务里依然要处理。3.4 修复并验证好工具也要看闭环修复代码直接给UserManager加析构函数~UserManager() { for (UserInfo* user : m_users) { delete user; } }然后给main里加一句delete[] buffer;重新编译再跑一次检测重点看最后的汇总54321 HEAP SUMMARY: 54321 in use at exit: 0 bytes in 0 blocks 54321 total heap usage: 12 allocs, 12 frees, 352 bytes allocated 54321 54321 All heap blocks were freed -- no leaks are possible看到All heap blocks were freed心里那块石头就落下来了。这个“修复后清零”的验证步骤我在项目里每次都做它不只是给测试看更是给自己一个确认这次改动真的把问题解决干净了不是碰巧换了一种写法继续泄漏。4. 参数选型与高频用法4.1 五个关键的启动参数Valgrind的参数很多但不是每个都常用。我整理了一份高频参数对照表按重要程度排列。参数作用建议--leak-checkfull输出每个泄漏点的详细信息必开最简单的方式是直接用--leak-checkfull--show-leak-kindsall显示所有类型的泄漏包括still reachable默认只显示definitely和possibly建议开了看全貌--track-fdsyes检测文件描述符泄漏长驻服务类程序强烈推荐打开--log-filevalgrind.log把报告输出到文件报告很长时用避免终端刷屏截断--error-exitcode1检测到问题让退出码非0集成到CI里做自动化检查时用其中--track-fds非常容易被忽视。在很多项目里fd泄漏比内存泄漏的危害更直接——进程能打开的fd数量有限一旦跑几天就把fd耗尽accept和open就会直接失败。Valgrind的fd检测和内存检测类似会在进程退出时列出未关闭的文件描述符及打开位置。我遇到过一个大并发服务程序内存一直没事但fd一直涨最后就是用这个参数定位到某个日志文件fd在每个请求里打开但没关闭。4.2 默认输出和全量输出的差别默认情况下Valgrind不会把still reachable和suppressed的泄漏点逐个列出只会在LEAK SUMMARY里显示一个总数字。加上--show-leak-kindsall之后才会展开显示每个指向仍然有效但未释放的内存块的分配栈。你可能觉得still reachable没有绑定价值毕竟指针还在程序退出操作系统会回收。但对于运行几周才重启一次的服务still reachable积累起来同样会撑满内存。很多时候它实际上是全局单例里的数据不断增长属于“假单例真缓存”的问题。所以我的习惯是开了all类型的显示先看完所有分配点再判断哪些要处理哪些可以忽略。4.3 suppression机制过滤你不想管的第三方泄漏实际项目里Valgrind报告不一定全是你自己代码的问题。系统库、第三方静态库内部有时候也会有小块的泄漏它们不影响你的业务逻辑但每次都出现在报告里会让你忽略真正重要的信息。Valgrind提供了suppression文件机制可以针对某个调用栈或函数范围“屏蔽”报告。先跑一次带--gen-suppressionsall的检测Valgrind会把所有泄漏点的suppression描述打印出来。你把想要屏蔽的段落复制到一个.supp文件中下次运行加上--suppressionsmy.supp即可。有几个点要注意suppression文件里的函数名要和报告里的完全一致最好直接复制不要手打。suppressions是按调用栈模式匹配的如果代码改动后调用路径变了旧的suppression可能失效。对自家代码的泄漏不建议用suppression掩饰正确的做法是修掉。suppression只适合确认过不是自己问提的第三方依赖场景。4.4 与GDB联动报告里的地址是可以进去看的Valgrind报告会给你一个“at 0x...”的地址。对于复杂结构你可以把Valgrind和GDB联合在一起调试。操作方法是先启动一个带调试的Valgrind会话valgrind --vgdbyes --vgdb-error1 ./your_program然后在另一个终端启动GDB连接gdb ./your_program (gdb) target remote | vgdb这样你可以在内存出错的那一条指令前停下来仔细看寄存器和内存内容。这个技巧不算常用但当你遇到“Valgrind报告说这里有非法内存访问但代码看起来没问题”的硬骨头时它就是救命招。它可以直接把程序的当前状态拉到你面前而不只是事后的一份报告。4.5 让Valgrind进入CI一个简易的自动化脚本如果有持续集成环境可以把Valgrind作为内存回归测试的一部分跑起来。思路很简单跑一遍测试用例如果Valgrind检测到任何内存问题就让命令返回非0状态从而拦截构建或测试流水线。一个最小可行的示例脚本#!/bin/bash # 在CI的测试阶段运行 valgrind --leak-checkfull \ --show-leak-kindsall \ --track-fdsyes \ --error-exitcode1 \ --log-filevalgrind_${BUILD_NUMBER}.log \ ./unit_tests如果unit_tests里存在任何内存错误或泄漏Valgrind的退出码就是1CI判定为失败然后把日志文件作为构建产物保留下来。这个方案落地成本很低但对很多长期没人清理的存量项目来说第一次跑的时候可能会爆出大量历史问题。建议从“新增用例必须零告警”开始老问题先记录在案逐步清零别一上来就要求历史代码全部达标。5. 四个报告类别与常见场景的解读5.1 Leak种类各自代表什么Valgrind把泄漏分成了几类很多人只看到“definitely lost”就去改代码其他几类不清楚。这里详细说一下类型含义处理优先级definitely lost指针彻底丢失内存无法释放最高必须修indirectly lost因为某个definitely lost的根节点丢失导致这个节点也无法释放高修根节点即可possibly lost指针可能还存在某个位置但Valgrind不能确认它能否被释放中需要人工审查still reachable指针仍然可到达只是程序退出前未释放低但长驻进程要重视间接丢失和直接丢失经常成对出现。比如一个链表的头节点指针丢了那么后面所有节点的内存都会标记为indirectly lost。你只要修复头节点丢失的问题整条链的内存都能被正常释放不需要逐个节点去改。possibly lost是最需要动脑子的类别。它通常出现在把指针强制转成了整数存到别处、或者用一些位运算做了指针掩码的场景。Valgrind无法追踪这种“指针变形”后的可达性就会保守地标记为possibly lost。碰到这类报告需要结合实际代码判断如果确实不再需要这块内存那它就是泄漏如果只是写法隐晦、后续还能找到并释放就可以忽略。5.2 不止泄漏Memcheck能查的其他内存错误Valgrind的Memcheck除了查泄漏还擅长抓另一类更难定位的内存错误越界读写和使用未初值变量。这类问题在程序里往往不会立刻报错而是在很久之后造成一个看似无关的崩溃。报告里常见的形式有Invalid write of size 4 at 0x400...: main (foo.cpp:12) Address 0x5a... is 4 bytes after a block of size 8 allocd这行信息的价值在于它不仅告诉你写越界了还告诉你是哪一次分配的内存被越界了。很多时候“写越界”发生在分配的缓冲区内直到堆元数据被破坏后才在某个神秘的地方唤起崩溃。用Valgrind跑一遍它能直接指认凶手行。使用未初始化的值也比较隐蔽Conditional jump or move depends on uninitialised value(s)这类问题在release模式下编译常常因为优化器的不同而表现得时好时坏。Valgrind不介意优化级别它在中间表示级别就追踪了每个字节的“已定义/未定义”状态。5.3 减少误报的小技巧Valgrind对大多数正统C/C代码的检测是比较准的但有一些场景会产生误报。最典型的是自定义内存分配器、对象池、用嵌入式汇编直接操作内存等。这类代码绕过了标准malloc/new接口Memcheck无法知道你在干什么于是给出一个“非法操作”的提示。应对思路有几个。一是可以给特定函数加抑制用suppression文件二是如果项目重度使用自定义内存池Valgrind不一定是最理想的工具可以考虑ASan或者自己写运行时检查三是在测试环境里尽量用debug版本并结合-O0减少优化带来的干扰。误报的处理原则只有一个工具是在帮你找问题不是替你定罪出现不理解的情况时优先去核对代码而不是盲目修改。6. 实际项目中的避坑经验与替代工具6.1 性能开销慢到你想骂人但是值得用Valgrind跑一个正常程序慢20倍基本是起步重度内存操作的代码慢50倍也不奇怪。我见过一个项目组第一次跑Valgrind一个原本2分钟的测试用例跑了快40分钟才结束。这个开销在排查问题阶段是可接受的但如果想把它长期嵌入CI尤其在大型测试集上很容易成为瓶颈。有个折中方案是用Valgrind的--toolmemcheck限定只检测某几个模块的测试用例把全量跑放在夜间任务。还有个更实际的建议不要用Valgrind跑性能测试也不要用它跑带GUI或严格时序依赖的程序。Valgrind会改变时间行为时序敏感的多线程程序在Valgrind下可能出现和线上完全不同的调度结果此时报告里的错误未必是真实线上会触发的错误。6.2 多线程程序helgrind与DRD的补充Memcheck的堆检查在多线程下是安全的但如果你需要排查数据竞争、死锁这类问题Valgrind还有另外两个工具helgrind和DRD。它们的原理是基于内存访问和锁操作的跟踪能发现多个线程在无锁保护下访问同一块内存的情况。实践中helgrind的误报会比Memcheck多一些因为很多自旋锁、原子操作类库的底层实现不一定被它识别为“正确的同步原语”。不过这不太影响它作为排查手段的价值跑一次看看有没有明显的锁问题比反复审查代码效率高得多。6.3 什么时候别用ValgrindValgrind不是万能的。有几种情况我会果断换其他方案程序里用到大量GPU显存、DMA内存或跨设备内存Valgrind管不到这些它们不经过常规的malloc/new。程序的启动时间极长或者依赖实时数据流Valgrind的插桩开销会导致莫名其妙的超时。已经确定问题出在某个模块且需要频繁迭代验证这时用AddressSanitizer会更轻便。6.4 替代方案一览ASan、LSan、Heaptrack现代GCC和Clang都内置了AddressSanitizerASan编译时加-fsanitizeaddress即可不需要额外安装工具。它比Valgrind快很多内存开销也更低是在本地开发迭代时非常顺手的替代品。把ASan和LeakSanitizer组合使用同样可以检测内存泄漏报告风格类似。Heaptrack是一个更轻量的堆内存剖析工具它关注的是“谁在大量分配内存”而不仅仅是“谁泄漏了”适合做内存占用优化时的热点分析。它不会像Valgrind那样大量插桩每一条内存访问因此运行速度也快不少。但它的边界检查能力较弱不能替代Memcheck做非法读写检测。我的建议是本地快速验证用ASan做深度排查、特别是需要全面了解一次运行中所有内存访问问题时用Valgrind分析内存占用分布用Heaptrack。这三者不冲突很多项目里是同时装的。6.5 项目里最实用的一行命令如果是第一次给一个不熟悉的项目做内存体检我最推荐先用这么一条命令起手valgrind --toolmemcheck --leak-checkfull --show-leak-kindsall \ --track-fdsyes --error-exitcode1 --log-filevalgrind_$(date %s).log \ ./your_program --your-test-args它会覆盖内存泄漏、非法访问、fd泄漏三类高频问题并且不出现问题时以1退出。跑完以后先看日志文件的LEAK SUMMARY和ERROR SUMMARY再决定下一步怎么做。对我来说这条命令基本是“先跑通再深挖”的统一入口比临时回忆参数快得多也方便直接让同事复制去执行。7. 几个常遇到的细节坑7.1 忘记加-g有地址没行号这是最常犯的低级错误。Valgrind会照常运行但报告里只有一串函数地址你要自己用addr2line去转换或者再编一次带调试信息的版本重新跑。既然检测本身就是一件耗时间的事第一次编译时就带上-g后面省去很多麻烦。为了保障符号更准我习惯在项目里专门加一个debug构建配置默认开-g -O0 -fno-omit-frame-pointer。7.2 程序不“干净退出”Report只统计正常结束Valgrind的泄漏汇总基于进程正常结束时的堆状态。如果程序被信号杀掉比如段错误或者人为kill -9那Valgrind可能来不及输出LEAK SUMMARY。遇到这种情况优先跑到测试用例自然结束或者先用GDB配合找崩溃原因再回头处理泄漏。多线程程序中还要注意所有非主线程是否都退出干净了否则可能卡在退出阶段。7.3 共享库与静态库的定位偏差静态库的符号如果被strip过Valgrind同样看不懂。这不是Valgrind的问题是二进制本身丢了调试信息。所以当你拿到一个只有release版的第三方库时能做的更多是判断泄漏总量和大致分配路径细节层面的定位往往受限。真实的项目重构中这种问题更常出现在老旧的闭源组件里处理办法基本就是更新版本或走替代方案。7.4 多线程栈跟踪不完整多线程场景下Valgrind的调用栈有时候看起来比预期短。比如某个线程池里的工作线程它的栈顶会显示具体的业务函数但往下可能看不到是从哪个任务队列取出来的任务。这是因为线程启动后一直循环等待真正的“用户意图”不在栈上而在队列里。这种情况需要配合业务日志来定位Valgrind只负责告诉你“哪块内存泄漏了”至于“为什么这个路径会被执行”还是要你自己梳理业务逻辑。7.5 用--undef-value-errorsno控制输出噪声有些项目不可避免地使用了一些隐含的未定义值比如从网络字节流里拆出来的非对齐字段Memcheck会给一大串警告反而让真正的问题淹没在噪声里。如果确认这些警告和核心内存安全无关可以加--undef-value-errorsno临时关掉未初始化值检查只保留泄漏和非法访问检测。它是一个“治疗噪声”的开关但用的时候要想清楚这是解决问题还是暂时闭眼。8. 手上项目接入Valgrind的落地顺序想在一个现存项目里把Valgrind用起来我建议不要试图一次性清洗完所有问题。先给测试用例或一个小的可重复运行场景接上Valgrind跑通基础链路然后把跑出来的问题按严重程度分桶——definitely lost优先修possibly lost找负责人确认still reachable先记流水账。每个修复完成后都要重跑一次确保没有新的泄漏点。我经手过的一个模拟项目X就是这么做的一开始Valgrind报告里有上百条记录看着吓人但真正需要立刻修的只有十来个点。修完那十来个点整体内存占用直接掉了30%多线上服务的OOM频率从每周好几次降到了几乎为零。剩下的记录里大多是第三方库的suppress问题和少量still reachable记录好以后并不影响主链路稳定性。如果你负责的是自动化测试框架的维护还有一个值得做的方向把Valgrind输出解析成JSON或文本摘要和现有的测试报告整合。Valgrind原生输出是给人看的文本不够结构化但它有--xmlyes选项可以输出XML格式方便后续脚本解析。把XML导入到报表系统后统计每个版本的泄漏趋势能很直观地看到是否有回归。这个功能在CI接入时非常实用。最后再说一个我自己比较偏好的使用习惯在每个项目的README里留一个“内存检查”小节写清楚最常用的那条Valgrind命令不给别人留解释的余地。新同事拿到项目跑测试时顺手就能完成内存检测这比自己写文档去推荐“应该用Valgrind”有效得多。内存泄漏这东西找到一次、修好一次下次再遇到就不慌了。工具本身没多复杂关键是养成“每写完一段涉及资源的代码就跑一次检测”的习惯。这个习惯一旦建立很多潜在问题在提交代码之前就会被拦住等到上了线再回头看追悔莫及的事情自然就会少很多。