弱引用 - 它们有多有用?

41

最近我一直在考虑自动内存管理的一些想法——具体来说,我一直在考虑基于引用计数实现内存管理器。当然,每个人都知道循环引用会破坏朴素的引用计数。 解决方法:弱引用。 我个人讨厌这种使用弱引用的方式(通过循环检测有其他更直观的处理方式),但这让我想到了:在哪些其他地方可以使用弱引用呢?

我认为它们必须存在某些原因,尤其是在具有追踪垃圾回收功能的语言中,这些语言不会遭受循环引用的问题(我熟悉的是C#和Java,甚至Java有三种类型的弱引用!)。 但是,当我试图找到它们的一些实际用例时,我几乎只得到像“使用它们来实现缓存”这样的想法(我在SO上看到过几次)。 我也不喜欢这种方式,因为它们依赖于追踪GC在对象不再强引用后可能不会立即收集对象,除非在低内存情况下。这些情况在引用计数GC中是绝对无效的,因为一个对象在不再被引用后会立即被销毁(除非可能是循环的情况)。

但这确实让我想知道:弱引用如何可能有用呢?如果您不能保证它引用一个对象,并且它不需要用于打破循环引用之类的事情,那么为什么要使用它呢?


3
当然,所有人都知道在应用程序级别上,引用是一个引用。要么你正在使用相关对象,要么你不关心它是否被垃圾回收。只有在处理垃圾回收时,弱引用才显得特别重要。如果你还不熟悉它,请参考这篇好文章:http://weblogs.java.net/blog/2006/05/04/understanding-weak-references - paulsm4
1
使用引用计数垃圾回收机制,因为对象在不再被引用后立即被销毁。实际上,当变量超出作用域时,引用计数会递减,这可能是自引用对象最后一次被引用之后很长一段时间。认为引用计数尽早销毁对象的想法是一个常见的误解。 - J D
@JonHarrop:这是一个很好的澄清。我倾向于使用“引用”一词表示“对象绑定到变量”,而不是“正在使用对象”,但我可以看出这可能会被解释得不同。 - Ken Wayne VanderLinde
@KenWayneVanderLinde 对的。也许“可达性”更好一些。像OCaml这样的追踪垃圾收集器可以在比基于作用域的引用计数收集(如C++中的shared_ptr)更早的时间收集值。 - J D
5个回答

41

事件处理程序是弱引用的一个很好的使用案例。触发事件的对象需要对要调用事件处理程序的对象进行引用,但通常不希望事件生成者的引用阻止事件消费者被垃圾回收。相反,您希望事件生成者具有弱引用,并且它将负责检查所引用的对象是否仍然存在。


如果存在一种委托类型,它不会保持目标对象的生命,但在目标对象被回收时会使自身无效,并且Delegate.Combine足够了解这种委托类型以在合并委托时跳过已失效的实例,则弱引用将有更好的用例。否则,.net事件模式对于创建许多短寿命对象、订阅来自长寿命对象的事件并放弃这些对象的情况没有很好的解决方法。 - supercat
2
@supercat:嗯,这不是关于 .Net 或任何其他平台或语言的问题 - 它只是关于弱引用可能有用的地方。具有讽刺意味的是,我正在处理的项目是用 C++ 编写的(没有事件模型),它涉及引用计数和事件处理程序! - Ken Wayne VanderLinde
为什么不直接取消订阅呢?这样不是会删除至少一个引用并缩小队列吗? - Markus Zeller

20
但这确实让我想知道:弱引用怎么可能有用呢?如果你不能指望它引用一个对象,而且它不需要用于像打破循环这样的事情,那么为什么要使用它呢?
我承认我的观点有些教条主义,即弱引用应该是持久存储对对象的引用的默认方式,而需要更明确的语法来使用强引用,如下所示:
class Foo
{
    ...
    // Stores a weak reference to bar. 'Foo' does not
    // own bar.
    private Bar bar;

    // Stores a strong reference to 'Baz'. 'Foo' does
    // own Baz.
    private strong Baz baz;
}

...同时在函数/方法内部对本地变量进行反转:

void some_function()
{
    // Stores a strong reference to 'Bar'. It will
    // not be destroyed until it goes out of scope.
    Bar bar = ...;

    // Stores a weak reference to 'Baz'. It can be
    // destroyed before the weak reference goes out 
    // of scope.
    weak Baz baz_weak = ...;

    ...

    // Acquire a strong reference to 'Baz'.
    Baz baz = baz_weak;
    if (baz)
    {
        // If 'baz' has not been destroyed,
        // do something with it.
        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
{
     // This makes 'Foo' instances cause 'bar' to leak, preventing
     // it from being destroyed until the 'Foo' instances are also
     // destroyed unless the 'Foo' instances set this to a null 
     // reference at the right time (ex: when the user requests 
     // to remove whatever Bar is from the software).
     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;

......那个简单的语法和命名约定的改变极大地帮助防止了更多的泄漏,只不过我们晚了几年才这样做,这使得它更像是一种损害控制而不是其他什么。


5

弱引用所引用的对象在进行垃圾回收之前是可以被访问的。

因此,如果我们希望在对象存在的同时获取其信息,就可以使用弱引用。例如,调试器和优化器通常需要获得对象的信息,但不希望影响GC进程。

顺便说一下,软引用与弱引用不同,因为仅当内存不足时,相关对象才会被回收。因此,软引用通常用于构建全局缓存。


实际上,关于SoftReference的最后一部分不适用于引用计数GC,因为对象会在内存情况下被收集。但我喜欢你澄清了这对于具有跟踪GC的语言是可靠的。 - Ken Wayne VanderLinde

1
我经常将WeakReferenceThreadLocalInheritableThreadLocal一起使用。如果我们希望在值有意义时使其对多个线程可访问,但稍后从这些线程中删除该值,我们实际上无法自己释放内存,因为除了当前线程之外,没有办法篡改在另一个线程中的ThreadLocal值。但是你可以在那些其他线程中将值放入WeakReference(当创建值时-假设相同的实例在多个线程间共享;请注意,仅当只有子集线程应访问此值时,才具有意义,否则会使用静态变量),并在另一个ThreadLocal中为将要删除该值的某个工作线程存储硬引用。然后当值不再有意义时,您可以请求工作线程删除硬引用,这会导致所有其他线程中的值立即排队等待垃圾回收(尽管它们可能不会立即被垃圾回收,因此最好有其他方法来防止访问该值)。

关于“..当值被创建时..”的问题,为什么需要确保其在创建阶段?创建后将对象引用传递给其他线程不行吗? - Pacerier
@Pacerier 如果您可以访问在其他线程中运行的代码,以通知这些线程期望一个值并将其添加到它们的“ThreadLocal”中,则是可以的。然而,通常我们无法访问这些线程的代码,因为它们是由某些第三方库(例如日志记录库、非阻塞IO库等)创建的。我们无法向这些线程传递任何消息或告诉它们在启动后该做什么。但是,我们可以通过“InheritableThreadLocal.childValue”方法(或者确实只是“InheritableThreadLocal”的默认行为)告诉它们在启动时该做什么。 - Adam Burley

0
弱引用保证必须销毁的对象最终将被销毁。以下是一个示例:
abstract class Service {

    @SuppressWarnings("InfiniteRecursion")
    protected void start(int data) throws InterruptedException {
        data++;
        notifyListener(data);
        Thread.sleep(1000);
        start(data);
    }

    protected abstract void notifyListener(int data);

    protected abstract void register(Listener l);

}


class ServiceWeak extends Service {
    private WeakReference<Listener> listenerWeak;

    @Override
    protected void notifyListener(int data) {
        if (listenerWeak.get() != null) listenerWeak.get().onEvent(data);
    }
    @Override
    public void register(Listener l) {
        listenerWeak = new WeakReference<>(l);
    }
}

class ServiceStrong extends Service {
    private Listener listener;

    @Override
    protected void notifyListener(int data) {
        listener.onEvent(data);
    }

    @Override
    protected void register(Listener l) {
        listener = l;
    }
}

public class Listener {

    public Listener(Service service) {
        service.register(this);
    }

    public void onEvent(int data) {
        System.out.println("received data=>" + data);
        // show this on screen, make some sound etc.
    }
}

public class Activity {

    private Listener listener;

    public void onStart() throws InterruptedException {
        Service service = new ServiceStrong();
        listener = new Listener(service);
        new Thread(() -> {
            try {
                service.start(1);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }).start();
        Thread.sleep(3000);
        listener = null;
        System.gc();
    }


    public static void main(String[] args) throws InterruptedException {
            new Activity().onStart();
    }
}



在第一种情况下,让我们使用对Listener有强引用的Service,即ServiceStrong。
public void onStart() throws InterruptedException {
        Service service = new ServiceStrong();
        listener = new Listener(service);
        new Thread(() -> {
            try {
                service.start(1);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }).start();
        Thread.sleep(3000); // Here your Activity is open, Listener is getting events
        listener = null; // Here you have closed your Activity 
        System.gc(); // Activity is destroyed, so must be Listener, but you can see that Listener is still working, getting events and printing it. This is because Service is holding a strong reference to it. So if you forget to unsubscribe or somehow manually dereference your Listener, then this is memory leak, because usually you have a list of listeners. I used only one for simplicity. 
    }

现在,如果您将Service实现更改为ServiceWeak,那么当调用System.gc()时,由于使用了WeakReference,您会发现Listener已经停止。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接