dotnet csharp performance

.NET'te Bellek Yönetimi 1: Stack, Heap ve String'ler

Selamlar. Bu hafta bir görüşme üzerine bellek yönetimi konusuna odaklanmam gerektiğine karar verdim. Konuya dair yüzeysel bilgim vardı, derinleştirmek istedim. Stack nedir, heap nedir, string neden heap'te yaşar, CLR bir obje yarattığında arka planda neler döner, Span<T> bellek ayırmadan nasıl çalışır, GC ne zaman devreye girer, IDisposable kime yarar - hepsini sırayla anlatacağım.

Ama önce temelden başlayalım. "Stack ve heap" dediğimiz şey aslında ne?

Veriye Yer Bulmak

Siz bir değişken tanımladığınızda:

int x = 5;
string name = "Erdinç";
User user = new User { Name = "Erdinç", Age = 30 };

Bu x, name ve user nerede duruyor? Bilgisayarınızın RAM'inde - tamam. Peki RAM'in neresinde? Bir köşesinde mi, ortasında mı? Cevap: tipine göre.

.NET runtime'ı bellek yönetimi için iki ana bölge kullanır:

BölgeAçıklama
StackMetod çağrılarının, yerel değişkenlerin ve parametrelerin yaşadığı LIFO (Last-In-First-Out) yapı. Küçük, hızlı, otomatik temizlenir.
HeapDinamik olarak ayrılan, uzun ömürlü nesnelerin yaşadığı bölge. Büyük, GC tarafından yönetilir.

İkisi arasındaki farkı anlamak, yazdığınız kodun performansını kavramanın ilk adımıdır.

Stack: LIFO Yapısı

Stack'in çalışma prensibini anlamak için en iyi örnek üst üste dizilmiş tabaklardır. Bir tabak eklemek istediğinizde en üste koyarsınız. Bir tabak almak istediğinizde en üsttekini alırsınız. Ortadan tabak çekemezsiniz. En son koyduğunuz tabak, ilk alınan tabaktır. İşte bu LIFO'dur.

[Tabak 3]  ← En son kondu, ilk alınır (Last-In)
[Tabak 2]
[Tabak 1]  ← İlk kondu, en son alınır (First-Out)

Stack'te olan da tam olarak budur. Metodlar üst üste binerek çalışır ve biten metodun değişkenleri anında temizlenir:

/*
 * Calculate metodu bittiğinde içerideki tüm değişkenler (a, b, result)
 * bellekten tamamen kaldırılır. Peki bu "kaldırma" işlemi tam olarak nedir?
 *
 * CPU seviyesinde stack, RSP (Stack Pointer) register'ı ile yönetilir.
 * Metoda girerken RSP aşağı çekilir (sub rsp, N), böylece N byte'lık
 * alan "tahsis edilmiş" olur. Metod dönerken RSP yukarı itilir
 * (add rsp, N), bu alanı "serbest bırakır."
 *
 * Buna "Stack Pointer Adjustment" veya kısaca "stack unwinding" denir.
 *
 * Önemli nokta: serbest bırakılan bölgedeki veriler fiziksel olarak
 * SİLİNMEZ. Sadece artık o bölge "kullanımda değil" olarak işaretlenir.
 * İşletim sistemi bir sonraki metod çağrısında aynı bellek adreslerinin
 * üzerine yeni değerler yazabilir. Bu yüzden C#'ta initialize edilmemiş
 * bir yerel değişkeni okumaya çalışırsan derleyici hata verir,
 * okuduğun değer bir önceki metodun çöp verisi olabilir.
 */
void Calculate()
{
    int a = 10;       // Stack'e a = 10 yazıldı
    int b = 20;       // Stack'e b = 20 yazıldı
    int result = Add(a, b);  // Add metodu çağrıldı
}   // Calculate dönünce RSP yukarı çekilir, frame serbest kalır

int Add(int x, int y)
{
    int sum = x + y;  // Stack'te x, y, sum için yeni frame açıldı
    return sum;       // Add dönünce kendi frame'i serbest kalır
}

Metod çağrıldıkça stack büyür, metod döndükçe stack küçülür. Her metod kendi stack frame'ini oluşturur. Frame, o metodun parametrelerini, yerel değişkenlerini ve dönüş adresini içerir. Görsel olarak:

flowchart TB
    subgraph T1["1. Calculate çağrıldı"]
        direction LR
        A1["[Boş]"]
    end
    subgraph T2["2. int a = 10"]
        direction LR
        A2["a = 10"]
    end
    subgraph T3["3. int b = 20"]
        direction LR
        A3["b = 20"]
        A3b["a = 10"]
    end
    subgraph T4["4. Add(10, 20) çağrıldı"]
        direction LR
        A4["sum = 30"]
        A4b["y = 20"]
        A4c["x = 10"]
        A4d["--- frame sınırı ---"]
        A4e["b = 20"]
        A4f["a = 10"]
    end
    subgraph T5["5. Add döndü (frame silindi)"]
        direction LR
        A5["result = 30"]
        A5b["b = 20"]
        A5c["a = 10"]
    end
    subgraph T6["6. Calculate döndü (hepsi silindi)"]
        direction LR
        A6["[Boş]"]
    end

    T1 --> T2 --> T3 --> T4 --> T5 --> T6

Dikkat edin: Add metodu döndüğünde x, y, sum - frame ile birlikte hemen silindi. GC'nin gelmesini beklemedik. Buna deterministik temizleme denir.

Çöp Değer ve Bellek Güvenliği

Yukarıda dedik ki: serbest bırakılan stack bölgesindeki veriler fiziksel olarak silinmez. Peki bu "silinmemiş" veriye erişmeye kalksak ne olur?

C++ Tarafı: Adres Ver, İstediğin Yere Git

C++ bu konuda hiçbir engel koymaz. Stack'te bir değişken tanımlar, initialize etmez ve okumaya kalkarsanız:

void Foo()
{
    int previousData = 42;    // stack'te bir değer bıraktı
}

void Bar()
{
    int x;                    // initialize edilmedi!
    std::cout << x;           // 42 veya başka rastgele bir sayı basar
}

Bar çağrıldığında stack frame'i Foo'nun bıraktığı bölgeye denk gelebilir. x'in değeri 42 olur - ama bu tamamen tesadüftür. Başka bir çalışma sırasında bambaşka bir sayı alırsınız. Buna undefined behavior denir. Derleyici hata vermez, çalışma zamanında patlamazsınız; yanlış sonuç alırsınız, haberiniz bile olmaz.

Daha da kötüsü, C++ pointer aritmetiği ile stack'in tamamen dışına çıkıp aynı process içindeki bambaşka bir veriye erişebilirsiniz:

int arr[3] = {1, 2, 3};
int* p = arr;
p += 100;                // dizinin 100 eleman ötesi - tanımsız bölge
std::cout << *p;         // orada ne varsa onu okur

Modern işletim sistemlerinde sanal bellek (virtual memory) sayesinde başka bir programın belleğine erişemezsiniz - MMU buna izin vermez, segfault yersiniz. Ama kendi process'iniz içinde istediğiniz adrese gidip okuyabilirsiniz.

C# Tarafı: Derleyici Engel Koyar

C# bu konuda C++'ın tam tersidir. C# bellek hareketleri güvenlidir. Aynı senaryoyu C#'ta deneyelim:

void Bar()
{
    int x;
    Console.WriteLine(x);  // DERLEME HATASI: Use of unassigned local variable 'x'
}

C# derleyicisi definite assignment analizi yapar. Yerel bir değişkenin okunmadan önce mutlaka bir değer atanmış olmasını zorunlu tutar. Atanmamışsa derleme aşamasında hata alırsınız - çalışma zamanına bile gelemez.

Bunun sebebi tam olarak sizin dediğiniz gibidir: initialize edilmemiş bir değişkeni okumak, bellekte o anda ne varsa onu almanız demektir. Orada rastgele bir değer vardır ve bu değer programınızın mantığını bozabilir.

Bir hastane yazılımı düşünün:

// C#'ta bu kod derlenmez - ama derlendiğini varsayalım
int insulinDose;                  // atanmadı!
AdministerInsulin(patient, insulinDose);  // rastgele bir doz uygulanır

Eğer insulinDose bellekte kalan bir önceki değeri (diyelim ki 999) okursa, hasta ölebilir. C# tam olarak bu senaryoyu önlemek için "kullanmadan önce ata" kuralını koyar. C++ ise böyle bir koruma sunmaz - hata sessizce çalışmaya devam eder.

C#'ın unsafe bağlamında pointer kullanabilirsiniz, ama bu kasıtlı bir tercihtir ve /unsafe derleme bayrağı gerektirir. Normal C# kodunda yanlışlıkla initialize edilmemiş bir değeri okuyamazsınız.

Özet: Neden C# Engel Koyar?

DurumC++C#
Initialize edilmemiş yerel değişken okumaDerlenir, rastgele değer döner (undefined behavior)Derleme hatası (CS0165)
Pointer ile stack dışına çıkmaDerlenir, segfault veya rastgele veriunsafe olmadan yapamazsınız
Dizi sınırlarını aşmaDerlenir, undefined behaviorIndexOutOfRangeException fırlatır
Null referans kullanmaDerlenir, segfaultNullReferenceException fırlatır

C# runtime'ı sizi korumak için ekstra kontroller yapar. Bu kontrollerin bir performans maliyeti vardır (array bounds check gibi), ama güvenlik ve hata ayıklama kolaylığı açısından buna değer.

Stack'in özellikleri:

  • Boyut: Thread başına 1-8 MB. stackalloc yapmadığınız sürece dolmaz.
  • Hız: Allocation ve deallocation tek CPU komutu (stack pointer hareketi). Cache-friendly.
  • Ömür: Metod scope'u ile sınırlı. Metod bitince değişken ölür.
  • Ne yaşar: int, bool, double, struct, enum gibi value type'lar. Ayrıca reference type'ların referansları (adresleri).

Stack'in en kritik kuralı: boyutu derleme zamanında bilinmeyen hiçbir şey stack'te yaşayamaz. Bir metod çağrısında stack frame'in boyutu önceden hesaplanmak zorundadır.

Bu yüzden:

byte[] buffer = new byte[4096];  // buffer referansı (8 byte) stack'te, 4096 byte heap'te

buffer değişkeni (8 byte'lık bir adres) stack'tedir. Ama işaret ettiği 4096 byte'lık alan heap'tedir. Çünkü o 4096 byte'ın ömrü metod bitince sona ermeyebilir - başkası hala kullanıyor olabilir.

Array'in Heap'teki Gerçek Yapısı

Peki byte[4096] heap'te tam olarak nasıl duruyor? Ve CLR, buffer[150] dediğinizde 150. byte'ı nasıl buluyor? Sınırı nereden biliyor? Hadi parçalayalım.

Heap'te bir dizi objesinin yapısı:

flowchart LR
    subgraph Stack["Stack"]
        U["buffer = 0x00E100 (heap'teki byte[] objesinin başlangıç adresi)"]
    end
    subgraph Heap["Heap"]
        direction TB
        subgraph Arr["byte[] objesi (adres: 0x00E100)"]
            direction LR
            A1["MethodTable* (0x00A000) (8 byte, Byte[] MethodTable'ının adresi)"]
            A2["Length = 4096 (4 byte)"]
            A3["padding (4 byte)"]
            A4["[0] = 0 (1 byte)"]
            A5["[1] = 0 (1 byte)"]
            A6["... (4094 byte)"]
            A7["[4095] = 0 (1 byte)"]
        end
        subgraph MT["Byte[] MethodTable (0x00A000)"]
            direction LR
            M1["BaseSize: 16"]
            M2["ComponentSize: 1"]
            M3["ElementType: System.Byte"]
            M4["Rank: 1"]
            M5["IsArray: true"]
        end
    end
    U -.->|"heap'teki adresi tutar"| Arr
    A1 -.->|"MethodTable'ın adresini tutar"| MT

MethodTable* (8 byte): Bu, Byte[] tipine ait MethodTable'ın heap'teki adresidir. Yani 0x00A000 gibi bir bellek adresidir. CLR bu adresi okuyarak objenin hangi tipe ait olduğunu, elemanlarının boyutunu, sınırlarını ve davranışlarını (method'larını) öğrenir. Her tip için bir tane MethodTable vardır ve o tipteki bütün objeler aynı MethodTable'ı paylaşır.

Dikkat edin: stack'teki buffer değişkeni 0x00E100 değerini tutar. Bu değer, heap'teki byte[] objesinin başlangıç adresidir. Başka bir deyişle buffer, heap'teki 4096 byte'lık alanın "adres kartıdır." Bu referansı elinize aldığınızda CLR şunları yapabilir:

1. Tipi öğrenmek: buffer.GetType() → önce buffer'ın tuttuğu 0x00E100 adresine gider, oradaki 8 byte'lık MethodTable* değerini (0x00A000) okur. 0x00A000, Byte[] MethodTable'ının adresidir. O MethodTable'dan IsArray = true, ElementType = System.Byte, Rank = 1 bilgilerine ulaşır.

2. Uzunluğu öğrenmek: buffer.Length → 0x00E100 + 8. byte'taki 4 byte'lık Length değerini okur. 4096.

3. Elemana erişmek: buffer[150] dediğinizde CLR üç kontrollü adım atar:

1. Sınır kontrolü:   if (150 < 0 || 150 >= 4096) → throw IndexOutOfRangeException
2. Adres hesabı:      hedef = 0x00E100 + 16 (header) + (150 × 1) (componentSize)
                      = 0x00E100 + 16 + 150
                      = 0x00E196
3. Okuma/Yazma:       *(byte*)0x00E196

Formülün parçaları:

ParçaDeğerKaynak
Base adres0x00E100Stack'teki buffer referansı
Header boyutu16 byte (8 MT + 4 Length + 4 padding)Array MethodTable'daki BaseSize
ComponentSize1 (byte için)Array MethodTable'daki ComponentSize alanı
Length4096Objenin 8. byte offset'inde
index150Sizin yazdığınız kod

MethodTable'da diziler için iki özel alan daha vardır:

  • ComponentSize: Her bir elemanın byte cinsinden boyutu. byte[] için 1, int[] için 4, bir struct dizisi için struct'ın boyutu.
  • BaseSize: Dizinin header kısmının boyutu (MT + Length + padding). Gerçek obje boyutu = BaseSize + (Length × ComponentSize).

CLR, buffer[150] için gereken her şeyi MethodTable + Length ikilisinden çıkarır. Ayrı bir "start/length" struct'ına ihtiyaç duymaz, çünkü dizi her zaman 0. indexten başlar. start her zaman 0'dır.

Span<T>'de Start/Length Durumu

"Start ve length değerlerine sahip struct tipi" dediğiniz şey aslında Span<T>'dir. Span, bir dizinin üzerinde çalışan bir view (görünüm) struct'ıdır:

byte[] fullBuffer = new byte[4096];
Span<byte> slice = fullBuffer.AsSpan(100, 50);  // 100. byte'tan başla, 50 byte al

Span'in kendisi stack'te yaşayan bir struct'tır (value type) ve içinde şunlar vardır:

flowchart LR
    subgraph Stack["Stack"]
        direction TB
        subgraph Span["Span<byte> slice (stack)"]
            S1["_reference (8 byte, 100. elemanın heap adresini tutar)"]
            S2["_length = 50 (4 byte)"]
        end
    end
    subgraph Heap["Heap"]
        direction TB
        subgraph Arr["byte[] fullBuffer (0x00E100)"]
            direction LR
            MT["MethodTable* (8)"]
            LEN["Length = 4096 (4)"]
            PAD["padding (4)"]
            EL0["[0]...[99] (100 byte, header + 0..99)"]
            EL100["[100] = slice başlangıcı"]
            EL149["[149] = slice sonu"]
            EL150["[150]...[4095]"]
        end
    end
    S1 -.->|"header'ı atlayıp doğrudan 100. elemanı gösterir"| EL100

Span'in _reference alanı, dizinin başlangıcını değil, dilimin başlangıcını işaret eder (offset eklenmiş hali). Span üzerinde indeksleme yaparken CLR:

1. Dizi sınırı:       if (index < 0 || index >= 50) → hata (Span'in _length'i)
2. Gerçek adres:      hedef = _reference + (index × 1)

Gördüğünüz gibi Span, "start ve length" bilgisini taşır. Ama byte[]'in kendisi taşımaz, dizi her zaman 0'dan başlar, length ise objenin içinde gömülüdür. Start/length ikilisi bir view ihtiyacı olduğunda devreye girer.

Eleman Tipi Nereden Biliniyor?

buffer[150] okurken CLR, 150. byte'ın bir byte olduğunu ComponentSize değerinden anlar. Byte[] MethodTable'ında ComponentSize = 1 yazar. Int32[] için 4, Double[] için 8.

Peki eleman bir reference type ise? string[] düşünelim:

string[] names = new string[3];

Bu dizinin heap'teki yapısı:

[MethodTable* → String[]]    (8 byte)
[Length = 3]                 (4 byte)
[padding]                    (4 byte)
[names[0] = null]            (8 byte - string referansı)
[names[1] = null]            (8 byte - string referansı)
[names[2] = null]            (8 byte - string referansı)
Toplam: 16 + (3 × 8) = 40 byte

Burada ComponentSize = 8 (64-bit'te referans boyutu). Her eleman bir referanstır, string'in kendisi heap'te başka bir yerdedir. Dizi sadece adresleri tutar. names[1] okurken:

1. Adres:    0x00E100 + 16 + (1 × 8) = 0x00E118
2. Oku:      *(string*)0x00E118 → 0x00F200 (string'in adresi)
3. String'e git: 0x00F200'deki string objesini oku

GC de bu referansları takip eder. string[] dizisinin kendisi hayatta olduğu sürece, dizinin içindeki string referanslarının gösterdiği string'ler de hayatta kalır (başka hiçbir yer referans vermiyor olsa bile).

Heap ve MethodTable

Heap'i büyük bir arsa gibi düşünün. İhtiyacınız olduğunda bir parsel ayırırsınız (allocation), işiniz bitince boşaltırsınız (GC toplar). Stack'in aksine burada sıra yoktur - her parsel bağımsızdır.

Ama asıl mesele, heap'te bir obje yaratıldığında CLR'ın perde arkasında neler yaptığıdır. new User() dediğiniz anda olanları adım adım inceleyelim.

CLR Obje Yaratırken Ne Olur?

var user = new User { Name = "Erdinç", Age = 30 };

Bu satır çalıştığında CLR şu adımları izler:

  1. Tip bilgisine bak. CLR, User tipini tanıyor mu? User sınıfına ait MethodTable nerede? (Eğer ilk kez kullanılıyorsa tip yüklenir - type load.)

  2. Bellek ayır. MethodTable'daki BaseSize alanına bakar. Bu alan, User tipindeki bir objenin heap'te kaç byte yer kaplayacağını söyler. GC heap'inden bu kadar byte'lık alan tahsis edilir.

  3. MethodTable işaretçisini yaz. Ayrılan belleğin ilk 8 byte'ına (64-bit) veya 4 byte'ına (32-bit) MethodTable'ın adresi yazılır. Bu işaretçi (TypeHandle), objenin hangi tipe ait olduğunu çalışma zamanında söyleyen kritik bir referanstır.

  4. Field'ları sıfırla. Kalan alan sıfırlanır. int → 0, string → null, bool → false.

  5. Constructor'ı çağır. User'ın constructor'ı çalışır, field'lara gerçek değerleri yazar.

  6. Referansı döndür. var user değişkenine, heap'te ayrılan bu objenin başlangıç adresi atanır. Bu adres stack'te tutulur.

Görsel olarak:

flowchart LR
    subgraph Stack["Stack"]
        direction TB
        U["user = 0x001A3F (8 byte)"]
    end

    subgraph Heap["Heap"]
        direction TB
        subgraph Obj["User objesi (adres: 0x001A3F)"]
            direction LR
            MT["MethodTable* (8 byte)"]
            F1["Age = 30 (4 byte)"]
            PAD["padding (4 byte)"]
            F2["Name = 0x00B210 (8 byte)"]
        end
        subgraph MTBlock["User MethodTable"]
            direction LR
            MTInfo["BaseSize: 24
            EEClass*: 0xF0A100
            Parent MethodTable*: System.Object
            Interface count: 0
            Method slots: ..."]
        end
        subgraph Str["String 'Erdinç' (0x00B210)"]
            direction LR
            SM["MethodTable* (System.String)"]
            SL["Length = 6"]
            SC["chars: E r d i n ç"]
        end
    end

    U -.->|"işaret eder"| Obj
    MT -.->|"tip bilgisi"| MTBlock
    F2 -.->|"Name işaret eder"| Str

MethodTable Nedir, Ne İşe Yarar?

Her tipin bir MethodTable'ı vardır. CLR, bu tabloyu tip ilk yüklendiğinde oluşturur ve o tipin tüm objeleri aynı MethodTable'ı paylaşır. Yani milyon tane User objesi yaratsanız da tek bir User MethodTable'ı vardır.

MethodTable'ın içeriği (basitleştirilmiş):

AlanAçıklama
BaseSizeBu tipteki bir objenin heap'te kaç byte yer kaplayacağı. GC'nin allocation için kullandığı ilk alan.
EEClass pointerEEClass yapısına işaret eder. Field'ların offset'leri, interface listesi, property metadata'ları buradadır.
Parent MethodTableKalıtım zinciri. User ise parent'ı System.Object'in MethodTable'ıdır.
Interface count ve Interface mapHangi interface'leri implement ettiği. Cast ve is kontrolü için kullanılır.
Method slotsVirtual method'ların adresleri. ToString(), GetHashCode() gibi.

Yani user.GetType() dediğinizde CLR'ın yaptığı tek şey, objenin ilk 8 byte'ındaki MethodTable işaretçisini okuyup size ilgili Type nesnesini döndürmektir.

Namespace ile MethodTable İlişkisi

MethodTable, namespace'lere göre değil, tip başına oluşturulur. Namespace dediğimiz şey derleme zamanında kullanılan mantıksal bir gruplandırmadır. Runtime'ta "namespace MethodTable" diye bir yapı yoktur.

Bir tipin runtime'taki tam kimliği üç parçadan oluşur:

Assembly + Namespace + TypeName

Örneğin:

namespace MyApp.Models
{
    public class User { ... }     // Kimlik: MyApp.dll + MyApp.Models + User
}

namespace MyApp.DTOs
{
    public class User { ... }     // Kimlik: MyApp.dll + MyApp.DTOs + User
}

Bu iki User sınıfının isimleri aynı olsa da, namespace'leri farklıdır. Dolayısıyla CLR bunlar için iki ayrı MethodTable oluşturur. Her birinin kendi BaseSize, field offset'leri ve method slot'ları vardır. Models.User ile DTOs.User tamamen farklı iki tiptir, sadece isim benzerliği vardır.

Namespace bilgisi MethodTable'ın içinde ayrı bir alan olarak değil, tipin adının bir parçası olarak tutulur. Type.FullName dediğinizde "MyApp.Models.User" döner; bu string, tipin metadata'sından gelir. CLR bir tipi yüklerken bu tam ismi kullanır:

1. assembly'de "MyApp.Models.User" diye bir tip var mı?
2. Varsa, onun MethodTable'ını yükle (veya zaten yüklüyse onu kullan)
3. Yoksa TypeLoadException fırlat

Yani namespace, tip çözümleme (type resolution) için gereklidir ama MethodTable'ın kendisi namespace bazlı değil, tip bazlıdır. Aynı namespace içindeki 50 sınıf = 50 ayrı MethodTable.

Property'lere Erişim: Offset Mekanizması

Peki user.Name dediğinizde CLR, heap'teki objenin hangi byte'larının Name property'sine ait olduğunu nasıl bilir? Cevap: field offset.

EEClass içinde her field için bir offset değeri tutulur:

User Class EEClass (basitleştirilmiş):
  Field: Age      → offset: 8   (MethodTable işaretçisinden hemen sonra)
  Field: Name     → offset: 16  (Age 4 byte + padding 4 byte = 8 byte sonra)
  Total size      → 24 byte     (8 MT + 4 Age + 4 pad + 8 Name)

Peki padding neden var? CPU, bellekteki verilere daha hızlı erişmek için hizalama (alignment) ister. 64-bit sistemde 8 byte'lık referanslar 8'in katı olan adreslerde durmalıdır. Bu yüzden Age (4 byte) ile Name (8 byte) arasına 4 byte padding konur.

CLR bu offset değerlerini kullanarak user.Name çağrısını şuna dönüştürür:

// user.Name aslında şudur:
string name = *(string*)((byte*)user + 16);  // objenin 16. byte'ındaki referansı oku

Offset ile erişimin avantajı: Her obje için ayrı bir property adresi haritası tutulmaz. Tüm User objeleri aynı offset'leri kullanır. Sadece base address değişir: objenin_adresi + Age_offset = Age'in adresi.

Dikkat edin: Age value type olduğu için değerin kendisi heap'teki objenin içindedir. Ama Name reference type olduğu için heap'teki objenin içinde sadece adresi vardır. Asıl string verisi başka bir heap adresindedir.

Kalıtımda Offset Durumu

class Person
{
    public int Id { get; set; }
}

class User : Person
{
    public string Name { get; set; }
    public int Age { get; set; }
}

Heap'teki User objesi:

[MethodTable* → User]  (8 byte, en başta)
[Id: int]              (offset 8, Person'dan gelen field)
[Name: string ref]     (offset 16, 4 byte Id + 4 padding = 8)
[Age: int]             (offset 24)
Toplam: 32 byte

Burada kritik nokta: parent class'ın field'ları her zaman child'ın field'larından önce gelir. Bu sayede Person person = user; yaptığınızda bile person.Id aynı offset'ten okunur. Çünkü Person MethodTable'ı da Id'nin 8. byte'ta olduğunu bilir - ve user objesinin 8. byte'ında gerçekten de Id durur.

Value Type vs Reference Type: Özet Tablo

ÖzellikValue Type (struct, int, bool)Reference Type (class, string)
Yaşadığı yerSahibinin bulunduğu yer (stack veya heap içinde)Her zaman heap
Atama davranışıDeğer kopyalanırReferans (adres) kopyalanır
Null olabilir mi?Hayır (int? nullable wrapper ile evet)Evet
Varsayılan değerSıfırlanmış hali (0, false, default)null
KalıtımSystem.ValueType'tan, sealedObject'ten
GC etkisiSahibi toplanınca otomatik giderGC toplaması gerekir
Objede nasıl durur?Değer objenin içindedirAdres objenin içindedir

String Meselesi

String bu hikayede özel bir yere sahip. Kafaları en çok karıştıran tip.

string a = "hello";
string b = a;
b = "world";
// a hala "hello", değil mi? Evet.

String reference type'tır - heap'tedir. Ama immutable'dır (değiştirilemez). Bu yüzden yukarıdaki b = "world" işlemi a'yı etkilemez, çünkü b'nin işaret ettiği string'in içeriğini değiştirmez - b'ye yeni bir string'in adresini atar.

Aslında string'in heap'teki yapısı da MethodTable ile başlar:

String objesi (heap):
  [MethodTable* → System.String]  (8 byte)
  [Length: int = 5]               (4 byte)
  [Padding]                       (4 byte)
  [First Char: 'h']               (2 byte)
  [Second Char: 'e']              (2 byte)
  ...

Length ve karakterler objenin içinde, sabit offset'lerle erişilebilir. Ama içeriği değiştiremezsiniz çünkü CLR string'in yazılabilir olduğunu bilmez - obje readonly olarak işaretlenmiştir.

Neden Immutable?

  1. Thread safety. Birden fazla thread korksuzca aynı string'i okuyabilir.
  2. Interning. Aynı içerikteki string'ler bellekte bir kez tutulur.
  3. Güvenlik. Bir string'i metoda parametre geçtiniz diye metod onu değiştiremez.

Ama bu immutability'nin bir bedeli var:

string result = "";
for (int i = 0; i < 10000; i++)
{
    result += "x";   // Her döngüde yeni string objesi. 10.000 allocation.
}

Bu kod 10.000 defa yeni string objesi yaratır, heap'i kirletir. Doğrusu:

var sb = new StringBuilder();
for (int i = 0; i < 10000; i++)
{
    sb.Append("x");
}
string result = sb.ToString();  // Tek allocation.

String, reference type olmasına rağmen value type gibi davranır. == operatörü içerik karşılaştırır (adres değil). Immutable olduğu için kopyalama güvenlidir. Ama bellekte hep heap'tedir - stack'te yaşayamaz, çünkü boyutu derleme zamanında bilinmez ve ömrü metod scope'unu aşabilir.

String Interning

CLR sık kullanılan string'leri intern pool denen bir havuzda tutar:

string a = "dotnet";
string b = "dotnet";
Console.WriteLine(Object.ReferenceEquals(a, b)); // True - aynı obje.

Derleme zamanında sabit olan string'ler otomatik intern edilir. Runtime'da manuel de yapabilirsiniz:

string c = new string(new char[] { 'd', 'o', 't', 'n', 'e', 't' });
string d = string.Intern(c);

Peki bir metod runtime'da string döndüğünde intern otomatik gerçekleşir mi? İki senaryo var:

Senaryo A: Metod compile-time literal dönerse - intern edilir.

string GetHelloWorld()
{
    return "Merhaba dunya";   // compile-time literal
}

string a = "Merhaba dunya";
string b = GetHelloWorld();

Object.ReferenceEquals(a, b); // True - ikisi de aynı intern objesi

GetHelloWorld() gövdesindeki "Merhaba dunya" literal'ı assembly metadata'sına gömülür. CLR assembly yüklerken onu da intern havuzuna atar. Metod çağrıldığında havuzdaki aynı objeyi döner. İki literal + ile birleşiyorsa derleyici build time'da optimize edip tek literal'a indirgeyebiliyorsa, sonuç yine intern olur.

Senaryo B: Metod string'i runtime'da oluşturursa - intern edilmez.

string GetHelloWorld()
{
    var merhaba = "Merhaba ";
    var dunya = "dunya";
    return merhaba + dunya;   // runtime concat, yeni obje
}

string a = "Merhaba dunya";
string b = GetHelloWorld();

Object.ReferenceEquals(a, b); // False - b runtime'da oluştu, intern havuzunda değil

string c = string.Intern(b);  // Manuel intern
Object.ReferenceEquals(a, c); // True - artık aynı havuz objesi

Değişkenlerle + concat, StringBuilder.ToString(), Substring(), ToUpper(), dosyadan okuma, JSON deserialize - bunların hepsi runtime'da yeni heap objesi yaratır. İçerik aynı olsa bile intern havuzuna kendiliğinden girmez. string.Intern() çağırmanız gerekir.

Özetle mesele string'in içeriği değil, nasıl oluştuğudur. Dahasına sonra bakacağız.

Interning sayesinde aynı string defalarca heap'te yer kaplamaz. Ama aşırı kullanımı GC'nin Gen 2'sini şişirebilir - çünkü intern edilen string'ler asla GC tarafından toplanmaz (AppDomain kapanana kadar yaşar).

Özet: Stack, Heap, MethodTable İlişkisi

Bir new User() çağrısının baştan sona yolculuğu:

1. CLR, User MethodTable'ına bakar → BaseSize: 24 byte
2. GC heap'inden 24 byte ayırır
3. Ayrılan alanın ilk 8 byte'ına User MethodTable'ının adresini yazar
4. Kalan alanı sıfırlar, constructor'ı çağırır
5. Heap adresini stack'teki user değişkenine atar
6. user.Name çağrısı: user adresi + 16 offset'inden string adresini okur

Bu mekanizmayı anladığınızda, performans problemlerinin çoğunu da anlamaya başlarsınız. Her new bir allocation'dır. GC'nin Gen 0 bölgesinde bir tahsis bütçesi (allocation budget) vardır, yeni bir obje yaratmak, bu bütçeyi tüketir. Bütçe sıfırlandığında GC tetiklenir ve kullanılmayan nesneleri toplar. Yani her allocation, GC'nin bir sonraki toplama döngüsüne sizi bir adım daha yaklaştırır. Her gereksiz allocation, önlenebilir bir maliyettir.

Bundan Sonrası

Stack ve heap'in ne olduğunu, CLR'ın obje yaratırken neler yaptığını, MethodTable'ın rolünü ve string'in neden özel olduğunu gördük. Ama asıl soru şu:

"String işlerken heap allocation'dan nasıl kaçarız?"

Cevap: Span<T>, Memory<T>, stackalloc ve IDisposable pattern'i. Yazının devamında bunları kod örnekleriyle ele alacağım. Ayrıca GC'nin nesil (generation) mekanizmasına, finalizer'ların neden tehlikeli olduğuna ve bellek sızıntılarını nasıl yakalayacağımıza da bakacağız.

Kaynaklar

Paylaş

Yorumlar