.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ölge | Açıklama |
|---|---|
| Stack | Metod ç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. |
| Heap | Dinamik 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?
| Durum | C++ | C# |
|---|---|---|
| Initialize edilmemiş yerel değişken okuma | Derlenir, rastgele değer döner (undefined behavior) | Derleme hatası (CS0165) |
| Pointer ile stack dışına çıkma | Derlenir, segfault veya rastgele veri | unsafe olmadan yapamazsınız |
| Dizi sınırlarını aşma | Derlenir, undefined behavior | IndexOutOfRangeException fırlatır |
| Null referans kullanma | Derlenir, segfault | NullReferenceException 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.
stackallocyapmadığı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,enumgibi 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ça | Değer | Kaynak |
|---|---|---|
| Base adres | 0x00E100 | Stack'teki buffer referansı |
| Header boyutu | 16 byte (8 MT + 4 Length + 4 padding) | Array MethodTable'daki BaseSize |
| ComponentSize | 1 (byte için) | Array MethodTable'daki ComponentSize alanı |
| Length | 4096 | Objenin 8. byte offset'inde |
| index | 150 | Sizin 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, birstructdizisi 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:
-
Tip bilgisine bak. CLR,
Usertipini tanıyor mu?Usersınıfına aitMethodTablenerede? (Eğer ilk kez kullanılıyorsa tip yüklenir - type load.) -
Bellek ayır.
MethodTable'dakiBaseSizealanına bakar. Bu alan,Usertipindeki bir objenin heap'te kaç byte yer kaplayacağını söyler. GC heap'inden bu kadar byte'lık alan tahsis edilir. -
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. -
Field'ları sıfırla. Kalan alan sıfırlanır.
int→ 0,string→ null,bool→ false. -
Constructor'ı çağır.
User'ın constructor'ı çalışır, field'lara gerçek değerleri yazar. -
Referansı döndür.
var userdeğ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ş):
| Alan | Açıklama |
|---|---|
| BaseSize | Bu tipteki bir objenin heap'te kaç byte yer kaplayacağı. GC'nin allocation için kullandığı ilk alan. |
| EEClass pointer | EEClass yapısına işaret eder. Field'ların offset'leri, interface listesi, property metadata'ları buradadır. |
| Parent MethodTable | Kalıtım zinciri. User ise parent'ı System.Object'in MethodTable'ıdır. |
| Interface count ve Interface map | Hangi interface'leri implement ettiği. Cast ve is kontrolü için kullanılır. |
| Method slots | Virtual 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
| Özellik | Value Type (struct, int, bool) | Reference Type (class, string) |
|---|---|---|
| Yaşadığı yer | Sahibinin bulunduğu yer (stack veya heap içinde) | Her zaman heap |
| Atama davranışı | Değer kopyalanır | Referans (adres) kopyalanır |
| Null olabilir mi? | Hayır (int? nullable wrapper ile evet) | Evet |
| Varsayılan değer | Sıfırlanmış hali (0, false, default) | null |
| Kalıtım | System.ValueType'tan, sealed | Object'ten |
| GC etkisi | Sahibi toplanınca otomatik gider | GC toplaması gerekir |
| Objede nasıl durur? | Değer objenin içindedir | Adres 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?
- Thread safety. Birden fazla thread korksuzca aynı string'i okuyabilir.
- Interning. Aynı içerikteki string'ler bellekte bir kez tutulur.
- 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.