有了 GC 還會不會發生記憶體洩漏?

Taney發表於2016-05-10

問題的發現

這個問題是我在寫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<ISubscriber> _subscribers = new List<ISubscriber>();

    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<ISubscriber> _subscribers = new List<ISubscriber>();

    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<WeakReference<ISubscriber>> _subscribers = new List<WeakReference<ISubscriber>>();

    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<ISubscriber>(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模式

方法很簡單,就是在物件中用一個方法來專門釋放這些非託管資源,比如叫closedisposefreerelease之類的,然後顯式呼叫這些方法。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就可以高枕無憂了。

相關文章