托管堆基礎
一般創建一個對象就是通過調用IL指令newobj分配內存,然後初始化內存,也就是實例構造器時做這個事。
然後在使用完對象後,摧毀資源的狀態以進行清理,然後由垃圾回收器來釋放內存。
托管堆除了能避免錯誤使用已經被釋放的內存,也會減少內存洩漏,大多數類型都無需資源清理,垃圾回收器會自動釋放資源。
當然也有需要立即清理的,比如一些包含了本機資源的類型(如文件、套接字和數據庫連接等),可在這些類中調用一個Dispose方法。(當然有的類對這個方法封裝了一下,可能是別的名字比如斷開數據庫連接的close)
在托管堆上分配資源
CLR要求所有對象都從托管堆分配。
進程初始化時,CLR劃出一個地址空間區域作為托管堆。CLR還維護一個指針,即NextObjPtr。該指針指向下一個對象在堆中的分配未知。
一個區域被非垃圾對象填滿後,CLR會分配更多的區域,這個過程不斷重復,直到整個進程地址空間都被填滿。32位進程最多分配1.5G,64位進程最多分配8TB。
當創建一個對象時,首先會計算該對象類型字段(包括基類)所需字節數,加上對象的開銷所需的字節數(即類型對象指針和同步塊索引)。
然後CLR會檢查區域中是否有分配對象所需字節數大小的內存。如果托管堆有,那麼就在NextObjPtr指向的地址放入對象,且NextObjPtr會加上對象占用的字節數得到新值,即下個對象放入時的地址。
通過垃圾回收器(GC)回收資源
CLR在創建對象時發現沒有足夠內存分配該對象,那麼就會執行垃圾回收。
CLR在進行垃圾回收時,首先會暫停所有線程,
標記階段:然後CLR會遍歷堆中所有的引用對象,將同步塊索引字段中的一位設為0,表示所有對象都要刪除。然後檢查所有的活動根(即所有引用類型的字段以及方法的參數和局部變量),查看它們引用了哪些對象。任何根引用了堆上的對象,那麼CLR就標記那個對象,將同步塊索引字段中的位設為1,如果對象已經被標記為1了,那麼就不再重新檢查對象的字段。標記為1的也就是被引用的對象,稱為可達的,標記為0的就是不可達的。此時CLR就知道了哪些對象可以刪除,哪些對象不能刪除。
壓縮階段:CLR對堆中已標記的對象進行搬移內存位置(且對象所有的根的引用也自然會跟著變動),使得被標記的對象緊密相連,即占用連續的內存空間。這樣不僅減小了應用程序的工作集,從而提升了訪問性能,還得到了大量的未占用內存空間,並且解決了內存碎片化的問題。
最後,恢復所有線程。
靜態字段引用的對象一直存在,直到用於加載類型的AppDomain卸載為止。內存洩漏的一個常見原因就是讓靜態字段引用某個集合對象,然後不停地往集合添加數據項。靜態字段使集合對象一直存活,而集合對象使所有數據項一直存活。因此應該盡量避免使用靜態字段。(或者參照前面的玩法,當我們不用靜態變量的時候,可以立馬置為null,那麼垃圾就會被回收)。
有一個神奇的垃圾回收特例——Timer。原因是它會每隔一段時間去調用回調函數,但是根據之前學的垃圾回收玩法可以知道當Timer的變量離開了作用域,且沒有其它函數引用了Timer對象,那麼在垃圾回收時Timer就會被回收掉。也就不會去執行回調函數了。(所以說慎用Timer,這裡有這麼一個大坑)
代:提升性能
CLR的GC是基於代的垃圾回收器。它對代碼做了如下假設:
第0代:新添加到堆的對象稱為第0代對象,垃圾回收器從未檢查過它們。
第1代:第0代對象經過一次垃圾回收,但是並沒有被當做垃圾釋放掉,那麼就會在壓縮階段一起放入第1代對象區域。
第2代:第1代對象又經過了一次垃圾回收,但是並沒有被當做垃圾釋放掉,那麼就會在壓縮階段一起放入第2代對象區域。沒有第3代,第2代放著的就是經過了2次和2次以上垃圾回收的對象。
第0代內存區域滿了就會進行垃圾回收,此時不僅會回收第0代的區域,還會去判斷第1代區域是否也滿了,滿了也回收第1代,不滿的話即時第1代裡面有不可達的對象,那麼也不會回收第1代。
CLR初始化時,會為這三代分別選擇內存預算,以此判斷什麼時候該回收了。但是CLR的垃圾回收器是自調節的。
也就是說
如果垃圾回收器發現第0代回收後存活下來的對象很少,那麼就會減少第0代的預算,這樣的話垃圾回收就會發生得更頻繁了,然而垃圾回收器每次做的事更少了,這減小了工作集。如果沒有一個存活的話,連壓縮都免了。
如果垃圾回收器發現第0代回收後存活下來的對象很多,那麼就會加大第0代的預算,這樣的話垃圾回收就會發生得不頻繁了,然而垃圾回收器每次回收的內存要多得多。(如果沒有回收到足夠的內存,那麼垃圾回收器會執行一次完整回收,如果還是沒有足夠內存,那麼就會拋出OutOfMemoryException異常)。
上面是用第0代舉例,第1、2代也如是。
垃圾回收觸發條件
CLR在檢測第0代超過預算時會觸發一次GC,這是GC最常見的觸發條件,還有其它的觸發如下:
大對象
CLR將對象分為大對象和小對象,以85000字節為界限。
大對象不是在小對象的地址空間分配,而是在進程地址空間的其它地方分配。
目前版本的GC不壓縮大對象,因為在內存中移動它們代價過高。(可能會造成空間碎片)
大對象總是第2代,所以只能為需要長時間存活的資源生成大對象,否則若短時間存活的大對象放在第二代中,因為之前講到一次回收過多內存,就會將代的預算減少,導致更頻繁回收第2代,會損害性能。
垃圾回收模式
CLR啟動時會選擇一個GC模式,進程終止前該模式不會改變:
應用程序默認以工作站GC模式運行。寄宿了CLR的服務器應用程序(比如ASP.NET和Sql Server)可請求CLR加載“服務器”GC,但如果是單處理器計算機上運行,CLR將總是使用工作站GC模式。
獨立應用程序可在配置文件中,加上下面配置項告訴CLR使用服務器模式:
<configuration> <runtime> <gcServer enabled="true"/> </runtime> </configuration>
除了這兩種模式,GC還支持兩種子模式:並發(默認)和非並發。
在並發模式下,GC有一個額外線程,能在運行時並發標記對象。
而由另一個線程去判斷是否壓縮對象,GC可以更傾向於決定不壓縮,有利於增強性能,但會增大應用程序工作集。使用並發垃圾回收器,消耗的內存比非並發更多。
加上以下配置項告訴CLR使用非並發模式:
<configuration> <runtime> <gcConcurrent enabled="false"/> </runtime> </configuration>
使用需要特殊清理的類型
大多數類型只需要內存就可以了,然而有的類型還需要本機資源。比如System.IO.FileStream類型需要打開一個文件(本機資源)並保存文件的句柄。
包含本機資源的類型被GC時,GC會回收對象在托管堆中使用的內存。但這樣會造成本機資源(GC對它一無所知)的洩漏,所以CLR提供了稱為終結的機制,允許對象在被判定為垃圾之後,但在對象內存被回收之前執行一些代碼。
任何包裝了本機資源(文件,網絡連接,套接字,互斥體)的類型都支持終結。CLR判定一個對象不可達時,對象將終結它自己,釋放它包裝的本機資源。之後GC會從托管堆回收對象。
C#的語法,跟析構函數差不多,但是所代表的意義不同
public class Troy { ~Troy() { //這裡的代碼就是垃圾回收前執行的代碼,這段代碼會被放在一個try塊中,而finally部分放的是base.Finalize } }
這個語法最後在IL代碼裡還是生成一個叫Finalize的方法。
被視為垃圾的對象在垃圾回收完畢後才調用Finalize方法,所以這些對象的內存不是馬上被回收,因為Finalize方法可能要執行訪問字段的代碼。
可終結對象在回收時必須存活,造成它被提升到另一代,使對象活得比正常時間長。這增大了內存耗用,所以應盡量避免終結。
終結的內部原理
在創建新對象的時候,會在堆中分配內存。如果對象的類型定義了Finalize方法,那麼在該類型的實例構造器被調用之前,會將指向該對象的指針放入到一個終結列表裡。
終結列表是一個由垃圾回收器控制的內部數據結構,列表的每一項都指向一個個對象——回收該對象的內存前應調用它的Finalize方法。
在每次要回收垃圾對象時標記階段走完都會去掃描終結列表,如果存在垃圾對象的引用,該引用被移除終結列表,並附加到freachable隊列。(此時對象將不再被認為是垃圾,不能回收其內存,被稱為對象復活了)
freachable隊列也是垃圾回收器的一個內部數據結構,隊列中的每個引用所指向的對象都已經准備好調用Finalize方法了。
CLR用一個特殊的、高優先級的專用線程調用Finalize方法來避免死鎖。
如果freachable隊列為空,那麼此線程睡眠,一旦不為空,此線程會被喚醒,將每一項都從隊列中移除,並且同時調用每個對象的Finalize方法。
然後進入壓縮階段,將這些復活的對象提升到下一代。
然後清空freachable隊列,並執行每個對象的Finalize方法。
到了下次執行垃圾回收時,因為終結列表已經沒有這些對象的指針了,所以現在它們被認為是真正的垃圾了,也就會被釋放。
整個過程中,執行了兩次垃圾回收才釋放掉內存,在實際的過程中,由於對象可能被提升至另一代,所以可能要求不止進行兩次垃圾回收。
手動監視和控制對象的生存期
CLR為每個AppDomain都提供了一個GC句柄表,允許應用程序監視和手動控制對象的生存期。這個就太6了,感覺用不到,用得到的時候回來再看吧。
PS:
最近兩章效率真是慢,一方面因為雙休沒看書和一些突發狀況,另一方面也是因為已經開始了CLR的核心機制之旅,裡面的很多東西確實沒聽過,感覺難度開始增大了。
在此過程中鍵盤莫名其妙壞了,並且兩次關機廢了寫了一半的博客。今天才發現原來強行關機後再開機,浏覽器中寫了一半的博客是可以恢復的。