.NET 内存泄漏分析

目的

内存泄漏就像是程序生了病,是程序员职业生涯中必然遇到的疑难杂症之一,作为程序的主治医生,我们必须解决它。不过导致内存泄漏的病因有很多,只有找对了病因才能对症下药,这就需要我们通过观察分析甚至借助工具来分析病情。就像导致咳嗽的原因可能有感冒、咽炎、肺炎、支气管炎一样,导致内存泄漏的原因则可能有堆栈泄漏、非托管内存泄漏、托管内存泄漏等等,只有先知道有哪些病因以及它们对应的症状,才能够快速定位问题。本文讨论的就是导致内存泄漏问题的病因和症状。

内存管理

由于内存的执行速度远超过硬盘,所以计算程序正常都是将数据加载到内存中执行,虽然现代计算机的内存足够大,但随着程序的不断执行,内存依旧会不够用,所以需要对内存进行管理。不过内存管理是一件很麻烦的事儿,为了让开发人员高效编程,.NET CLR为开发人员提供了自动内存管理服务,由GC(垃圾回收器)管理应用程序内存的分配和释放。

餐厅模型

内存管理是一个很抽象的概念,我一直在思考怎么更形象的帮助大家去理解它,直到有一天我和几个同事一起去餐厅吃饭时,突然想到餐厅吃饭和内存管理不是很相似嘛,下面我就以餐厅来举例,.NET内存管理都做了什么。

假设你一家餐厅的经理,这个餐厅有4张桌子,每张桌子有4个位置,那16个位置就是我这个餐厅所能容纳最大客流量了,我们称之为物理内存(比如16G内存)。有时候为了多赚钱,你会在餐厅外(磁盘)再摆两张桌子,客户也可以用,我们称之为虚拟内存,只不过因为在餐厅外的缘故,点餐和送餐的响应总是比餐厅内要慢(内存交换)。

注:这里我简化了概念,实际上同一台机器上的所有进程共享物理内存。
餐厅来了客人,餐厅总归要知道还有没有空桌,客人吃完了,桌子是不是可以清扫一下迎接下一波客人,不过我这个当老板的忙前忙后,没有那么多精力处理这些事儿,所以我专门请了个员工(GC)来帮我管理餐位,毕竟客人坐不到位置就会去投诉我们(内存溢出)。GC带客人去对应餐桌的过程我门称之为分配内存;客人用餐完毕离开后,GC清扫餐桌供下一桌客人使用的过程我们称为垃圾回收,GC主要干的就是这两件事儿。我们观察上图可以发现,不管1号桌还是2号桌亦或者3号桌,都是没有坐满的(红色代表有繁忙,绿色表示空闲),这3张桌子虽有空闲也不太可能再次安排人去坐,于是内存碎片就产生了。一般情况下,餐厅客来客往,餐厅正常运转,但是某些情况会导致客人走了之后,GC无法正常的垃圾回收,于是内存泄漏就产生了,直到内存耗尽,应用程序崩溃。

人流量太多,就跟老板(操作系统)报告你需要更大的地方来迎客,老板也很大方,答应给你两栋楼,这个过程被称为Commit,这个空间的大小就是Committed Size;虽然老板大方,你也不能瞎霍霍,要根据需要使用空间,你使用的这部分空间大小就是Allocated Size。

.NET 内存模型

.NET中主要使用以下几种内存:堆栈、非托管堆和托管堆(除了堆栈外,CLR还维护了一些其他的内存区域,如静态存储区、常量存储区等),下面我简单介绍以下它们:

堆栈

堆栈是应用程序执行期间用于存储局部变量、方法参数、返回值和其他临时值的地方,就像餐厅知道你点的每道菜,这些菜在后厨制作,但这只是暂时的,堆栈会为每个线程分配工作的暂存区。GC并不负责清理堆栈,方法返回时,堆栈空间会自动处理。虽然堆栈会自动清理,但并不表示堆栈就不会发生泄漏,我知道的堆栈泄漏就有两种:一种是进行极其耗费资源且从不返回的方法调用,从而使其关联的堆栈无法释放(如例1中的无限递归);另一种是线程堆栈泄漏,如果一个应用程序只顾着创建线程而忽视了去终止这些线程,就可能引发线程堆栈泄漏。
例1:无限递归

internal class RedisPool
{
    protected static Dictionary<string, ConnectionMultiplexer> ConnPool = new Dictionary<string, ConnectionMultiplexer>(); 

    static SemaphoreSlim hore = new SemaphoreSlim(1);

    public static ConnectionMultiplexer GetRedisConn(string connStr, int rDb = 0)
    {
        try
        {
            ConnPool.TryGetValue(connStr, out var conn);
            if (conn == null)
            {
                hore.Wait();
                conn = ConnectionMultiplexer.Connect(connStr);
                ConnPool.Add(connStr, conn);
            	hore.Release();
            }
        }
        catch (Exception ex)
        {
            hore.Release();
            GetRedisConn(connStr, rDb);
        }
        return ConnPool[connStr];
    }
}

我本人不太喜欢用递归,因为递归会让程序的执行逻辑变得复杂,上面的这段代码就是一个平时运行起来正常但特定情况下无限递归的例子。在示例中,我们需要通过GetRedisConn方法获取Redis链接,但是如果Redis无法正常链接,就会引发GetRedisConn的无限递归。

💡 在Windows环境下,堆栈的默认大小为1MB,如果发生了无限递归,程序会很快抛出StackOverflow异常。
例2:线程泄漏

    class Program
    {
        static void Main(string[] args)
        {
            for (int i = 0; i < 1000; i++)
            {
                Console.WriteLine("创建一个新的线程:");
                Thread t = new Thread(() => { TreadProc(); });
                t.Start();
                Console.ReadLine();
            }
        }

        static void TreadProc()
        {
            Thread.CurrentThread.Join();
        }
    }

上面的例子中,线程启动后,尝试调用他自己的Join()方法,这就导致线程陷入一个自己等待自己终止的尴尬局面,如果打开任务管理器,就会发现每次按下回车时,内存都会增长。堆栈空间不足时,最可能引发的异常为StackOverflowException,在.NET中,每个线程都有一个默认的堆栈大小,在32位平台上为1MB,64位平台上为4MB。如果在性能计数器(perfmon)中,我们观察到 Process->Private Bytes和.NET CLR LocksAndThreads-># of current logical Threads随着时间的的推移同步增大,那么罪魁祸首就极有可能是线程堆栈泄漏。

非托管堆

.NET中分为托管堆和非托管堆,之所以把非托管堆放到前面来讲,是因为GC并不负责管理非托管堆的内存,需要你使用完后,显示的释放资源,所以并不需要太多的文字来阐述。非托管堆就像一些外带的客户,在餐厅里占了位置,吃的却是自带的食物,古板的GC并不知道如何清理这些垃圾,这都需要需要客户自行清理。.NET中最常用的非托管资源类型是包装操作系统资源的对象,如文件、窗口、Win32API、网络连接和数据库连接。.NET提供了Dispose和Finalize两种方式来确定非托管资源的释放。
非托管内存泄漏的最常见问题就是忘记调用Dispose,如下面这个例子:

   class Program
    {
        static void Main(string[] args)
        {
            var qUrl = "http://www.baidu.com";
            ServicePointManager.DefaultConnectionLimit = 1000;
            for (int i = 0; i < 1000; i++)
            {
                HttpWebRequest req = (HttpWebRequest)WebRequest.Create(qUrl);
                req.Method = "GET";
                req.ContentType = "application/x-www-form-urlencoded";
                var rep = req.GetResponse();
                Console.WriteLine(rep.ContentLength);
                //rep.Dispose();
                Console.ReadLine();
            }
        }
    }

示例中的第12行,返回了WebResponse对象,这个对象继承了IDisposable接口,但示例中我们却没有使用Dispose()方法,当然你也可以通过using来释放资源,比如这样:

    class Program
    {
        static void Main(string[] args)
        {
            var qUrl = "http://www.baidu.com";
            //ServicePointManager.DefaultConnectionLimit = 1000;
            for (int i = 0; i < 1000; i++)
            {
                HttpWebRequest req = (HttpWebRequest)WebRequest.Create(qUrl);
                req.Method = "GET";
                req.ContentType = "application/x-www-form-urlencoded";
                using (var rep = req.GetResponse())
                {
                    Console.WriteLine(rep.ContentLength);
                }
                Console.ReadLine();
            }
        }
    }

题外话:我这个人总喜欢说一些废话,相信细心的同学会发现,我额外写的第6行代码,这是因为.NET在创建HTTP链接时会为了复用TCP链接,对于相同的主机和端口,会复用ServicePoint对象,但是.NET中ServicePoint的最大并发连接默认为2,超出这个连接的请求会等待直至超时。所以有时候接口调用超时并不一定是服务端接口的问题,也有可能是ServicePoint并发达到了瓶颈。
不过,我们也会遇见一些不调用Dispose或Close方法,内存却并未升高的情况,如StreamReader,这是因为.NET框架进行了优化,会自动进行垃圾回收。但是为了保证代码的健壮性,还是建议大家在使用非托管代码时,手动的释放资源。
当然,还有一种更为隐蔽的泄漏方式,就是错误的使用我们前面提到的终结器(Finalize,也有人叫他析构器),终结器可以告诉GC如何清理实例的资源,毕竟总有些人会忘记调用Dispose,请看下面这个例子:

    internal class Car
    {
        public Car(string color, string model)
        {
            Color = color;
            Model = model;
        }

        public string Color { get; set; }

        public string Model { get; set; }

        public void Run()
        {
            Console.WriteLine("一辆{0}的{1}在路上行驶", Color, Model);
        }

        /// <summary>
        /// 终结器
        /// </summary>
        ~Car()
        {
            if (Model == "特斯拉")
                throw new Exception("新车不让报废");
            Console.WriteLine("准备销毁{0}", Model);
            Color = null;
            Model = null;
        }
    }
  class Program
    {
        static void Main(string[] args)
        {
            try
            {
                RunningCar();
                GC.Collect();//手动触发GC
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.Message);
            }

            Console.WriteLine("执行完毕");
            Console.ReadLine();
        }

        public static void RunningCar()
        {
            Car car1 = new Car("红色", "桑塔纳");
            car1.Run();

            Car car2 = new Car("红色", "特斯拉");
            car2.Run();
        }
    }

在上述的例子中,我在终结器中抛出了异常,特定情况下会触发,由于CLR中只维护了一个线程来处理终结器,这会导致某些情况下资源无法被正确回收。不过由于终结器的调用时间不确定,程序的性能和可靠性都无法得到保证,微软从.NET 5开始废弃了终结器的使用。

程序集泄漏

其实程序集泄漏放在这个位置并不合适,我只是懒得后面再单独开篇来写,你可以把他理解为餐厅中的烹饪流程,以前我是做中餐的,只要洗菜->切菜->炒菜就行;现在我要做西餐,就又引入了一套和面->发酵->烘焙的流程。当你需要执行程序集中的代码之前,必须先将程序集加载到应用程序域中。不过一旦程序集被加载,知道AppDomain卸载前,它无法卸载,这本身没什么问题,除非你使用了动态编程,我本人并不喜欢动态编程,原因嘛,很简单,因为我不熟。但是仍有一种泄漏是需要我们关注的,就是XmlSerializer泄漏,且看下面的例子:

    class Program
    {
        static void Main(string[] args)
        {
            try
            {
                var xml = @"<Tesla>
                              <Id>0001</Id>
                              <Color>红色</Color>
                              <Model>特斯拉</Model>
                            </Tesla>";
                Console.ReadLine();
                for (int i = 0; i < 10000; i++)
                {
                    GetCar(i, "Tesla", xml);
                }
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.Message);
            }

            Console.WriteLine("执行完毕");
            Console.ReadLine();
        }


        public static Car GetCar(int i, string rootName, string xml)
        {
            var xs = new XmlSerializer(typeof(Car), new XmlRootAttribute(rootName));
            using (var textReader = new StringReader(xml))
            {
                using (var xmlReader = XmlReader.Create(textReader))
                {
                    Console.WriteLine(i);
                    return (Car)xs.Deserialize(xmlReader);
                }
            }
        }
    }

    public class Car
    {
        public string Color { get; set; }

        public string Model { get; set; }
    }

泄漏的位置发生在:
var xs = new XmlSerializer(typeof(Car), new XmlRootAttribute(rootName));
按照MSDN官方的说法,Xml序列化会动态生成程序集,当使用下面两个构造函数时:

XmlSerializer.XmlSerializer(Type)
XmlSerializer.XmlSerializer(Type, String)

这些程序集会被复用,但是如果使用任何其他构造函数,则会生成同一程序集的多个版本,并且永远不会卸载,这会导致内存泄漏和性能不佳。
要解决这个问题也很简单,使用上面两个构造函数,或者缓存生成的程序集。

    [XmlRoot("Tesla")]
    public class Car
    {
        public string Color { get; set; }

        public string Model { get; set; }
    }

    var xs = new XmlSerializer(typeof(Car));

托管堆

初始化新进程时,CLR会为进程保留一段连续的地址空间用于保存托管对象,这段保留的地址空间被称为托管堆,GC就像餐厅的大堂的服务员那样既负责将食客们领到空闲的位置上(内存分配),又负责清理已经使用完的座位(内存回收)。不过由于大内存对象的回收成本比较高,为了避免频繁回收,GC根据对象大小,将托管堆分为了SOH(小对象堆)和LOH(大对象堆),在.NET 5之后又引入了一个名为POH(固定堆)用来分配钉住的对象。
SOH通常是一些数据库连接、大型数组和集合、大型数据结构如树和图、缓存、音视频文件等,由于大内存对象的特性(后续会讲到),我们在使用大内存对象时要非常小心,避免出现内存泄漏的问题。
POH是.NET5里引入的概念,主要为Pinning(钉住)操作设计,钉住的对象不能被移动,如果钉住的对象很多,会导致内存碎片的增加。

分代压缩

.NET GC采用分代压缩算法,该算法基于启发式算法,即一个对象已经存活了一段时间,那么它继续存活的一段时间的概率会非常大。So~,GC堆中的对象被分为3代:gen0(新生代/第0代)、gen1(中生代/第1代)、gen2(老年代/第2代),分代GC不会在每次回收整个堆,GC更偏向于年轻代的GC,也就是说gen0比gen1回收的更频繁。如果一个对象在gen0回收后存活下来,就会升代到gen1,以此类推,直到gen2为止。
● 第0代:最年轻一代,用户代码新分配的对象都在这一代,也是回收最频繁的一代,但如果新对象是大型对象的,则会被分配到LOH(大对象堆)上,也有人称之为第3代,这个第3代与第2代在一起回收。大多数对象会在第0代中被回收,不会保留到下一代;
● 第1代:这一代用作短生存期对象和长生存期对象的缓冲区,第0代的托管堆回收后,会压缩可访问对象的内存,并升代到第1代,这样GC在回收时,就不需要重复检查这部分对象,因为这部分对象可能的生存期会更长,gen1回收时,会同时回收gen0;
● 第2代:这一代包含长生存期对象,第2代垃圾回收也称为完整垃圾回收(Full GC),它会回收所有代中的对象。

回收算法

GC会定期扫描堆中的所有对象,先找到幸存对象,然后清理死对象,最后压缩幸存对象,GC主要通过以下两种方式来判断对象是否存活:

引用计数法

引用计数法是一种最简单的垃圾回收算法,它通过在对象上维护一个引用计数器来判断对象是否存活,当对象被引用时,引用计数器加1;当对象被释放时,引用计数器减1。当引用计数器为0时,对象即可被GC回收。然而,引用计数法有一个致命缺陷,就是无法处理循环引用。例如,两个对象之间相互引用时,它们的引用计数器都不为0,即使它们不再被其他对象引用,也无法被回收,最终导致内存泄漏。

可达性分析法

可达性分析法是一种更为常用的垃圾回收算法,它通过判断对象是否可达来判断对象是否存活。当对象可以被程序中的任意一个根对象(GC根)访问到时,该对象即为可达对象;当对象不可被任何一个GC根访问到时,该对象即为未被引用的对象,可以被垃圾回收器回收。GC根指的是直接或者间接应用其他对象的对象,如活动线程的堆栈中引用的对象、静态对象、Finalizer队列中等待执行的对象,尚未完成异步操作的对象等。

分配与回收

内存是一个动态变化的过程,就像餐厅人来人往,内存也会被GC不停的分配和回收,我们可以通过微软提供的官方示例来探秘GC的过程:

上图向大家展示了gen0和gen1的GC过程,由于其GC的时间并不长,所以被称为短暂GC,gen0和gen1也总是生活在同一个段上,被称为短暂段。如果SOH的增长超过了一个段的容量容量,在GC期间将获得一个新的段。gen0和gen1所在的段是新的短暂段,另一个段变成了gen2段。

对象回收后,会出现空闲空间,如上图的Obj0和Obj3,如果空间足够,新分配的对象会填充进来,但随着内存块被分配和释放的次数越来越多时,就会出现内存碎片,比如在LOH中一个Free空间的大小小于85000字节,这段空间就永远不可能被利用,所以需要进行压缩操作。移动对象是一件很耗时的操作,尤其是LOH对象,所以.NET4.5之前GC并不会进行LOH的压缩操作,碎片会一直存在,.NET4.5之后可以通过GCSettings.LargeObjectHeapCompactionMode设置来指定GC期间压缩LOH(默认为不压缩),当该属性被设置为CompactOnce之后,GC会在下一次完整回收时压缩LOH,不过由于这个过程想当耗时,所以压缩完后,这个设置又被被置为默认值Default。

托管内存泄漏

托管堆的泄漏方式五花八门,但大部分的案例都可归咎于对象引用被长期持有,无法正确释放这一点,下面我会给出一些我所知道内存泄漏类型(有机会会继续补充):

1. 对象引用泄漏

当一个对象不再使用时,应用程序未能及时释放该对象的引用,导致该对象无法被垃圾回收器回收,比较常见的是在事件通信时发生:

    //首先,定义了一个事件发布者,用于发布TaskChanged事件
    /// <summary>
    /// 事件发布。
    /// </summary>
    internal class Publisher
    {
        public Action<object> TaskChanged; //变更事件
    }

    //然后,定义一个消费者来订阅事件
    /// <summary>
    /// 订阅者
    /// </summary>
    internal class Subscriber
    {
        public int Id;

        private readonly Publisher _publisher;

        public Subscriber(int i, Publisher publisher)
        {
            _publisher = publisher;
            _publisher.TaskChanged += OnChanged; //订阅OnChange事件
            Id = i;
        }

        public void UnSubscriber()
        {
            _publisher.TaskChanged -= OnChanged; //取消订阅事件
        }

        public void OnChanged(object sender)
        {

        }

        ~Subscriber()
        {
            Console.WriteLine("实例{0}被回收", Id); //在GC时会触发
        }
    }

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

            for (int i = 0; i < 100; i++)
            {
                Subscriber task = new Subscriber(i, mychange);
                //task.UnSubscriber(); //如果忘记取消订阅,则会导致对象引用泄漏
            }

            GC.Collect();//手动GC,如果没有取消订阅,终结器~Subscriber不会触发,取消了订阅后,~Subscriber才会触发
            Console.ReadLine();
        }
    }

示例中的100个task实例,在mychange消亡前由于其引用被长期持有,所以无法被GC回收,所以在编码过程中,我们应当尽量避免长期生存对象引用生存期较短的对象。

2. 静态对象泄漏

静态对象泄漏也是一种比较常见的引用泄漏,比如一些开发者喜欢用静态对象做缓存,但却忘了移除他们:
当一个对象不再使用时,应用程序未能及时释放该对象的引用,导致该对象无法被垃圾回收器回收,比较常见的是在事件通信时发生:

    //用户信息
    public class UserInfo
    {
        public int Id { get; set; }

        public string Name { get; set; }

        public string BirthDay { get; set; }

        ~UserInfo()
        {
            Console.WriteLine("用户{0}被释放", Id);
        }
    }

	//用户缓存
    internal class LoginCache
    {
        public static Dictionary<int, UserInfo> UserCache = new Dictionary<int, UserInfo>();

        public static void AddCache(UserInfo info)
        {
            UserCache.TryAdd(info.Id, info);
        }

        public static void RemoveCache(int id)
        {
            //移除
            UserCache.Remove(id);
        }


        public static UserInfo GetCache(int id)
        {
            if (UserCache.ContainsKey(id))
                return UserCache[id];
            return null;
        }
    }

    internal class Task01
    {
        public static void Run()
        {

            for (int i = 0; i < 100; i++)
            {
                //假设这里i用户进行了登录操作,LoginCache中会缓存UserInfo
                UserInfo info = new UserInfo()
                {
                    Id = i,
                    Name = i.ToString(),
                    BirthDay = DateTime.Now.ToString("yyyy-MM-dd")
                };
                LoginCache.AddCache(info);
                //做了一些操作,用到了UserInfo
                LoginCache.GetCache(info.Id);
                //假设这里就不需要用到UserInfo了,但由于没有从静态变量中移除,UserInfo就无法被回收
                //LoginCache.RemoveCache(info.Id);
            }

            GC.Collect();//手动GC,移除缓存后,~UserInfo才会触发
            Console.ReadLine();
        }
    }

其实有不少人认为这种并不算内存泄漏,因为对象确实被引用了,但设想一下,如果只增加不移除,缓存的用户量就可能达到千万甚至上亿级别,我认为这是一种内存泄漏。

3. 永不终止的线程

如果处于某种原因,你需要创建一个永不停止的线程,那么就有可能引起内存泄漏:
当一个对象不再使用时,应用程序未能及时释放该对象的引用,导致该对象无法被垃圾回收器回收,比较常见的是在事件通信时发生:

    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();
        }
    }

上面的示例中由于计时器没有停止,GC就无法回收创建的实例。

4. LOH泄漏

由于LOH本身的特性,在程序中,我们当尽量避免频繁的使用大内存对象,如果不能就应当尽量避免内存碎片,请先看下面这个例子:

    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();
        }
    }

实例中我们交替产生了150000字节和85000字节的大内存对象,同时我们模拟了GC的频繁触发,我们通过Winddbg中的!dumpheap命令分析,就会看到内存中出现了大量的碎片,Free和Live交替出现:

但如果我们把数据的大小固定住85000,那么后续新分配的对象就有很大概率继续使用前面的空闲空间,大大减少了内存碎片:

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

        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,这样对的性能和内存占用都有好处。

工作站模式和服务器模式

GC操作本身是一件成本比较高的操作,尤其是Full GC和内存压缩的时候,可能会涉及到GC暂停,所以GC在回收时通过短暂暂停和分代GC来尽量避免影响应用程序。根据GC类型的不同,.NET提供了两种GC模式,服务器模式和工作站模式:

工作站模式

工作站模式适用于单CPU系统或者少量CPU的系统,回收发生在触发回收的用户线程上,并保留相同的优先级,也就是说垃圾回收期必须与其他先线程竞争CPU时间。当计算机只有一个逻辑CPU时,无论如何配置,GC都只会采用工作站模式。

服务器模式

服务器回收发生在多个专用线程上,且优先级很高,提高了垃圾回收效率,提升了应用程序的性能。不过服务器垃圾回收可能会增加一些额外的内存和CPU开销,不过和应用想必,这些开销基本可以忽略不计。

💡 传统的ASP.NET程序最初通常运行在单个CPU的计算机上,所以其默认使用的是工作站垃圾回收(Workstation GC mode),所以大家可以尝试将ASP.NET的回收模式改为服务器模式(Server GC mode)来提升性能ASP.NET Core中已经将GC模式默认为服务器模式。不过一般情况下,也不需要对这个模式进行调优,如果说应用程序通过调优GC模式性能得到了极大改善,我还是建议大家仔细排查一下内存使用是否存在问题。

扩展阅读:https://learn.microsoft.com/zh-cn/dotnet/standard/garbage-collection/workstation-server-gc

结束语

由于内存的特性,我们想要分析内存泄漏问题,就需要借助内存分析工具,不过正所谓富人靠科技,穷人靠努力,那些商用的如Ants Memory Profiler、dotMemory我就不做介绍了,大家有兴趣的可以去了解下。这里给大家推荐的是PerfView+Windbg,这两个工具基本可以定位到绝大部分的内存泄漏问题。
PerfView:https://learn.microsoft.com/zh-cn/shows/perfview-tutorial/
WinDbg:https://learn.microsoft.com/zh-cn/windows-hardware/drivers/debugger/getting-started-with-windows-debugging
不过,这两个对新手使用不太友好,我会在后续的章节给大家讲解,大家也可以关注一线码农的博客,有很多的案例来讲解PerfView和WinDbg的使用。
推荐阅读:
关于.NET内存的更多知识,大家可以从Microsoft Learn了解关于.NET内存的更多知识,也可以关注Maoni Steaphens(微软架构师,负责.NET Runtime GC的设计与实现)的个人博客,另外《.NET 内存管理宝典》一书也值得一读。

泄漏分析

书接上文,内存发生泄漏,往往很难定位,除了排查代码,我们就需要借助工具了,这里给大家简单介绍一下程序员装逼利器——WinDbg。
很多人觉得WinDbg很难,其实并不然,大部分问题,我们用几个关键性的命令就能解决。多多练习,你也能够成为那个面对小学弟和小学妹们的崇拜时,撩一撩额前为数不多的秀发,来一句:“无他,唯手熟尔。”,事了拂衣去,深藏功与名。

安装配置

WinDbg是一个调试器,可帮我们分析内存泄露、线程阻塞等问题,下载链接,下载完成后,一步步安装即可。安装完成后,并不需要过多介绍,打开菜单你就知道怎么用,比如Open dump file就是打开转储文件、Attach to process就是附加到进程,不过Attach to process这个我也没研究过,正常情况下,生产上面不太可能让你附加到进程进行调试的,再说VS的附加到进程肯定比这个来的直观,有兴趣的同学可以自行研究一下,这里我就单讲如何定位内存泄露和线程死锁。
首先,要配置符号文件:

  1. .symfix:重置符号文件目录,非必须,当你想重置以前的配置时,可以执行此命令。
  2. .sympath srvC:\MyServerSymbolshttps://msdl.microsoft.com/download/symbols

基础命令

  1. 打开 WinDbg。
  2. 在“ 文件 ”菜单上,选择“ 打开可执行文件”。 在“打开可执行文件”对话框中,转到 C:\MyApp\x64\Debug。 对于 “文件名”,请输入 MyApp.exe。 选择“打开”。
  3. 加载SOS调试扩展dll:.loadby sos clr
  4. 设置并重新加载符号文件,将.NET一些重要的pdb文件下载到指定的路径中,加载到Windbg调试环境中,这样我们可以看到程序在哪一行出错,运行到哪一行了。
  5. 输入以下命令:.symfix.sympath+ C:\MyApp\x64\Debug命令告知 WinDbg 在何处查找应用程序的符号和源代码。 在这种情况下,无需使用 .srcpath 设置源代码位置,因为符号具有源文件的完全限定路径。
  6. .sympath:打印当前符号调试文件搜索路径。
  7. !threadpool:分析并确认CPU使用率。
  8. !threads:查看线程的整体运行情况。
  9. ~34s:查看指定线程的调用堆栈,如34号线程。> !clrstack。
  10. !runaway:查看线程消耗CPU资源情况
    Thread Time
    5:8320 0 days 0:00:00.015
    6:1e84 0 days 0:00:00.000
    4:8e84 0 days 0:00:00.000
    3:5074 0 days 0:00:00.000
    2:5aa4 0 days 0:00:00.000
    1:732c 0 days 0:00:00.000
    0:49b4 0 days 0:00:00.000
    第一列是线程号,第二列是Total的CPU使用时间。
  11. !dso:查看当前线程栈上的所有对象的信息,Dump statk objects。
  12. 查询内存中指定的对象信息:!do
  13. 查询内存中指定数组对象的信息(dump array):!da
  14. 查看当前线程的堆栈和每行堆栈上的变量信息:!clrstack -a
  15. 查看托管堆上的内存对象分布,三个代的信息:!eeheap -gc
  16. 查看托管堆上加载的dll:!eeheap -loader
  17. !dumpheap -stat:查看内存中各个对象的总个数和总内存占用
  18. !dump -stat -mt -min 85000:查看内存中大对象的个数和对象大小
  19. !finalizequeue:查看内存的析构队列的指令
  20. !syncblk:查看线程阻塞的指令
  21. !dumpheap -type System.Net.Sockets.Socket -stat:查看所有System.Net.Sockets.Socket对象统计信息的指令
  22. 查看数组:!DumpArray /d 000001b5d23f52b8,只输出前50行:!DumpArray -length 50 000001b5d23f52b8
  23. 查看某个特定Moule的名称:lm 00007ffc663dbce0
  24. !dumpgen 0 -free -stat :统计三个代中的free空间
  25. !eeversion:查看sos插件版本
  26. !address -summary:查看计算机使用的内存信息

符号文件

链接应用程序、库、驱动程序或操作系统时,创建.exe和.dll文件的链接器也会创建一些称为 符号文件的其他文件。
符号文件包含大量的数据,这些数据在运行二进制文件时实际上并不需要,但在调试过程中很有用。
通常,符号文件可能包含:
● 全局变量
● 局部变量
● 函数名称及其入口点的地址
● FPO) 记录 (框架指针省略
● 源行号
调试器从位于本地文件系统的或从远程符号服务器加载的符号文件中获取其有关符号的信息。 使用符号服务器时,调试器将自动使用正确的符号文件版本来匹配目标中的模块。

💡:可以使用 .symfix(设置符号存储路径)命令自动加载符号,前提是在调试器运行时可以访问 Internet。 然后使用 .reload(重新加载模块)命令加载符号。
.sympath srvC:\MyServerSymbolshttps://msdl.microsoft.com/download/symbols调试器将使用符号服务器从 符号存储 中获取符号,并将其缓存在 localcache 目录中。
如果在符号路径中包含字符串 cache;,则从该字符串右侧的任何元素加载的符号都存储在本地计算机的默认符号缓存目录中。 例如,以下命令指示调试器从 Microsoft 符号服务器 存储中获取符号并将其缓存在默认符号缓存目录中。
.sympath cache
;srv*https://msdl.microsoft.com/download/symbols
lml:显示包含符号信息的已加载模块的列表
默认在:C:\ProgramData\Dbg\sym

环境配置

  1. .symfix:重置符号文件目录
  2. https://learn.microsoft.com/zh-cn/windows-hardware/drivers/debugger/using-a-symbol-server
  3. https://learn.microsoft.com/zh-cn/windows-hardware/drivers/debugger/-symfix--set-symbol-store-path-?source=recommendations
  4. .sympath+ D:\Repository\WinDbgSymbols:将符号查找路径设置为:D:\Repository\WinDbgSymbols
  5. .srcpath:查看当前源文件查找路径
  6. .loadby sos coreclr
  7. .srcpath+ D:\Repository\WinDbgSymbols:添加源文件查找路径
  8. 若是无法加载.load sos,可能是因为Windbg不支持自动加载扩展的语法,可以尝试手动加载:.load C:\Windows\Microsoft.NET\Framework64\v4.0.30319\sos.dll
  9. 若是无法加载.load clr,可能是因为Windbg不支持自动加载扩展的语法,可以尝试手动加载:.load C:\Windows\Microsoft.NET\Framework64\v4.0.30319\clr.dll
  10. .load C:\Program Files\dotnet\shared\Microsoft.NETCore.App\6.0.13\mscordaccore.dll
  11. .load C:\Program Files\dotnet\shared\Microsoft.NETCore.App\6.0.13\coreclr.dll
  12. .load:加载新的dll、.unload:卸载dll、.unloadall:卸载所有的调试器扩展
  13. .extpath 设置dll搜索路径
  14. .setdll 设置默认扩展dll

如果需要调试.net core程序,需要安装.net sos工具

  1. dotnet tool install -global dotnet-sos:命令提示上会提示你加载:.load C:\Users\Administrator.dotnet\sos\sos.dll
  2. 在.netcore中.loadby sos clr不管用,要指定目录安装sos调试器扩展。
    有时候会报:
    Failed to load data access module的问题,原因可能是:symbolpath与版本不匹配

mex插件的使用
下载链接:https://www.microsoft.com/en-us/download/confirmation.aspx?id=53304
解压加载命令:.load D:\Repository\WinDbgSymbols\mex.dll
继续加载sos:.loadby sos clr
查看Mex的各种命令:!mex.help
!dae:查看所有的Exception
!mex.mthreads:查看所有的线程状态
!clrstack2:查询线程堆栈
!do2 00007ffc97355e70:查看对象
!mex.pe2:查看当前线程的异常
!foreachobject -x "!do2 @#Obj" System.Net.Socket:循环统计

GC Mode中的Preemptive(抢占式),一种垃圾回收策略,在Preemptive模式下,垃圾回收期会定期进行暴击和清理阶段,在清理阶段,应用程序会被暂停一会儿

内存泄漏问题

.net core中,内存地址是一个指向内存中特定位置的指针,指针的大小为8字节(64位的二进制值),在windbg中显示的是一个16位十六进制的数字。

内存泄漏是一个持续性的问题,一般出现问题时,最好连续、间隔抓两到三个Dump,查看内存的增量。
多核CPU查看每个GC托管堆的大小:!eeheap -gc
查看各类对象的总个数和总内存的占用:!dumpheap -stat
查询内存中大对象的个数和对象大小:!dumpheap -stat -mt -min 5000 -max 100000
如果一类或者几类对象占用的内存很多,分析此类对象:!dumpheap -mt **
查看对象的gcroot :!gcroot addr
线程阻塞问题
线程阻塞属于程序挂起的一种,主要表现有:

  1. 随着请求的增加,线程数一直增加,把线程池打爆;
  2. 降低CPU使用率;
  3. 请求没有返回,客户端一直在等待,直至timeout。
  4. ~* e!clrstack:查看所有线程堆栈
  5. !t:查看线程状况
  6. !tp:查看线程状况
  7. !ext tqp查看任务积压
    查看线程阻塞:!syncblk
    !eeversion:查看当前运行时的gc模式

dotnet诊断工具
https://learn.microsoft.com/zh-cn/dotnet/core/diagnostics/dotnet-counters

posted @ 2023-07-19 19:40  破落户儿  阅读(1072)  评论(0)    收藏  举报