内存泄漏 :动态内存不再被使用或回收的现象

更新时间:2024-09-21 03:19

内存泄漏(Memory Leak),是指是指由于疏忽或错误,造成程序未能释放已经不再使用的内存的情况。程序在申请获得动态内存块并使用完毕后,没有释放所申请的动态内存就将保存动态内存地址的变量用于其他用途,使得这些动态内存不可能再被程序使用,也无法被操作系统回收。

内存泄漏以发生的方式来分类可分为常发性内存泄漏、偶发性内存泄漏、一次性内存泄漏和隐式内存泄漏。产生内存泄漏原因主要包括忘记释放内存、存在内存泄漏的析构函数以及未释放指针指向的对象等。起初,内存泄漏的变化常常被忽视,但是由于系统内存耗尽,反复泄漏会导致应用程序的性能下降,进而影响程序和系统运行。如果被有意者对其恢复应用,也就会出现信息泄漏,导致发生系统安全问题。

通常在内存泄漏检测过程中,可以将其分成动态检测方法以及静态检测方法两种,常用的动态检测工具有Mtrace,Memwatch,Puriy等,静态检测工具有BEAM、PC-Lint等。内存泄漏检测采用的关键技术包括垃圾回收、智能指针和虚拟技术等,可通过监控内存、清理栈等方法进行解决。

产生原因

未释放分配的内存

导致内存泄漏最常见的一个原因是分配了内存后,忘记了释放内存。一个进程运行时会占据一块内存空间,进程结束后若不释放内存,则该进程下次启动时,将会占据新的内存空间。进程频繁的启动、停止,导致该进程占据的内存空间越来越大。一般来说,在软件中若有某个功能频繁启动关闭,则容易产生内存泄漏。

存在内存泄漏的析构函数

当人们使用C/C++、delphi或java等开发工具编写应用程序时,一般在构造函数中使用malloc、realloc、new等函数从堆中分配到一块内存,该内存空间使用结束后,程序必须相应地调用free或delete等函数释放该内存块,以便操作系统重新分配与再次使用此内存。若在使用结束后,没有及时在程序的全部执行路径或析构函数中释放此内存块,并且无合适的指针指向该块内存,使得该部分内存失去重用性,则会造成内存泄漏。

指针操作

循环引用

在某些语言中,两个对象相互引用可能会导致两个对象都无法被垃圾回收的情况,即使程序的其他部分没有引用它们。例如,一个简单的循环引用问题:有对象A和对象B,对象A中含有对象B的引用,对象B中含有对象A的引用。此时,对象A和B的引用计数器都不为0。但是,在系统中却不存在任何第3个对象引用了A或B。也就是说,A和B是应该被回收的垃圾对象,但由于垃圾对象间的相互引用,从而使垃圾回收器无法识别,引起内存泄漏。

静态集合

使用随时间增长而从未被清除的静态数据结构可能会导致内存泄漏。例如,向静态列表添加元素而不删除它们可能会导致列表无限增长。

外部类引用内部类

这个场景主要出现在非静态内部类中,在类初始化时,内部类总是需要外部类的一个实例。每个非静态内部类默认都持有外部类的隐式引用。如果在应用程序中使用该内部类的对象,即使外部类使用完毕,也不会对其进行垃圾回收。前端闭包与外部类引用内部类的情况非常类似,所以前端闭包运用得不好也会造成内存泄漏。

未关闭连接

在Java中,在对数据库进行操作的过程时,首先需要建立与数据库的连接,当不再使用时,需要调用close方法来释放与数据库的连接。只有连接被关闭后,垃圾回收器才会回收对应的对象。否则,如果在访问数据库的过程中,对Connection、Statement 或ResultSet不显式地关闭,将会造成大量的对象无法被回收,从而引起内存泄漏。

事件侦听器

不分离事件侦听器或回调可能会导致内存泄漏,尤其是在Web浏览器等环境中。如果一个对象附加到某个事件但不再使用,则它不会被垃圾收集,因为该事件仍然保留对其的引用。

中间件和第三方库

有时造成内存泄漏的原因可能不是因为应用程序代码,而是在其使用的中间件或第三方库中。这些组件中的错误或低效代码可能会导致内存泄漏。

内存碎片

内存碎片虽然不是传统意义上的泄漏,但仍可能会导致内存使用效率低下。随着时间的推移,内存分配之间的小间隙会累积,从而难以分配更大的内存块。

孤立线程

产生但未正确终止的线程可能会消耗内存资源。这些孤立线程会随着时间的推移而积累,尤其是在长时间运行的应用程序中。

缓存过度使用

如果没有合适的逐出策略,实现缓存机制可能会导致内存无限期地消耗,特别是在缓存不断无限制增长的情况下,可能会导致内存泄漏。

主要类型

以发生的方式来分类,内存泄漏可以分为以下4种类型:

常发性内存泄漏

常发性内存泄漏指的是每次执行特定操作时都会发生的内存泄漏。这类泄漏通常更容易被发现和修复,因为它们的发生模式一致,通过监测内存使用情况可以较容易地定位到问题源头。例如,每次调用某个函数时分配内存而忘记释放,或者循环中创建对象而没有适当的清理机制则会造成常发性内存泄漏。

偶发性内存泄漏

偶发性内存泄漏是指发生内存泄漏的代码只有在某些特定环境下或操作过程中才会发生。例如在funB()函数中,如果funB()函数只有在特定环境下才返回True,那么pB指向的HBITMAP对象并不总是发生泄漏。常发性和偶发性是相对的,对于特定的环境,偶发性的也许就变成了常发性的。

一次性内存泄漏

一次性内存泄漏是指发生内存泄漏的代码只会被执行一次,或者由于算法上的缺陷,导致总会有一块且仅一块内存发生泄漏。从用户使用程序的角度来看,内存泄漏本身不会产生什么危害,作为一般的用户,根本感觉不到内存泄漏的存在。真正有危害的是内存泄漏的堆积,这会最终消耗尽系统所有的内存。从这个角度来说,一次性内存泄漏并没有什么危害,因为它不会堆积。比如,在类的构造数中分配内存,在析构函数中却没有释放该内存,但是因为这个类是一个Singleton,所以内存泄漏只会发生一次。

隐式内存泄漏

程序在运行过程中不停地分配内存,但是直到结束的时候才释放内存,哪怕最终程序释放了所有申请的内存,但是对于一个服务器程序,需要运行几天、几周甚至几个月,然而隐式内存泄漏很难被检测和定位,内存泄漏一旦暴露,可能会造成难以预估的损失。例如对于一个服务器程序,这类泄漏同样会堆积,造成系统内存资源的耗尽。因此,称这类内存泄漏为隐式内存泄漏。

危害

内存资源浪费:内存泄漏在软件程序设计中属于是缺陷之一,在用户程序运行中,如果设计中存在缺陷,就会导致在内存应用后程序没有主动通知操作系统,从而丧失其使用权,那么这一堆内存块就会无法控制也不能重新应用,致使系统无法释放不需要的内存空间,也就会出现内存资源浪费。

内存使用量增加:随着更多内存泄漏且未释放,整个系统内存使用量会增加,使得可用于其他进程和应用程序的内存减少。

应用程序不稳定:随着内存使用量随着时间的推移而增加,存在内存泄漏的应用程序可能会遇到崩溃、意外行为和间歇性故障。这会导致不稳定和可靠性问题。

性能下降:起初,内存泄漏的变化可能相当离散,并且常常被忽视,但是由于系统内存耗尽,反复泄漏会导致应用程序的性能下降。随后,系统可能由于所谓的“过度分配”或“虚拟内存耗尽”而无法正常工作或大幅减慢。

资源争用:当系统试图管理有限的资源时,较高的内存使用量还会导致对缓存和 CPU 时间等资源的更多争用。这进一步降低了性能。

响应速度降低:当单个进程消耗大量内存时,它往往会占用越来越多的主内存,迫使其他程序移至辅助存储器,并大大降低系统的响应速度。即使泄漏的程序被终止,其他程序可能需要一些时间才能切换回主内存,性能可能需要一些时间才能恢复到正常水平。

系统崩溃:最终,内存泄漏会使得程序因内存耗尽而崩溃,对于内存受限系统(如嵌入式系统),或程序执行时间较长的系统(如服务器系统)而言,这种问题更加严重。例如1992年在英国伦敦发生的救护服务系统崩溃事件,就是因系统中存在内存泄漏,在程序连续运行三周后最终引发了危险事故。随着计算机内存容量的不断增大和虚拟内存技术的发展,内存泄漏缺陷越来越难以觉察。

安全风险:内存泄漏导致数据在内存中停留的时间比预期的要长。这些数据可能包含密码、密钥或其他敏感信息,如果被恶意软件或攻击者访问,这些信息会带来安全风险。

检测方法

通常在内存泄漏检测过程中,可以将其分成动态检测方法以及静态检测方法两种。

动态检测方法

动态检测方法在应用中,是针对应用程序实施动态内存分配过程中,实现对堆内存的标记。如果程序退出,同时将已经分配内存进行释放过程中,分析堆上残留对象,这些对象即为应用程序所泄漏的内存。在这一方法应用中可以对应用中存在的程序缺陷及时发现,然而动态特性要求在程序实际执行过程中需要有较大性能以及时间成本,且在执行路径覆盖过程中也存在死角,导致检测过程中存在有不完备性,存在较高的漏报率。

检测工具

静态检测方法

静态检测方法是指对程序源代码的分析,对于可能会出现的执行路径均模拟分析,以此实现对程序执行路径中可能出现安全问题的判定。在此检测过程中,不需要实际执行程序,可以改善动态分析中存在较大性能开销问题,但是这一方法在应用中对于程序输入、环境变量等信息无法实现精确判断,在执行路径模拟中可能会出现不可行路径。

检测工具

诊断过程

内存分配成功性验证:在进行内存分配后,应验证操作是否成功。常规方法包括:

内存的初始化:分配的内存可能包含随机数据。立即初始化这些内存区域是防止使用未定义数据的关键步骤。

边界控制:处理数据结构如数组时,必须确保所有访问都在有效边界内。应通过适当的循环条件和索引检查来避免越界访问。

内存需求计算:准确计算所需内存量,以确保为数据结构分配足够空间,防止溢出。

动态分配内存的释放:维护对动态分配内存的引用,并在不再需要时适时释放,以避免内存泄漏。

错误处理与资源管理:在出现错误时,确保释放任何已分配的资源,防止内存泄漏。

处理已释放内存的引用:释放内存后,应立即更新任何相关指针,以避免悬挂指针或未定义行为。

解决方法与技术

方法

内存监控

内存监控的一个选择是通过管理虚拟机的影子页表实现。在虚拟机运行时,内存管理模块载入影子页表,试图完成从GVA到HPA的映射。如果影子页表中已经存在某个GVA到其HPA的映射,那么这个转换过程会自动完成;否则,虚拟机会陷入到虚拟机管理器中,由后者完善GVA到HPA的映射。但是影子页表可能会产生额外的内存访问延迟,从而导致严重的性能损失。基于影子页表的这种特征,通过如下步骤即可完成对内存的监控:

首先,计算出内存片断所跨越的所有页面,例如在页面大小为4KB的虚拟机中,首地址为0x80a8400,长度为10KB的内存片断,跨越了3个页面;其中占第1个和第3个页面的部分,完全占用第2个页面。随后,在影子页表中消除对这些页面的GVA到HPA的映射关系。由于这些页面的映射关系被消除,只要虚拟机试图访问该内存片断,都会导致虚拟机的陷入;最后,在虚拟机陷入时,分析陷入指令所访问的内存地址是否属于该内存片断。

此外,英特尔 VT的VMX架构通过引入扩展页表(Extended Page Tabe,EPT)机制实现对物理内存的访问控制。EPT是Intel在VT-x技术基础上增加的一种硬件辅助内存虚拟化技术。在处理器端,VMX架构通过引入EPT机制来实现VM物理地址空间的隔离。当客户机通过指令访问内存时,首先,客户机操作系统通过分页机制将线性地址(linear address)转换为客户机物理地址GPA(Guest-Physical Address),然后,通过定义在VMM中EPT页表将GPA转换为主机物理地址HPA(Host-PhysicalAddress),从而访问真正的物理地址。

清理栈

异常处理

在编程中,异常处理是一种结构化的方法,用于处理程序运行时可能遇到的错误和异常情况。它主要依赖于几个核心概念:抛出异常、捕获异常、以及确保资源被适当管理。虽然具体的实现细节可能因编程语言的不同而有所差异,但基本原理是通用的。以Java为例,异常处理的方法有两种:一是通过throws和throw抛出异常,二是使用try-catch-finally结构对异常进行捕获和处理。异常处理也称捕捉异常。异常处理用到5个关键字:try、catch、fnally、throw、throws。

抛出异常

程序在执行过程中,当遇到错误或异常情况时,会创建并抛出一个异常对象。这个异常对象包含了异常的类型和发生时的状态信息。如果当前作用域无法处理该异常,它将被传递到调用栈的上一层,直至找到适当的异常处理代码进行处理。

捕获异常

为了防止异常导致程序崩溃,编程语言提供了结构化的异常捕获机制,如try-catch-finally结构(或等效的语言结构),允许开发者有效地捕获并处理异常。

Try:try是为了保证出现异常后程序不至于崩溃,可以正常运行。当函数内部出现try语句,就需要写另一个语句保护,直到所有的try语句将出现的异常处理完。就不再生成新的try语句。通常情况下,try块不应该过大,过大的try块可能导致代码难以理解和维护。为了使代码更加清晰和易于理解,应该对try块进行细分和拆分,一遍处理出现异常情况的代码块,同时也方便后续代码的迭代和维护。

catch:捕捉try语句块内发生异常。此外,在捕获异常时,应该使用最具体的异常类型来捕获异常,而不是使用异常基类来捕获所有类型的异常。其次,处理异常时提供有意义的错误消息。除了捕获和处理异常外,还应该为捕获的异常提供有意义的错误消息。

Finally:不管出现任何情况都会被执行。如果try中所有语句被执行完毕,则进人finally阶段。如果finally阶段没有异常,则整个try-catch-finally完成。如果finally阶段出现异常,那么将重新被catch捕获,return会try语句块内,重新执行直至不再生成新的try语句,完成语句的执行,直接终止。

技术

垃圾回收

GC,全称Garbage Collection,即垃圾回收,是一种自动内存管理的机制。当程序向操作系统申请的内存不再需要时,垃圾回收主动将其回收并供其他代码进行内存申请时候复用,或者将其归还给操作系统,这种针对内存级别资源的自动回收过程,即为垃圾回收。而负责垃圾回收的程序组件,即为垃圾回收器。通常,垃圾回收器的执行过程被划分为两个半独立的组件:

(1)赋值器(Mutator):这一名称本质上是在指代用户态的代码。因为对垃圾回收器而言,用户态的代码仅只修改对象之间的引用关系,即在对象图(对象之间引用关系的一个有向图)上进行操作;

(2)回收器(Collector):负责执行垃圾回收的代码。

垃圾回收的算法

垃圾回收主要依赖以下两种算法来识别不再需要的内存:

垃圾回收的限制

尽管垃圾回收提高了内存管理的效率,但在特定环境下,它也面临一些限制:

智能指针

为了简化动态内存管理,C++11引入了标准的智能指针解决方案,通过\u003cmemory\u003e头文件中的模板库提供。智能指针采用代理设计模式,自动管理动态内存的生命周期。基本思想是将new操作返回的指针封装到一个局部对象中,这个对象成为动态内存的所有者。当所有者对象离开作用域被销毁时,其析构函数会自动释放动态内存。

C++11的智能指针类型
代码示例

“unique_ptr”是“独占式智能指针”。使用它管理前面的O类指针:

例中p是一个智能指针。其中的“\u003cO\u003e”指明它所指向的数据类型是“O”。除了创建方法不太一样,以及不用手工释放之外,智能指针使用上和它所管理的裸指针基本一样。

“std::shared_ptr”是“共享式智能指针”。shared_ptr可以被复制很多次,并且指针指向的对象直到最后一个shared_ptr被销毁,都会保持有效。

在该示例中,如果有一个指向节点的shared_ptr实例,就可以确保节点存在,但是当删除共享指针后,并不关心节点的存在:它可能被删除;如果有另一个节点连接到它,则也可能被保留。

虚拟存储技术

Storage Virtualization(译为:虚拟存储)是利用硬、软件技术,将分别独立存储的、种类各异的物理存储体集合成一个可以供网络用户共同使用的综合逻辑虚拟存储空间。这个虚拟存储空间的容量等于存储共享信息的所有物理存储体的容量之和,虚拟存储空间的访问带宽也是约等于存储共享信息的所有物理存储体访问带宽的总和。

虚拟存储器不同于一般物理存储体,它是一种利用逻辑方法对物理存储体进行编辑、并将处理后的图像向用户显示的逻辑存储器。所以,用户在查阅共享信息时,使用的是虚拟设备而不是实物,这样能够合理使用存储空间,并使利用率最大化、合理化。

具体案例

案例1

假设在Client从Server端断开后,Server并没有呼叫Disconnected()函数,那么代表那次连接的Connection对象就不会被及时地删除,在Server程序退出的时候,所有Connection对象会在ConnectionManager的析构函数里被删除。当不断地有连接建立或断开时,隐式内存泄漏就发生了。

案例2

在这个例子里,如果函数做了某些导致异常的操作,异常处理函数就不会释放pObject,从而导致了内存泄漏。

参考资料

How to Identify Memory Leaks.atatus.2024-02-05

What is a Memory Leak?.codereliant.2024-02-29

What Are Memory Leaks and How To Detect Them?.codete.2024-02-05

免责声明
隐私政策
用户协议
目录 22
0{{catalogNumber[index]}}. {{item.title}}
{{item.title}}
友情链接: