有了 GC 还会不会发生内存泄漏?

问题的发现

这个问题是我在写C++时考虑到的,C++需要手动管理内存,虽然现在标准库中提供了一些智能指针,可以实现基于引用计数的自动内存管理,但现实环境是很复杂的,我们仍要注意循环引用的问题。还有一个容易被忽视的问题就是对象间关系的“占有”和“非占有”,这个问题其实在具有GC的C#和Java中也一样存在。

目前.NET和Java的GC策略都属于Tracing garbage collection,基本原理是从一系列的root开始,沿着引用链进行遍历,对遍历过的对象进行标记(mark),表示其“可达(reachable)”,然后回收那些没有标记的,即“不可达”对象所占用的内存。如果你的代码中明明有的对象已经没用了,但在某些地方仍然保持有对它的引用,就会造成这个对象长期处于“可达”状态,以至其占用的内存无法被及时回收。

对象关系的问题

占有 与 非占有

好吧,这两个词是我自己发明的。这两个词是针对“拥有”而言的,占有 是表示强的拥有,宿主对象会影响被拥有对象的生命周期,宿主对象不死,被拥有的对象就不会死;非占有 表示弱的拥有,宿主对象不影响被拥有对象的生命周期。

在处理对象间关系时,如果应该是非占有关系,但却实现成了占有关系,则占有关系就会妨碍GC对被占有对象的回收,轻则造成内存回收的不及时,重则造成内存无法被回收。这里我用C#实现观察者模式作为示例:

public interface IPublisher
{
    void Subscribe(ISubscriber sub);
    void UnSubscribe(ISubscriber sub);
    void Notify();
}

public interface ISubscriber
{
    void OnNotify();
}

public class Subscriber : ISubscriber
{
    public String Name { get; set; }
    public void OnNotify()
    {
        Console.WriteLine($"{this.Name} 收到通知");
    }
}

public class Publisher : IPublisher
{
    private List_subscribers = new List();

    public void Notify()
    {
        foreach (var s in this._subscribers)
            s.OnNotify();
    }

    public void Subscribe(ISubscriber sub)
    {
        this._subscribers.Add(sub);
    }

    public void UnSubscribe(ISubscriber sub)
    {
        this._subscribers.Remove(sub);
    }
}

class Program
{
    static void Main(string[] args)
    {
        IPublisher pub = new Publisher();
        AttachSubscribers(pub);
        pub.Notify();

        GC.Collect();
        Console.WriteLine("垃圾回收结束");

        pub.Notify();

        Console.ReadKey();
    }

    static void AttachSubscribers(IPublisher pub)
    {
        var sub1 = new Subscriber { Name = "订阅者 甲" };
        var sub2 = new Subscriber { Name = "订阅者 乙" };
        pub.Subscribe(sub1);
        pub.Subscribe(sub2);
        // 这里其实赋不赋null都一样,只是为了突出效果
        sub1 = null;
        sub2 = null;
    }
}

这段代码有什么问题吗?

在AttachSubscribers方法里,创建了两个订阅者,并进行了订阅,这里的两个订阅者都是在局部创建的,也并没有打算在外部引用它们,它们应该在不久的某个时刻被回收了,但是由于同时它们又存在于发布者的订阅者列表里,发布者“占有”了订阅者,虽然它们都没用了,但暂时不会被销毁,如果发布者一直活着,则这些没用的订阅者也一直得不到回收,那为什么不调用UnSubscribe呢?因为在实际中情况可能很复杂,有些时候UnSubscribe调用的时机会很难确定,而且发布者的任务在于登记和通知订阅者,不应该因此而“占有”它们,不应干涉它们的死活,所以对于这种情况,可以使用“弱引用”实现“非占用”。

弱引用

弱引用是一种包装类型,用于间接访问被包装的对象,而又不会产生对此对象的实际引用。所以就不会妨碍被包装的对象的回收。

给上面的例子加入弱引用:

class Program
{
    static void Main(string[] args)
    {
        IPublisher pub = new Publisher();
        AttachSubscribers(pub);
        pub.Notify();

        GC.Collect();
        Console.WriteLine("垃圾回收结束");

        pub.Notify();

        Console.WriteLine("=============================================");

        pub = new WeakPublisher();
        AttachSubscribers(pub);
        pub.Notify();

        GC.Collect();
        Console.WriteLine("垃圾回收结束");

        pub.Notify();

        Console.ReadKey();
    }

    static void AttachSubscribers(IPublisher pub)
    {
        var sub1 = new Subscriber { Name = "订阅者 甲" };
        var sub2 = new Subscriber { Name = "订阅者 乙" };
        pub.Subscribe(sub1);
        pub.Subscribe(sub2);
        // 这里其实赋不赋null都一样,只是为了突出效果
        sub1 = null;
        sub2 = null;
    }
}

public interface IPublisher
{
    void Subscribe(ISubscriber sub);
    void UnSubscribe(ISubscriber sub);
    void Notify();
}

public interface ISubscriber
{
    void OnNotify();
}

public class Subscriber : ISubscriber
{
    public String Name { get; set; }
    public void OnNotify()
    {
        Console.WriteLine($"{this.Name} 收到通知");
    }
}

public class Publisher : IPublisher
{
    private List_subscribers = new List();

    public void Notify()
    {
        foreach (var s in this._subscribers)
            s.OnNotify();
    }

    public void Subscribe(ISubscriber sub)
    {
        this._subscribers.Add(sub);
    }

    public void UnSubscribe(ISubscriber sub)
    {
        this._subscribers.Remove(sub);
    }
}

public class WeakPublisher : IPublisher
{
    private List_subscribers = new List();

    public void Notify()
    {
        for (var i = 0; i < this._subscribers.Count();) { ISubscriber s; if (this._subscribers[i].TryGetTarget(out s)) { s.OnNotify(); ++i; } else this._subscribers.RemoveAt(i); } } public void Subscribe(ISubscriber sub) { this._subscribers.Add(new WeakReference(sub));
    }

    public void UnSubscribe(ISubscriber sub)
    {
        for (var i = 0; i < this._subscribers.Count(); ++i) { ISubscriber s; if (this._subscribers[i].TryGetTarget(out s) && Object.ReferenceEquals(s, sub)) { this._subscribers.RemoveAt(i); return; } } } }

其实弱引用也不是完美的解决方案,因为限制了API使用者的自由,当然这里也没打算实现一个通用的、完美的解决办法,只是想通过个例子让你知道,即使是在有GC的情况下,不注意代码设计的话,仍有可能会发生内存泄漏的问题。

非托管资源

GC不能释放非托管资源吗?

GC的作用在于清理托管对象,托管对象是可以定义析构方法(准确点说应该叫finalizer,C#中的~类名,Java中的finalize)的,这个方法会在托管对象被GC回收前被调用,析构方法里完全可以释放非托管资源(实际上很多托管对象的实现也都这么做了),也就是说GC是可以释放非托管资源的

但是GC的运行时间是不确定的,现在计算机的内存也都足够大,内存迟点回收不会有什么问题,但托管对象内部包装的其它资源可能属于“紧张的资源”,比如非托管内存、文件句柄、socket连接,这些资源是必须要被及时回收的,比如文件句柄不及时释放会导致该文件一直被占用,影响其它进程对该文件的读写、socket连接不及时释放会导致端口号一直被占用,那如何保证释放的及时呢?

Dispose模式

方法很简单,就是在对象中用一个方法来专门释放这些非托管资源,比如叫close,?dispose,?free,?release之类的,然后显式调用这些方法。C#中的IDisposable接口和Java中的Closeable接口就是这个作用,因为大多数带GC的语言都使用这种设计,所以这也算是一种模式。

伪代码示例:

File f = new File("data.txt");
f.writeBytes((new String("Hello, world!")).getBytes("ascii"));
f.close();

这样就够了吗?如果close前发生异常或直接return了怎么办??– finally语句块

finally语句块保证了其中的语句一定会被执行,配合close方法,就能确保非托管资源的及时释放。(注:不调用close其实一般来讲非托管资源也是会被释放的,只是这种释放不够“及时”,因为要等到托管对象被回收

C++中没有finally语句结构,这并不奇怪,因为C++有RAII机制,对象的销毁是确定的,而且确保析构函数的调用,所以不需要finally这种语法。

结语

其实以上所列举的种种情况,大多数情况资源最终都会得到回收,只是回收不够及时,但这种回收不及时在资源紧张或出现极端情况时,还是有可能会发生内存泄漏的,所以说不是有了GC就可以高枕无忧了。

标签:

上一篇:五分钟重温C#委托,匿名方法,Lambda,泛型委托,表达式树

下一篇:JSON.NET框架实现C#对象和JSON字符串的转换