ChatGPT解决这个技术问题 Extra ChatGPT

为什么 C# 中没有引用计数 + 垃圾回收?

我来自 C++ 背景,我已经使用 C# 工作了大约一年。像许多其他人一样,我对为什么确定性资源管理没有内置在语言中感到困惑。我们有 dispose 模式,而不是确定性析构函数。 People start to wonder 通过他们的代码传播 IDisposable 癌症是否值得付出努力。

在我偏向 C++ 的大脑中,使用带有确定性析构函数的引用计数智能指针似乎是垃圾收集器的一大进步,垃圾收集器需要你实现 IDisposable 并调用 dispose 来清理你的非内存资源。诚然,我不是很聪明......所以我问这个纯粹是为了更好地理解为什么事情会这样。

如果 C# 被修改为:

对象是引用计数的。当一个对象的引用计数变为零时,会在该对象上确定性地调用资源清理方法,然后将该对象标记为进行垃圾回收。垃圾收集发生在未来某个不确定的时间,此时内存被回收。在这种情况下,您不必实现 IDisposable 或记得调用 Dispose。如果您有非内存资源要释放,您只需实现资源清理功能。

为什么这是个坏主意?

这会破坏垃圾收集器的目的吗?

实施这样的事情是否可行?

编辑:从到目前为止的评论来看,这是一个坏主意,因为

GC 更快,没有处理对象图中循环的引用计数问题

我认为第一个是有效的,但第二个很容易使用弱引用来处理。

速度优化是否超过了您的缺点:

可能无法及时释放非内存资源 可能过早释放非内存资源

如果您的资源清理机制是确定性的并且内置于语言中,那么您可以消除这些可能性。

Apple 刚刚宣布 iOS 5 中的 Objective C 将支持“自动引用计数”。所有 Objective C 指针都会自动引用计数并保留/释放。但是,Objective C 仍然支持 dealloc,它为您提供了一个确定性的“析构函数”,您可以在其中释放非内存资源,而不必弄乱 IDisposable。他们明确表示他们不会支持 Objective C 的 GC。听起来对我来说是正确的方法。 Java 和 .NET 搞错了,IMO。
当涉及到 GC 时,我担心的一件事是事件订阅者,即使实现了弱事件模式,在等待垃圾收集时仍会继续接收通知。这使得 IDisposable 在所有事件订阅者类中的实现都是强制性的。
是的,从公认的答案来看:“我们认为在不强迫程序员理解、追踪和围绕这些复杂的数据结构问题进行设计的情况下,解决循环问题非常重要。”今天我想起了这一点,因为我被迫理解、追踪和设计复杂的数据结构和对象生命周期交互,以便弄清楚为什么没有释放内存。有人忘记在 Dispose() 中处理取消订阅全局事件处理程序的对象。反应式扩展使一切都是一次性的。
你是什么意思“非内存资源”?
关于原因#1(GC 在没有引用计数的情况下更快),该速度是相对的。我们设计了处理内存中数百万个长期存在的类实例的系统,并发现性能会大大降低(构建 GC 可达图成为一个杀手)。在这种情况下,引用计数的成本将与引用跟踪的成本相形见绌)。我们采取了 a) 将类迁移到结构,以便容器只需要跟踪后备数组引用 - 和 b) 使用非托管内存。您的提议将对我们这样的案例大有裨益。

o
openshac

Brad Abrams 发布了在 .Net 框架开发期间编写的 an e-mail from Brian Harry。它详细说明了未使用引用计数的许多原因,即使早期的优先事项之一是保持与使用引用计数的 VB6 的语义等价。它研究了一些可能性,例如计算某些类型的引用而不计算其他类型(IRefCounted!),或者计算特定实例的引用,以及为什么这些解决方案都不被认为是可接受的。

因为[资源管理和确定性最终确定的问题]是一个如此敏感的话题,我将尽可能地在我的解释中做到准确和完整。我为邮件的长度道歉。这封邮件的前 90% 是试图让你相信这个问题确实很困难。在最后一部分中,我将讨论我们正在尝试做的事情,但您需要在第一部分了解我们为什么要考虑这些选项。 ...我们最初假设解决方案将采用自动引用计数的形式(因此程序员不会忘记)加上其他一些东西来自动检测和处理周期。 ...我们最终得出结论,这在一般情况下是行不通的。 ... 总结:我们觉得解决循环问题而不强迫程序员围绕这些复杂的数据结构问题去理解、追踪和设计是非常重要的。我们希望确保我们拥有一个高性能(速度和工作集)系统,我们的分析表明,对系统中的每个对象使用引用计数将无法实现这一目标。由于各种原因,包括组合和铸造问题,没有简单的透明解决方案来仅对那些需要它的对象进行引用计数。我们选择不选择为单一语言/上下文提供确定性终结的解决方案,因为它抑制与其他语言的互操作,并通过创建特定语言的版本导致类库的分叉。


一定要阅读完整的邮件——它非常详细地解释了决定背后的原因。
微软已经破坏了他们所有的博客链接。我认为这是此回复中链接的 Resource management 文章的新家。我花了一段时间才找到。
G
Gishu

垃圾收集器不需要您为您定义的每个类/类型编写 Dispose 方法。当你需要明确地做一些事情来清理时,你只定义一个;当您明确分配本机资源时。大多数时候,GC 只是回收内存,即使您只对对象执行 new() 之类的操作。

GC 确实引用计数 - 但是它以不同的方式通过查找哪些对象是“可访问的”(Ref Count > 0)每次收集时......它只是不这样做以整数计数器方式。 .收集无法访问的对象 (Ref Count = 0)。这样,运行时不必在每次分配或释放对象时都进行内务处理/更新表......应该更快。

C++(确定性)和 C#(非确定性)之间的唯一主要区别是何时清理对象。您无法预测在 C# 中收集对象的确切时间。

无数个插件:如果您真的对 GC 的工作原理感兴趣,我建议您阅读 Jeffrey Richter 在 CLR via C# 中关于 GC 的站立章节。


因此,请避开 RAII,因为引用计数较慢,而且您不必在每个类上都处理 Dispose。对我来说似乎是一个糟糕的权衡。
不明白你在这里的评论......我只是解释说,对于每个 C# 类型/类来说,实现 Dispose 并不是强制性的。即使您的类都没有实现 Dispose,GC 也能完美运行。
我不确定我的问题是否被完全理解。框架中的许多类都实现了 IDisposable。您必须对这些类调用 dispose。如果 RAII 能够提供帮助,那就太好了。无论如何,似乎大多数人都同意你的推理。
实际上,您不必对 IDisposable 的每个实现者都调用 dispose。如果对象写得很好/有终结器,资源仍然会被收集。如果您需要它是确定性的,IDisposable 提供了一种确定性释放的方法。如果您只是偶尔获取特定的操作系统资源,那么非确定性的最终确定可能就足够了。可以将其视为将经典的 C++“选择加入”概念扩展到资源管理。
@Skrymsli:雷克斯 M 是对的。并且可以安全地假设框架中的所有内容(即您在我之前的评论中所指的内容)都写得很好。
u
user8032

在 C# 中尝试了引用计数。我相信,发布 Rotor(提供了源代码的 CLR 的参考实现)的人确实引用了基于计数的 GC,只是为了看看它与一代代的比较如何。结果令人惊讶——“普通”GC 速度如此之快,甚至都不好笑。我不记得我是在哪里听到的,我认为这是 Hanselmuntes 播客之一。如果你想看到 C++ 在与 C# 的性能比较中基本被压垮——谷歌 Raymond Chen 的中文词典应用程序。他做了一个 C++ 版本,然后 Rico Mariani 做了一个 C# 版本。我认为 Raymond 经过 6 次迭代才最终击败了 C# 版本,但到那时他不得不放弃 C++ 的所有优秀的面向对象特性,并降到 win32 API 级别。整个事情变成了性能黑客。同时,C#程序只优化了一次,最终看起来还是一个像样的OO项目


众所周知,引用计数总体上比跟踪 GC 慢。但是,它的好处是最大暂停时间较短,并且内存通常会尽快释放。
从内存来看,我认为转子运行时不是非常优化,所以我认为这不是一个很好的数据点。 +1 用于有趣的字典应用程序参考。以下是参考链接:blogs.msdn.com/ricom/archive/2005/05/10/416151.aspx
@Simon:尽管如此,事实仍然是,使用当前可用的工具,编写性能良好的 c# 代码比编写性能良好的 c++ 代码更容易、更快且更不容易出错。
@Unknown:“[引用计数] 的好处是最大暂停时间较短,并且通常会尽快释放内存”。相反,引用计数引入了无限的暂停时间,因为整个堆可以在大量析构函数中收集,并且引用计数将收集延迟到范围结束,这可能(并且通常)晚于活动结束。
这取决于引用计数算法。如果您在每次分配时都使用它,它将杀死您,因为它不仅是写访问,而且是带有写屏障的完整 CAS,以使引用计数 MT 安全。但是如果你使用 Cocoa 风格的内存策略,我怀疑跟踪 GC 会更快。
C
Community

C++ 风格的智能指针引用计数和引用计数垃圾回收之间存在差异。我还谈到了 my blog 上的差异,但这里有一个简短的总结:

C++ 样式引用计数:

递减的无限成本:如果大型数据结构的根递减为零,则释放所有数据的成本是无限的。

手动循环收集:为防止循环数据结构泄漏内存,程序员必须通过用弱智能指针替换部分循环来手动破坏任何潜在结构。这是潜在缺陷的另一个来源。

引用计数垃圾回收

延迟 RC:堆栈和寄存器引用忽略对对象引用计数的更改。相反,当触发 GC 时,这些对象通过收集根集来保留。可以推迟和批量处理对引用计数的更改。这导致更高的吞吐量。

合并:使用写屏障可以合并对引用计数的更改。这使得忽略对象引用计数的大多数更改成为可能,从而提高了频繁变异引用的 RC 性能。

循环检测:对于完整的 GC 实施,还必须使用循环检测器。然而,可以以增量方式执行循环检测,这反过来意味着有限的 GC 时间。

基本上,可以为 Java 的 JVM 和 .net CLR 运行时等运行时实现高性能的基于 RC 的垃圾收集器。

我认为跟踪收集器的使用部分是出于历史原因:最近在引用计数方面的许多改进都是在 JVM 和 .net 运行时发布之后出现的。研究工作也需要时间才能过渡到生产项目。

确定性资源处置

这几乎是一个单独的问题。 .net 运行时使用 IDisposable 接口使这成为可能,示例如下。我也喜欢Gishu's的答案。

@Skrymsli,这就是“using”关键字的用途。例如:

public abstract class BaseCriticalResource : IDiposable {
    ~ BaseCriticalResource () {
        Dispose(false);
    }

    public void Dispose() {
        Dispose(true);
        GC.SuppressFinalize(this); // No need to call finalizer now
    }

    protected virtual void Dispose(bool disposing) { }
}

然后添加一个具有关键资源的类:

public class ComFileCritical : BaseCriticalResource {

    private IntPtr nativeResource;

    protected override Dispose(bool disposing) {
        // free native resources if there are any.
        if (nativeResource != IntPtr.Zero) {
            ComCallToFreeUnmangedPointer(nativeResource);
            nativeResource = IntPtr.Zero;
        }
    }
}
  

然后使用它很简单:

using (ComFileCritical fileResource = new ComFileCritical()) {
    // Some actions on fileResource
}

// fileResource's critical resources freed at this point

另见implementing IDisposable correctly


有趣的博客文章...我不太关心释放内存而不是释放可能对时间至关重要的非内存资源。 (想想持有文件锁的 COM 对象的句柄。您希望在完成后释放该锁,而不是在 GC 在将来某个时间运行终结器时释放。)我认为必须有一些组合GC 和智能指针提供了两全其美的优势,您可以获得出色的内存管理,但可以确定性地释放关键资源,而不会给程序员带来过度负担。
当我问这不是因为我不知道如何使用或正确的方式来处理资源。我只是认为我们必须这样做是错误的!它通常会按照您上面的描述进行。如果您需要保留该资源怎么办?您现在需要实现 IDisposable。此外,程序员必须知道将这些东西包装在 using 构造中。 (我知道,使用 FXCop!)这只是调用 free、release、dispose 的语法糖。编译器应该知道这样做。这是 C++ RAII 习语的主要优点。对不起,我可能只是愚蠢。
@Skrymsli 你并不孤单“愚蠢”,几年前我也建议过:dotnet247.com/247reference/msgs/26/131667.aspx
“可以为 Java 的 JVM 和 .net CLR 运行时等运行时实现高性能的基于 RC 的垃圾收集器”。生产 GC 使用跟踪而不是 RC 正是因为那是不正确的。
@LukeQuinane 他们声称引用计数“可以在不再引用对象时立即回收对象”,但随后承认这将“非常昂贵”并声明他们“不知道任何依赖引用计数的高性能系统” .此外,假装引用计数比跟踪集合更具增量是错误的。幼稚的引用计数会导致雪崩,但可以增加。 Naive mark-sweep 是批处理操作,但 Dijkstra 风格的三色标记可以使其成为增量。
J
J D

我来自 C++ 背景,我已经使用 C# 工作了大约一年。像许多其他人一样,我对为什么确定性资源管理没有内置在语言中感到困惑。

using 构造提供“确定性”资源管理并内置于 C# 语言中。请注意,“确定性”是指保证在 using 块开始执行之后的代码之前已调用 Dispose。另请注意,这不是“确定性”一词的含义,但每个人似乎都在这种情况下以这种方式滥用它,这很糟糕。

在我偏向 C++ 的大脑中,使用带有确定性析构函数的引用计数智能指针似乎是垃圾收集器的一大进步,垃圾收集器需要你实现 IDisposable 并调用 dispose 来清理你的非内存资源。

垃圾收集器不需要您实现 IDisposable。事实上,GC 完全没有注意到它。

诚然,我不是很聪明......所以我问这个纯粹是为了更好地理解为什么事情会这样。

跟踪垃圾收集是一种模拟无限内存机器的快速可靠的方法,将程序员从手动内存管理的负担中解放出来。这消除了几类错误(悬空指针、过早释放、双重释放、忘记释放)。

如果 C# 被修改为: 对象被引用计数。当对象的引用计数变为零时,会在对象上确定性地调用资源清理方法,

考虑一个在两个线程之间共享的对象。线程竞相将引用计数减为零。一个线程将赢得比赛,另一个负责清理。那是不确定的。认为引用计数本质上是确定性的信念是一个神话。

另一个常见的神话是引用计数在程序中最早的可能点释放对象。它没有。递减总是被推迟,通常到作用域的末尾。这使对象的存活时间超过了必要的时间,留下了所谓的“浮动垃圾”。请注意,特别是,一些跟踪垃圾收集器可以并且确实比基于范围的引用计数实现更早地回收对象。

然后将该对象标记为垃圾回收。垃圾收集发生在未来某个不确定的时间,此时内存被回收。在这种情况下,您不必实现 IDisposable 或记得调用 Dispose。

无论如何,您不必为垃圾收集的对象实现 IDisposable,因此这是无益的。

如果您有非内存资源要释放,您只需实现资源清理功能。为什么这是个坏主意?

天真的引用计数非常慢并且会泄漏周期。例如,Boost's shared_ptr in C++ is up to 10x slower than OCaml's tracing GC。在多线程程序(几乎所有现代程序)存在的情况下,即使是简单的基于范围的引用计数也是不确定的。

这会破坏垃圾收集器的目的吗?

一点也不,不。事实上,这是一个坏主意,它是在 1960 年代发明的,并在接下来的 54 年中进行了密集的学术研究,得出的结论是引用计数在一般情况下很糟糕。

实施这样的事情是否可行?

绝对地。早期的原型 .NET 和 JVM 使用引用计数。他们还发现它很糟糕并放弃了它以支持跟踪 GC。

编辑:从到目前为止的评论来看,这是一个坏主意,因为 GC 在没有引用计数的情况下更快

是的。请注意,您可以通过延迟计数器递增和递减来更快地进行引用计数,但这会牺牲您非常渴望的确定性,并且它仍然比使用当今堆大小跟踪 GC 慢。然而,引用计数越来越快,所以在未来某个时候,当堆变得非常大时,也许我们会开始在生产自动化内存管理解决方案中使用 RC。

处理对象图中的循环问题

试验删除是一种专门设计用于检测和收集参考计数系统中的循环的算法。但是,它是缓慢且不确定的。

我认为第一个是有效的,但第二个很容易使用弱引用来处理。

将弱引用称为“简单”是希望战胜现实的胜利。他们是一场噩梦。它们不仅不可预测且难以架构,而且会污染 API。

速度优化是否超过了您的缺点:可能无法及时释放非内存资源

using 不及时释放非内存资源吗?

可能过早释放非内存资源如果您的资源清理机制是确定性的并且内置于语言中,则可以消除这些可能性。

using 构造是确定性的并内置于语言中。

我认为您真正想问的问题是为什么 IDisposable 不使用引用计数。我的回答是轶事:我已经使用垃圾收集语言 18 年了,我从来不需要求助于引用计数。因此,我更喜欢更简单的 API,它们不会被诸如弱引用之类的偶然复杂性所污染。


我希望看到一个标准模式来处理存在多个独立引用的场景,这些引用存在于一个包含资源的不可变对象(例如,位图或其他永远不会被修改的此类 GDI 对象),并且不知道哪个会成为最后一个需要它的人。如果计数不是在每个引用副本上增加,而是在每次调用 AddOwner 时增加,并且在 Disposed 时减少计数,那么类似引用计数之类的东西会很好。因此,一种可以通过创建新的位图来满足位图请求的方法......
...它没有进一步的兴趣,或者通过返回对任何人都不会修改的共享引用的引用,可以返回未调用 AddOwner 的新位图,或者可以调用 AddOwner共享位图并返回对其的引用。然后,接收者可以在任何情况下调用共享位图上的 Dispose,并且当且仅当该接收者恰好是需要它保持活动状态的最后一个实体时,才会释放该位图。
@supercat:我想知道在使用跟踪垃圾收集的环境中引用计数是否可以正常工作。对于从未从可能正常工作的堆中引用的对象。
我认为关键是跟踪包含所有权利益的参考资料。这些数量的变化几乎没有现有参考的数量那么频繁。此外,我认为跟踪垃圾收集是引用计数的一个非常有用的辅助工具——与其说是当事情没有被释放时的后盾,倒不如说是:确保资源是否从代码中释放出来尝试使用它,代码可以找到它,而不是仅仅徘徊在未定义的行为领域。
U
Unknown

我对垃圾收集有所了解。这是一个简短的摘要,因为完整的解释超出了这个问题的范围。

.NET 使用复制和压缩分代垃圾收集器。这比引用计数更先进,并且具有能够收集直接或通过链引用自己的对象的好处。

引用计数不会收集循环。引用计数也具有较低的吞吐量(总体上较慢),但具有比跟踪收集器更快的暂停(最大暂停更小)的好处。


这一点可以争论,但在使用弱引用的引用计数系统中避免循环似乎比使用 IDisposable 避免资源泄漏或延迟释放更容易。
@Skrymsli 实际上不能争论这一点。有时您不知道何时会创建参考循环。
只有幼稚的引用计数实现不会收集循环。 .net 等运行时也可以实现高性能引用计数:cs.anu.edu.au/~Steve.Blackburn/pubs/papers/urc-oopsla-2003.pdf
@Luke,这是不正确的。你引用的论文用词不当。他们的“不可告人的引用计数”实现了跟踪,按照标准定义,它不是纯粹的引用计数 GC。
你是对的,它是一个混合的 MS/RC 收集器,但是它只将 MS 用于苗圃。即它主要是一个引用计数垃圾收集器。许多 RC 实现也使用 MS 来收集周期。
B
Brian Rasmussen

这里有很多问题。首先,您需要区分释放托管内存和清理其他资源。前者可能非常快,而后者可能非常慢。在 .NET 中,两者是分开的,这样可以更快地清理托管内存。这也意味着,当您有超出托管内存要清理的内容时,您应该只实施 Dispose/Finalizer。

.NET 采用了一种标记和清除技术,它遍历堆寻找对象的根。有根的实例在垃圾收集中幸存下来。只需回收内存即可清除其他所有内容。 GC 必须时不时地压缩内存,但除此之外,即使在回收多个实例时,回收内存也是一个简单的指针操作。将此与 C++ 中对析构函数的多次调用进行比较。


+1 用于注意两个不同的方面。人们倾向于认为 Dispose() 是为了释放内存......
自从我第一次问这个问题以来已经有 10 年了,我现在已经习惯了 GC。在今天重新阅读这些评论时,重申我不需要 IDisposable 来让 GC 工作。当我问这个问题时,我就知道了。这是题外话。投诉是关于必须调用 dispose 的。我仍然希望找到内存泄漏就像运行 valgrind 一样简单的美好时光。在 C# 中有一堆我觉得很难使用的分析器。目前 C# 中内存泄漏的典型来源是在 Dispose 中取消订阅的事件订阅,但未调用 Dispose。
m
mlarsen

当用户没有显式调用 Dispose 时,实现 IDisposable 的对象还必须实现由 GC 调用的终结器 - 请参阅 IDisposable.Dispose at MSDN

IDisposable 的全部意义在于 GC 在某个不确定的时间运行,而您实现 IDisposable 是因为您拥有宝贵的资源并希望在确定的时间释放它。

因此,就 IDisposable 而言,您的提议不会改变任何内容。

编辑:

对不起。没有正确阅读您的建议。 :-(

维基百科对 shortcomings of References counted GC 有一个简单的解释


FWIW,维基百科上关于垃圾收集和引用计数的内容大多是错误的,包括你引用的部分。
S
SO User

参考计数

使用引用计数的成本是双重的:首先,每个对象都需要特殊的引用计数字段。通常,这意味着必须在每个对象中分配一个额外的存储字。其次,每次将一个引用分配给另一个引用时,都必须调整引用计数。这显着增加了赋值语句所花费的时间。

.NET 中的垃圾收集

C# 不使用对象的引用计数。相反,它维护堆栈中对象引用的图,并从根导航以覆盖所有引用的对象。图中所有引用的对象都在堆中压缩,以便为未来的对象提供连续的内存。回收所有不需要最终确定的未引用对象的内存。那些未被引用但要在其上执行终结器的那些被移动到称为 f-reachable 队列的单独队列中,垃圾收集器在后台调用它们的终结器。

除了上述 GC 之外,还使用了生成的概念来进行更有效的垃圾收集。它基于以下概念 1. 压缩托管堆的一部分的内存比压缩整个托管堆更快 2. 较新的对象将具有较短的生命周期,而较旧的对象将具有较长的生命周期 3. 较新的对象倾向于彼此相关并由应用程序大约在同一时间访问

托管堆分为三代:0、1、2。新对象存储在gen 0中。经过一次GC循环没有回收的对象被提升到下一代。因此,如果第 0 代中较新的对象在 GC 周期 1 中存活,那么它们将被提升到第 1 代。其中那些在 GC 周期 2 中存活的对象被提升到第 2 代。因为垃圾收集器仅支持三代,所以第 2 代中的对象在第 2 代中保留一个集合,直到确定它们在未来的集合中无法访问。

垃圾收集器在第 0 代已满并且需要为新对象分配内存时执行收集。如果第 0 代的收集没有回收足够的内存,垃圾收集器可以执行第 1 代的收集,然后是第 0 代。如果这没有回收足够的内存,垃圾收集器可以执行第 2、1 和 0 代的收集.

因此 GC 比引用计数更有效。


从技术上讲,它是一个图,而不是一棵树,GC 不会“维护”它,它只是遍历对象内部的引用。另外,我不确定您所说的“在后台被 [GC] 删除”是什么意思-您是指扫描阶段吗?我认为紧随其后。
谢谢西蒙。根据您的建议编辑回复。我不确定扫描阶段是什么,我的意思是需要完成的超出范围的对象在堆压缩后由 GC 在后台单独处理。
-1 表示不受支持的语句“因此 GC 比引用计数更有效”。
V
Viktor Söderqvist

确定性非内存资源管理是语言的一部分,但它不是用析构函数完成的。

您的观点在来自 C++ 背景的人中很常见,他们试图使用 RAII 设计模式。在 C++ 中,您可以保证某些代码将在作用域末尾运行(即使抛出异常)的唯一方法是在堆栈上分配一个对象并将清理代码放入析构函数中。

在其他语言(C#、Java、Python、Ruby、Erlang 等)中,您可以改用 try-finally(或 try-catch-finally)来确保清理代码始终运行。

// Initialize some resource.
try {
    // Use the resource.
}
finally {
    // Clean-up.
    // This code will always run, whether there was an exception or not.
}

IC#,您还可以使用 using 构造:

using (Foo foo = new Foo()) {
    // Do something with foo.
}
// foo.Dispose() will be called afterwards, even if there
// was an exception.

因此,对于 C++ 程序员来说,将“运行清理代码”和“释放内存”视为两个独立的事情可能会有所帮助。将您的清理代码放在 finally 块中,并留给 GC 处理内存。