C# 是使用引用計數來發現垃圾對象的嗎?

看到這篇文章: 雲風的 BLOG: 引用計數與垃圾收集之比較

文章里說引用計數和垃圾回收是兩種不同的解決方案, 我一直以為 C# 就是引用計數實現的, 而垃圾回收是對失去所有引用的對象的內存的回收, 引用計數和垃圾回收是不同階段而已.

在我看來 C# 這樣的高級語言里, 引用計數並不會有什麼問題 (除了 unsafe 代碼), 既簡單又高效. 那麼求解釋我的理解錯在哪.

補充:

class A { public B b; }
class B { public A a; }

void Func()
{
A a = new A(); // 創建了對象 x, x.refCount = 1
B b = new B(); // 創建了對象 y, y.refCount = 1

a.b = b; // y 增加了引用, y.refCount = 2
b.a = a; // x 增加了引用, x.refCount = 2

a = null; // x 減少了引用, x.refCount = 1
b.a = null; // x 減少了引用, x.refCount = 0
// x 變成垃圾, x.b 被重置, 導致 y 減少引用, y.refCount = 1

b = null; // y 減少了引用, y.refCount = 0
// y 變成垃圾
}


C#與CLI規範都沒有禁止具體實現採用引用計數法來實現自動內存管理。但是在現有的CLI實現中,無一採用引用計數法來管理託管對象。

來看幾種運行C#的環境的狀況:

  • CLR / CoreCLR / .NET Native / CoreRT:分代式mark-compact + mark-sweep,外加mark-sweep的並發變種。分3代:Gen0、Gen1、Gen2 + LOH。Gen0與Gen1總是用stop-the-world mark-compact收集。

  • Mono:在使用SGen之後,分代式copying + mark-sweep/copy。分2代,nursery、old。Nuesery總是用copying演算法收集,old/major GC用mark-sweep/copy收集(也可以配置為只用copying演算法)。SGen的major GC的做法挺有趣的,基本上是mark-sweep,但過程中發現碎片量太大則會把部分內容用copying演算法收集一次。Working With SGen
  • .NET Compact Framework CLR:不分代的mark-sweep/compact。An Overview of the .Net Compact Framework Garbage Collector
  • .NET Micro Framework TinyCLR:mark-sweep / mark-compact。NETMF/netmf-interpreter · GitHub
  • DotGNU Portable .NET:Boehm GC,mark-sweep。Portable .NET是從GCJ拿來Boehm GC的。
  • Unity IL2CPP:目前也在用Boehm GC,mark-sweep。未來希望能把CoreCLR的GC移植過去(orz)。IL2CPP INTERNALS – GARBAGE COLLECTOR INTEGRATION

嗯,沒有用引用計數的。當然,要是誰蛋疼想實現一個基於引用計數的CLI實現,那完全是可以的。別人肯不肯用那是另一回事。

題主的疑問的來源就是沒有正確把圖論的知識應用在垃圾回收的領域裡。

對象通過引用(指針)連在一起,構成一個有向圖,G = (V, E)。其中「對象」構成這個有向圖的節點,而引用關係構成這個有向圖的有向邊。

想像一下這個情況:

-&> a -&> c
/
&> &< b

在這個有向圖裡,有:

  • 3個節點:V = { a, b, c }
  • 4條有向邊:E = { (..., a), (a, b), (a, c), (c, b) }

當我們使用一個全局的演算法去遍歷整個對象圖,就可以發現這個對象圖裡的所有引用關係,從而正確的理解它們的存活狀況:

從根集合出發,能順著有向邊遍歷到的節點就是活的,否則則是死的。這就是tracing GC的基本思想。注意:引用關係是重要的,不是所有引用都能保持對象的存活,而一定要是能從根集合出發遍歷到的引用才算活引用,才會讓被引用的對象存活。

這個例子里,「...」就表示一個根集合里的指針,它並不在對象圖之內,而是從「外部」引用著對象圖裡的節點。從圖論的角度說,遍歷一個有向圖需要選擇一個(或一組)起點;根集合就是指明「起點」是什麼的東西。

而我們也可以採用一種局部的、壓縮的方式來描述上面的對象圖信息。對於圖中的每個節點,有:

  • a: { in = 1, out = 2 }
  • b: { in = 2, out = 0 }
  • c: { in = 1, out = 1 }

這其實就是把前面的對象圖中的「有向邊」(E集合)壓縮到每個節點的局部,並且忽略有向邊的首尾實際是什麼節點,而將其壓縮為一個「計數」。這就是「引用計數」演算法的根本。

「引用計數」對有向圖的壓縮是有損的——丟失了有向邊的首尾——因而也丟失了「從根集合出發」的這個重要信息,所有的引用都被同等對待。而這正是它最常被提起的弱點的根源:無法處理形成孤島的循環引用

所謂「孤島」,就是有向圖中,從根集合出發遍歷無法遍歷到的子圖。

這張圖裡,假設 -&> A 的引用是從根集合出發的,那麼圖中A、B、C節點都是存活的,而D與E雖然相互引用,但卻形成了「孤島」,實質上是死的。回來看題主給的例子。題主只試圖構造了循環引用,但沒有將其置於「孤島」的狀態,所以才沒有觸發到引用計數的弊端。

在題主的例子里:

class A { public B b; }
class B { public A a; }

void Func()
{
A a = new A(); // 創建了對象 x, x.refCount = 1
B b = new B(); // 創建了對象 y, y.refCount = 1

a.b = b; // y 增加了引用, y.refCount = 2
b.a = a; // x 增加了引用, x.refCount = 2

a = null; // x 減少了引用, x.refCount = 1

// 下面這句是問題的根源:b 引用對 y 對象的引用仍然存活,所以 x 與 y 對象尚未形成「孤島」
// 通過 b 引用去操縱 y.a 對 x 對象的引用關係,實質上是「幫助」引用計數逃離「孤島」的弊端
// 下面這句使得原本存在的循環引用被從外部先解除了
b.a = null; // x 減少了引用, x.refCount = 0
// x 變成垃圾, x.b 被重置, 導致 y 減少引用, y.refCount = 1

// 然後才解除最後一個對 y 的活引用,不觸發任何問題
b = null; // y 減少了引用, y.refCount = 0
// y 變成垃圾
}

只要把這個例子稍微改寫一下就可以演示出效果了。假定還是使用簡單的引用計數法來自動管理內存:

class A { public B b; }
class B { public A a; }

void Func()
{
A a = new A(); // 創建了對象 x, x.refCount = 1
B b = new B(); // 創建了對象 y, y.refCount = 1

a.b = b; // y 增加了引用, y.refCount = 2
b.a = a; // x 增加了引用, x.refCount = 2

a = null; // x 減少了引用, x.refCount = 1
b = null; // y 減少了引用, y.refCount = 1

// 現在 x 與 y 兩個對象相互引用,但是沒有任何活引用指向 x 或 y,形成了「孤島」

// 題主原本想像的以下操作都無法進行,因為程序已經不再擁有對 x 或 y 對象的活引用
// b.a = null; // x 減少了引用, x.refCount = 0
// x 變成垃圾, x.b 被重置, 導致 y 減少引用, y.refCount = 1
// b = null; // y 減少了引用, y.refCount = 0
// y 變成垃圾
}

很明顯對不對?

最後送給題主傳送門:垃圾回收機制中,引用計數法是如何維護所有對象引用的? - RednaxelaFX 的回答


Maoni"s WebLog

把.net GC它娘的博客讀完,你就懂了,不要看什麼亂七八糟的二手文章


官方實現的C#用的不是ref count,當然不排除有一些其他實現用這種方式

ref count和GC是兩種方案這種觀點,確切說是針對狹義GC而言,雖然有缺陷,但是廣義上還是屬於一種GC方式的,循環引用的缺陷可以用局部mark sweep結合分代來彌補,python的標準版本就是這麼乾的,再者程序中的循環引用究竟是不是一個廣泛的情況也是有爭議,有人認為很常見,比如很多基礎的數據結構就大量包含,但也有人認為只在一些特定領域,通過一些很小的工作量就可以在程序層面規避

至於說ref count低級之類的,我覺得是帶有感情色彩了,這個方案是很直觀初級,但演算法要看適用的場景,只有適不適合,沒有絕對的好壞高低,ref count最大優點是回收實時,當然一個盛傳的觀點是在釋放類似超長鏈表時候也會造成卡頓,然而就算你手工new delete,這種情況一樣會集中處理,這是場景問題

ref count有很多改進方案,《GC》書中講了不少


你的理解是完全錯誤的。C#不是用引用計數,是整套的GC。引用計數無法解決循環引用的問題,所以不是個GC。而且,引用計數不一定比GC高效。


不是,建議樓主看看CLR via C#,講得很明確。引用計數無法解決循環引用(可能說成相互引用更好懂)

C#使用的是引用追蹤演算法(圖演算法),具體細節還是看書吧


C#里並不是引用計數。

用的是MarkSweep演算法,可以檢測循環引用和網狀結構。

另外,還有一個compact階段,重新排序壓縮。為了提高性能,還有generational 分代演算法,避免full gc,更快識別可能的垃圾對象。finalization queue和freachable queue這兩個隊列用於處理對象的finalize方法,這些對象只會在下個gc才會被刪除。

大概就是這幾個重點了。


記得說GC是(分代+引用計數)來確定垃圾回收的,不知道是不是這樣


1、引用計數難以解決互相引用的問題

2、引用計數在計數為0就釋放,不能選擇更好的時機釋放。

3、垃圾收集通過預測分代的方法,可以有效提高命中率。

4、引用計數是低級的,所以這句話讀起來怪怪,請題主解釋中間的邏輯:

在我看來 C# 這樣的高級語言里, 引用計數並不會有什麼問題

=================================================

@沉默的羔羊

我知道你的意思,你這代碼是建立在已知循環引用情況下的代碼,且不說用法繁瑣。現實中,因為對象封裝,會形成根本不可能知道循環引用。如果要完全避免,你就只有在釋放前把所有引用都清理。。。。那麼這引用的意義就不大。當然我知道有的GC是有一部分使用引用的。但是.net 沒有。

.net清理的演算法,是從根部擴散搜索,如果與根部直接或者間接連上的則保留,連不上則清理。


推薦閱讀:

反編譯DLL問題?
C++與C++/CLI的運行速度相比哪個快? C++/CLI和C#的運行速度一樣快?
Node.js和.Net 4.5下的await、async相比有什麼不同?

TAG:NET | C# | GC垃圾回收計算機科學 | CLR |