.NET Bellek Yönetimi

Cem Doğan
11 min readJun 15, 2020

Merhabalar, bu yazımda her .NET geliştiricisinin bilgi sahibi olması gereken bellek yönetiminden bahsedeceğim. Bellek yönetimi için temel kavramlara değineceğim. Bu kavramlardan sonra Common Language Runtime (CLR)’daki GC(Garbage Collector)’ı inceleyip, bellek alokasyonunu daha verimli hale getirebilmek için bazı noktalardan bahsedeceğim.

Bu konuya ben de önceden gereken önemi vermiyordum. Ama yaşadığımız bir problemden dolayı daha detaylı incelememiz gerektiğini anladım. Bununla ilgili şirket içinde bir sunum yaptım ve yazıya dökmeye karar verdim. Yaşadığımız problem kısaca çalışan bir servisin sunucudaki CPU ve RAM kullanımını arttırmasıydı. dotMemory profiler aracı ile kontrol ettiğimizde bellekte gereksiz nesne referanslarının olduğunu gözlemledik. Yazdığımız kod GC’ye göre verimsizdi ve GC bunları kaldırmak için ekstra CPU kullanımını arttırıyordu. Kodu yeniden düzenledikten sonra durum normale döndü. Aslında GC’nin çalışma mantığını anlarsak daha verimli kodlar yazıp bu sorunları minimuma indirebiliriz. Aşağıdaki basit bir sınıf tanımıyla konumuza başlayalım;

Bu sınıftaki int tipindeki count değişkeni belleğin özel bir bölgesi olan stackte tutulur. Gördüğümüz her new anahtar kelimesi ile yine belleğin özel bir bölgesi olan heap’te yer aloke edilir. Son olarak gördüğümüz method çağrısı ise parametreleri ve lokal değişkenleri ile birlikte stack’te tutulur. İlerleyen kısımlarda bunların detayına ineceğiz. Peki bunlar bellekten nasıl siliniyor? .NET Framework’ün managed code’u destekleyen bir mimarisi vardır. CLR ayağa kalktıktan sonra managed heap oluşur ve GC manage heap üzerindeki nesneleri yaşam durumuna göre bellekten siler. İlk sürümünden itibaren bellek yönetimini kontrolü altına almıştır. Stack’te tutulan değişkenler herhangi bir işlemi beklemeden kullanıldıktan sonra bellekten direkt silinir. Heap için ise devreye GC girer ve bu silme işlemini geliştirici için otomatik yapar. GC’nin temel görevi, arka planda çalışıp geliştiriciyi nesnelerin yaşam döngüsünden uzak tutmasıdır.

Geliştiricilerin programlarda yaptığı ortak hataları incelersek;

  • Bellek sızıntısı(memory leak): new ile bir nesneyi aloke edip sonra silmeyi unuttuğumuzda karşılaştığımız durumdur.
  • Bellek bozulması(memory corruption): Silinmiş bir nesneye erişmek istediğimiz zaman karşılaştığımız durumdur.
  • Bellek hatası(memory error): Bellekte henüz aloke olmamış bir nesneyi silmeye çalıştığımızda karşılaştığımız durumdur.

Bu ortak hatalardan aslında .NET Framework’ün bellek yönetimini neden otomatik hale getirdiğini anlayabiliriz. GC bu hataları almamızı minimuma indirir. Tabii ki GC’nin avantajları olduğu kadar dezavantajları da mevcut. Bellek yönetimi otomatik olduğundan geliştiriciyi bu kısımdan uzak tutar. Dolayısıyla geliştirici olarak bellek özelinde bakış açımızı kaybedebilir ve yazdığımız programlarda ihtiyacımızdan daha fazla bellek kullanabiliriz. (bence yukarıdaki karşılaştığımız sorunun kaynağı) Günümüzde sistemler çok yüksek özelliklerde olmasına rağmen yazılan kodun performansının düşük olması programlarımızı verimsiz hale getirebilir.

Stack

Stack, method çağrılarında method’a ait lokal değişkenlerin, parametrelerin ve dönüş değerlerinin tutulduğu bellek bölgesidir. Herhangi bir method çağrısı meydana geldiğinde, stack frame adında methodla ilgili her şeyi tutan bir blok oluşturur. Her method çağrısında o methoda özel stack frame’ler son çağrılan methodun stack frame’i üzerine biner ve bu şekilde devam eder.

https://www.red-gate.com/simple-talk/wp-content/uploads/imported/1523-img36.gif

Method çağrısı bittiği zaman o stack frame içindeki verilerle birlikte stackten otomatik silinir. En üstteki stack frame her zaman çalıştırılan methodu gösterir. Methodların bu şekilde üst üste binip stack’in limitini aşması durumunda .NET Framework bize StackOverflowException fırlatır. Primitive tipleri yani değer tipleri (byte, int, double, boolean, char, decimal, struct vb.) genellikle stack’te tutulur.

Heap

Heap, referans tiplerin (class, interface, string vb.) tutulduğu belleğin diğer bir özel bölgesidir. Bu nesneler asla stack’te tutulmaz. Stack’te oluşan stack frame’ler içerisindeki referans tipler heap’te bir alanı gösterir.

https://www.red-gate.com/simple-talk/wp-content/uploads/imported/1523-img38.gif

Bu referans tiplerden genellikle new anahtar kelimesi ile yeni bir nesne yaratıldığında stack üzerinde değişkeni tutulurken, nesnenin kendisi heap’te tutulur. Stack’ten stack frame silindiğinde heap’te ilişkili olduğu yani referans verdiği nesneler silinmez ve nesne referanssız kalır. .NET Framework bellekte bu şekilde referanssız kalan nesneleri temizlemeyi mümkün olduğunca erteler. Çünkü heap’i temizlemek zaman alan bir işlemdir. Ama en sonunda GC işlemini başlatarak bu nesnelerin heap’teki alokasyonunu kaldırır ve temizler. Bu temizleme işlemi arka planda gerçekleşir. Temizlemeye başlamadığı durumlarda ise heap sınırı dolduğu zaman .NET Framework OutOfMemoryException fırlatır. Bu kısmı GC’yi anlatırken detaylı inceleyeceğim.

Değer Tipleri (Value Types)

Değer tipleri, değişkenin hem tipi hem de değerinin birlikte stack’te tutulduğu tiplerdir. Değer tipleri sadece stack’te tutulmaz, heap’te de tutulur. Örnek olarak değer tipi içeren herhangi bir sınıf düşünün.

https://www.red-gate.com/simple-talk/wp-content/uploads/imported/1523-img3A.gif

Bu sınıfın new ile herhangi bir örneği yaratıldığı zaman içerdiği değer tipleri ile birlikte heap’te tutulur. Değer tiplerinde atama da karşılaştırma da değerleri üzerinden yapılır. Değer tiplerini ref anahtar kelimesi ile referans tip gibi davranmasını sağlayabiliz.

Referans Tipler (Reference Types)

Referans tipler, değişkenin değerinin heap’te tutulduğu tiplerdir. Değişkenin kendisi stack’te tutulurken değeri yani bellekteki adresi heap’te tutulur.

https://www.red-gate.com/simple-talk/wp-content/uploads/imported/1523-img3C.gif

Referans tiplerinde atama da karşılaştırma da referansları üzerinden yapılır.

Referans tipler stack’te de heap’te de tutulur ama referans verdiği değerler sadece heap’te tutulur.

Boxing ve Unboxing

.NET’te tüm tipler object tipinden türemiştir. Object tipi de bir referans tiptir. Object tipine bir değer tipinden değer atadığımız zaman önceden bahsettiğimiz gibi referans tiplerin değerleri sadece heap’te tutulacağından bu atama işleminde değer tipi için boxing adı verilen bir işlem gerçekleşir. Bu işlemde değer tipi box’lanarak heap’e taşınır. Yani değer tipi için heap’te bir alan ayrılır. Unboxing işlemi ise tam tersidir yani heap’teki bir değer tipinin stack’e taşınmasıdır.

Garbage Collection

Bellek yönetimi için bilmemiz gereken kavramlara kısa kısa değindikten sonra asıl konumuz olan GC’nin çalışma mantığını, yapısını, nesnelerin yaşam sürelerini ve boyutlarını incelemeye başlayabiliriz.

GC’nin temel görevi arka planda çalışıp geliştiriciyi nesnelerin yaşam döngüsünden uzak tutmaktır. Heap’i anlatırken method çağrısından sonra methodun stack’ten atıldığını ve referans verdiği nesnelerin heap’te kaldığını söylemiştik. Şimdi çağırılan bir method içerisinde beş elemanlı bir dizi olduğunu düşünelim.

Bu dizi için heap’te bu şekilde yer aloke edilir. Şimdi de 2. ve 3. elemanlarına null atadığımızı varsayalım. Onların referansı olan nesneler referanssız bir şekilde heap’te kalmaya devam eder. Şimdi GC’nin bu nesneleri nasıl temizlediğine bakalım. GC’de temel olarak iki ayrı evre vardır. Bunlar; mark ve sweep işlemleridir.

Mark evresinde, GC heap’teki yaşayan yani referansı olan tüm nesneleri işaretler. 2. ve 3. nesneler işaretlenmez çünkü herhangi bir referansı yoktur. Bu işlemden sonra sweep işlemine geçilir. Bu evrede ise işaretlenmeyen nesneler bellekten silinir. Sweep işleminden sonra compact adında bir evre daha vardır. Compact işleminde silinen nesnelerin yerlerine yaşayan nesnelerin kaydırılması yani sıkıştırma işlemi yapılır. 4. nesne diğer nesneler ile birleştirilir. Mark, sweep ve compact adımlarının bazı dezavantajları vardır. GC her döngüsünde heap’te yaşayan nesnelerin referansı var mı yok mu diye kontrol eder. Eğer ki heap’te binlerce nesneniz olduğunu düşünürsek bu durum programda donmaların meydana gelmesine sebep olabilir. Bu işlem ayrıca çok verimsizdir. Çünkü heap’teki long-lived (ileride değineceğim) nesneler işaretlenip tekrar işaretlenmesi kaldırılması da GC’ye ayrı bir maliyet oluşturur. Bu verimsizliğin önüne geçmek için Generational Garbage Collector’a geçilmiştir.

Generational GC’de heap gen:0, gen:1 ve gen:2 olmak üzere üç tane generation’a sahiptir. İlk alokasyonda nesneler için gen:0'da yer aloke edilir.

GC ilk döngüsünde mark, sweep ve compact işlemleri gerçekleşir. Bu işlemlerden sonra hayatta kalan nesneler gen:1'e taşınır. Şimdi de 2. nesneye yeni bir eleman atadığımızı düşünelim. Heap memory aşağıdaki duruma gelir.

Dizi gen:1'e taşınmış durumdadır ama yeni eleman için gen:0'da yer aloke edilir. Şimdi de 3. nesneye yeni bir eleman atayalım ve GC yeni bir cycle gerçekleştirdiğini düşünelim.

Yeni nesne yine gen:0'da oluşur. Diğer nesneler ise gen’ler arasında taşınmaya devam eder. Bu generation yapısı aslında gen:0'da bulunan nesneleri sınırlamaya yardımcı olur. Her GC döngüsünde gen:0 tüm nesnelerden temizlenir. Bir sonraki döngüde önceki döngüden sonra oluşan yeni nesneleri kontrol eder. Bu durumda da bazı sıkıntılar ortaya çıkar. GC her zaman yaşayan nesneleri bellekte başka bir alana taşır. GC herhangi bir nesneyi gen:2'ye kadar taşımış ise o nesneyi long-lived bir nesne olarak düşünür. Dolayısıyla GC gen:1 ve gen:2'yi gen:0 kadar sık kontrol edilmeye gerek olmadığını düşünür ve daha az döngü gerçekleştirir.

Bu yapı temelde iki problemi çözer;

  • gen:0'daki nesneler azalır ve GC daha az çalışır.
  • gen:2'de yaşayan long-lived nesneler sık sık kontrol edilmez.

Bu durumun da getirdiği bazı dezavantajlar vardır. :) long-lived nesneler GC’nin iki tane cycle’ından geçip yani iki tane mark, sweep, compact ve move işlemlerini gerçekleştirir. Bir nesne için toplamda dört tane memory kopyalama işlemi gerçekleşir. Bu kopyalama işlemleri nesnenin büyüklüğüne göre performansı çok etkileyebilir. Bu problemlere çözüm olarak small object heap (SOH) ve large object heap (LOH) adında iki ayrı heap yapısına geçilmiştir.

SOH yukarıdaki bahsettiğim üç generation’lı yapıda kalmaya devam eder. LOH ise direkt olarak SOH’teki gen:2 ile senkronize çalışan tek generation’lı yapıdır. Yani SOH’te bulunan gen:2'de GC döngüsü başladığında aynı zamanda LOH’te de aynı döngü başlatılır. LOH’te compact işlemi gerçekleşmez. Herhangi bir nesnenin SOH veya LOH’e gitmesi 85kb gibi bir değer ile belirlenir. Nesneler bu değere eşit veya büyükse direkt olarak LOH’e diğer durumda ise SOH’e gider. Heap’in bu şekilde ayrılması yukarıda konuştuğumuz long-lived nesne problemini çözer yani ekstra kopyalama işlemi yapılmaz ve performans artar.

GC’ye göre;

  • Nesneler short-lived veya long-lived olabilir.
  • Nesne aloke olduğunda ilk döngüde bellekten silinirse short-lived nesne olarak kabul edilir.
  • Nesne iki tane GC döngüsünden canlı çıkarsa bunlar long-lived nesne olarak kabul edilir.
  • Nesne 85kb ve üzerinde ise bunlar large long-lived nesnelerdir.

GC’nin çalışmasını tetikleyen durumlar ise; SOH veya LOH’in sınır değerlerini geçmesi, GC.Collect’in manuel çağırılması(mümkünse çağırılmaması) ve işletim sisteminin düşük memory sinyalidir.

Unmanaged memory’ler ise managed heap’in dışında kalan ve GC tarafından yönetilmeyen memory alokasyonlarıdır. Genellikle burası .Net CLR, dinamik kütüphaneler, yoğun grafik kullanan uygulamalar vb. için gereken memory alanıdır. Memory’nin bu bölgesi profiler’lar tarafından da analiz edilmez.

Workstation ve Server adında iki GC modu vardır. Workstation modu desktop uygulamaları için server modu ise server uygulamaları içindir. ASP.Net’te varsayılan olarak server mod tanımlıdır. Workstation modda GC eş zamanlı (concurrent) olarak çalışır. Uygulama ile birlikte ayrı bir thread üzerinden işlemlerini gerçekleştirir. Kullanıcıya daha iyi bir deneyim yaşatılır. Server modunda ise performans ve verimlilik ön plandadır. GC bu modda paralel ve multi-thread olarak çalışır. GC çalıştıkça uygulama thread’leri suspend moda geçer. Workstation moda göre daha verimlidir. Detaylı bilgi için linkini inceleyebilirsiniz.

Buraya kadar GC’nin çalışmasını inceledik.Bu kısımdan sonra GC’nin üzerine daha az yük bindirmek için bazı optimizasyonlara değinmek istiyorum. İlk önce GC için yapmamız gereken optimizasyonlardan bahsedeceğim.

  • gen:0'da oluşturduğumuz nesneleri sınırlayabiliriz. Daha az nesne oluşması demek GC’nin daha az çalışması demektir. Kodumuzun herhangi bir yerinde gereksiz nesnelerin olmadığından emin olmalıyız. Nesneleri aloke edip kullandıktan sonra mümkün olan en kısa zamanda onlardan kurtulmalıyız. Bu işlemleri geç yapmamız durumunda ise nesneler gen:1 ve gen:2'ye taşınması gerçekleşir bu da istemediğimiz bir durumdur.
  • Nesnelerin yaşam sürelerini optimize edebiliriz. GC, small nesnelerin büyük çoğunlugunu short-lived, large nesneleri de long-lived olarak kabul eder. Biz bunların tam tersini önlemeliyiz. Yani large short-lived ve small long-lived nesnelerden kaçınmalıyız. Amacımız kodumuzu GC’nin en verimli çalıştığı duruma göre geliştirmek. Large short-lived nesneleri long-lived’e çevirebiliriz. Bunu da object pooling pattern ile yapabiliriz. Yani yeni nesneler üretmek yerine önceden kullandığımız nesneleri tekrardan kullanabiliriz. Diğer bir durum small long-lived nesneleri small short-lived nesnelere çevirmeliyiz. Bu duruma örnek olarak 85kb ve üzeri bir List nesnemizin olduğunu düşünelim. Bu nesneye short-lived nesneleri eklediğimizde List nesnesi bu small nesnelerin referansını her zaman tutacağından bu small nesneler long-lived olacaktır. Bu durumda kodumuzu refactor etmemiz gerekir.
  • Nesnelerin boyutlarını optimize edebiliriz. Large short-lived nesneyi parçalayarak her birini 85kb altına indirebiliriz. Tersi olarak small nesneleri birleştirerek large nesne haline getirebiliriz. Başlangıç değeri vermediğimiz static nesneler tüm generationlardan geçer ve tüm memory copy işlemlerini gerçekleştirir. Ama sınırını 85kb’ı geçecek şekilde verirsek generationlar arasında taşınmasının önüne geçebiliriz ve direk LOH’e gitmesini sağlarız. Burada nesne boyutunu arttırmak performans açısından daha iyi bir seçenek olabilir.

Şimdi de memory alokasyonunu daha iyi yapmak için kodumuzda uygulamamız gereken best practice’lerden bahsedeceğim.

Koleksiyonlara başlangıç değeri vermeliyiz; .Net’teki list, dictionary, hashset gibi koleksiyonlar varsayılan olarak dinamik boyuttadır. Yani siz eleman ekledikçe koleksiyonların da boyutu otomatik artar. Bu kullanışlı gibi görünse de bellek yönetimi için iyi değildir. Bu koleksiyonlar sınıra ulaştığında boyutu iki katı olacak şekilde yeni bir koleksiyon bellekte aloke olur ve eski veri bu yeni koleksiyona kopyalanır.

Boxing ve Unboxing’ten kaçınmalıyız; Nesnelerin heap’ten stack’e, stack’ten heap’e taşındığı durumlardır. Her boxing işleminde heap’te yeni bir nesne oluşur ve stack’te tutulan value type’a göre daha fazla bellek tüketir. Yeni oluşan nesneler GC’ye ekstra yük oluşturur.

StringBuilder kullanmalıyız; String değişmez (immutable) olduğundan aynı string üzerinde işlem yapılmaz. Yeni bir string oluşur eski string ise referanssız bir şekilde heap’te tutulur. Uzun döngülerde string ekleme işlemi yaptığımızı varsayarsak döngü sayısı kadar string heap’te oluşacaktır. Bunun yerine StringBuilder sınıfını kullanabiliriz. Bu sınıf stringi kopyalamaz aynı string üzerinde işlem yapar. String birleştirmeye göre çok çok performanslı çalışır.

Bazı durumlarda sınıflar yerine struct’ları kullanabiliriz; Struct değer tipi, class ise referans tiptir. Struct’lar değer tipi olduğundan GC tarafından toplanmaz. Eğer tuttuğumuz veri tek bir değeri temsil ediyorsa struct kullanabiliriz. Veri boyutumuz küçükse ve bu veriden binlerce instance üretmemiz gerekirse struct kullanmalıyız. Diğer senaryolarda class daha avantajlıdır.

Large Short-lived diziler için ArrayPool class’ını kullanabiliriz; Dizi’lerin alokasyonu ve alokasyonun kaldırılması GC için çok yük oluşturabilir. Böyle durumlar için .NET’te System.Buffers.ArrayPool sınıfını kullanabiliriz. Bu sınıf threadpool’a benzer şekilde çalışır. Bir bellek ayırmadan yeniden kullanabileceğiniz diziler için paylaşılan bir buffer alanı ayırır. Bu sınıf large short-lived dizileri için kullanılmalıdır. System.Buffers namespace’i ise performansı kritik olan uygulamalar için ve sık sık nesne alokasyonu için kaynak sağlar. Böyle bir uygulamada geliştirme yapacaksanız bu namespace’i incelemenizi öneririm.

Finalizer’dan uzak durmalıyız; Yazıda finalizer’dan bahsetmedim. Kısaca finalizer (destructor) nesnelerin GC tarafından temizlenmeden hemen önce otomatik olarak çağırılan methodtur. Finalizer’a sahip ve gen:0'da boşta kalan nesneler GC’nin ilk döngüsünde finalization queue adında bir kuyruğa taşınır. GC döngüde bu nesneleri silmez ve gen1'e taşıma işlemini de gerçekleştirir. GC’nin çalışması bittiğinde ayrı bir finalization thread’i çalışmaya başlar ve bu kuyruktaki nesneleri temizler. Finalizer’ın ne zaman ve hangi sırada çalışacağı da belli değildir. Nesnelerimizi finalizer içinde kullanmamalıyız çünkü finalize olmuş olabilirler ve hata alırız. Finalizer içinde alınan hata da uygulamanın sonlanmasına yol açar. Finalizer bu gibi nedenlerden dolayı GC’nin çalışma mantığını da bozabilir.

Dispose pattern’i uygulamalıyız; Geliştirici olarak herhangi bir nesne yarattığımızda onun yaşam döngüsüyle ilgilenmeyiz. GC bizim için takip eder ve temizler. Bazı durumlarda nesnemizin hemen temizlenmesini isteyebiliriz. Çünkü GC’nin ne zaman çalışacağı belli değildir. Sistemin memory’si yeterli ise GC çalışma sayısı azalabilir. Kaynakların günlerce bellekten silinmemesi kötü bir practice olur. Dispose pattern ile ilgili detaylı bilgiye linkinden ulaşabilirsiniz.

GC.Collect’i manuel çağırmamalıyız; Bu method tüm generation’larda tam toplama işlemini yapar. Bu işlem pahalı bir işlemdir çünkü her canlı nesne tek tek kontrol edilir. Bırakın GC ne zaman çalışması gerektiğine kendi karar versin.

Sonuç

Bu yazımda sizlere GC’nin çalışma mantığı ve yapmamız gerekenlerden bahsetmeye çalıştım. Tabii ki bunların dışında yapmamız gereken daha çok işlem var. Memory leak konusuna çok dikkat etmeliyiz. ANTS Memory Profiler, dotMemory vb. profiler araçları yardımıyla bellek ölçümlerimizi yapıp önlemler alabiliriz. Bellek yönetimi konusunun daha detayına inmek ve CLR’ın çalışma şeklini daha iyi öğrenmek istiyorsanız Pro .NET Memory Management ve CLR via C# kitaplarını okumanızı öneririm. Ayrıca linkinden yayınlanan ücretsiz pdf’i okuyabilirsiniz. Umarım faydalı olabilmişimdir. Yazı ile ilgili yorumlarınızı almaktan her zaman memnun olurum. Herhangi bir durumda Linkedin ve Twitter üzerinden bana ulaşabilirsiniz.

--

--