.Net Bellek Yönetimine Detaylı Bakış

Mehmet Can Tas
11 min readJun 26, 2022

Selam,

Bu yazımda sizlere kısaca hem referans ve değer tipli objelerin oluşma aşamasından, bellekte nasıl tutulduğundan ve nasıl yok edildiğinden bahsetmeye çalışacağım. Konumuz biraz uzun olabilir ama elimden geldiğince kısaltıp yalın bir halde bilgileri aktarmaya çalışacağım.

Özellikle .Net ya da Java gibi programlama dillerini öğrenen kişiler ilk olarak referans ve değer tiplerini mutlaka duymuş ya da araştırmıştır. Ancak temelden başlayıp daha karmaşık konulara doğru gitmeyi amaçladığımdan dolayı bazı ön bilgileri şimdiden vermek istiyorum.

İki veri tipi ‘==’ ile birbirleri arasında eşitlik kontrolü yapıldığında :

  • Referans tiplerde bellek adreslerine göre aynı objeyi işaret edip etmediklerinin kontrolu yapılır.
  • Değer tiplerde ise barındırdıkları değerlerin aynı olup olmadığı kontrol edilir.

Metotlarımıza bir değişken ya da objeyi parametre olarak gönderdiğimizde, eğer gönderdiğimiz değer referans tipli ise o metot içerisinde yapılan her şey gönderilen değişkeni ya da objeyi etkiler. Ancak değer tipli bir değişken ya da obje gönderilirse yapılan tüm değişiklikler parametre gönderilen metot içerisinde geçerlidir. Kısaca referans tiplerde objenin bizzat kendisi üzerinde işlem yaparken, değer tiplerde bir kopyası üzerinde işlem yapılır.

Hiç bilmeyenler için;

Referans tiplere örnek : class, array, delegates,string vs.

Değer tiplere örnek : int,double,struct,enum vs.

Her bir .Net uygulamasında bellek üzerindeki işlemler CLR tarafından bir takım kısımlara bölünmüştür. Makale boyunca bahsedeceğimiz alanlar :

  • Managed Heap ya da bir diğer adıyla GC Heap
  • Thread Stack

Tabi bu iki alanı çatı konseptler olarak düşünebilirsiniz. Bu alanlar da kendileri içerisinde farklı amaçlar için bölünmeler barındırır. Örnek olarak; loader heap (high-frequency heap, low-frequency heap), Large Object Heap, JIT Code Heap vb. Şimdi yavaştan konunun detaylarına inelim ve bazı terimlerin ne olduğunu inceleyelim.

Referans tipler

Referans tipler bellekte Heap adı verilen bölgeyi kullanılarak saklanır. Bu alan Garbage Collector (GC) tarafından yönetilmektedir. Büyük objeler ise Large Object Heap (LOH) kısmında saklanır.

Yeni bir obje için yapılacak allocation işlemi nispeten daha maliyetsiz bir işlemdir. En basit haliyle “Next Object Pointer” (NOP) değeri arttırılarak allocation işlemi gerçekleştirilir.

Bellekte artık kullanılmayan objelerin kaldırılması yani de-allocation işlemi ise yeni bir obje yaratmaya nazaran daha maliyetli diyebiliriz. GC uygulamanın ihtiyacı olmayan objeleri belirleyerek Heap’de bu objeler için ayrılmış alanları tekrar kullanıma açar. GC operasyonlarının maliyetli olmasının başlıca sebeplerinden birisi yeni bir obje yaratma sürecine göre daha kompleks bir algoritmaya sahip olması ve Mark, Sweep, Compact aşamalarını barındırması.

Kısaca bu aşamaları açıklayacak olursak :

  • Mark : GC uygulama tarafından kullanılan tüm objelere göz atar.
  • Sweep : Artık kullanılmayan yani ölü objelerin kapladığı alanı geri alır.
  • Compact : Uygulama tarafından hala kullanılan objeleri bellek içerisinde hareket ettirir. Böylece tüm objeler ardışık olarak saklanmaya devam eder.

.Net uygulamalarımızda istediğimiz alanlar ve metotlar ile bir obje oluşturduğumuzda bu obje bellekte 2 ekstra alan daha barındırarak saklanır.

  • Object Header Word (ya da diğer adıyla Sync Block Index — SBI)
  • Type Object Pointer (ya da diğer adıyla Method Table Pointer — MT Pointer)

Özetle geliştirici tarafından eklenen veriler ve fonksiyonalite haricinde .Net (compiler ve CLR) çalışma zamanında işlemleri gerçekleştirebilmek için bu iki alanı ve diğer ihtiyacı olan veri yapılarını ekler. Bu alanlar JIT derleyicisi ve CLR tarafından çalışma anında (runtime) kullanılır.

Managed Heap / GC Heap

CLR bir .Net process’i oluşturduğunda GC, Managed Heap ve Large Object Heap için belirlenmiş bir alanı bellekte korumaya alır. Elbette bu alan oluşturulan process’in kendi sanal adres alanından ayrılmaktadır.

GC içerisinde “Next Object Pointer” (NextObjPtr/NOP) adı verilen ve Managed Heap içerisinde bir sonraki kullanılabilir adres bilgisini tutan bir veri barındırır.

Bu allocation yöntemi birden fazla objenin aynı anda allocate edilmesi durumunda verilerin bitişik olarak saklanmasını sağlar. Bu duruma giren çoğu obje birbiriyle ilişkili olduğundan ötürü uygulamanın performansına direkt etki etmektedir.

Large Object Heap (LOH) adından da yola çıkarak büyük objeleri (≥ 85KB) içerisinde barındıran yapıdır. Bu yapı Managed Heap alanından biraz daha farklı bir davranış sergilemektedir. Bunu ayrı bir başlık altında inceledikten sonra allocation ve de-allocation konularıyla devam edelim.

Large Object Heap (LOH)

Large Object Heap, boyutu 85KB ve üzeri olan verileri saklamak için Heap içerisinde atanan bi alandır. Bu tarz bir alanın ayrıca oluşmasının sebebi ise GC’nin Sweep & Compact sürecinden tamamen koparmaktır.

Bu tarz büyük objeleri GC jenerasyonları arasında kopyalamak uygulama performansına negatif etki edeceğinden dolayı 85KB ve üzeri boyuta sahip veriler direkt olarak LOH içerisinde barındırılır.

Orjinal Sweep & Compact implementasyonu LOH için uyumlu olmadığından, burada kullanılan GC algoritması kullanılabilir bellek alanlarını tutan Linked List yapısından yararlanır.

Her yeni allocation isteğinde GC kullanabileceği bellek alanlarını arar. Eğer bir obje artık kullanılmıyorsa o objeye ait bellek alanı Linked List’e eklenir. Elbette bu implementasyon GC için işleri karışıklaştırmakta ve ekstra performans yükleri oluştursa da büyük objeleri kopyalamaktan daha iyi diyebiliriz.

Büyük objeler GC içerisindeki jenerasyonlardan 2. jenerasyonun bir parçasır. 2. jenerasyon limite ulaştığında ve artık temizlenmesi gerektiğinde LOH içerisindeki liste de temizlenir. Bu da “full garbage collection” dediğimiz olayın kısa açıklamasıdır.

Garbage Collector

Yukarıda GC ile ilgili Mark, Sweep ve Compact aşamalarından bahsetmiştik. Gelin bu aşamalara daha yakından bakalım ve GC hangi işlemleri gerçekleştiriyor inceleyelim.

Mark

Bellekte kullanılmayan objelerin temizlenme aşaması Mark süreci ile başlar. Mark aşamasında GC hala kullanılan ve bir diğer deyişle “yaşayan” objelerin bir listesini tutar ya da grafiğini çıkarır.

GC bu grafik çıkarma işlemini 2 adımda gerçekleştirir :

1- GC Roots :

GC hangi objenin hala kullanıldığını hangisinin kullanılmadığını bilmek için GC Roots adı verilen yapıyı kullanır.

GC Roots içerisinde bulunan objeler :

  • static variables
  • local variables
  • f-reachable queue
  • GC Handles

2- GC rooots içerisindeki dahili referans alanlarının keşfedilmesi

GC, GC Roots’u oluşturduktan sonra dahili referans alanlarını incelemeye başlar ve bu alanları “live objects” grafiğini oluşturmak için kullanır. GC bu işlemi tüm referans tipli objeleri inceleyene kadar tekrar eder.

GC bu işaretleme (mark) işlemini “Reference Cycles” sorunundan kaçmak için kullanır. Bildiğin üzere .Net ve diğer dillerde bir objeler birbirilerinden referans alabilirler. GC bu tarz objelerde sorun yaşamamak için zaten incelediği objeleri işaretler.

GC objeleri işaretlemek için Sync Block Index (SBI) alanını kullanmaktadır.

Böylece işaretleme (Mark) süreci tamamlanmış oluyor. Şimdi sırada Sweep & Compact süreci var. Sonrasında GC Roots hakkında kısaca bilgilendirme yapmak istiyorum.

Not : Sync Block Index konusuna makalenin ilerisinde değiniyorum.

Sweep & Compact

Objelerin işaretlenmesi ve “live objects” grafiğinin çıkartılması tamamlandıktan sonra başlayan süreçlerdir.

Managed Heap’de yer alan ve uygulama tarafından kullanılan objeler dışında artık kullanılmayan objeler “çöp” olarak nitelendirilir ve bu objeler ait bellek alanları temizlenir.

Bu kısımda GC aşağıdaki 4 işlemi gerçekleştirmektedir :

1- Artık kullanılmayan objelerin bellek alanlarının toplanması

2- Uygulama tarafından kullanılan objelerin Managed Heap içerisinde yer değişikliğinin gerçekleştirilmesi. Böylece objeler bitişik olarak saklanabilecekler.

3- Yer değiştirilen objelerin bellek adreslerinin güncellenmesi

4- “Next Object Pointer” değerinin yapılan düzenlemeden sonra bir sonraki kullanılabilir bellek alanını işaret edecek şekilde güncellenmesi

Aşağıdaki görselde yukarıdaki işlemleri inceleyebilirsiniz.

GC Roots

Yukarıda bahsettiğimiz gibi GC “live objects” grafiğini oluşturmak için bir dizi kök (root) kullanır.

1.Local Variables :

Local referans tipli değişkenler barındırdıkları objelerin kökeni olduğundan dolayı tanımlayıcı metotları Thread Stack’inde yer aldığı sürece GC tarafından GC Roots olarak kullanılabilirler.

Kısaca işaretleme sürecinde GC Thread Stack’te yer alan tüm metotları inceler ve referans tipli değişkenleri “live objects” grafiğini oluşturmak için birer root (başlangıç noktası) olarak kullanır.

2. Static Variables:

Bildiğiniz gibi static değişkenler içerisinde yer aldıkları sınıf belleğe yüklendiğinde oluşturulur. Bunun haricinde new anahtar kelimesi ile bir nesne örneği yaratılamaz.

Static değişkenler process içerisindeki AppDomain’in tüm yaşam süresi boyunca “hayatta kalabilir”.

Bu yüzden static bir class farklı objeleri barındırırsa, barındırdığı objelerin kökeni olarak işlem görür ve GC tarafından “live objects” grafiği çıkarmak için kullanılır.

3. GC Handles:

.Net bir process oluşturduğunda her bir AppDomain için bir “GC Handle Table” tahsis eder. GC Handle Table içerisinde unmanaged code içerisinde kullandığımız objeye ait tüm pointer’ları (bellek adreslerini) barındırır. GC çalıştığında bu tabloya bakar ve ona göre hareket eder.

Kısaca GC, GC Handle Table içerisindeki objelere ait bellek alanlarını toplayamaz ya da bellek içerisinde adresini değiştiremez.

Unsafe kod ile belirlediğimiz direktifler ile objelerin bulundukları bellek adresinin değişmemesini ve bu objeler ile ilgili bir işlem yapılmamasını GC’a söylemiş oluyoruz. Bu da bellek fragmantasyonuna neden olarak uygulamanızın performansına direkt etki etmektedir.

4. F-reachable queue:

f-reachable queue GC Finalization mekanizmasının bir parçasıdır. f-reachable queue CLR’a ait olan ve içerisinde objelerin adreslerini barındıran bir yapıdır. Bu objeler uygulama tarafından artık kullanılmamasına rağmen GC bu objelerin bellekteki alanlarını toplayamaz bu yüzden GC bu objeler GC root olarak kullanabilir.

GC Finalization

Yukarıda bahsettiğimiz objeler file, network connection vb. native kaynaklardır. Bu yapılar Managed Heap’de yer almazlar ve GC tarafından değil OS tarafından yönetilirler.

Bu yapıların haricinde Finalizer’ı implemente eden class’lar da aynı şekilde işlem görür.

Aslında burda bahsettiğimiz Finalizer bildiğimiz fakat belki de hiç kullanmadığımız destructor metodundan başka bir şey değil. Fakat buradaki yapı C++’da yer alan destructor’a göre farklılık göstermektedir. Aşağıdaki kodun MSIL koduna baktığınızda compiler’ın Finalize isminde bir metot eklediğini göreceksiniz.

public class NativeResource
{
// Fields and implementations // Finalize method ~NativeResource()
{
// Implement custom cleanup code
}}

Finalization konusu bu makaleyi ciddi manada uzatacağından dolayı onu ayrı bir makalede daha detaylı anlatmayı düşünüyorum. Yine de merak edenler olursa aşağıdaki linkte yer alan makaleyi okuyabilirler.

Virtual Address Space

Virtual Address Space, işletim sistemi tarafından sağlanan ve yönetilen, uygulama çalışırken kullanabileceği sanal adres aralığıdır (range). Şimdi 32-bit ve 64-bit mimarilerdeki farklara göz atalım.

32-bit

32-bit miömarilerde işletim sistemi virtual address space için 4GB’lık bir alan tahsis eder. Ancak bu alanın sadece 2GB’lık bölümü process’ler tarafından kullanılırken diğer 2GB’lık kısım ise işletim sisteminin kendisi tarafından kullanılır.

Bazı 32-bit sistemlerde IMAGE_FILE_LARGE_ADDRESS_AWARE değerini değiştirerek bu 2GB’lık değeri 3GB olarak güncellemek mümkün.

64-bit

64-bit sistemlerde bildiğiniz üzere hem 32-bit hem de 64-bit process’leri çalıştırabiliyoruz. 32-bit process’ler 4GB’a kadar, 64-bit processler ise 8TB’a kadar alan kullanabilir.

Bu atanan alan kapasitesi işletim sistemi versiyonları arasında farklılık göstermektedir. Bu durum uygulamamızın performansına direkt olarak etki ettiğinden dolayı bu konu hakkında yüzeysel olarak da olsa bilgi sahibi olmak önemli.

Virtual Adress Space 3 farklı durum arasında geçiş sağlar :

  • Free : Virtual adress’lerin belirli bir kısmının allocation için kullanılabilir olduğunu temsil eder. Bir diğer deyişle uygulamamız bu alanlarda herhangi bir veri barındırmıyor demektir.
  • Reserved : Sağlanan bellek alanı belirli objeler için tutulmaktadır. Bu da kısaca başka hiçbir objenin bu alanı kullanamayacağını temsil eder. Fakat bu alan commited durumuna geçmeden hiçbir veri barındıramaz.
  • Commited : Reserved durumundaki bellek alanının fiziksel belleğe atandığını temsil eder. Commited durumundan sonra bu bellek alanı içerisinde veri barındırabilir.

CLR içerisindeki her bir objenin header denilen bir bölümü vardır. CLR bu header içerisinde yer alan bilgiyi runtime anında kullanır. Header bellek içerisinde obje pointer’ından önce yer alır. Buradaki -4/-8 değerleri uygulamanızın çalıştığı işletim sistemine göre değişkenlik gösterdiğini temsil eder. 32 bit için -4, 64 bit için -8.

Header içerisindeki bilgileri açıklayacak olursak her bir bit’in barındırdığı veriler şu şekilde :

  • 0–15 Thin lock information : Buradaki 10 bit lock’ı elinde tutan Thread id (eğer değer 0 ise hiçbir thread lock’ı elinde tutmuyor demektir) için kullanılırken 6 bit ise recursion seviyesini barındırır ( eğer değer 0 ise lock hiç alınmamış ya da anyı thread tarafından sadece bir kez alınmış demektir.).
  • 16–26 App Domain Index : 11 bit, objenin bağlı olduğu app domain bilgisini barındırır. (Örn: COM interop)
  • 0–25 Sync Block Index : 26 bit objenin hashcode değerini tutar (eğer override edilmediyse)
  • 26 BIT_SBLK_IS_HASHCODE : 1 bit kelimenin geriye kalanın bir hash code ya da SyncBlockIndex olup olmadığını işaretler.
  • 27 BIT_SBLK_IS_HASH_OR_SYNCBLKINDEX: 1 bit hashcode ya da SyncBlockIndex ayarlanıp ayarlanmadığını belirtir
  • 28 BIT_SBLK_SPIN_LOCK : 1 bit, header’ı kitleyip değerini değiştirmek istiyorsak kullanılır.
  • 29 BIT_SBLK_GC_RESERVE : 1 bit, sadece GC çalıştığında ve obje pinli ise kullanılır.
  • 30 BIT_SBLK_FINALIZER_RUN : 1 bit, bir objenin sonlandırılıp (örneğin GC.SuppressFinalize ile) sonlandırılmadığının bilgisini barındırır. Objeyi sonlandıracak thred çalıştığında her bu bit içerisindeki değer true ise pas geçer.
  • 31 BIT_SBLK_AGILE_IN_PROGRESS : Aralarında en önemsiz olan ve sadece Debug ayarlarındayken kullanılan bir bit. İki obje arasındaki sonsuz döngüyü kontrol ediyor ama tam olarak ne anlama geldiğini ben de anlamadım.

Object Header Word / Sync Block Index

SyncBlock thread senkronizasyonu için kullanılır (Monitor.Enter, Exit ve Monitor.Wait ya da lock anahtar kelimesinin kullanımı gibi). Bunun yanı sıra HashCode ve AppDomainIndex gibi bilgileri de içerisinde barındırır. Obje header’ı için yeterli alana sahip değilsek SyncBlock yaratılır ve tüm bilgiler içerisinde tutulur.

Type Object Pointer (TOP) / Method Table Pointer (MT)

The Type Object Pointer, objenin Method Table dediğimiz yapısını işaret eder. Method table ise Type üzerindeki bilgileri ve ekstra ihtiyaç duyulabilecek bilgileri barındıran CLR veri yapısıdır. Bazı bilgiler bu Method Table içerisinde barındırılır. Örneğin, objenin her bir metodunun adresi, base sınıfın method table’ını işaret eden bir pointer ve EEClass’ı işaret eden bir pointer vb.

Burda Method Table’ı derinlemesine incelemeyecek olsak da EEClass hakkında birkaç bilgi vermek istiyorum. EEClass, Execution Engine Class’ın kısaltmasıdır. EEClass da CLR’a ait bir veri yapısı olup tipe (type) ait static alanların lokasyonu, reflection vb. ekstra bilgiler barındırır.

Method Table içerisindeki bilgileri EEClass’ın içerisindeki bilgilerden daha çok kullanılmakta. Buna rağmen her iki yapı da CLR’ın runtime anında işlemlerini gerçekleştirmek için ihtiyaç duyabileceği tüm bilgileri barındırmaktadır.

Loader Heap

Son bahsetmemiz gereken kavram Loader Heap. Yukarıda bahsettiğimiz her iki yapı da Loader Heap içerisinde saklanır. Loader Heap, process’in CLR’ın tüm dahili veri yapılarını tahsis etmek için kullandığı bir bellek alanıdır.

Uygulamamızın referans tipli objelerini barındıran Managed Heap adı verilen alanının aksine Loader Heap GC tarafından yönetilmez.

Loader Heap iki alana bölünmüştür :

  • HighFrequencyHeap
  • LowFrequencyHeap

Method Table, Field Descriptor vb. veri yapılarına sıkça erişildiği için bu yapılar HighFrequencyHeap içerisinde tutulur.

EEClass gibi daha az erişilen veri yapıları ise LowFrequencyHeap içerisinde saklanır.

Değer Tipler / Value Types

Bir metot içerisinde yaratılan int, double vb. tipteki değişkenler, çalışan Thread’in belleğindeki stack alanında saklanır. Bir sınıf içerisinde tanımlanan değişkenler ya da değer tipli alanlar ise Managed Heap ya da Large Object Heap alanında tutulur.

Allocation

Değer tipteki değişkenlerin allocation süreci oldukça basit bir işlemdir. Sadece Stack Pointer Register — ESP değerini değiştirmek allocation işlemini gerçekleştirmek için yeterli.

De-allocation

Kullanılmayan objelere ait bellek alanının geri alınması işlemi de yine oldukça basittir. ESP üzerinde yapılan değişikliklerin geri alınması ve o bellek alanının tekrar kullanılmaması bu işlem için yeterlidir. (32 bit mimarilerde)

Burada Stack ikiye bölünmüş halde diyebiliriz. Valid ve invalid olarak bölünen bu iki kısmın sınırları ise 32 bit mimarilerde ESP tarafından işaret edilmektedir.

Yukarda referans tipler için bahsettiğimiz SBI,TOP vb. ekstra bilgiler değer tiplerde yer almamaktadır. Elbette değer tiple boxing işlemine maruz kalabilirler. Bu maliyetli bir işlem olmanın yanı sıra değer tipleri Stack’den çıkıp Heap’e taşınmasına da yol açmaktadır.

Boxing nedir?

Boxing, bir değer tipin referans tipe dönüştürülmesi ya da bir değer tipine bir referans tip olarak davranılması olarak nitelendirilebilir.

Örnek :

int sayi = 120;
object obj = sayi;,

Bu işlemden sonra referans tipe dönüşen değer Thread Stack’ten Managed Heap’e taşınır. Sonrasında yukarıda bahsettiğimiz MT Pointer (Method Table Pointer), SBI (Sync Block Index) ve Method Table gibi ekstra bilgiler bu objeye eklenir. Günün sonunda önceden değer tip olan bir obje artık tamamen referans tipe dönüşmüş olur. Boxing yani kutulama terimi bu bahsettiğimiz işlemlerden gelmektedir.

Elbette tüm bu işlemler uygulama çalışırken gerçekleştirilir. Bu da doğal olarak uygulama performansına etki eder. Bunun yanı sıra, GC üzerinde ekstra bir baskı yaratır. Bu yüzden Boxing işleminden olabildiğince kaçınmanızı tavsiye ederim.

Bu makalede elimden geldiğince sizlere objeler ile ilgili bilgileri vermeye çalıştım. Bu makale biraz kısa oldu ancak temel bilgileri aktardığımı düşünüyorum. Hatalı anlattığımı düşündüğünüz bir nokta varsa belirtebilirsiniz. Şimdilik bu kadar bir sonraki makalede görüşmek üzere. İyi kodlamalar.

https://blog.weghos.com/coreclr/CoreCLR/vm/syncblk.cpp.html

http://yonifedaeli.blogspot.com/2016/12/net-type-internals.html

https://mycodingplace.wordpress.com/2018/01/10/object-header-get-complicated/

--

--