(十五)垃圾回收、析构与IDisposable

1.读懂GC

image

1.1 值类型与引用类型

引用类型指的是变量保存着的是内存地址的引用,所以相对应的,我们所说的值类型数据则是真正保存在内存中的数据。一般来说,所有的基本类型,如整数、浮点数、字符、布尔、甚至是结构体struct这些都属于值类型,他们的数据是直接保存在stack栈内存中的。

我们在代码中使用变量 c 的时候,实际上使用的是变量c所代表的内存地址,而正式因为类似变量c这样的对象其实都是对一个堆内存地址的引用,所以这也就是为什么所有的对象都被称作引用类型reference type的原因。

1.2 删除一个对象

image

  • 变量c所引用的这一坨对内存的引用就无法访问了,因为他所对应的内存地址丢失了。
  • 也就是说,这一部分的内存空间将会永远被占据,既不能访问、也不能删掉、更不能重复利用。
  • 于是,这坨代码就产生了内存泄露。如果不注意内存管理,类似这样的垃圾代码越来越多,那么总有一天你的内存会爆掉。
  • 而我们的电脑越用越卡的真正原因也就正是内存泄露所导致的。如果同学们想把丢失的内存重新找回来,那就只好关机重启了。
    image

删除一个对象,正确的操作方式应该是先删除堆内存中的数据,然后再删除栈内存的数据。
而 C# 则借鉴了java的成功经验,也同也引入了垃圾回收器的概念。
其实,垃圾车就是通过计算对象的引用数量来辨别它的生命周期的,c# 中把不用的对象清理掉的过程就叫做垃圾回收 Garbage Collection,简称GC。

2. GC如何工作

2.1 垃圾回收机制

运行垃圾回收的成本很高,因为它需要垃圾回收器 garbage collector(GC) 在你的应用程序中遍历所有的对象,并检查每个对象的使用情况,确定这个对象生命状态和引用情况。因此C#也就是.Net平台的垃圾回收器使用了一个比较复杂的机制来确保它的高效运行。

2.2 分代回收

这种垃圾回收机制就是generations,中文名称叫做分代回收。

基本思路是按照对象的生命周期来给他们断代,垃圾回收器 GC 会将不同的对象按照生命周期的长短分配到不同的年龄组。

对于需要长期存在的对象,我们的垃圾回收器将会把他们丢进长期租,而长期组中的对象只需要在运行GC的时候偶尔来检查一下就可以了。

而暂时性的对象、或者说哪些转瞬即逝的数据,我们就把他们放在短期租,每次运行GC都要检查他们的生命周期。

而.NET 中的垃圾回收实际上分为三个年龄集合,或者叫做代龄集合,Gen 0、Gen 1 和 Gen 2,Gen就是generation的缩写。
image
如果一个对象在 Gen 0 中被检查到了,如果没有任何引用,那么它将会被标记为删除,GC会这一个合适的时机,统一删除所有被标记的对象。但是,如果这个对象幸存下来了,它在程序中仍然被使用、被引用,那么这个对象会被转移到 Gen 1,而进入Gen 1以后GC对它的检查频率就降低了。

而 Gen 1 对象也被适用同样的规则,GC会以相对较低的频率来检查Gen 1中的对象,如果某个对象没有任何引用了,那么它将会被标记为删除。而如果某个对象仍在使用中,那么垃圾回收器将会把这个对象从Gen 1转移到Gen 2中,进入Gen 2以后GC对它的检查频率就更低了。而进入Gen 2中的对象可以被视为那些长期存在的对象。

不过呢,当系统感知到内存不足时,GC还是会强制进行一次完整的遍历,检查所有三个代龄分组中的所有对象。

2.3 GC 不止处理垃圾清理

image

当 GC 运行时,它可以检查堆内存中的死掉的对象,把他们标记出来、进行清理。

当GC在回收释放了死对象以后,它就会压缩堆内存,消除内存之间的间隙,让内存的区块连接在一起,因为在创建新对象的时候,使用大区块或者一整个内存区域给对象分配内存的效率远高于把对象分割成若干个小内存区块来保存的效率。这也就是为什么我们电脑用久了,时不时整理一下内存碎片,会突然有种系统重生的感觉。

所以当垃圾收集器运行时,他它主要工作其实并不只是清理对象,更重要的是保持内存与程序的平衡。内存经过GC的调整以后,理想情况下的内存会变成连续的、整体的,因此内存分配速度会更快。不过,如果对象太大,GC会放弃挣扎,他会放弃对这部分内存进行压缩,因此GC不会花时间移动过于巨大的大内存块。

2.4 GC 的运行时机

image

但是 GC 什么时候真正运行一个垃圾回收呢? 实际上,针对不同类型对象的回收会有不同的触发时机。

当我们在代码中分配新对象时,GC 会检查应用程序正在使用多少内存。

如果 GC 认为我们的程序已经占用大量内存了,也就是说内存压力比较大的,就会会触发垃圾回收。

而在回收过程中,GC也可以针对不同的对象选择部分回收还是全部回收,也就是说一次清理不一定会清空全部垃圾,决定权取决于当前内存的压力。 GC 有一个阈值来决定它到底是只回收 Gen 0,还是同时回收Gen 0 和 Gen 1,甚至是完整回收Gen 2, 当然,完整回收需要付出比较高的性能代价。

2.5 独立线程

image

通常情况下,GC在运行垃圾回收的时候都是跑在一个独立的后台线程中,因为他是独立运行的,所以不会影响到我们的应用程序的其余部分的主线进程。

但 GC 每次运行都需要付出代价,需要消耗计算资源。 因此,它的目标就是在尽量不影响我们程序运行的情况下,尽可能的减少运行频率、并且尽可能提高运行效率。

3. 析构方法vs终结器

在之前,经常会使用类的构造器,也就是构造方法,我们可以通过这个构造方法来创建对象实例。
不过既然我们有创建对象的方法,那么相对应的,C#中每个对象都有自己独立的销毁对象的方法,叫做析构方法(destructor),或者叫做终结器(Finalizer)。

https://docs.microsoft.com/zh-cn/dotnet/csharp/programming-guide/classes-and-structs/finalizers

终结器以前称为析构函数,也就是说他们是一回事,唯一的作用就是用于垃圾回收的最终清理工作。

而文档也明确说明了,在大多数情况下,可以免去编写终结器。这句话隐含的意思就是说,我的C#代码中绝大部分对象都是托管在.net声明周期内的,对于托管型的资源,c#的垃圾回收可以自动进行处理无需我们的干预。也就是说,C#会自动判断当前这个对象还有没有用,如果没有用,那么他会找一个合适的时机自动销毁这个对象,并且回收内存。而垃圾回收的时机是c#编译器自我决定的,我们不能干预,所以,在实际工作中,真正需要我们手动编写终结器或者调用析构方法的情况是比较罕见的。

如果真的需要手动回收资源,更加常用的还是使用 Idisposable 接口。

3.1 语法

image

首先,看看终结器的语法。这是一个标准的类终结器声明,这个方法用波浪线开头,方法名称与类名必须完全一致。注意,MSDN 文档也明确说明,使用终结器会造成性能的损失,所以,终结器的第一特点就是“若无必要不要使用”。
image

而终结器我们我没有办法直接调用的,只能在对象生命周期结束以后,等待垃圾回收自动调用。所以,我们没有办法通过代码来控制终结器的调用时机。而垃圾回收器可以自行判断是否有必要调用终结器的必要,有时候即便我们明确声明了终结器,垃圾回收器为了提高代码效率,还是可以不执行终结器。所以,终结器的第二个特点就是,“不可靠”。也就是说,我们一定要避免对非托管资源,比如数据库关闭、文件关闭,等类似的外部系统资源,在终结器中进行回收。

在文档中有这么一句话,终结器将会隐式调用基类的finalize方法,所以,当我们的随便class被销毁的时候,最终调用的其实是这个 finalize 方法,而 finalize 翻译成中文就是终结、终止的意思,这也就是 c# 中把析构方法称为做终结器的原因。
image

image

回到代码,我们也可以试一下这个finalize方法。把finalize方法复制到我们的代码中,结果,报错了。其实报错信息也很明确,就是警告我们如果重写这个finalize方法将会改变垃圾回收的游戏规则;而第二个报错就更明确了,我们不可以重写这个finalize方法,如果真的需要终结某个对象,应该使用析构方法而不是finalize。

3.2 实例->析构什么时候运行

public class SuibianClass
{
    public SuibianClass()
    {
        Console.WriteLine($"SuibianClass 创建");
    }

    ~SuibianClass()
    {
        Console.WriteLine($"SuibianClass 销毁");
    }
}

//main
var suibian=new SuibianClass();

按道理来说,程序结束,也就是当Main方法结束的时候,垃圾回收器会启动,并且销毁这个随便class,运行一下试试看吧。

奇怪的是,我们只看到了实例创建的说明,没看到实例销毁的文字,这是怎么回事呢?难道是垃圾回收器没有正常工作导致内存泄漏了吗?

实际上,垃圾回收器是不会罢工的,只不过它的工作方式有点别致。垃圾回收器会通过上下文来判断当前的实例是否不会再需要被引用,而这个引用是由语句的作用域所决定的。而这个随便 class 实例的作用域是整个main方法,所以垃圾回收器就不知道什么时候应该销毁这个实例了,或者说垃圾回收器在mian方法结束以后才销毁对象,所以我们自然看不到析构方法的输出

其实,这个问题的答案,MSDN文档也已经回答了。
image

所以,我们必须降低对象的作用域,在main方法外部创建一个独立的方法,在这个方法中实例化随便class,那么这个实例的作用域仅限于这个 dosomething 方法中,超过这个方法作用域则以后实例对象就不能被访问了,也就是说这个对象的生命结束了。那么垃圾回收器就会启动并且回收他。

static void DoStuff()
{
    new SuibianClass();
}
static void Main(string[] args)
{
    DoStuff();
    GC.WaitForPendingFinalizers();
    GC.Collect();
}

所以,在mian方法中调用方法doSomething。不过,还有个问题,就是垃圾的回收时机是不确定的,时快时慢,如果它在main方法的作用域外执行,我们还是看不到析构方法的执行过程。

所以,这里有一个小技巧,我们需要主动使用垃圾回收器,GC,调用WaitForPendingFinalizers,等待所有的、需要被回收的对象全部由完成,然后再调用 GC.Collect() 。
image

因为调用终结器比较消耗性能,所以垃圾回收器的自主权很大,它有权决定最终那个对象需要被回收,那个对象可以不用被回收。所以,可能会看不到“SuibianClass 销毁”,看不到的话不用担心,这是正常现象。我们可以多运行几次,或者多rebuild几次就可以了。

3.3 继承的情况

public class SuibianClass
{
    public SuibianClass()
    {
        Console.WriteLine($"SuibianClass 创建");
    }

    ~SuibianClass()
    {
        Console.WriteLine($"SuibianClass 销毁");
    }
}

public class SecondClass : SuibianClass
{
    public SecondClass()
    {
        Console.WriteLine($"SecondClass 创建");
    }

    ~SecondClass()
    {
        Console.WriteLine($"SecondClass 销毁");
    }
}

public class ThirdClass : SecondClass
{
    public ThirdClass()
    {
        Console.WriteLine($"SecondClass 创建");
    }

    ~ThirdClass()
    {
        Console.WriteLine($"SecondClass 销毁");
    }
}

image

我们可以看到对象的终结器也会沿着继承链一层一层向上调用父类的终结器。

3.4 小结

以上就是终结器的全部内容,稍微做一下总结:
image

一般来说,在绝大部分情况下我们的对象的生命周期都会被c#自动托管,对于这部分托管资源,我们是不需要手动进行任何处理的,垃圾回收器可以自动管理这些资源的内存分配和释放。

4.什么是Disposable

4.1 GC 是万能的吗?

image
但是,如果我们需要访问网络连接、访问数据库、访问文件等资源,.Net 还能帮我们管理这些外部资源的生命周期吗?

答案是否定的,我们的.Net能处理的对象是“托管资源”,所谓“托管资源”指的是那些使用 new 关键词在代码中创建的对象。而所有的外部资源都被称作“非托管资源”。

image

比如我要从网上下载一个文件,能不能下载这个文件取决于你有没有交网费,而不是你的代码,更不是.Net平台。所以,外部资源,比如文件读取、网络访问、数据库请求都无法托管在.Net平台内部。
image

对于类似上面的非托管类型资源,我们无法依赖于垃圾回收器自动处理,所以,我们需要手动来完成资源的释放。而这种资源释放的方式需要使用一个叫做 IDisposable 的接口,通过实现 Dispose 模式来完成外部资源的清理工作。

4.2 IDisposable

那么,什么是IDisposable接口呢?其实它就是一个代码规范而已,结构非常简单,最简单的示例代码如下所示:

我们只需要简单的完成一个 Dispose 方法,在这个方法中释放资源就可以了。
image
而 IDisposable 接口定义在System命名空间下,它属于 .NET 基本框架中的定义,所以,无论我们使用任何第三方插件、或者是我们自己的代码都可以100%使用这个接口。

IDisposable 不会于垃圾回收机制绑定,垃圾回收器不会调用dispose方法,也不会主动释放IDisposable接口所实现的对象。

IDisposable 处理的是垃圾回收所不能完成的任务,或者说所有涉及到外部资源、涉及到非托管资源释放的情况我们都需要由 IDisposable 接口来处理。 因此,只要我们实现了 IDisposable 接口,实际上就是在告诉我们的代码这里有非托管资源,就意味着它需要在使用结束后手动清理释放。

每当我们的 .NET 应用程序与外部世界交互时,我们就会离开托管沙箱,因此几乎每个与文件或数据库或 REST API 交流的应用程序都在使用非托管资源。
image

4.3 典型案例

cusome类只需要完成 Dispose 方法,在这个方法中释放外部资源就可以了
image
当我们想使用这个类的时候,需要配合 using 关键词来创建它的对象实例。这里的 using 是 C# 的一个特殊语法,编译器实际上会把using关键词翻译为类似 try finally 的语句块,在 finally 的内部调用 dispose 方法。
image

所以,IDisposable 接口配合 using 关键词,就构成了Disposable 模式的完成实现过程。当using花括号所包裹的语句块结束的时候, dispose 方法就会自动被调用,然后执行方法内部的代码,来释放它所分配的外部资源,而这一切都是using自我管理的,不需要我们的参与。也就是相当于using关键词来代替我们管理对象的生命周期。

注意,我们可以把 IDisposable 和 using 看作是一个固定搭配,如果使用了IDisposable 就必须使用using来管理他的实例对象的生命周期。

5.使用IDispoable回收非托管资源

5.1 使用的原因->例子

image

static void Main(string[] args)
{
    for(int i = 0; i < 1000; i++)
    {
        var db = new Database();
        Console.WriteLine($"[{DateTime.Now.ToLongTimeString()} ; {date}]");
    }
}

代码跑起来,一起都感觉挺顺利的,不过很快,程序就卡住了,最后直接崩溃了。
image
报错信息显示 “InvalidOperationException: 'Timeout expired.” timeout的原因就是数据库连接超过了最大连接限制。

所以,问题就出在我们完成数据库读取操作以后,数据库连接依然保持在开启的状态。导致我们的数据库连接数量越来越多,最后数据库连接数量超过连接池上线程序就崩溃了。

5.2 手动close

public void Close()
{
    Console.WriteLine("数据库关闭");
    _connection.Close();
    _connection.Dispose();
    _connection = null;
}

回到main方法,在每次执行数据库完成以后,我们来调用这个Close方法。

不过,这种代码方式有个弊端,就是每次打开数据库连接以后,一定一定一定要记得手动调用close方法来关闭数据库。但问题是,在真正工作中,程序员忘记关闭数据库连接是一个经常会发生的低级错误,为此,C# 有一种比较特殊的设计模式来回收代码中类似数据库连接这样的的非托管资源。

5.3 使用Dispose

实现 Idisposable 接口。
public class DatabaseHelper: IDisposable

右键点击红色波浪线,选择 “实现接口with Dispose 模式”
image

private bool disposedValue;

protected virtual void Dispose(bool disposing)
{
    if (!disposedValue)
    {
        if (disposing)
        {
            // TODO: dispose managed state (managed objects)
        }

        // TODO: free unmanaged resources (unmanaged objects) and override finalizer
        // TODO: set large fields to null
        disposedValue = true;
    }
}

// // TODO: override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources
// ~DatabaseHelper()
// {
//     // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
//     Dispose(disposing: false);
// }

public void Dispose()
{
    // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
    Dispose(disposing: true);
    GC.SuppressFinalize(this);
}

然后把 close 方法中的所有代码复制粘贴到 Dispose 虚方法的if语句中。

ok,我们的close方法可以删掉了,然后回到main方法,删掉close方法的调用。

接下来关键来了,因为 DatabaseHelper 实现了 IDispose 接口,那么我们就可以使用一个特殊的句法结构来自动管理生命周期,这就是using。

using 加上小括号,小括号内是需要被托管的对象,而使用花括号包裹的语句则属于对象生命周期内需要执行的任务,比如说,我们需要在创建了数据库以后做一个数据库读取的工作,而当代码执行超越了using语句块以后,则代表DatabaseHelper的生命周期结束了,这个时候,编译器会自动调用 Dispose 方法,执行垃圾回收工作,释放所有包括未托管资源在内的所有对象。

for (int i = 0; i < 1000; i++)
{
    //var db = new DatabaseHelper();
    //Console.WriteLine($"[{DateTime.Now.ToLongTimeString()} ; {db.GetDate()}]");
    using (var db = new DatabaseHelper())
    {
        Console.WriteLine($"[{DateTime.Now.ToLongTimeString()} ; {db.GetDate()}]");
    }
}

5.4 小结

image
这就是using最新的语法,即使不使用花括号,我们依然可以托管对象的生命周期。

static void Main(string[] args)
{
    for (int i = 0; i < 1000; i++)
    {
        using var db = new Database();
        Console.WriteLine($"[{DateTime.Now.ToLongTimeString()} ; {db.GetDate()}]");
    }
}
posted @ 2023-12-07 16:16  huihui不会写代码  阅读(195)  评论(0)    收藏  举报