.NET’te Hafıza, Değişkenler Nerede Saklanıyor?

Bu yazının ilk halini 2019’da yazmıştım. Jon Skeet’in “Memory in .NET, what goes where” başlıklı yazısını çok beğenmiş ve dilim döndüğünce Türkçeye kazandırmak istemiştim.

Şimdi eski yazılarımı kendi sitemde toplarken bu yazıyı tekrar açtım ve çevirirken birkaç yeri atladığımı fark ettim. Bu sürümde eksiklerimi tamamladım ve düne göre daha sağlıklı bir kaynak haline getirdim.

Kaynak yazı Jon Skeet’e ait ve hâlâ jonskeet.uk adresinde duruyor. Buradaki metin onun bir uyarlaması, kendi eklediğim yerleri ayrıca belirttim.

Yazının Amacı

Skeet yazısına şu tespitle başlıyor; değer tipleriyle referans tiplerinin farkını anlatırken kullanılan “değer tipleri stack’te, referans tipleri heap’te tutulur” cümlesi çok fazla kafa karışıklığına yol açıyor ve bu cümleyi olduğu gibi dümdüz alırsak aslında yanlış yanlarını da görmek mümkün oluyor. Aslında Skeet de yazısını bu tespiti üzerine kuruyor.

Yavaş yavaş derine inecek olursak, bir şeyin nerede saklanacağını belirleyen şey, tipinin değer tipi mi referans tipi mi olduğu değil; bağlamı, yani nerede tanımlandığı…

Değişken nedir

Şunu gönül rahatlığıyla dile getirebiliriz; .NET’te hafızanın nasıl çalıştığını anlamanın yolu, bir değişkenin ne olduğunu anlamaktan geçer.

Çok basit bir tanım yapacak olursak; değişken, programın kodunda kullandığımız bir isim ile hafızadaki bir slot arasındaki ilişkilendirmedir. Bir değişkenin değeri, ilişkilendirildiği o slotta tutulan şeydir. Slotun boyutu ve içindeki değerin nasıl yorumlanacağı ise değişkenin tipine bağlıdır. Değer tipi ile referans tipi arasındaki fark da tam buradan doğar. Biliyorum, ard arda bu cümleleri okuyunca biraz ezbermiş gibi geliyor, kendinize haksızlık etmeyin, zamanla asansör konuşması kadar rahat olacak bu tanımlar zincirini saymak.

Referans tipli bir değişkenin değeri

Çok basit bir temel kural yazacağım şimdi; referans tipli bir değişkenin değeri her zaman ya null’dır ya da bir referanstır. Referans ise, değişkenin tipiyle uyumlu bir nesneyi işaret etmek zorundadır.

Örneğin Stream s şeklinde tanımlanmış bir değişkenin değeri ya null olur ya da Stream sınıfından bir örneğin referansı olur. Burada şunu da unutmamak gerek; Stream sınıfından türemiş FileStream gibi bir sınıfın örneği de aynı zamanda bir Stream örneğidir.

Bir referans tipli değişkenle ilişkilendirilmiş hafıza slotunun boyutu, işaret ettiği nesne ne kadar büyük olursa olsun, yalnızca bir referans kadardır. Skeet’in yazdığı dönemde bu, 32 bit .NET’te her zaman 4 byte demekti.

Bugünse işler biraz farklı; .NET bugün ağırlıklı olarak 64 bit çalışıyor ve orada bir referans 8 byte yer kaplıyor. Gene bir ezber cümlesi kuracağım ama buna mecburum, günün sonunda sayının kendisinin ne olduğu önemli değil. Önemli olan şu; bir referans tipli değişkenin slotu, işaret ettiği nesnenin büyüklüğünden bağımsız ele alınır ve hep sabit boyuttadır.

Değer tipli bir değişkenin değeri

Değer tipli bir değişkenin değeri, tipin örneğinin kendi verisidir, yani kendisidir. Aşağıda biraz daha açıklamaya çalışayım.

struct PairOfInts
{
    public int a;
    public int b;
}

Yukardaki struct’ı kullanarak, “PairOfInts pair” şeklinde bir değişken tanımladığımızda hafızada neler olur, ona bir bakalım dostlar. Bu kod parçası ile bir hafıza slotu açmış oluruz ve bu slotta tutulan şey içinde iki adet integer değer bulunduran bu struct’un kendisi olur. Yani demem o ki, tutulan şey objenin kendisidir, referansı değil. 2 integer barındırdığı için de 8 byte eder.

Burada dikkat edilmesi gereken bir şey daha var; değer tipli bir değişken asla null olamaz. Null olması anlamsız olur, çünkü null referans tiplerine ait bir kavram ve “bu değişkenin değeri hiçbir nesneyi işaret eden bir referans değil” demek. Değer tipli bir değişken, kendine ayrılan slotta tutulacak ve kesinlikle bir değeri işaret edecektir.

Stack’te mi Heap’te mi?

Bir değişken ya stack’te ya heap’te tutulur. Bunu belirleyen şeyse tanımlandığı bağlamdır. Bu kitap bilgisi cümleyi biraz daha açıklamaya çalışacağım.

Yerel değişkenler stack’te saklanır. Yani bir fonksiyonun içinde tanımlanan bir değişken, stack’te saklanır. Ve fasulyenin buradaki faydası olarak, referans tipli değişkenler de bu kurala uyarlar. Fakat şunu unutmamakta fayda var; stack’te duran şey sadece değişkenin kendisidir. Ne demek istiyorum, bir örnekle açıklayayım. Stream s yazdığımızda stack’te duran şey s değişkeni ve içindeki referanstır, o referansın işaret ettiği Stream nesnesi ise heap’tedir.. Fonksiyon parametreleri de yerel değişken sayılır bu arada. Ancak parametrenin önünde ref belirteci varsa o parametrenin kendi slotu olmaz, çağıran koddaki değişkenin slotunu paylaşır.

Instance variables for a reference type are always on the heap.

Referans tipinin örnek değişkenleri her zaman heap’tedir. Bu cümleyi Türkçe’ye nasıl daha iyi çevirebilirim, bilemedim dostlar. Ancak nesnenin kendisi tam da orada yaşar.

Instance variables for a value type are stored in the same context as the variable that declares the value type.

Bir struct’ın içindeki alanlar, struct’ın kendisi nerede duruyorsa orada durur. Bak bu çeviride de içim rahat değil, samimi olacağım, cümleyi bire bir çevirmek yerine, varmak istediği noktadan bahsedip bırakıyorum. Ve bu cümlenin yazının en önemli kuralı olduğuna değinmek istiyorum. Bir fonksiyon içinde tanımlanan struct stack’te olur, bir sınıfın instance’ı olan struct ise heap’te olur. Burada dikkat etmemiz gereken şey de tam olarak şu; struct’ın kendisi değişmiyor, sadece nerede tanımlandığı değişiyor.

Şimdi çok keskin bir kuralla devam edelim; Bütün static değişkenler heap’tedir. Değer tipi olarak mı referans tipi olarak mı tanımlandıklarının hiçbir önemi yok. Sonuç olarak şöyle bir kanıya varsak yanılmayız; static bir değişkenden hafızada her zaman bir tane bulunur, kaç tane nesne oluşturulduğunun hiç bir önemi olmaz burada.

İstisnalar…

Eğri oturup doğru konuşma vakti; yukarıdaki kuralların iki tane istisnası var. Bu iki istisna da yerel değişkenlerle ilgili olan kurallarımızı ihlâl ediyor.

Birinci istisna, anonim metotlarda ve lambda ifadelerinde yakalanan değişkenlerle ilgili. Bunlar C# kodu açısından yerel değişkenlerdir, ama derlendiklerinde anonim metodun oluşturduğu delegate ile ilişkili bir tipin örnek değişkenlerine dönüşürler, yani heap’e giderler. Bu çok önemli, bu konuda lütfen daha fazla okuma yapın.

İkinci istisna ise, iterator bloğu içindeki yerel değişkenler. Onlar için de aynı durum geçerli, onlar da doğru heap’e…

Bu istisnalar olmasaydı “bütün yerel değişkenler stack’te saklanır” cümlesi koşulsuz kabul edilebilirdi. Ama istisnalar burada kaideyi dert ile keder eyledi.

Bir örnekle derine inmemiz gerektiğini hissediyorum

Yukarıdaki örnekler konusunda kendinize sert davranmayın lütfen, biraz karışık gelmiş olabilir, bu beklenen bir şey. Skeet’in örneği üzerinden gidersek biraz daha aydınlık bir yola çıkacağız, buna inancım tam. Bu arada örnek olsun diye yazılan programda hiç anlam aramayın, o sadece bir örnek ve sadece bizim konuyu anlamamız için hizmet ediyor, şimdiden anlaşalım bu konuda.

using System;

struct PairOfInts
{
    static int counter = 0;

    public int a;
    public int b;

    internal PairOfInts (int x, int y)
    {
        a = x;
        b = y;
        counter++;
    }
}

class Test
{
    PairOfInts pair;
    string name;

    Test (PairOfInts p, string s, int x)
    {
        pair = p;
        name = s;
        pair.a += x;
    }

    static void Main()
    {
        PairOfInts z = new PairOfInts (1, 2);
        Test t1 = new Test(z, "first", 1);
        Test t2 = new Test(z, "second", 2);
        Test t3 = null;
        Test t4 = t1;
        // XXX
    }
}

Şimdi program XXX yorumunun olduğu satıra geldiğinde hafızada neler var, sırayla bakalım. Garbage collector da çalışmamış, böyle varsayalım lütfen.

İlk olarak görebileceğimiz üzere, z için stack’te bir PairOfInts instance’ı var. İçinde a=1 ve b=2 duruyor. z’nin ihtiyaç duyduğu 8 byte’lık slot ise hafızada 01 00 00 00 02 00 00 00 şeklinde temsil ediliyor diye varsayalım, bu olası bir durum çünkü.

İkinci olarak t1 için stack’te bir Test referansı dikkatimizi çekmeli. Bu referans heap’teki bir instance’ı işaret ediyor ve o instance aşağı yukarı 20 byte yer kaplıyor. Bu 20 byte’a nasıl ulaştığımızı da dilim döndüğünce yazıyorum. 8 byte’ı bütün heap nesnelerinde bulunan başlık bilgisi, 8 byte’ı PairOfInts instance’ı, 4 byte’ı da string referansı…

Bir önceki paragrafta aşağı yukarı kelimesi çok dikkat çekti, farkındayım. Bu sayı eğer 64 bit varsayımına göre hesaplansa, yaklaşık 32 byte çıkacaktı. Dahası da var dostlar, eğer .Net Core, Mono ya da .Net Framework dünyalarından birini işaret edersek, bu sayı gene değişebilir. O yüzden aşağı yukarı kelimesi, Skeet’in de hesabı 32 bit varsayım ile yapmasına dayanıyor. 20 sayısının nasıl hesaplandığını ezberlemeyin, sadece bir örnek bu, açıklamamız için bir araç…

Konuya dönecek olursak bu instance’ın içindeki pair değişkeninde a=2 ve b=2 var. Neden a=1 değil de a=2 sorusunun cevabını ise bize constructor fonksiyon açıklıyor; pair.a += x satırı çalıştığı için ve t1 için x=1 olduğundan a, 1’den 2’ye çıkıyor. Hafızada muhtemel temsilinin 02 00 00 00 02 00 00 00 şeklinde olduğunu da varsayabiliriz. name değişkeni ise heap’teki bir string nesnesinin referansı ve muhtemelen başka nesneler üzerinden “first” kelimesinin karakter dizisini işaret ediyor.

t2 için stack’te ikinci bir Test referansı var ve heap’te ikinci bir instance’ı işaret ediyor. Bir öncekinden tek farkı, “first” yerine “second” tutması ve pair içindeki a değerinin 3 olması, çünkü bu sefer başlangıç değeri olan 1’e 2 eklendi. Kodu yazarak ifade etmek cidden zor ha bu arada, robotik hissediyorum yazarken, okuyan gözleriniz dert görmesin, devam edelim…

Bu paragrafta durup şunu düşünmek bizi güzel bir yere ulaştıracak kanımca; PairOfInts değer tipi yerine referans tipi olsaydı, program boyunca ondan tek bir instance olurdu ve bu tek örneği işaret eden birden çok referans bulunurdu. Değer tipi olduğu için her biri kendi değerlerini taşıyan birden çok instance var.

t3 için stack’te üçüncü bir Test referansı var ve bu referans null, yani hiçbir Test örneğini işaret etmiyor. Null’ın bir Test referansı sayılıp sayılmayacağı konusunda bir bulanıklık var ama, bu bizi şimdilik çok da âlâkadar etmiyor. Skeet null’ı, referansın hiç var olmaması olarak değil, hiçbir nesneyi işaret etmeyen bir referans olarak düşünmeyi tercih ettiğini söylüyor. Aslında bu güzel bir tanım dostlar. Bu arada Java Dili de güzel bir tanım yapıyor: bir referans ya null’dır ya da uygun tipte bir nesnenin işaretidir. Basit, çiçek gibi…

t4 için stack’te dördüncü bir Test referansı var ve t1 ile aynı örneği işaret ediyor. Yani t1 ile t4’ün değerleri aynı. Bu iki değişkenden birinin değerini değiştirmek diğerini etkilemez, mesela t4’e null atadığınızda t1 null olmaz. Ama referansların işaret ettiği nesnenin içindeki bir değeri birinden değiştirirseniz, bu değişim diğerini de etkiler. Demem o ki, t1.name = "third"; dediğimizde t4.name de “third” olur.

Son olarak, PairOfInts.counter static olduğu için heap’te, onun kaçışı yok dostlar. Kaç tane PairOfInts değeri olursa olsun bu değişken için hafızada tek bir slot var, bu da kaçınılmaz bir gerçek.

İlk sürüme gelen tek yorum

İlk sürüme bir yorum gelmiş ve ben onu cevapsız bırakmışım. Bu konuda üzgünüm, yorum bırakan Malik Masis isimli arkadaşım, lütfen kusura bakmasın.

Yorumu bırakan arkadaşımız; okuduğu ve dinlediği birçok kaynakta primitive değerlerin stack’te, referans tabanlı olanların heap’te tutulduğu söyleniyormuş, bu yazıyı okuyunca tekrar araştırma ihtiyacı doğmuş.

Yedi yıl sonra da olsa cevaplamak istiyorum, çünkü sorduğu şey yazının tam da kalbinde.

O cümle tamamen yanlış değil, ama eksik ve eksik olduğu için yanıltıyor, yanıltınca da kafayı bir güzel karıştıyor. Bir metot içinde tanımlanmış bir int gerçekten stack’te olur. Ama bir sınıfın alanı olan int heap’te olur, çünkü nesnenin kendisi orada. Static bir int de heap’te olur. Lambda içinde yakalanmış bir int de heap’e taşınır.

Yani belirleyici olan tipin değer tipi olması değil, nerede tanımlandığı. Doğru cümle şu: değişkenler tanımlandıkları bağlamda saklanır.

Bugüne dair bir not…

Yazının aslı yıllar önce yazıldı ve o zamandan bu yana bu konuya dair yerleşen bir düşünce var; stack ve heap ayrımının kendisi bir uygulama detayı…

Aslına bakarsanız C# dili bir değişkenin fiziksel olarak nerede saklanacağını garanti etmiyor. Yukarıda anlattığımız her şey, bir değişkenin fiziksel olarak nerede saklanacağı davranışını tarih etmeye çalışıyor. Bu davranışı bilmek gayet faydalı, çünkü performans problemi ararken ya da bir davranışı açıklarken işe yarıyor, bizleri daha bilinçli birer yazılım geliştirici yapıyor. Ancak bu desenlere bir çınar ağacına yaslanırcasına yaslanmak, pek de doğru olmayabilir. Değer tipliler stack’te, referans tipliler de heap’te diye ezberlemek yerine, davranışı anlamak ve uygulamanın onu doğru yorumlamasını sağlamak, bizlerin asıl amacı olmalı.

Okumaya değer bulduğunuz için teşekkür ederim.

Referans

Jon Skeet, Memory in .NET, what goes where.

Leave a Reply

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir