内存泄漏的几种情况

1、.Net 应用中的内存

1.1、托管堆

由 .NET 运行时(CLR)自动管理的内存区域,用于存储对象实例和数组等引用类型数据

在堆上分配的内存会通过垃圾回收器(GC)进行自动回收,对象的创建和销毁都是由GC负责管理。

1.2、非托管堆

不由CLR控制和管理,通常用于与非托管代码(如C、C++)进行交互、进行底层的系统编程或使用特定的外部库

非托管堆用于存储非托管代码中分配的对象,非托管代码通过内存分配函数(如 malloc)分配和管理非托管堆。

没有自动垃圾回收的机制,需要显示的释放资源(Dispose())。

最常用的非托管资源类型是包装操作系统资源的对象,如文件、窗口、网络连接、数据库连接、画刷、图像、图标等。

1.3、栈内存

栈内存的作用域仅限于所属的代码块或方法,用于存储函数的执行上下文,包括函数的参数、局部变量和函数返回地址等。

存储值类型数据和引用类型数据的引用。

栈内存的分配和释放是由编译器自动完成的,遵循先进后出的原则,具有较高的效率。

1.4、静态/常量存储区

存储Static变量(值类型或者引用类型的指针)及常量存储的区域。

 

2、内存泄漏

内存溢出(Out of Memory):当程序占用的内存超过了系统分配的最大内存时,会发生内存溢出错误。这通常发生在处理大数据集或无限递归时。

内存泄漏(Memory Leak):内存泄漏指的是程序在申请内存后,未能在不需要时正确释放,一直占用。严重会导致内存溢出。

在C#中,内存泄漏通常指的是由于长时间运行的应用程序或者某个操作导致的不再需要的对象无法被垃圾回收器回收的情况。这通常发生在以下几种情况:

2.1、事件订阅

原因:未取消的事件订阅可能导致订阅者对象始终保持对其的引用,从而无法释放。

解决方法:使用弱事件模式或者手动管理事件订阅,以确保订阅者不会阻止垃圾回收器回收其资源。

如下,弱未取消订阅,当EventPublisher的寿命超过EventSubscriber,那么你就已经造成了内存泄漏。 EventPublisher会引用EventSubscriber的任何实例,并且垃圾回收器永远不会回收它们。

查看代码
 public class EventPublisher
{
    public event EventHandler SomeEvent;

    public void PublishEvent()
    {
        // 发布事件
        SomeEvent?.Invoke(this, EventArgs.Empty);
    }

    public void UnsubscribeEvent(EventHandler handler)
    {
        // 解注册事件处理程序
        SomeEvent -= handler;
    }
}

public class EventSubscriber
{
    private EventPublisher publisher;

    public EventSubscriber(EventPublisher publisher)
    {
        this.publisher = publisher;
        // 订阅事件
        publisher.SomeEvent += HandleEvent;
    }

    private void HandleEvent(object sender, EventArgs e)
    {
        // 处理事件
    }

    public void UnsubscribeFromEvent()
    {
        // 解注册事件处理程序
        publisher.UnsubscribeEvent(HandleEvent);
    }
    
    ~EventSubscriber()
    {
        Console.WriteLine("实例{0}被回收", Id); //在GC时会触发
    }
}

internal class Task01
{
    public static void Run()
    {
        PublishEvent mychange = new PublishEvent();

        for (int i = 0; i < 100; i++)
        {
            EventSubscriber task = new Subscriber(mychange);
            //task.UnsubscribeFromEvent(); //如果忘记取消订阅,则会导致对象引用泄漏
        }
        //手动GC,如果没有取消订阅,终结器~Subscriber不会触发,取消了订阅后,~Subscriber才会触发
        GC.Collect();
        Console.ReadLine();
    }
}

 

2.2、静态变量、单例

原因:静态变量会在应用程序的整个生命周期内持有对象的引用,不会被垃圾回收,如果不慎使用,可能导致泄漏。

解决方法:避免使用全局静态变量或者确保在不需要时将它们设为null。

GC遍历所有GC Root对象并将其标记为“不可收集”。 然后,GC转到它们引用的所有对象,并将它们也标记为“不可收集”。 最后,GC收集剩下的所有内容。

那么什么会被认为是一个GC Root?

  1. 正在运行的线程的实时堆栈。
  2. 静态变量。
  3. 通过interop传递到COM对象的托管对象(内存回收将通过引用计数来完成)。

如下,任何MyClass的实例将永远留在内存中,从而导致内存泄漏。

public class MyClass
{
    static List<MyClass> _instances = new List<MyClass>();
    public MyClass()
    {
        _instances.Add(this);
    }
}

 

2.3、永不终止的线程

原因:实时堆栈会被视为GC root。 实时堆栈包括正在运行的线程中的所有局部变量和调用堆栈的成员。

解决方法:线程使用完后及时清理。

以下,timer一直阻止GC回收。

查看代码
 public class Scheduler
    {
        public Scheduler()
        {
            Timer timer = new Timer(Handle);
            timer.Change(0, 5000); //创建了一个timer,这个timer每隔5秒执行
        }

        private void Handle(object e)
        {
            Console.WriteLine("任务调度中……");
        }


        ~Scheduler()
        {
            Console.WriteLine("资源被释放");
        }
    }

    internal class Task01
    {
        public static void Run()
        {

            for (int i = 0; i < 3; i++)
            {
                Scheduler scheduler = new Scheduler();
            }
            GC.Collect();//手动GC
            Console.ReadLine();
        }
    }

 

2.4、非托管资源

问题:如文件句柄、数据库连接等,不受GC管理,如果没有正确释放,可能导致泄漏。

解决方法:实现IDisposable接口,在Dispose方法中释放非托管资源。使用using语句或try-finally块来确保资源被释放。

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

在一个包含非托管资源的类中,关于资源释放的标准做法是:

1)继承IDisposable接口;
2)实现Dispose()方法,在其中释放托管资源和非托管资源,并将对象本身从垃圾回收器中移除(垃圾回收器不在回收此资源);
3)实现类析构函数,在其中释放非托管资源。
只要按照上面要求的步骤编写代码,该类就属于资源安全的类。

析构函数只能由垃圾回收器调用,Despose()方法只能由类的使用者用。

如下,在使用时,显示调用Dispose()方法,可以及时的释放资源,同时通过移除Finalize()方法的执行,提高了性能;如果没有显示调用Dispose()方法,垃圾回收器也可以通过析构函数来释放非托管资源,垃圾回收器本身就具有回收托管资源的功能,从而保证资源的正常释放,只不过由垃圾回收器回收会导致非托管资源的未及时释放的浪费。

查看代码
 public class BaseResource : IDisposable
{
    // 指向外部非托管资源
    private IntPtr handle;
    // 此类使用的其它托管资源.
    private Component Components;
    // 跟踪是否调用.Dispose方法,标识位,控制垃圾收集器的行为
    private bool disposed = false;
    // 构造函数
    public BaseResource()
    {
        // Insert appropriate constructor code here.
    }
    // 实现接口IDisposable.
    // 不能声明为虚方法virtual.
    // 子类不能重写这个方法.
    public void Dispose()
    {
        Dispose(true);
        // 离开终结队列Finalization queue,阻止GC调用Finalize方法
        GC.SuppressFinalize(this);
    }
    
    // 如果disposing 等于 true, 方法已经被调用
    // 或者间接被用户代码调用. 托管和非托管的代码都能被释放
    // 如果disposing 等于false, 方法已经被终结器 finalizer 从内部调用过,
    // 你就不能在引用其他对象,只有非托管资源可以被释放。
    protected virtual void Dispose(bool disposing)
    {
        // 检查Dispose 是否被调用过.
        if (!this.disposed)
        {
            // 如果等于true, 释放所有托管和非托管资源
            if (disposing)
            {
                // 释放托管资源.
                Components.Dispose();
            }
            // 释放非托管资源
            CloseHandle(handle);
            handle = IntPtr.Zero;
            // 注意这里是非线程安全的.
            // 在托管资源释放以后可以启动其它线程销毁对象,
            // 但是在disposed标记设置为true前
            // 如果线程安全是必须的,客户端必须实现。
        }
        disposed = true;
    }
    
    // 使用interop 调用方法,清除非托管资源.
    [System.Runtime.InteropServices.DllImport("Kernel32")]
    private extern static Boolean CloseHandle(IntPtr handle);
    
    // 使用析构函数来实现终结器代码,由GC调用
    // 这个只在Dispose方法没被调用的前提下,才能调用执行。
    ~BaseResource()
    {
        // 不要重复创建清理的代码.
        // 基于可靠性和可维护性考虑,调用Dispose(false) 是最佳的方式
        Dispose(false);
    }
    
    // 允许你多次调用Dispose方法,
    // check to see if it has been disposed.
    public void DoSomething()
    {
        if (this.disposed)
        {
            thrownew ObjectDisposedException();
        }
    }
    
    public static void Main()
    {
        // Insert code here to create
        // and use a BaseResource object.
    }
}
 
.NET Framework的System.GC类提供了控制Finalize的两个方法,ReRegisterForFinalize和SuppressFinalize。前者是请求系统完成对象的Finalize方法,后者是请求系统不要完成对象的Finalize方法。当你用Dispose方法释放未托管对象的时候,应该调用GC.SuppressFinalize。GC.SuppressFinalize会阻止GC调用Finalize方法。因为Finalize方法的调用会牺牲部分性能。如果你的Dispose方法已经对委托管资源作了清理,就没必要让GC再调用对象的Finalize方法。
注意:
在.NET中应该尽可能的少用析构函数释放资源。在没有析构函数的对象在垃圾处理器一次处理中从内存删除,但有析构函数的对象,需要两次,第一次调用析构函数,第二次删除对象。而且在析构函数中包含大量的释放资源代码,会降低垃圾回收器的工作效率,影响性能。所以对于包含非托管资源的对象,最好及时的调用Dispose()方法来回收资源,而不是依赖垃圾回收器。

 

2.5、LOH泄漏

.NET CLR中对于大于85000字节的内存既不像引用类型那样分配到普通堆上,也不像值类型那样分配到栈上,而是分配到了一个特殊的称为LOH的内部堆上,这部分的内存只有在GC执行完全回收,也就是回收二代内存的时候才会回收。因此,考虑如下情形:

假设你的程序每次都要分配一个大型对象(大于85000字节),但却很少分配小对象,导致2代垃圾回收从不执行,即使这些大对象不再被引用,依然得不到释放,最终导致内存泄漏。

解决方法:由于LOH本身的特性,在程序中,我们当尽量避免频繁的使用大内存对象,如果不能就应当尽量避免内存碎片。

如下实例中我们交替产生了150000字节和85000字节的大内存对象,同时我们模拟了GC的频繁触发,我们通过Winddbg中的!dumpheap命令分析,就会看到内存中出现了大量的碎片,Free和Live交替出现。但如果我们把数据的大小固定住85000,那么后续新分配的对象就有很大概率继续使用前面的空闲空间,大大减少了内存碎片。
查看代码
 internal class Task01
    {
        public static void Run()
        {
            List<byte[]> objs = new List<byte[]>();
            for (int i = 0; i < 500; i++)
            {
                //两种大对象交替出现
                if (i % 2 == 0)
                {
                    objs.Add(new byte[150000]);
                    objs[i] = null;
                    if (i % 10 == 0)
                        GC.Collect(); //模拟GC触发
                }
                else
                {
                    objs.Add(new byte[85000]);
                }
            }
            Console.WriteLine("执行完毕");
            Console.ReadLine();
        }
    }

在应用中,我们对于大对象的使用通常可能来自于某些大对象的更新缓存,比如:

查看代码
 public static void Main()
        {
            Console.WriteLine("开始执行");
            byte[] bigFastCache = new byte[150000];
            for (int i = 0; i < 500; i++)
            {
                //更新操作,数据大小会不同
                if (i % 2 == 0)
                {
                    bigFastCache = new byte[150000];
                }
                else
                {
                    bigFastCache = new byte[85000];
                }
            }
			GC.Collect(); //模拟GC触发
            Console.WriteLine("执行完毕");
            Console.ReadLine();
        }

只是对于这个数据的交替更新,其对象创建和销毁的开销都很大,这里我建议使用池化对象,在使用的时候从池中租借一个新对象,使用完成后归还即可:

查看代码
 public static void Main()
        {
            Console.WriteLine("开始执行");

            byte[] bigFastCache = null;
            var bigPool = ArrayPool<byte>.Shared; //使用池化对象要慎重
            for (int i = 0; i < 500; i++)
            {
                //更新操作,数据大小会不同
                if (i % 2 == 0)
                {
                    bigFastCache = bigPool.Rent(100000);
                    Console.WriteLine(bigFastCache.Length);
                }
                else
                {
                    bigFastCache = bigPool.Rent(85000);
                    Console.WriteLine(bigFastCache.Length);
                }
                bigPool.Return(bigFastCache);
            }
            Console.WriteLine("执行完毕");
            Console.ReadLine();
        }

如果你用到了Dictionary大对象缓存,建议提前在构造函数中设置Capacity来优化GC,这样对的性能和内存占用都有好处。

 

3、内存优化

托管堆内存优化:

  • 使用对象池:避免频繁地创建和销毁对象,可以使用对象池来重复利用对象实例。
  • 减少装箱和拆箱:尽量使用泛型集合(如`List`)来避免值类型的装箱和拆箱操作。
  • 及时释放资源:手动释放不再使用的托管内存,如调用对象的`Dispose()`方法或使用`using`语句来确保及时释放资源。

非托管堆内存优化:

  • 尽量避免直接使用非托管内存:推荐优先使用托管内存,仅在必要时与非托管代码交互,并使用`Marshal`类的相关方法来管理非托管内存的分配和释放。
  • 避免内存泄漏:确保将非托管内存正确释放,避免内存泄漏问题。

栈内存优化:

  • 尽量使用局部变量:将数据存储在栈上的局部变量中,而不是使用类的实例变量。这样可以减少托管堆内存的压力,同时也提高访问速度。
  • 使用值类型:对于小型数据,考虑使用值类型而不是引用类型来减少内存开销和垃圾回收的成本。

其他优化技巧:

  • 避免使用过多的字符串拼接操作:频繁的字符串拼接可能会导致内存碎片和性能下降,尽量使用`StringBuilder`类来处理大量字符串拼接。
  • 缓存重复计算结果:如果有一些计算结果会被重复使用,可以将结果缓存起来,避免重复计算和内存消耗。
  • 使用合适的数据结构:选择适当的数据结构和算法来优化内存和性能,如使用哈希表、集合等数据结构。
  • 使用性能分析工具:使用性能分析工具(如.NET Memory Profiler)来检测内存泄漏、高内存使用和潜在性能问题。

需要注意的是,对内存的管理和操作大部分都是由 .NET 运行时处理的。开发者无需过多关注内存管理的细节,因为托管堆内存的垃圾回收机制可以自动处理对象的分配和释放。然而,在特定情况下,如与非托管代码交互、进行性能优化或处理大量数据等,了解这些内存区域的概念和用法可以帮助编写更高效和可靠的代码。

 
posted @ 2024-04-02 16:13  茜茜87  阅读(13)  评论(0编辑  收藏  举报