目录
1、问题描述
2、使用Process Explorer实时查看程序的虚拟内存占用
2.1、对于内存泄漏问题,需要查看程序占用的虚拟内存
2.2、Windows任务管理器中看不到程序进程占用的虚拟内存,使用Process Explorer工具可以看到
2.3、通过Process Explorer工具看到每次泄漏的内存大小
3、使用历史版本比对法排查内存泄漏问题
3.1、内存泄漏检测工具介绍
3.2、采用历史版本比对法去排查内存泄漏
3.3、找到最开始出现内存泄漏的那个版本,找到排查问题的线索
4、回想相关代码问题修改历史,最终找到了问题的原因
5、可能是调用了哪些操作符或接口去动态申请内存?
6、引发内存泄漏的常见场景总结
7、最后
C++软件异常排查从入门到精通系列教程(专栏文章列表,欢迎订阅,持续更新...)https://blog.csdn.net/chenlycly/article/details/125529931C/C++实战进阶(已更新到380多篇,持续更新中...)https://blog.csdn.net/chenlycly/category_11931267.htmlVC++常用功能开发汇总(专栏文章列表,欢迎订阅,持续更新...)https://blog.csdn.net/chenlycly/article/details/124272585Windows C++ 软件开发从入门到精通(专栏文章,持续更新中...)https://blog.csdn.net/chenlycly/category_12695902.htmlC++软件分析工具从入门到精通案例集锦(专栏文章,持续更新中...)https://blog.csdn.net/chenlycly/article/details/131405795 最近我们的程序在拷机测试过程中发现,程序中存在内存泄漏,长时间拷机运行后出现程序内存耗尽闪退的问题。对于此次内存泄漏的问题,我采用了历史版本比对法来排查,本文就来详细讲讲整个问题的排查过程。
1、问题描述
最近测试人员在拷机测试过程中发现,多次入会退会后程序会发生闪退的问题。根据经验,怀疑可能是退会后有内存泄漏,多次入会退会会引发持续的泄漏,将程序用户态虚拟内存耗尽了,导致了Out of memory的异常,从而导致程序闪退。
所谓内存泄漏,就是动态申请的内存在使用完后没有释放,就导致了泄漏。
我们的程序是32位的,系统给程序进程分配了4GB的虚拟内存大小(32 位程序是使用32位地址寻址的,所以只能分配4GB的虚拟内存),默认用户态虚拟内存占2GB,也可以扩充到3GB。而我们程序的业务是主要是运行在用户态的,所以占用的是用户态虚拟内存,当占用的用户态虚拟内存接近或等于程序进程的用户态虚拟内存的上限时,就会发生Out of memory的异常,程序进而发生闪退。
2、使用Process Explorer实时查看程序的虚拟内存占用
2.1、对于内存泄漏问题,需要查看程序占用的虚拟内存
这个问题是必现的,最开始只是怀疑可能是内存泄漏引发的程序闪退,想在复现的过程中去观察程序占用的虚拟内存的变化,看看是否存在内存泄漏。
对于内存泄漏问题,我们主要看虚拟内存占用,一般不看物理内存大小。程序运行过程中主要使用启动时系统给程序分配的虚拟内存空间,至于什么时候将虚拟内存调拨到物理内存上,是操作系统去做的,我们不需要去关注物理内存,关注程序占用的虚拟内存就好了。
2.2、Windows任务管理器中看不到程序进程占用的虚拟内存,使用Process Explorer工具可以看到
当程序发生内存泄漏时,直接查看Windows任务管理器中的提交内存等信息,是不明确的,我们要看程序进程占用的虚拟内存。
Windows自带的任务管理器中只能看到专用工作集内存、提交大小等内存信息,如下所示:
右键点击进程列表的列表头,在弹出的右键菜单中点击“选择列”,在打开的页面中也找不到程序占用的虚拟内存的勾选项,如下所示:
所以Windows自带的任务管理器是看不到系统虚拟内存占用的。
于是测试人员找到我,问有没有工具可以去查看程序虚拟内存的,推荐他们使用Process Explorer。
Process Explorer在默认情况下是不显示虚拟内存列的,在进程列表的标题栏中右键点击,弹出右键菜单,点击“Select Columns”,在弹出的窗口中切换到“Process Memory”标签页下,将“Virtual Size”勾选上:
进程列表中就会显示虚拟内存占用了:
在这里,给大家重点推荐一下我的几个热门畅销专栏,欢迎订阅:(博客主页还有其他专栏,可以去查看)
专栏1:(该精品技术专栏的订阅量已达到480多个,专栏中包含大量项目实战分析案例,有很强的实战参考价值,广受好评!专栏文章持续更新中,预计更新到200篇以上!欢迎订阅!)
C++软件调试与异常排查从入门到精通系列文章汇总https://blog.csdn.net/chenlycly/article/details/125529931
本专栏根据多年C++软件异常排查的项目实践,系统地总结了引发C++软件异常的常见原因以及排查C++软件异常的常用思路与方法,详细讲述了C++软件的调试方法与手段,以图文并茂的方式给出具体的项目问题实战分析实例(很有实战参考价值),带领大家逐步掌握C++软件调试与异常排查的相关技术,适合基础进阶和想做技术提升的相关C++开发人员!
考察一个开发人员的水平,一是看其编码及设计能力,二是要看其软件调试能力!所以软件调试能力(排查软件异常的能力)很重要,必须重视起来!能解决一般人解决不了的问题,既能提升个人能力及价值,也能体现对团队及公司的贡献!
专栏中的文章都是通过项目实战总结出来的,包含大量项目问题实战分析案例,有很强的实战参考价值!专栏文章还在持续更新中,预计文章篇数能更新到200篇以上!
专栏2:
C++常用软件分析工具从入门到精通案例集锦汇总(专栏文章,持续更新中...)https://blog.csdn.net/chenlycly/article/details/131405795
常用的C++软件辅助分析工具有PE工具、Dependency Walker、Process Explorer、Process Monitor、API Monitor、Clumsy、Windbg、IDA Pro等,本专栏详细介绍如何使用这些工具去巧妙地分析和解决日常工作中遇到的问题,很有实战参考价值!
专栏3:(本专栏涵盖了多方面的内容,是当前重点打造的专栏,专栏文章已经更新到380多篇,持续更新中...)
C/C++实战进阶(专栏文章,持续更新中...)https://blog.csdn.net/chenlycly/category_11931267.html
以多年的开发实战为基础,总结并讲解一些的C/C++基础与项目实战进阶内容,以图文并茂的方式对相关知识点进行详细地展开与阐述!专栏涉及了C/C++领域多个方面的内容,包括C++基础及编程要点(模版泛型编程、STL容器及算法函数的使用等)、C++11及以上新特性(不仅看开源代码会用到,日常编码中也会用到部分新特性,面试时也会涉及到)、常用C++开源库的介绍与使用、代码分享(调用系统API、使用开源库)、常用编程技术(动态库、多线程、多进程、数据库及网络编程等)、软件UI编程(Win32/duilib/QT/MFC)、C++软件调试技术(排查软件异常的手段与方法、分析C++软件异常的基础知识、常用软件分析工具使用、实战问题分析案例等)、设计模式、网络基础知识与网络问题分析进阶内容等。
专栏4:
VC++常用功能开发汇总(专栏文章,持续更新中...)https://blog.csdn.net/chenlycly/article/details/124272585
将10多年C++开发实践中常用的功能,以高质量的代码展现出来。这些常用的高质量规范代码,可以直接拿到项目中使用,能有效地解决软件开发过程中遇到的问题。
专栏5:
Windows C++ 软件开发从入门到精通(专栏文章,持续更新中...)https://blog.csdn.net/chenlycly/category_12695902.html
根据多年C++软件开发实践,详细地总结了Windows C++ 应用软件开发相关技术实现细节,分享了大量的实战案例,很有实战参考价值。
2.3、通过Process Explorer工具看到每次泄漏的内存大小
通过这个工具就能实时地观察到程序虚拟内存的变化,经观察得知,确定是内存泄漏导致的,每加入一次会议然后再退会,内存就会泄露几十MB,甚至上百MB,拷机执行多次,就会将程序的用户态虚拟内存耗尽,导致程序闪退。
注意:Process Explorer工具显示的不是程序进程占用的总虚拟内存(用户态虚拟内存+内核态虚拟内存),显示的只是用户态的虚拟内存。一般程序的业务代码都是运行在用户态中的,占用的基本是用户态虚拟内存,系统内核模块以及驱动模块才会占用内核态虚拟内存。我们用工具查看用户态虚拟内存即可。
测试人员根据现象,提了如下的bug:
每次记录一下未入会时的虚拟内存,等程序加入会议后记录一下入会状态下占用的虚拟内存,然后退出会议后再记录一下占用的虚拟内存,这样退会时占用的虚拟内存减去未入会时的虚拟内存,就是每次加入会议然后退出会议这种场景下泄漏的内存大小。每次都会泄露几十MB甚至上百MB。
3、使用历史版本比对法排查内存泄漏问题
这个内存泄漏问题是必现的(在我们的测试环境中也是必现的),我们开发这边也能轻易复现。这个问题以前的版本应该是没有的,如果有的话,之前就暴露出来了。
3.1、内存泄漏检测工具介绍
内存泄漏问题一般比较难查,使用内存泄漏检测工具呢,很多时候也查不准,代码中包含了上百个业务模块,代码中到处都是动态new内存的,有时专业的内存检测工具也无法界定哪块内存是泄漏的。有时可能需要尝试使用多个内存检测工具,甚至都无法定位泄露点。
至于专用的内存泄漏检测工具,Windows上有Visual Leak Detector(简称VLD)、VMMap、DebugDiag和Windbg调试器等,Linux上则有Valgrind和AddressSanitizer。
关于VLD检测内存泄漏的完整案例,可以查看我的文章:
使用Visual Leak Detector(VLD)排查C++程序内存泄漏https://blog.csdn.net/chenlycly/article/details/135472681 关于Windbg检测内存泄漏的案例,可以查看我的文章:
使用Windbg排查C++程序内存泄漏问题https://blog.csdn.net/chenlycly/article/details/121295720使用 Windbg 的 !heap 命令分析内存泄漏https://blog.csdn.net/chenlycly/article/details/131576063 关于Linux系统上的Valgrind和AddressSanitizer工具,可以查看我的文章:(微软从Visual Stdio 2019 19.6版本开始引入Google的AddressSanitizer内存检测工具,下面的文章有讲到)
Windows和Linux下排查C++软件异常的常用调试器与内存检测工具详细介绍https://blog.csdn.net/chenlycly/article/details/126381865为什么选择C/C++内存检测工具AddressSanitizer?如何使用AddressSanitizer?https://blog.csdn.net/chenlycly/article/details/132863447 有时使用一个工具可能无法定位内存泄漏问题,可能需要尝试使用多个内存检测工具去排查,之前记录了内存泄漏排查的专题,感兴趣也可以去看看:
C++内存泄漏排查https://blog.csdn.net/chenlycly/category_12370029.html
3.2、采用历史版本比对法去排查内存泄漏
当前内存泄漏问题是必现的,也是很容易复现的,并且在之前的版本中应该是没有泄漏的,如果有泄漏,测试人员应该早就发现这个问题了。鉴于使用内存泄漏分析工具可能无法定位问题,所以决定采用了一个有效的笨办法,采用历史版本比对法,使用二分法选择不同时间点的版本,看看内存泄漏是从那天开始出现的。
历史版本比对法,虽然执行起来比较笨拙,但确实很好用,也很实用,我们在项目中多次使用过,屡试不爽啊!
历史版本比对法,要依赖一套完备的自动化编译系统,要有修改(不管是修改代码,还是底层发布库过来)就自动编译版本。有足够小的版本颗粒度,这样更有利于我们使用历史版本比对法。我们这边就有这样的版本编译系统(通过复杂的多脚本控制的),编译版本的机器会每天凌晨通过svn或git将代码和库都更新成最新的,然后发起编译,生成版本(生成安装包或者升级包),然后自动将版本拷贝到文件服务器上供大家使用。这样只要代码有修改或者有库发布,在文件服务器上都会生成对应的版本,如下所示:
这样我们就好执行历史版本比对法了,就是用二分法安装不同时间点的版本,看看从哪天开始出现内存泄漏的,然后查看前一天svn或git上的修改记录就能快速找到方向和线索了。
3.3、找到最开始出现内存泄漏的那个版本,找到排查问题的线索
经使用二分法安装不同时间点的版本,发现20240326早上编译的版本没问题,然后从20240327早上编译的版本就开始有问题了。基本可以确定是20240326当天提交的代码或者发布的库引发的问题。
于是查看了SVN上的代码修改记录和底层库发布记录。代码有段时间没有修改了,只有底层的,只有底层发布的新版本的库xxrtcmpdll.dll。这个库之所以发布,肯定是该库修改代码了,于是找到维护该库的同事详细沟通了一下。
4、回想相关代码问题修改历史,最终找到了问题的原因
之前为了优化内存占用对代码进行了优化,当时相关问题是我这边在推进,对问题的细节与处理进程比较了解。在RTC会议中,每个视频窗口都对应一个CPlayer播放对象,最多有9个视频窗口,以前都是模块在主程序启动时初始化时就把这9个视频播放对象CPlayer创建好了,然后在程序退出时再将这几个对象销毁。
后面因为程序中引入了庞大的webrtc开源库,webrtc开源库比较大,初始化时就会申请大量的内存,即webrtc开源库可能会占用较大的内存,可能不适合用于我们的32位程序,可能会导致我们32程序的2GB的用户态虚拟内存很容易被占满,时不时会出现Out of memory内存耗尽的异常,导致程序发生闪退。
后来为了解决内存占用的问题,对多个模块的内存进行优化。对于播放视频的CPlayer对象,只有入会后才会使用,不用在模块初始化的时候就创建9个CPlayer对象,而是在入会时才去创建,然后在退会时将CPlayer对象销毁掉。
结果在退会时销毁CPlayer对象时,因为多个线程在操作这些Cplayer对象,导致死锁,但一直没查到原因。一个是xxrtcmpdll.dll模块中绘制视频的线程,一个是mediaplayer.dll内部的一个线程,一旦这两个线程因为死锁发生卡死,导致重新加入会议后视频窗口全是黑的,因为绘制视频的线程死锁了、不绘制图像了,所以视频窗口是黑的。
对于内存不够用的问题,搜索发现,可以在Visual Studio中对exe主工程进行配置,设置成大地址模式:
将32位程序的用户态虚拟内存的上限由默认的2GB调到3GB,这样就很好地解决了内存不够用的问题。关于将32位程序用户态虚拟内存从2GB扩充到3GB的详细案例,可以查看我的文章:(需要使用/LargeAddressAware编译配置选项)
如何配置32位C++程序启用大地址模式(将用户态虚拟内存从2GB扩充到3GB),以解决用户态虚拟内存不够用问题?(项目实战案例解析)https://blog.csdn.net/chenlycly/article/details/138460583 采用上述方法将内存不够用的问题解决了,而且退会时销毁CPlayer对象时会产生死锁,所以决定将关于CPlayer对象的内存优化取消,回退到之前没优化的版本,结果代码回退的不完全导致了当前的内存泄漏问题。
在退会时不销毁CPlayer对象,放到程序退出时才销毁。但没有处理入会时创建CPlayer对象的逻辑,每次都会创建新的CPlayer对象。这就是产生内存泄漏的原因,我在排查的时候大概猜到可能是这个原因导致的,于是把这个估计原因告诉维护该模块的同事。同事顺着这个思路,快速地找到了问题,和我估计的场景是完全一致的。就是每次创建新的CPlayer对象,之前创建的CPlayer对象没管了,所以产生了内存泄漏,每次泄漏的内存大小都很固定,实测时也确实是这样的。
解决办法是,入会时不再创建Cplayer对象就好了。
5、可能是调用了哪些操作符或接口去动态申请内存?
代码中去动态申请内存,可能是使用操作符new(要用delete去释放),可能使用malloc(要用free去释放),也可能调用系统API函数HeapAlloc从堆上分配内存(要用HeapFree去释放),还可能调用API函数VirtualAlloc或VirtualAllocEx从虚拟内存上分配内存(要用VirtualFree或VirtualFreeEx去释放),还可能调用其他的API函数。使用不同操作符或者函数动态申请内存,要用对应的操作符或者函数去释放,不能交叉使用,张冠李戴地使用可能会引发异常崩溃。
之前排查的一个WebRTC开源库中的内存泄漏问题,就是没调用HeapFree去释放使用HeapAlloc申请来的堆内存,导致的内存泄漏。可以去查看对应的文章:
开源WebRTC库放大器模式在采集桌面图像时遇到的DPI缩放与内存泄漏问题排查https://blog.csdn.net/chenlycly/article/details/131146506
6、引发内存泄漏的常见场景总结
所谓内存泄漏,就是动态申请的内存在使用完后没有释放,就导致了泄漏。有内存泄漏的代码如果被频繁地执行,则会导致频繁的内存泄漏,会导致程序占用的虚拟内存越来越多,程序运行可能会变慢(程序要可能会频繁地在虚拟内存与物理内存之间切换),最大的影响是,程序占用的越来越多的虚拟内存,可能会接近或达到程序虚拟内存的上限,导致Out of Memory内存耗尽的异常,程序会发生崩溃。
一般程序的业务代码基本都是运行在用户态的,占用的主要是用户态的虚拟内存,所以内存泄漏一般是用户态的内存泄漏。
对于32位程序,系统在程序启动时会给程序分配4GB的虚拟内存(内存按32位寻址),其中2GB是用户态的虚拟内存,如果程序中有内存泄漏的代码(用户态的泄漏)被频繁地执行,可能很快就能达到2GB的用户态虚拟内存的上限,从而引发Out of memory异常。当然我们可以在Visual Studio中对exe主程序工程开启/LargeAddressAware大地址编译选项,将32程序用户态虚拟内存从2GB扩大到3GB,虽然可以缓解内存泄漏的问题,但对于持续的泄漏也无济于事。
对于64位程序,内存寻址范围是64位,系统会分配比32位程序的4GB多的多的虚拟内存,但即使再大,也是有上限的,如果持续泄漏,也总会到耗尽的那一刻的。
根据多年的问题排查实践,总结出引发内存泄漏的原因与场景主要有以下几种:
1)通过malloc/new动态申请的内存,在使用完后,没有调用free/delete去释放(也可能是调用了上面讲到的HeapAlloc或VirtualAlloc接口)
动态申请的内存是给软件业务模块用的,受业务逻辑控制的,一般动态申请的内存没释放都是因为业务逻辑控制的有问题。比如本例中的问题,将几个CPlayer对象的析构放到程序退出时调用的接口中,但入会时的逻辑忘记修改了,每次入会都会重新创建新的CPlayer对象,这样老的Cplayer对象没有delete,导致内存泄漏。
2)调用某些特殊的接口,接口内部动态申请内存,然后将地址返回给接口调用者使用,调用者在使用完后负责释放对应的内存,如果没释放,则会造成内存泄漏
比如我们在调用GDI+库中的Bitmap静态成员函数FromFile、FromStream和FromHBITMAP等静态接口时,这些接口内部都会new出一个Bitmap对象,然后把new出的Bitmap对象地址作为返回值,返给外部使用,如下所示:
// 头文件中函数的声明
static Bitmap* FromFile(
IN const WCHAR *filename,
IN BOOL useEmbeddedColorManagement = FALSE
);
static Bitmap* FromStream(
IN IStream *stream,
IN BOOL useEmbeddedColorManagement = FALSE
);
static Bitmap* FromHBITMAP(IN HBITMAP hbm,
IN HPALETTE hpal);
// cpp文件中函数的实现
inline Bitmap*
Bitmap::FromFile(
IN const WCHAR *filename,
IN BOOL useEmbeddedColorManagement
)
{
return new Bitmap(
filename,
useEmbeddedColorManagement
);
}
inline Bitmap*
Bitmap::FromStream(
IN IStream *stream,
IN BOOL useEmbeddedColorManagement
)
{
return new Bitmap(
stream,
useEmbeddedColorManagement
);
}
inline Bitmap*
Bitmap::FromHBITMAP(
IN HBITMAP hbm,
IN HPALETTE hpal
)
{
return new Bitmap(hbm, hpal);
}
上述函数不会去管理这个返回的Bitmap对象的生命周期(从对象创建到对象销毁),因为是给外部使用的,外部根据自己的需要确定使用到什么时候,并且外部负责在不用的使用去delete这个对象,如下所示:
Bitmap *pSrcBmp = Bitmap::FromHBITMAP(hBmp, NULL);
if (pSrcBmp == NULL)
{
return;
}
// ... 中间代码省略
delete pSrcBmp;
如果不去delete,则会导致内存泄漏。
3)在多态中,没有将基类的析构函数声明为虚函数,导致delete多态时的子类对象时没有执行子类的析构函数,导致内存泄漏
这个场景有较强的隐蔽性。在多态中,将子类对象地址赋值给父类的指针,我们在delete该父类指针的时候,因为指针中存放的是子类的对象,应该要执行子类的析构函数,析构函数除了释放子类对象本身的内存,可能在析构中还会执行其他资源的释放,如果父类的析构函数不声明为虚函数,则这种场景下子类的析构就不会被执行到,就会导致内存泄漏。这个场景的示例代码如下:
// 基类
class CBase
{
public:
CBase();
~CBase();
};
// 派生类
class CDerived
{
public:
CDerived();
~CDerived();
};
// 多态
CBase* pBase = new CDerived;
delete pBase;
new出一个子类CDerived对象,赋值给父类的指针CBase* pBase,然后再delete pBase时因为父类CBase的析构没有声明为虚函数,导致没有执行子类CDerived的析构函数,从而导致内存泄漏。
有时,子类的析构函数不仅要析构子类对象的内存,可能还会在析构函数中去释放其他的资源,比如我们在子类对象中定义了一个STL列表vector<CChatDlg*> m_vtChatDLgList,在子类的析构函数中会去for循环遍历这个列表去delete列表中的CChatDlg对象,如下所示:
class CDerived
{
public:
CDerived();
~CDerived();
private:
vector<CChatDlg*> m_vtChatDLgList
};
CDerived::~CDerived()
{
for ( int i = 0; i < m_vtChatDLgList.size(); i++ )
{
CChatDlg* pChatDlg = m_vtChatDLgList[i];
delete pChatDlg;
}
}
如果子类的析构函数没执行到,则不仅子类对象本身的内存没释放,而且析构函数中释放STL列表中存放的类对象也没有释放,这些都是内存泄漏。
到底什么时候该将析构函数声明为虚函数?什么时候不要将析构函数声明为虚函数呢?
如果将C++类的析构函数声明为虚函数,C++类对象中会内置一个虚函数表指针,还会维护一个虚函数表(存放虚函数代码段的地址),这是声明为虚函数的开销。如果类可能会被继承,则最好将其析构函数声明为虚函数。如果类不会被继承,且明确使用了关键字final标识(使用该关键字就设定该类不能被继承,这是C++11新增的关键字),就不要将析构函数设置为虚函数。
关于C++11新增的几个常用关键字的说明,可以查看我的文章:
C++11 新特性 ① | C++11 常用关键字实战详解-CSDN博客文章浏览阅读2.1w次,点赞90次,收藏92次。本文结合工作实践详细介绍C++11 常用关键字,以供借鉴或参考。https://blog.csdn.net/chenlycly/article/details/132701306
4)第三方注入库有内存泄漏,注入到我们的程序进程中导致我们的程序发生内存泄漏
这是我们在项目中遇到的真实案例,客户系统中安装了第三方安全软件,安全软件基本都是通过远程注入到各个进程的方式实现对各个进程的监控。结果这个注入模块在某个场景下有内存泄漏,因为其注入到我们的程序进程中,位于我们的进程空间中,占用的是我们程序进程的虚拟内存,这个注入模块有泄漏,就是我们整个进程有泄漏。
当时我们的软件在客户的机器上运行半个小时后,就会出现闪退崩溃,后来我们发现程序的内存在运行过程中在不断的增长,判断是程序中发生了内存泄漏。客户一开始不认为是第三方安全软件的问题,因为其他软件在该问题电脑上运行并没有明显的问题。后来我们使用内存分析工具定位到泄漏发生在第三方安全软件的注入模块中,客户才愿意联系第三方安全软件去排查这个泄漏问题。
7、最后
历史版本比对法,虽然比较笨拙,但确实很好用,也很实用,我们在项目中多次使用过。本案例中使用历史版本比对法排查内存泄漏没啥难度,就是其中涉及到的诸多细节问题,还是值得关注一下的!
此外,排查和分析问题时,切记不能一知半解、只懂皮毛,要搞清楚问题的来龙去脉,去关注问题中诸多细节,才更有价值!细节出真知,这点想必大家是深有体会的!