跳至正文

C#静态集合导致的内存泄漏探究

起因是早上组里发的问题

原话:

函数尽量不要直接返回静态列表 要返回集合结果的

直接从外面传个列表进来就好了 这里就全泄漏了

描述:

有这样一个类

类里有个static List<xx>

有个返回值是IEnumerable<xx>的函数

在函数里面给list添加元素

最后把这个List作为迭代器的返回值返回出去


实际上泄露跟迭代器本身并没有什么关系

一开始ugg给我提供的思路是C++里面的集合增减后 迭代器失效的设定

但实际上C#并没有这样的东西

而且就把静态列表作为返回值传出去本身也挺奇怪的

所以咱就看返回static List<xx>到底导致了什么 最终产生了泄露


测试用类

Normal类作为对照组 验证正常GC后析构函数会被调用

Test类作为实验组一 有一个静态列表存放Normal类和一个静态函数

静态函数里执行add然后return

Test类作为实验组二 有一个静态列表存放自己

相比实验组一直接在构造函数里执行add操作

public class Normal
{
    public Normal()
    {
        Console.WriteLine("对照组创建");
    }

    ~Normal()
    {
        Console.WriteLine("对照组回收");
    }
}

public class Test
{
    public static List<Normal> TestList = new();

    public static List<Normal> TestReturn()
    {
        TestList.Add(new Normal());
        return TestList;
    }

    public Test()
    {
        TestReturn();
        Console.WriteLine("First创建");
    }

    ~Test()
    {
        Console.WriteLine("First回收");
    }
}

public class Test2
{
    public static List<Test2> TestList = new();

    public Test2()
    {
        Console.WriteLine("Second创建");
        TestList.Add(this);
    }

    ~Test2()
    {
        Console.WriteLine("Second回收");
    }
}

失败的测试方法

var test = new Test();
test = null;//null后 正常情况下GC会来回收这个对象
var test1 = new Test();
test1 = null;
var test2 = new Test();
test2 = null;

var testSecond = new Test2();
testSecond = null;

var normal = new Normal();
normal = null;

GC.Collect();
GC.WaitForPendingFinalizers();
Console.WriteLine("疯狂GC");
GC.Collect();
GC.Collect();
GC.Collect();
GC.Collect();
GC.Collect();
GC.Collect();
GC.Collect();
GC.Collect();
GC.Collect();
GC.Collect();

我发现这么做无论怎么改

对照组析构函数都不会打印

让我百思不得其解

最后通过多方查证查明了原因(nerd 我的nerd!)

来源链接

在main声明的类对象在程序结束之前不会调用析构函数

也就是说调用析构函数的时候你已经看不到他能打印的语句了

因为控制台程序已经结束了

要解决这个问题就要在其它方法中声明  然后在main里面调用这个方法


如何测试?

按照上面的思路改写了一版

void DoSomething()
{
    var test = new Test();
    test = null;//null后 正常情况下GC会来回收这个对象
    var test1 = new Test();
    test1 = null;
    var test2 = new Test();
    test2 = null;

    var testSecond = new Test2();
    testSecond = null;
    var testSecond1 = new Test2();
    testSecond1 = null;
    var testSecond2 = new Test2();
    testSecond2 = null;

    var normal = new Normal();
    normal = null;
}

DoSomething();
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
Console.ReadKey();

其实测试的思路还是有很多的

例如@一沙鸥 通过维护一个count做标识

例如@剑圣提供的两种思路

一是弱表(不大了解这东西) 二是多线程(根本想不起来)


测试结果

对照组析构函数正常执行

实验组一析构函数正常执行 new的类对象的析构函数不执行

实验组二析构函数不执行

也就是说 在构造函数里 对静态列表有add自身的操作的最后无法回收


结果分析

你还记得GC的标记清除的操作吗

Finalizer(终结器)被GC调用 用于释放托管资源

GC会遍历GC Root对象将其标记为“不可收集”

然后GC转到它们引用的所有对象 把他们也标记为“不可收集”

最后收集剩下的所有内容

那么什么会被视作是GC Root呢?

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

也就是说静态变量和他引用的所有内容都不会被垃圾回收

至于为什么要两次GC

那是因为终结器第一次并不会回收

而是把标记的对象添加进freachable队列

第二次GC时候放入队列的对象才会真正的回收

add自身的时候意味着自己被引用了

所以最终自己没有被回收

对于实验组1来说 add的不是自身而是new出来的Normal

虽然自己这个类被回收了

但是静态列表所引用的所有内容仍然不会被回收

也就是在这里被new的所有Normal对象仍然是没有回收的

表现出来就是四个Normal对象的析构函数只有一个执行了


参考文献

http://www.skcircle.com/?id=1889

https://www.cnblogs.com/Areas/p/2726198.html

https://zhuanlan.zhihu.com/p/141032986

《CLR via C#》(第4版)要點摘錄 – 「托管堆和垃圾回收」

《C#静态集合导致的内存泄漏探究》有1个想法

  1. Pingback: [C#] 略談析構器的調用時機問題 - LoneliNerd's Study Log

发表回复

您的电子邮箱地址不会被公开。