暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

内存管理的噩梦:泄漏与溢出

一杯咸茶 2022-07-04
1008

写这篇文章的动机来自技术社区和github上对.net5或.net6 runtime中暴露出的许多内存泄漏和GC未正常工作问题现象的思考,其实很多问题都是大家对于.net中内存消耗工作方式或者换言之是不知道如何测试度量所导致的。

计划从内存泄漏和内存溢出两个常见问题开始,深入浅出的聊一聊.net中内存模型、内存管理、GC工作原理、GC常见问题、内存分析、最佳实践

大家且看我如何装逼,哈哈~



1


内存模型





通常,在我开始作为一个码农参加工作面试时候,常常会被问及到.net的内存模型。当我从脑海搜寻相关记忆时,并解释的时候,面试官往往眼睛呆滞;意识到考官们也只是为了听到那两个神奇的词儿(Stack和Heap)。似乎做开发的每个人都知道.Net内存模型是使用堆栈和堆,但每个人并不是都那么深入了解它的含义。

【题外话:面试时候考这个,你是在靠记忆力还是真没题可以出了?大家思维惯性会觉得掌握这些“繁杂的细节”,往往会是解决问题的“原理”上的助力;可解决问题真的不在于这些,往往是你的思维思考方式,如何分析定位问题,是靠更抽象的“原子性“逻辑思考模式,俗话为“大道至简”;这些只是别人制造的“概念性工具”,千万不要陷入“经验主义”陷阱。】

首先,对于这两个概念来说。名字太过“专业”或者说糟糕。为什么堆栈?为什么是堆?如果The Stack是个堆,而The Heap是一个堆栈,你知道区别吗?我们来简单回顾一下历史,找找这两个词的来源。




早在1970年初期,当C语言被丹尼斯·里奇(Dennis Ritchie)定义时,AT&T的原始编译器就是基于DEC PDP-11/20电脑上的堆栈编译运行,这意味着CPU有一个专用于管理堆栈的寄存器,该寄存器保存返回地址函数调用,以及被“pushed”堆栈的寄存器的内容。由于是一个临时存储区,编译器设计者决定将函数局部变量存储在那里。现在,它们被直接访问,而不是通过堆栈的‘pop-push’机制,所以当它们在“the stack”上时,它们并不是真正的“a stack”的一部分。


现在,那是为了临时存储。对于更长期的内存需求,我们需要分配内存。在C中,这是在malloc()库函数中完成的(它是C运行时库的一部分,与C编译器完全分离)。它必须管理非常有限的内存空间(64KB是当时认为的最大内存),因而他们选择使用“the Heap”(堆数据结构--一种树)来管理正在分配和释放的RAM.


快进50年,随着我们从C到C++,到JAVA,再到C#,RAM内存管理本质上已经因为硬件的变迁,完全不同了。但是这些名称仍然遗留至今。甚至‘The Stack'可能都不是当年的‘a Stack’了,以及‘the Heap’都不一定是当年的‘a Heap’。

所以,我们可以忘了这两个“老家伙”的名字,重新赋予一个新的,更具描述性的名字;


我们可以把本地存储(又名“堆栈”)的地方称为“Here”。同时,顺其自然的,将长期存储的地方(“堆”)称为“There”.下面我们来看一个栗子:

    public class SomeClass
    {
    public SomeClass()
    {
    // the ctor
    }
    public void SomeMethod
    {
    int a = 12345;
    double b = 1.2564;
    string c = "Hello pig.";
    Node node = new Node();
    }

    }
    public class Node
    {
    int a;
    double b;
    string c;
    Node node;
    public Node()
    {
    a = 12345;
    b = 1.588;
    c = "Hello node";
    xNode=new Node(); //最难理解的就是这里!!!
    }
    }


    上述代码中'SomeMethod'本地值类型本质上具有较短的生命周期,存储在"Here":一个容易分配和释放且可以快速访问的内存空间。它具体是放在CPU的几级缓存并不太重要,我们也无需关心,这是微软他们设计C#语言运行时(CLR)需要考虑的东西。


    那么对于'SomeMethod'中的引用类型呢?它们是分开的--对象实际数据存储在"There",而'引用'(有人会认为这是从C里的古老法术术语“指针”)存储在'Here(栈)'。就好比使用“名片”来代替一个真实的人一样,告诉我们如果能“联系”到这个“人”。那么'SomeMethod'中的c和node就是这样的“人”。


    但是.net里的内存模型真像这么简单吗??非也,非也,且听我慢慢放。


    Node类的构造函数中,这是理解'The Stack'和'The Heap'让人最头疼的地方。当新建一个Node引用对象时候,是正在创建'There(堆)',然而Node它自己却在'Here(栈)'。Node在某个单独的'Theres'里保存了a,b和c;也就是在说a,b,c在“The Heap(堆)”,但是Node它的行为看上去就像是在‘Here(栈)’里一样。

    当然,xNode 引用了一个新的对象Node对象,它位于其他的'There(其他堆)'里,在这个‘There(其他堆)’里包含了它自己的一些‘Here(其他栈)’的数据.


    当然,从Node自身看,主要的(Node自己)‘Here’(栈)有一个参考指向那个临时的‘Here-t(xNode)’,实际上这个'Here-t(xNode)'是存在在'There(其他堆)'的。


    Stack(Here):是一个简单的LIFO(后进先出)结构.分配在栈上的变量直接存储到内存中,访问速度非常快,而且它的分配是由编译器在程序编译时完成的。调用方法时,CLR会在栈顶添加标签。然后,该方法会在执行时将数据压入栈。当方法执行后,CLR将栈重置为其先前的标签,弹出所有方法的内存分配是一个很简单的操作。


    Heap(There):可以看作时随机混杂的对象。它允许以随机顺序分配或释放对象。在堆上分配的变量在运行时分配其内存,访问速度偏慢,但堆的大小仅仅受虚拟内存大小的限制。堆需要垃圾回收的开销来保证其有序性。




    总结

    Stack(Here):栈区,由编译器自动分配释放,存放函数的参数值,局部变量的值等;




    Heap(There):堆区,由开发人员申请内存,在垃圾回收器的控制下工作。




    引述与拓展阅读:

    1.理论与实践中的C#内存模型(上):

    https://docs.microsoft.com/zh-cn/archive/msdn-magazine/2012/december/csharp-the-csharp-memory-model-in-theory-and-practice

    2.理论与实践中的C#内存模型(下):

    https://docs.microsoft.com/zh-cn/archive/msdn-magazine/2013/january/csharp-the-csharp-memory-model-in-theory-and-practice-part-2





    2


    内存管理





    在了解内存管理之前,我们先来了解一下最常见的两大关于内存的问题:内存溢出内存泄漏


    内存溢出(out of memory):通俗理解就是内存不够了,指程序要求的内存超出了系统所能分配的范围,通常在运行大型软件或3A游戏时,软件或3A游戏所需要的内存远远超出了主机内安装的内存所能承受的大小,就叫内存溢出。比如,你申请了一个int类型,但给了他一个long才能存放的数,那就会发生内存溢出,或者你创建一个大对象,而堆内放不下这个对象,这也是内存溢出


    内存泄漏(memory leak):指程序中已动态分配的堆内存由于某种原因,程序未释放或者无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。单次内存泄漏的危害可以忽略,但是很多内存泄漏的堆积造成的后果不可估量,无论多少内存,迟早都会被占用干净。


    二者关系:

    内存溢出会抛出异常"OutOfMemoryException",内存泄漏不会抛出异常,大多数时候程序看起来是正常的。


    了解了常见问题之后,我们来看看.NET世界里怎么进行内存管理来避免这两问题的,在深入之前,我们必须现有几个概念性的认知,如果从来没听过的或者忘了这部分基础知识的。请移步下面基础知识链接:

    https://docs.microsoft.com/zh-cn/dotnet/standard/managed-code






    内存分配

    • 垃圾收集器(GC)是.Net框架的一部分,它为.Net应用程序分配和释放内存

    • 当一个新进程启动时,运行时会为该进程保留一个称为托管堆的地址空间区域

    • 对象在堆中一个接一个地连续分配

    • 内存分配是一个非常快的过程,因为它只是像指针添加一个值

    • 除托管堆外,应用程序总是会消耗一些不受GC管理的所谓非托管的内存

      通常,.NET公共语言运行时(CLR)本身、应用程序使用的动态库、图形缓冲区等都需要非托管内存


    内存释放

    • 释放内存的过程称为垃圾回收

    • 当GC执行收集时,它只释放应用程序不再使用的对象(例,方法中的局部变量只能在方法执行期间访问,之后不再需要该变量了)

    • 为确定对象是否被使用,GC检查应用程序的根--对应用程序全局的强引用。通常,这些是全局和静态对象指针,局部变量和CPU寄存器。

    • 对于每个活动根,GC构建一个图,其中包含从这些根可到达的所有对象。

    • 如果一个对象不可达,GC认为它不再被使用,并从堆中移除该对象(释放该对象所占用的内存)

    • 对象被移除后,GC会在内存中压缩可达对象






    世代(Generations)

    • 为获得更好的内存释放性能,托管堆被划分为世代几段:Gen0,Gen1,Gen2

    • 刚创建对象时,它们被放置在第0代(Gen0)

    • 当Gen0已满时(堆和代的大小由GC定义),GC执行垃圾回收。在收集过程中,GC会从堆中删除所有无法访问的对象。所有可达对象都被提升至第1代(Gen1)

    • Gen0集合是一个相当快的操作

    • 当Gen1已满时,将执行堆Gen1的垃圾回收。所有在集合中幸存的对象都被再次提升至第2代。第0代集合也在此处再次重复上次动作

    • 当Gen2已满时,GC执行完整的垃圾回收。首先,执行Gen2收集,然后进行Gen1和Gen0的收集。如果仍然没有足够的内存进行新的分配,GC会将引发OutOfMemory异常

    • 在完全垃圾回收过程中,GC必须遍历堆中所有对象,因此,这个过程可能会对系统资源产生很大的影响





    大对象堆

    • 由于性能原因,大型对象(大于85KB)存储在托管堆的一个单独段中,称为大型对象堆(LOH)

    • LOH中的幸存对象未压缩。这意味着LOH会随时间的推移变得支离破碎。

      在.NET Framework4.5.1后,可以在完全垃圾回收期强制GC压缩LOH





    总结


    小对象回收

    遵从上述的世代规则,耗时的压缩过程仅仅在绝对必要时候才发生。

    注:如果在Gen2中看到较高比例的内存,则表明内存被占用很长时间,并且可能会有内存问题,需要借助内存分析工具


    大对象回收

    据上述所属,大于85KB对象会被分配到一个特殊的"大对象堆"(LOH)。由于复制它们会有大块的内存开销,所以它们基本上不会被压缩。当发生GC时,未使用的LOH对象的地址范围将记录在空闲空间分配表中。


    当分配一个新对象时,会检查这个可用空间表是否有足够大的地址范围来保存该对象。如果存在,则在此处分配对象,如果不存在,则在下一个可用空间中分配。


    因为对象不太可能是空地址范围的确切大小,所以几乎总是会在对象之间留下小块内存,从而导致大量碎片,如果这些块小于85KB,则根本不可能重用。因此,随着分配需求越来越高,即使碎片空间可用,也会保留新的段。


    此外,当需要分配大对象时,.NET倾向于将对象附加到末尾,而不是运行昂贵的Gen2 GC.这堆性能有好处,但也时导致内存碎片的主要原因。


    GC不同工作模式下的性能优化

    .NET通过GC提供两种模式来解决性能和堆效率之间的权衡。分为:工作站模式和服务器模式。


    工作站模式:为用户提供最大的响应速度,并减少GC导致的暂停。可作为"并发"或"非并发"运行,指的是GC运行的线程。默认为并发,它为GC使用单独的线程,因此应用程序可以在GC运行时继续执行。


    服务器模式:为服务器环境提供最大的吞吐量、可拓展性和性能。服务器模式下的段大小和生成阈值通常比工作站模式大得多,这反映堆服务器的更高要求。其在多个线程上并行运行垃圾收集,为每个逻辑处理器分配单独的SOH(小对象)和LOH,以防止线程互相干扰。


    .NET框架提供了交叉引用机制,因此对象仍然可以跨堆相互引用。但是,由于应用程序响应能力不是服务器模式的直接目标,因而所有应用程序线程都在GC期间暂停。


    弱引用--性能与效率折衷

    弱对象引用GC根的替代来源,让开发者可以保留对象,同时允许在GC需要时收集它们。它们时代码性能和内存效率的这种。创建对象需要CPU时间,但保持加载需要内存。


    弱引用特别适合大型数据结构。例如,假设有个应用程序允许用户浏览大型数据结构,其中一些它们可能会返回,您可以将任何对用户浏览过的对数据结构的强引用转换为弱引用。如果用户返回这些数据结构,他们是可用的。如果不返回,GC可以在需要的时候回收它。


    对象固定

    .NET使用GCHandle的结构来跟踪堆对象。GCHandle可用于在托管域


    引述与拓展阅读:

    1.托管代码:https://docs.microsoft.com/zh-cn/dotnet/standard/managed-code

    2.垃圾回收:https://docs.microsoft.com/zh-cn/dotnet/standard/garbage-collection/fundamentals

    3.GC根:https://www.jetbrains.com/help/dotmemory/Analyzing_GC_Roots.html

    4.GC工作模式:https://devblogs.microsoft.com/premier-developer/understanding-different-gc-modes-with-concurrency-visualizer/

    5.LOH:https://docs.microsoft.com/zh-cn/dotnet/standard/garbage-collection/large-object-heap

    6.弱引用:https://docs.microsoft.com/zh-cn/dotnet/standard/garbage-collection/weak-references






    3


    GC工作原理




    在开发.NET程序中,由于CLR中的垃圾回收(Garbage Collection)机制会管理已分配的对象,所以我们就不用关注对象什么时候释放内存空间了。但是了解垃圾回收机制还是很有必要的,有助于我们写出高性能和效率的代码。在上面我们其实已经有大致提及到GC的工作原理,但我们就再次详细了解一下GC工作原理。


    创建对象

    在c#中,我们可以通过new关键字创建一个引用类型的对象。这个对象本身存放在托管堆中,引用存放在调用栈。

    Customer nCustomer = new Customer();

    Customer对象被创建后,CLR就保留一块连续的内存空间(托管堆)。垃圾回收器后续会管理并在合适的时候清理托管堆,之后在必要的时候压缩空的内存块来实现优化,为辅助垃圾回收器这一行为,托管堆保存着一个指针,这个指针准确指向下一个对象将被分配的位置,称之为下个对象的指针(NextObjPtr).


    new关键字

    new在方法的视线中会加入一条CIL newobj命令,下面是IL代码

        IL_0000: newobj instance void Customer::.ctor()

      其实,newobj指令就是告诉CLR去执行以下操作:

      • 计算新建对象所需的内存总数

      • 检查托管堆,确保有足够的空间来存放新建的对象

        如果空间足够,调用类型的构造函数,将对象存放在NextObjPtr指向的内存地址

        如果空间不够,就会执行一次垃圾回收来清理托管堆(如果依然不够,报出内存溢出异常)

      • 最后,移动NextObjPtr指向托管堆下一个可用地址,然后将对象引用返回给调用者

      托管堆的大小是有限制的,可能会被耗尽的;如果下一次new关键字来创建对象时,NextObjPtr指向的空间超过了托管堆的地址空间,就需要进行一次GC,GC会从托管堆中删除那些不可访问的对象。


      GC根

      GC是如何确定一个对象不再被需要的,可以被安全销毁的呢?

      这个时候就需要一个应用程序根(application root)的概念。根(root)就是一个存储位置,其中保存着对托管堆上一个对象的引用,根可以属于以下任一类别:

      • 全局对象和静态对象的引用

      • 应用程序代码库中局部对象的引用

      • 传递进一个方法的对象参数的引用

      • 等待被总结(finalize)对象的引用

      • 任何引用对象的CPU寄存器

      垃圾回收关键由两步:标记对象和压缩托管堆


      标记对象

      在垃圾回收过程中,GC默认认为托管堆中的所有对象都是要回收的,然后GC逐个检查所有的根。为此,CLR会建立一个对象图,代表托管堆上所有可达对象




      注:上图灰色对象则为不可达的对象

      如上图,假设托管堆上有A-G七个对象,垃圾回收过程中GC会检查所有的对象是否有活动根。过程大致描述如下:

      • GC发现根引用了托管堆中的对象A时,GC会对A进行打标记

      • 会顺次对根进行检查,并对每个根进行标记时,检测到例如B对象引用了另一对象E,则同时也对E进行打标记;由于E引用了G,同样的G也会被标记

      • 重复上面步骤,检测Globals根,对对象D进行标记

      如上,在开发过程中代码中可能多个对象引用同一对象,垃圾回收只要检测之前已经对对象打过标记,则在后续检查中不会再对此对象内所引用的其他对象进行检测,这样有助于提高性能和避免无限循环“套娃”。

      所有的根都检查完之后,有标记的对象就是可达对象,未标记的对象就是不可达。


      压缩托管堆

      在标记完成后,GC将销毁那些没有被打标记的对象,同时释放这些垃圾所占用的内存空间,再把可达对象移动到这些释放出的内存,然后压缩堆空间。


      注:移动可达对象后,所有之前的引用都将无效,GC重新遍历一次所有根,然后修改其引用关系至移动后的内存地址,NextObjPtr指向最后一个对象后面,也就是G。在这个过程中如果各个线程正在执行,很有可能会有一些变量引用到无效的对象地址,所以GC的线程会高于其他正在执行托管代码的线程,这些托管代码线程是被挂起的。




      总结

      创建对象==>分配内存==>GC扫描==>GC标记==>GC回收==>移动可用对象==>压缩堆栈


      引述与拓展阅读:

      1.在线编写C#代码查看IL代码工具:https://sharplab.io/



      4


      GC常见问题


      什么是GC中强引用与弱引用?

      答:垃圾收集器无法收集应用程序正在使用的对象,而应用程序的代码可以访问该对象,该应用程序对该对象具有强引用。

      弱引用允许GC收集对象,同时仍允许应用程序访问该对象。当不存在强引用时,弱引用仅再对象被收集之前的等待期内有效。当使用弱引用时,应用程序仍然可以将该对象改为强引用,这会阻止GC对该对象的收集,但同时,在重新建立强引用之前,GC仍有可能提前到达该对象进行收集。

      讲个弱引用的特点:弱引用对于大内存消耗的对象很有用,哪怕被回收了,也可以很容易重建。


      Dispose vs Finalize

      DisposeFinalize
      用于随时释放非托管资源可用于对象“销毁前”释放该对象持有的非托管资源
      由用户代码调用由GC调用,不能有客户代码调用
      通过继承IDisposable接口的Dispose方法来实现通过析构函数帮助实现
      没有与此方法性能成本上的考虑由于不会立即清理内存且由GC自动调动,所以会有性能成本上的考虑

      为什么GC只扫描堆(Heap)?

      答:GC也扫描Stack(栈),只是为了查看堆中那些东西正在被栈上的吊毛们使用着(引用)

      GC考虑收集栈是没有实际意义的。因为栈上所有的内容都被认为是“正在使用的”。当方法调用返回时,栈使用的内存会自动回收。栈空间的内存管理极其简单容易且成本低,所以无需考虑栈上的垃圾收集。




      5


      最佳实践

      知道了GC的牛逼之处,很多人觉得GC都这么优秀了,替我做了内存管理,为啥还要多此一举呢?因为垃圾代码和习惯很容造成内存泄漏,所以还是要注意的。GC造成内存泄漏主要有两大原因:

      • 托管内存泄漏:当程序拥有仍被引用但实际上未使用的对象时,由于引用关系存在,GC不会回收,它们会一直存在占着内存。例如,注册事件但从未在必要的情况下取消注册时,可能会存在这样的问题。

      • 非托管内存泄漏:以某种方式分配非托管内存(GC不收集这部分)并且忘记释放它。这样的场景存在于对流、图形、文件系统或网络调用。通常通过实现Dispose释放内存。也可以使用特殊的 .NET 类(如Marshal)或使用PInvoke轻松地自己分配非托管内存

      注:很多开发人觉得托管内存泄漏不是内存泄漏,因为它们存在引用关系并且理论上可以被解除分配的。但是我觉得实属于内存泄露了,它们占着内存,内存管理单元无法将这部分被占内存释放掉,GC又认为它不需要被回收,那这部分内存会一直占着,最终导致内存不足异常。


      以下为几种常见的内存泄露元凶:


      "臭名昭著"的事件订阅

      订阅一个事件,该对象就会持有对调用类的引用。除非订阅了未捕获类成员的匿名方法

        public class SubscribeClass{
        public SubscribeClass(TcpManager tcpManager){
        tcpManager.ConnectionLost +=OnConnectionLost
            }
        private void OnConnectionLost(object sender,TcpConnectEventArgs e){
        // do somthing
            }
        }

        假设TcpManager比SubscribeClass生存期更长,现在SubscribeClass突发内存泄露。任一SubscribeClass实例都被TcpManager引用,基本上GC不会回收SubscribeClass。所以我们能有那些优化手段呢?

        • 添加对应的取消订阅事件

        • 让处理程序自行处理取消订阅

        • 将弱事件与事件聚合器一起使用

        • 如果可以。使用匿名函数订阅并不捕获任何成员


        匿名方法捕获成员

        虽然事件处理程序意味着对象被引用可能明显加剧,但当在匿名方法中捕获成员时,就不那么明显了。

          public class AnonymousClass{
          private JobQueue _jbQueue;
            private int _id;
            
            public AnonymousClass(JobQueue jobQueue){
               _jbQueue=jobQueue;
            }
            public void Foo(){
              _jobQueue.EnqueueJob(()=>{
               Logger.Log($"Executing job with Id {_id}");
          //do other stuff.
              });
            }
          }

          在上述代码,Foo中的匿名方法里_id被捕获,因此此类也被引用。这意味着虽然JobQueue存在并引用了此委托,但它同时也因为_id,引用了AnonymousClass类。解决方案也很简单给Foo添加一个局部变量;

            public class AnonymousClass{
            private JobQueue _jbQueue;
            private int _id;

            public AnonymousClass(JobQueue jobQueue){
            _jbQueue=jobQueue;
            }
            public void Foo(){
                var localId = _id;
            _jobQueue.EnqueueJob(()=>{
                  Logger.Log($"Executing job with Id {localId}");
            //do other stuff.
            });
            }
            }

            将值分配给局部变量,不会捕获任何内容,并且避免了潜在的内存泄露的风险。


            静态变量

            在我从事开发工作短暂生涯里,曾被我师傅教导不要惯性的使用静态变量和静态方法。虽说有点战战兢兢,但在讨论内存泄露这个视角确实有一定的意义。

            我们来回顾以下上述关于GC的工作原理。基本思想就是:GC遍历所有GC Root对象并将其进行标记为不回收。然后,GC会转到它们引用的所有对象,并其也标记。最后,GC开始收集那些没被标记的。

            那么提一个问题,什么样的才会被认为时GC Root呢?

            • 正在运行线程的实时堆栈

            • 静态变量

            • 通过互操作传递给COM对象的托管对象(内存释放将通过引用技术完成)

            这么意味着静态变量以及其引用的所有内容都不会被垃圾回收,一直会存在将永远留在内存里,增加了内存泄漏的风险。


            永不终止的线程

            由于实时堆栈(Live Stack)被看作GC Root,其包括所有局部变量和正在运行的线程中调用堆栈的成员;所以极有可能产生内存泄漏。

            我们有时候需要无限运行的线程来运行一个计时周期性任务,该线程什么都不做,并且具有对对象的引用。如下:

              public class TimerExceutionClass{
              public TimerExceutionClass(){
                  var timer=new Timer(HandleTick);
                  timer.Change(TimeSpan.FromSeconds(5),
                  TimeSpan.FromSeconds(5));
              }

                private void HandleTick(object state){
                 //do some stuff.
                }
              }

              如果不停止计时器,并让他在单独的线程中运行,timer由于引用HandleTick方法而引用实例TimerExceutionClass,GC是不会收集此实例的。


              不取消已分配的非托管内存

              虽然事件处理程序意味着对象被引用可能明显加剧,但当在匿名方法中捕获成员时,就不那么明显了。

                public class MemoryClass{
                  private IntPtr _buffer;
                  public MemoryClass(){
                    _buffer= Marshal.AllocaHGloable(1000);
                  }
                  //do stuff without freeing the buffer memory.
                }

                上面方法中,使用Marshal.AllocaHGloable(1000),其分配了一个非托管内存的缓冲区。在后台,AllocalHGloable调用Kernel32.dll的LocalAlloc方法。如果不使用Marshal.FreeHGloable显式释放句柄,则该缓冲区在进程内存堆中将会被看作被占用,从而导致内存泄漏。处理此问题的方式就是添加一个Dispose方法,以释放未托管资源:

                  public class MemoryClass :IDisposable{
                  private IntPtr _buffer;
                  public MemoryClass(){
                       _buffer = Marshal.AllocaHGlobal(1000);
                       //do stuff without freeing the buffer memory. 
                  }

                    public void Dispose(){
                       Marshal.FreeHGlobal(_buffer);
                  }
                  }

                  注意:由于内存碎片化问题,非托管资源泄漏比托管内存泄漏更严重。GC可以移动托管内存,为其他对象腾出空间。然而,非托管内存永远卡在原地。


                  添加Dispose而不调用。

                  上个例子中,添加了Dispose释放非托管资源,这只是迈出的第一步,但当调用这个类的程序员忘了调用Dispose,同样也会于事无补。最常见的解决方式有下面几种:

                  • 在c#中使用Using

                    using(var instance = new MemoryClass()){
                    //..
                    }

                    上面会被编译器直接翻译为:

                      var instance = new MemoryClass();


                      try{
                      //..
                      }finally{
                        if(instance !=null)
                            ((IDisposable)instance).Dispose();
                      }

                      由于finally的执行顺序原因,那么发生了异常,Dispose方法也会被调用。

                      • 使用Dispose Pattern

                        public class MemoryClass :IDisposable{
                        private IntPtr _buffer;
                          public int Buffer_Size = 1024 * 1024; //1MB
                          private bool _isDisposed = false;
                          
                        public MemoryClass(){
                             _buffer = Marshal.AllocaHGlobal(Buffer_Size);
                        //do stuff without freeing the buffer memory.
                        }
                          protected virtual void Dispose(bool disposing){
                            if(_isDisposed) return;
                            
                            if(disposing){
                              //Free any other managed objects here
                            }
                            
                            //Free any unmanaged objectes here.
                            Marshal.FreeHGloabl(_buffer);
                            _idDisposed = true;
                          }
                        public void Dispose(){
                             Dispose(true);
                             GC.SupperssFinalize(this);
                        }
                          ~MemoryClass(){
                            Dispose(false);
                          }
                        }

                        这种模式确保即使Dispose没被调用,当实例被GC回收时,它最终也会被调用。另一方面,如果Dispose调用了终结器,则会抑制终结器。抑制终结器很重要,因为终结器很昂贵并且会导致性能问题。所以尽量避免使用终结器。


                        然而Dispose Pattern并不是万无一失的。如果Dispose从来没有调用过并且由于托管内存泄漏而未对此类进行回收,则同样也不会释放非托管资源。


                        总结

                        要避免和注意的还很多,譬如ArrayPools最小化分配,对某些数据结构预先分配大小,使用StringBuilder之类的。总而言之,有很多方法可以避免.NET和.NET Core应用程序中GC的压力。当不再需要对象引用时,应及时释放他们。避免使用具有多个引用的对象。并且应避免使用大对象来降低Gen2代垃圾回收。


                        引述与拓展阅读:

                        1.Marshal:https://docs.microsoft.com/en-us/dotnet/api/system.runtime.interopservices.marshal.allochglobal?redirectedfrom=MSDN&view=netframework-4.7.2#System_Runtime_InteropServices_Marshal_AllocHGlobal_System_Int32_

                        2.内存性能参考:

                        https://docs.microsoft.com/zh-cn/aspnet/core/performance/memory?view=aspnetcore-6.0

                        3.减轻GC压力的常见手段:

                        https://www.infoworld.com/article/3614084/how-to-avoid-gc-pressure-in-c-and-net.html

                        3.net core内存和GC的常见问题和优化手段:

                        https://docs.microsoft.com/zh-cn/aspnet/core/performance/memory?view=aspnetcore-6.0

                        4.强烈推荐大佬写的深入GC的内容(一定要看):

                        https://github.com/Maoni0/mem-doc



                        END


                        CQRS与DDD的“基”伴(上)

                        软件开发领域的‘996’成因

                        事件总线降低耦合总是对的吗?


                        关注个再走呗~

                        排版 | Ethan

                        文章 | Ethan

                        图文 | 部分摘至网络

                        我知道你在看










                               



                        文章转载自一杯咸茶,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

                        评论