問題的發現
這個問題是我在寫C++時考慮到的,C++需要手動管理記憶體,雖然現在標準庫中提供了一些智慧指標,可以實現基於引用計數的自動記憶體管理,但現實環境是很複雜的,我們仍要注意迴圈引用的問題。還有一個容易被忽視的問題就是物件間關係的“佔有”和“非佔有”,這個問題其實在具有GC的C#和Java中也一樣存在。
目前.NET和Java的GC策略都屬於Tracing garbage collection
,基本原理是從一系列的root開始,沿著引用鏈進行遍歷,對遍歷過的物件進行標記(mark),表示其“可達(reachable)”,然後回收那些沒有標記的,即“不可達”物件所佔用的記憶體。如果你的程式碼中明明有的物件已經沒用了,但在某些地方仍然保持有對它的引用,就會造成這個物件長期處於“可達”狀態,以至其佔用的記憶體無法被及時回收。
物件關係的問題
佔有 與 非佔有
好吧,這兩個詞是我自己發明的。這兩個詞是針對“擁有”而言的,佔有 是表示強的擁有,宿主物件會影響被擁有物件的生命週期,宿主物件不死,被擁有的物件就不會死;非佔有 表示弱的擁有,宿主物件不影響被擁有物件的生命週期。
在處理物件間關係時,如果應該是非佔有關係,但卻實現成了佔有關係,則佔有關係就會妨礙GC對被佔有物件的回收,輕則造成記憶體回收的不及時,重則造成記憶體無法被回收。這裡我用C#實現觀察者模式作為示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
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呼叫的時機會很難確定,而且釋出者的任務在於登記和通知訂閱者,不應該因此而“佔有”它們,不應干涉它們的死活,所以對於這種情況,可以使用“弱引用”實現“非佔用”。
弱引用
弱引用是一種包裝型別,用於間接訪問被包裝的物件,而又不會產生對此物件的實際引用。所以就不會妨礙被包裝的物件的回收。
給上面的例子加入弱引用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 |
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回收前被呼叫,析構方法裡完全可以通過呼叫平臺API釋放非託管資源(實際上很多託管物件的實現也都這麼做了),也就是說GC是可以釋放非託管資源的。以下程式碼摘自.NET類庫中FileStream:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
[System.Security.SecuritySafeCritical] // auto-generated ~FileStream() { if (_handle != null) { BCLDebug.Correctness(_handle.IsClosed, "You didn't close a FileStream & it got finalized. Name: \""+_fileName+"\""); Dispose(false); } } [System.Security.SecuritySafeCritical] // auto-generated protected override void Dispose(bool disposing) { // Nothing will be done differently based on whether we are // disposing vs. finalizing. This is taking advantage of the // weak ordering between normal finalizable objects & critical // finalizable objects, which I included in the SafeHandle // design for FileStream, which would often "just work" when // finalized. try { if (_handle != null && !_handle.IsClosed) { // Flush data to disk iff we were writing. After // thinking about this, we also don't need to flush // our read position, regardless of whether the handle // was exposed to the user. They probably would NOT // want us to do this. if (_writePos > 0) { FlushWrite(!disposing); } } } finally { if (_handle != null & !_handle.IsClosed) _handle.Dispose(); _canRead = false; _canWrite = false; _canSeek = false; // Don't set the buffer to null, to avoid a NullReferenceException // when users have a race condition in their code (ie, they call // Close when calling another method on Stream like Read). //_buffer = null; base.Dispose(disposing); } } |
可以看到FileStream的析構方法裡呼叫了Dispose,繼而呼叫了_handle.Dispose,_handle.Dispose內部呼叫的可能是一些native api(一般是用C實現的)。
但是如果託管物件的生命很長,甚至比如說它的靜態的,則它內部包裝的資源將一直得不到回收,而且託管物件內部包裝資源可能屬於“緊張的資源”,比如非託管記憶體、檔案控制程式碼、socket連線,這些資源是必須要被及時回收的,比如檔案控制程式碼不及時釋放會導致該檔案一直被佔用,影響其它程式對該檔案的讀寫、socket連線不及時釋放會導致埠號一直被佔用,為了解決這些問題,我們需要顯式地去釋放這些資源。
Dispose模式
一個常見的做法就是在物件中定義一個方法來專門釋放這些非託管資源,比如叫close
, dispose
, free
, release
之類,然後在不需要使用此物件時顯式呼叫這個方法。C#中的IDisposable介面和Java中的Closeable介面就是這個作用,因為大多數帶GC的語言都使用這種設計,所以這也算是一種模式。
虛擬碼示例:
1 2 3 |
File f = File.openWrite("data.txt"); f.writeBytes((new String("Hello, world!")).getBytes("ascii")); f.close(); |
這樣就夠了嗎?如果close前發生異常或直接return了怎麼辦? — finally語句塊
finally語句塊保證了其中的語句一定會被執行,配合close方法,就能確保非託管資源的釋放。
C++中沒有finally語句結構,這並不奇怪,因為C++有RAII機制,物件的銷燬是確定的,而且確保解構函式的呼叫,所以不需要finally這種語法。
打賞支援我寫出更多好文章,謝謝!
打賞作者
打賞支援我寫出更多好文章,謝謝!
任選一種支付方式