.NET 内存泄漏分析

目的

相信很多小伙伴,除了编码以外,还经常需要和服务器打交道,处理服务器警报,这些警报中最常见的问题之一就是内存泄漏,大部分时候这个问题很难通过传统的日志手段来定位,所以很多的小伙伴遇见了内存泄漏问题常常急的抓耳挠腮,一边百度(现在有了ChatGPT),一边连蒙带猜的尝试,运气好,完美定位,运气不好,加内存!!!不过,目前网上的大部分文章都是着眼于解决具体问题,很少会系统性的提及.NET的内存结构和内存泄漏为什么会产生,所以我打算写两到三篇文章来说一说内存泄漏以及如何分析内存泄漏,希望能对大家解决此类问题有所帮助。

内存管理

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

餐厅模型

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

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

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

.NET 内存模型

.NET中主要使用以下几种内存:堆栈、非托管堆和托管堆,下面我简单介绍以下它们:

堆栈

堆栈是应用程序执行期间用于存储局部变量、方法参数、返回值和其他临时值的地方,就像餐厅知道你点的每道菜,这些菜在后厨制作,但这只是暂时的,堆栈会为每个线程分配工作的暂存区。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的无限递归。
例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)。不过若是单纯的通过修改GC模式就能大幅度提升应用程序的性能,那大概率还是内存使用出现了问题,建议好好排查,MVC和ASP.NET Core中已经将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 内存管理宝典》一书也值得一读。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章