但这确实让我想知道:弱引用怎么可能有用呢?如果你不能指望它引用一个对象,而且它不需要用于像打破循环这样的事情,那么为什么要使用它呢?
我承认我的观点有些教条主义,即弱引用应该是持久存储对对象的引用的默认方式,而需要更明确的语法来使用强引用,如下所示:
class Foo
{
...
private Bar bar;
private strong Baz baz;
}
...同时在函数/方法内部对本地变量进行反转:
void some_function()
{
Bar bar = ...;
weak Baz baz_weak = ...;
...
Baz baz = baz_weak;
if (baz)
{
baz.do_something();
}
}
恐怖故事
为了理解我为什么有这个坚定的观点以及弱引用为什么有用,我将分享一个个人经历,在我曾经工作的一家公司中,他们全面采用了GC。
这是针对一个3D产品的,处理像网格和纹理这样沉重的东西,其中一些可以单独占用超过1GB的内存。该软件围绕着场景图和插件架构展开,任何插件都可以访问场景图和其中的元素,如纹理、网格、灯光或相机。
现在发生的事情是,我们的团队和第三方开发人员不太熟悉弱引用,因此我们有人将对象引用存储在场景图中的各个位置。相机插件将存储要从相机视图中排除的强对象引用列表。渲染器将存储要在渲染中使用的对象列表,如光源引用列表。灯光会像相机一样进行排除/包含列表。着色器插件将存储它们使用的纹理的引用。列表还在继续。
我们团队在开发一年后发现了很多内存泄漏问题,于是我被要求做一个关于弱引用重要性的演讲,尽管当初并不是我推动使用垃圾回收机制的人(事实上我反对这个决定)。演讲之后,我还得将弱引用支持加入到我们专有的垃圾回收器中,因为我们的垃圾回收器(由其他人编写)最初甚至不支持弱引用。
逻辑泄漏
果然,我们最终开发出来的软件存在一个问题:当用户想要从场景中移除一个对象(如网格或纹理)时,应该释放该内存,但应用程序却继续使用该内存,因为代码库中某个地方仍然持有对这些场景对象的引用,并且在用户明确请求释放它们时不会释放。即使清空场景后,软件也可能占用3GB以上的内存,而且使用时间越长,内存占用量就越大。所有这些都是因为代码库(包括第三方开发者)未能在适当的情况下使用弱引用所致。
因此,当用户请求从场景中删除网格时,也许有9/10的地方存储了对给定网格的引用会正确地释放引用,将其设置为null引用或从列表中删除引用以允许垃圾收集器收集它。然而,通常会有第十个位置忘记处理这种事件,直到该事物本身也从场景中移除(有时这些事物存在于场景之外,并存储在应用程序根目录中)。有时会发生级联效应,导致软件使用时间越长,就会消耗越多的内存,甚至处理程序插件(即使在清除场景后仍然存在)也会通过存储对DI场景根的注入引用来延长整个场景的生命周期,在这种情况下,即使清除了整个场景,内存也不会被释放,需要用户每隔一两个小时重新启动软件才能使内存使用量恢复到正常水平。
这些bug不是易于发现的。我们所能看到的只是应用程序在运行时间越长,使用的内存就越多。这不是我们可以轻松地在短暂的单元或集成测试中重现的事情。有时候,经过数小时的详尽调查后,我们会发现这甚至不是我们自己的代码引起的内存泄漏。它在第三方插件中,用户经常使用该插件,插件最终仅仅是在响应场景移除事件时存储了对某个网格或纹理之类的东西的引用而没有释放。
而在垃圾回收语言编写的软件中,这种趋向于泄漏更多内存的倾向往往存在于程序员没有在适当的情况下使用弱引用。弱引用应该在所有对象不拥有另一个对象的情况下使用。在大多数情况下,这样做更有意义。并不是每个引用所有内容的对象都应该共享所有权。对于大多数软件来说,最明智的设计是系统中的一件事物拥有另一件事物,比如“场景图拥有场景对象”,而不是“相机也拥有网格,因为它们在相机排除列表中引用了它们”。
可怕!
现在,在大规模、性能关键的软件中,GC非常可怕,因为这些逻辑泄漏可能会导致应用程序在长时间内比它应该占用数百GB的内存更多,同时运行速度变慢,直到你重新启动它。
当你试图调查所有这些泄漏的源头时,你可能需要查看2000万行代码,包括你无法控制的插件开发人员编写的更多代码,其中任何一行都可能通过仅仅存储对象引用并未在响应适当事件时释放它而将对象的生命周期静默地延长得更久。更糟糕的是,所有这些都在QA和自动化测试的雷达之外。
在这种情况下,这是一个噩梦般的场景,我唯一合理的避免这种情况的方法是有一个编码标准,如果你使用GC,则严重依赖于弱引用,或者首先避免使用GC。
GC泄漏
我必须承认,对于垃圾回收机制,我一直持有不太积极的看法。至少在我的领域中,与其说拥有一个逻辑资源泄漏被测试忽略,不如说拥有一个悬空指针崩溃更为理想。如果有一个完善的测试和CI程序,开发人员可以在提交代码之前轻松检测和修复这种崩溃。
就我个人而言,在选择恶中取其轻时,最理想的错误是最容易发现和重现的错误。而GC类型的资源泄漏并不容易发现,也无法以任何有助于发现泄漏源头的方式进行重现。
然而,在那些大量使用弱引用,并且只在高级设计角度上需要延长对象生命周期的团队和代码库中,我对GC的看法变得更加积极。
垃圾回收(GC)并不是防止内存泄漏的实用方法,相反,如果是这样的话,世界上最少泄漏的应用程序将会使用支持GC的语言编写,例如Flash、Java、JavaScript、C#,而最容易泄漏的软件则会使用手动内存管理最多的语言C来编写。此时,Linux内核应该是一个非常容易泄漏的操作系统,需要每隔一两个小时重启以减少内存使用量。但事实并非如此。通常情况下,使用GC编写的应用程序泄漏问题更加严重,这是因为GC实际上往往使避免逻辑泄漏变得更加困难。它确实有助于避免物理泄漏(但无论使用哪种语言,物理泄漏都很容易检测和避免),并且在使人的生命处于危险状态或者崩溃可能导致服务器长时间不可用的关键任务软件中,它有助于防止悬空指针崩溃。我不在关键任务领域工作;我在性能和内存关键领域工作,处理每个渲染帧的史诗级数据集。
毕竟,我们只需要这样做就可以使用GC创建逻辑泄漏:
class Foo
{
private Bar bar;
}
...但是弱引用不会有这个问题。当你在一手上看着数百万行代码,另一手上却面临着大量内存泄漏的时候,如果你不得不调查哪个类似的Foo
没有在适当的时间将类似的Bar
设置为null引用,那么这就是一个噩梦般的场景。因为这是最可怕的部分:只要忽略掉泄漏的几十GB内存,代码就可以正常工作。没有任何触发错误/异常、断言失败等的情况。没有崩溃。所有的单元测试和集成测试都通过了。它们都可以正常工作,除了泄漏了几十GB的内存,导致用户不断抱怨,整个团队都在想哪些代码是有泄漏的,哪些是没有泄漏的,而QA则试图通过实际建议用户每半小时保存他们的工作并重新启动软件来进行损害控制,好像这是某种解决方案一样。
弱引用对此有很大帮助
因此,请在适当的时候使用弱引用,适当的意思是当一个对象不应该共享另一个对象的所有权时。
它们非常有用,因为你可以在不延长对象生命周期的情况下,仍然检测到对象是否被销毁。当你真正需要延长对象生命周期时,强引用非常有用,比如在一个短暂的线程中,以防止对象在线程完成处理之前被销毁,或者在一个完全理所当然需要拥有另一个对象的对象内。
以我的场景图示例为例,相机排除列表不需要拥有已经由场景图拥有的场景对象。从逻辑上讲,这是没有意义的。如果我们正在制定计划,没有人会认为,“是的,相机还应该除了场景图本身之外,还要拥有场景对象。”
它只需要那些引用来能够方便地引用回这些元素。当它这样做时,它可以从之前存储的弱引用中获取对它们的强引用,并在进行处理之前检查用户是否已将其移除,而不是无限期地延长它们的生命周期,直到相机本身也被移除之前可能导致内存泄漏的程度。
如果相机想要使用一种方便的懒惰实现方式,而不必费心处理场景移除事件,那么弱引用至少可以让它在不会在各个地方泄漏大量内存的情况下实现这一点。弱引用仍然允许它事后发现对象已从场景中移除,然后可能将销毁的弱引用从列表中删除,而无需费心处理场景移除事件。对我来说,理想的解决方案是同时使用弱引用和处理场景移除事件,但至少相机排除列表应该使用弱引用,而不是强引用。
团队环境中弱引用的有用性
这就涉及到了弱引用对我而言的有用性的核心。如果您团队中的每个开发人员都能够在适当的时间响应适当的事件彻底删除/清空对象引用,那么它们永远不是绝对必需的。但是,在大型团队中,即使工程标准不能完全防止的错误也经常发生,并且有时以惊人的速度发生。在这种情况下,弱引用是一种非常好的防御机制,可以帮助应用程序避免在长时间运行后出现逻辑泄漏的情况。在我看来,它们是一种防御机制,可以将可能表现为难以检测的内存泄漏的错误转化为对已销毁对象的无效引用的易于检测的使用。
安全性
从同样的意义上来说,它们可能看起来并不那么有用,就像汇编程序员可能不会发现类型安全性有多大用处一样。毕竟,他可以只使用原始位和字节以及适当的汇编指令来完成所需的所有操作。然而,类型安全性通过使人类开发人员更明确地表达他们想要做什么并限制他们在特定类型上所允许做的事情,有助于更轻松地检测人为错误。我认为弱引用也是以类似的方式实现的。如果不使用弱引用,它们将帮助检测到本应导致资源泄漏的人为错误。这是故意对自己施加约束,比如说,“好吧,这是一个对象的弱引用,因此它不可能延长其生命周期并导致逻辑泄漏”,这很不方便,但对于汇编程序员来说,类型安全性也是如此。它仍然可以帮助防止一些非常严重的错误。
如果你问我,它们是一种语言安全功能,就像任何安全功能一样,不是绝对必需的,你通常不会欣赏它,直到你遇到一个团队在同样的事情上反复绊倒,因为缺乏这样的安全功能或没有充分使用。对于独立开发者来说,安全通常是最容易忽略的事情之一,因为如果你很有能力和小心,你真的可能不需要它。但是,将错误风险乘以一个技能混合的整个团队,安全功能可能成为你急切需要的东西,而人们开始在你每天小心避免的湿滑地板上类比滑倒,导致死尸在你周围堆积。我发现,与大型团队相比,如果你没有一个简单易行但铁面无私地制定安全工程实践的编码标准,在一个月内,你可能已经累积了超过十万行极其有缺陷的代码,像上面提到的GC逻辑泄漏那样晦涩难懂、难以检测。在没有防止常见错误的标准的情况下,一月内可能积累的破碎代码量非常惊人。
无论如何,我承认在这个问题上有点教条主义,但这个观点是在处理大量的内存泄漏后形成的。除了告诉开发人员“要更加小心!你们正在疯狂地泄漏内存!”之外,我所看到的唯一答案就是让他们更经常地使用弱引用,这样任何粗心大意都不会导致大量的内存泄漏。事实上,我们发现了很多在测试中被忽略的泄漏点,以至于我故意在我们的SDK中破坏了向后源代码兼容性(尽管没有破坏二进制兼容性)。我们曾经有过这样的约定:
typedef Strong<Mesh> MeshRef
typedef Weak<Mesh> MeshWeakRef
...这是一个在单独线程中运行的C++实现的专有GC。我将其更改为:
typedef Weak<Mesh> MeshRef
typedef Strong<Mesh> MeshStrongRef
......那个简单的语法和命名约定的改变极大地帮助防止了更多的泄漏,只不过我们晚了几年才这样做,这使得它更像是一种损害控制而不是其他什么。
shared_ptr
)更早的时间收集值。 - J D