程序员的踩坑经验总结(三):内存泄露

内存泄露,是不是很多程序员挥之不去的噩梦呢,哈哈,我也有过这样的踩坑经历,但人都是在踩坑中成长的。。。

最早接触内存泄露还是上一家,做数字电视中间件的,技术杠杠的。很多技术的思路和见识从这里而来,呆了两三年吧,后面到了现在这家。那时我的上司自己编写了一个C语言内存泄露的检测类。有几次用在了一些开源库的排查。来的这里,后面就是自己写检测类,不止C语言的,C++的也有。检测的工具也不止这些,后面还会介绍。

 

案例分析

在现在的公司曾经有段时间,都成了解决内存泄露的专业户,一旦有内存泄露的问题,都往我这里丢!有图有真相(为了排版的效果,我把图片都缩小了)。

    程序员的踩坑经验总结(三):内存泄露_第1张图片

这是有记录的一般比较严重的,没记录的还有更多。记得当时另一个部门嵌入式软件也在用我的写的检测类来排查定位,其中还有一位工程师请我吃了一顿饭:)

而后,写了总结和PPT文档,在公司范围内进行了培训。工具和经验传授出去了,慢慢的这些活就少了!

今天我们先来看看当年的一个非常严重的问题的解决过程,先介绍下背景。

    程序员的踩坑经验总结(三):内存泄露_第2张图片

划重点:客户端视频解码过程中出现严重内存泄露,没几天就会崩溃,在现场和家里,都可重现。

问题的有多严重,现在回忆起来又历历在目。现场电话轮番轰炸,领导找人开会,周末加班,好像是历经了两周才搞定,投入了包括我在内三个人为主,两人为辅。人员我在文档上都有记录,还有关键词等。

    

这个关键词对后面我们讲解会有用。当时这个时候我进这个部门不是很久(以前前端SDK组),年初进来的,对客户端的业务不是很熟。也是因这个Bug对平台的代码和业务就慢慢熟悉起来了。

但当看到客户端的代码着实吓我一跳,用庞大一定也不过分。要不后面也不会重构一个新的平台。现在也可以再看上面的关键字,客户端还在用MFC。大也算了,问题是耦合程度也让人一辈子都不会忘记。我依然记得看到三个模块之间的相互依赖,是那种三角型的关系!!学数学还是学专业课,我记得有位老师讲过,他说多边形里面三角型是最稳定的,因为要使上超过三分之二的力气才能破坏它的平衡。所以基本没法解耦,改60%以上的代码,又这么庞大?所以后面到了新平台的阶段了!同时看到这里你就会理解解决这个问题的难处了!问题还是要解决,先了解业务模块和原理。

我们来看问题的进一步描述。 

    程序员的踩坑经验总结(三):内存泄露_第3张图片

划重点:解码库是控件实现,渲染库有智能指针,初步验证锁定在后者。

后面接着就是跟踪分析,首先需要工具,如下图。

    程序员的踩坑经验总结(三):内存泄露_第4张图片

第一种是windbg的umdh工具,主要特点运行一段时间可以进行差量分析,哪些类和函数的堆栈使用的变化。这个工具的优点是不需要动代码也不需要重启,但不是很准确,只能是了解个大概变化。

第二种就是前面提过的自己写的检测工具。这个工具的实现原理是重载new/delete,new重载时需要记录所在文件和所在行数并加入一链表中,delete重载时则只需要剔除相应节点即可。那么系统推出时候,链表剩下的节点就是没有释放的内存。全部打印出来。用这个方法可以直接定位到某个类的某行!但是缺点是,要加宏控制,C++还好,只需重新定义宏变量即可。

工具也有了,那开始干活吧。可是怎么干活,你得先了解类图吧,数据流程图吧,可发现这些资料一律没有。当时我竟然画了一系列图(只截了名称)。

    

    

    

    

可是经过这么大努力,效果甚微!

    程序员的踩坑经验总结(三):内存泄露_第5张图片

 

但也许转机就在拐弯处。

    程序员的踩坑经验总结(三):内存泄露_第6张图片

 

其实还是柳暗花明,只是能确定在XML的解析库了。具体哪个位置,不知道!

    

  

最终的结果,也让我大跌眼镜!

    程序员的踩坑经验总结(三):内存泄露_第7张图片

划重点:一个DLL库根本用不上MFC却选择了其中的配置选项!去掉只需加上”windows.h“头文件即可!有妖在作怪。

真的是妖吗,你信吗,我不信!我只信科学:)后面我在ppt培训内存知识的时候,提出了一个观点”全身而退“!

    程序员的踩坑经验总结(三):内存泄露_第8张图片

后面还有专题讲解。

案例还没分析完成,我们回到当时的总结。

    程序员的踩坑经验总结(三):内存泄露_第9张图片

划重点:指针和内存的使用相当零散、混乱、复杂,没有文档,类、模块的定义模糊以及之间调用关系复杂。最终的原因是配置项的使用,而这个库竟然还在多处使用!

 

总结和建议

上面的案例是翻新,还是让我感慨万千!也许我早已忘了,或许尘封起来了,今天竟然又不得不重新过目一遍甚至多遍。这是找自虐吗?没办法,为了写这篇文档:)

其实当时可能没有现在这么痛苦,当时只有一个念头,解决问题!事实上,通过这个问题,我也名声鹊起:)但是人有“后怕”这个玩意,你知道吗。

我也希望我的余生不要碰到这样的问题,当然,我想也不会了。如果是我主导的程序是不可能出现这样的问题了,如果是别人的程序,我丢给他几个工具,自己找去!

其实经过我手的再加上协助分析的内存泄露的问题应该不下二十个。所以我在这里好好梳理一下,通用的原则

当然,大家不要太担心,一般的内存泄露,用通用的方法足矣。如果说你碰到像上面这样棘手的,你可以强烈建议重构。但重构还是不能马上解决问题,像这种问题出现的急解决又要求快。但是,通用的原则也是同样适用的,只是你可能要花更多的心思和时间。

(一)进程的内存分布

首先,要做到知己知彼,我们要了解内存的分布。如下图,一个进程的内存分布。

程序员的踩坑经验总结(三):内存泄露_第10张图片

最下面三个区是编译好了就固定了,变化的是上面两个区。

栈区的特点是,向下增长,类似数据结构的栈操作方式LIFO。

堆区的特点是,向上增长,动态分配,和数据结构堆操作方式不同,而类似链表。

栈区由编译器自动分配释放,连续的,一般32位操作系统默认为1MB。堆区一般由程序员分配释放,是不连续的!

所以内存泄露指的是堆区数据分配后没释放。

当然有个别情况,有释放也存在内存泄露,跟系统回收有关,也不是这里的重点哈。

(二)如何预防 

1. 早发现,早解决

每写完一个功能的代码,可以是函数、或者类、或者模块都应该进行测试。

如果公司有单元测试工具,那自然最好。如果没有可以自己写些测试函数。

这个除了对内存,对一般功能测试、函数接口测试等都是应该的。

程序的可调试性也是考虑一个程序员的功底,个人认为。

2. 有良好的设计

设计是个很大的话题,这里专门是针对内存的建议。

2.1 养成良好的编码习惯

创建和释放要集中,在一个类中要配对,如 Init---UnInit,Create---Destroy。

释放的顺序应和分配的顺序相反。这个说起来容易,做起来难。

2.2 集中管理

例如使用内存池。内存使用对象比较多,或者使用频繁,例如像我们对文件的读写循环一般就需要使用内存池。

如果只是小量使用,可能就是对象的初始化,或者定义一些文件名,一般用不上内存池,那么也应该集中放在一个函数中,例如上面提到的配对函数。

原则不能太分散了,见过有些不规范的编程,可以叫做“随用随调(内存分配)”,这种情况看代码费劲,往往很容易出各种内存问题。

2.3 用try catch,捕获异常

对所有的new/malloc、delete/free等相关的函数都应该加上,这在一些检查工具例如pc-lint有要求的。

这里往往也能捕获到一些内存越界,踩坑经验总结(一)的案例二。

2.4 对内存的变化加日志跟踪

特别是异常情况,例如判断输入的缓冲长度和输出长度。

2.5 DLL动态库的特别之处

我们以提问的方式来说明,一些注意事项。

(1)DDL的库内部分配的内存,是否可以在调用者模块中释放? 即 A库分配的内存可以在B库释放吗?

(2)如果不释放,除了内存泄露外,有没有其他影响?

 答:(1)模块间内存使用一黄金原则:谁分配谁释放。

(2)当系统退出时,该DLL需要5秒的时间来清理资源。也就是说比正常退出延时5秒。

这是几个亲身经历总结出来的经验!比本文提到的案例还要早!这是windows系统的现象,不知道现在有没有改进,不过遵循下规则也是没毛病的!

(三)如何解决

上面的方法适用于开发阶段。而真正到了维护阶段,重点不一样了。

1. 了解程序的流程和设计原理

你要解决一个问题,首先要了解它的来龙去脉。

1.1 主体流程

首先要对程序要有个大体认识,理解业务大体流程和模块之间的关系。

尽量拿到框架设计图和类图,如果没有简单画一画。

1.2 关键细节

数据的流向往往都需要通过缓存作为载体,所以抓住关键的对象,这些对象一般使用频率较高,注意内存指针的移动,可以画画时序图。

看到关键细节处,一定要去理解作者的原意,不能靠猜测,否则可能带出新的问题。这一点适用于一般任意的Bug。所以作者留下文档的意义也在这里。

1.3 尽量重现,找到规律

找到规律了,我认为就成功一半了。找到规律可以缩小范围,可以定位到某个功能点或者某个模块,要是某个类就更好了。

1.4 开源库的排查

主要排查启动和退出的时候内存的使用。

我一直认为开源库的稳定性一般没有太大问题,因为有很多高手在维护。问题是我们在使用的时候,有时没有理解他的流程和原理,所以由回到了上面。这里举两个小例子说明。

SIP协议库,还在上一家公司好像是VOIP的一个项目,出现了内存泄露,后面排查是会话的退出有个释放函数没有被调用。当时经验不足,用了C语言的检测工具,调试时间还是比较久的。 

SNMP开源库,是在这家公司做一个批量升级工具,出现了内存泄露,当时直接查了下退出的一些函数,一个个释放函数试试,调试几番就解决了。

开源库一般会比较复杂点,我记得这两个库的释放函数都不简单,又都是C语言写的,指针飞来飞去的,会把你给看晕,文档可能不是你想要的,最重要的是你可能只是使用下,没想过要深入。但是同样解决起来也是相对比较容易的。 

2.工具

本文案例提到过两种工具,一种是自研的,可以跨平台。一种是windbg的umdb。各自优缺点也介绍了。

linux下的valgrind我用过一两次吧,总之用的不多,我们linux的平台后面重构的,是跨平台的。重构的代码肯定会吸取前面的教训,所以极少出现了内存泄露。

所以也就造成了我对这个工具的印象不深。但其实我们也有文档对它进行过介绍,然后网上也有很多资料,可以自行查阅。

(四)难点

最后,复杂问题的内存泄露的难点是什么?

是编写一个检测工具,还是工具的熟练使用?我的回答都不是,工具固然重要。但是有了工具,如何使用,真的都能查得出来?

例如我们的案例里面,是和三、四个模块,好像都有关。所以虽然自研的工具应该更好用,但是不可能每个模块的每个类都去改下宏,工作量极大。但是后面用umdb也没有找到原因。当然正规的查找也还是需要,事实上umdb还是提供了很好的线索。

但是最后谁也没想到是一个不该使用而使用了的配置项!但是为什么还是发现了,有个很重要的观点,全局观

再例如我们上面说的DLL库、开源库等等,用普通的思维(定势思维)可能很难理解,但是你站在更高一点,你从外面全局审视一下,你发现就可以理解了。

后续,就这个观点我还会写一篇文章。最后,我们回到主题上,总结一下,解决复杂的内存泄露的难点是:

 在庞大的程序中,程序结构或者系统分析才是重点和难点。当系统较复杂时候,是否需要全部检查还是检查某个模块;以及在哪个时候哪个地方进行释放。

 

 

 推荐阅读:

虚拟内存:分页技术

如何用巧力解决问题

如何把Bug的偶现变必现

 

你可能感兴趣的:(程序员的踩坑经验总结(三):内存泄露)