Project Valhalla ile ilgili yazı dizimizin 1. kısmında Değer Nesneleri ve Sınıfları (Value Object / Class) konusundan bahsettik. İlk yazımızda JVM'in belleği nasıl kullandığı konusuna değinmiştik. Bu yazımızda Java Bellek Modeli'nden biraz daha detaylı olarak bahsedeceğiz çünkü Valhalla'nın sunduğu yeniliklerden olan Primitif Nesne ve Sınıflar (Primitive Object / Class); sentaks seviyesinde yüzeysel bir yenilik olmaktan çok daha derin, Java Bellek Modeli ve modern işlemci mimarileri ile fazlaca ilgili.
Geçen yazımızdaki giriş tarzını sürdürelim. "Primitif" ve "nesne" nedir biliyoruz, bir önceki yazıda JVM ve bellek açısından farklı olduklarını da konuştuk, o halde "primitif nesne" olur mu ? Gelin beraber inceleyelim.
Java Bellek Modeli (Thread'ler ve Frame'ler)
- Stack (yığın olarak çevrilir) Bölgesi: çalıştıralabilir kodun (metotların) üzerinde işlem yapacağı lokal (yerel) değişkenlerin tutulduğu bölge
- Heap (sık kullanılmasa da öbek olarak dilimize çevrilebilir) Bölgesi: boyutu önceden belli olmayan ve kodun çalışması sırasında dinamik olarak büyüyen ve küçülebilen bölgedir, nesneler (1. kısımda bahsettiğimiz başlıkları ile birlikte) bu bölgede tutulur
- Metot Bölgesi: çalıştırılabilir kodun tutulduğu bölgedir, yine ilk kısımda bahsettiğimiz sınıf veri yapısı da (sınıfta hangi alan ve metotlar var vb) yine bu bölgede tutulur
- Primitif tipteki değişkenler. Yukarıdaki görselde "int" tipindeki "a" ve "boolean" tipindeki "b" değişkenlerinin değerleri (sırası ile 5 ve 0) direk olarak stack bölgesinde tutulur ("false" değerinin JVM tarafından 0 değerinde bir int olarak temsil edilmesi ilginç midir?)
- Referans tipindeki değişkenler. Referanslar özel bir tip olarak düşünülebilir. Referans değişkenlerinin değerleri; nesnelerin "heap" bölgesindeki adreslerini içerir. Yukaridaki görselde "ClassA" tipindeki "c" referans değişkeninin değeri (→) de direk olarak stack bölgesinde tutulur.
Primitiflerin ve referansların değerlerinin direk olarak stack bölgesinde tutulduğunu söyledikten sonra "nesneler ve heap bölgesi" konusuna daha detaylı olarak bakmadan önce referansları gelin biraz daha inceleyelim.
Referanslar, İşaretçiler (Pointer) ve Güvenlik
Referans değerleri için "heap bölgesindeki bir adrestir" yerine "adresi içerir" dememizin nedenini özellikle vurgulamakta fayda var: Java referansları örn. C/C++ işaretçileri (pointer) gibi bir bellek adresinden ibaret değildir. Java platformunun reklam cümlelerinden "güvenli bir dil" iddiasına tek başına dayanak olan tek şey referanslardır denebilir. Tabii burada "güvenli" kelimesinin "bellek güvenliği" demek olduğunu belirtelim. Referansları işaretçilere göre daha bellek güvenli yapan şey nümerik bir değer gibi serbestçe üzerlerinde manipülasyon ve aritmetik yapılamaması. C/C++ işaretçileri üzerinde nümerik değerler gibi toplama / çıkarma yapılarak işaret ettikleri bellek bölgesi kolayca değiştirilebilir. Bu da programın, kasıtlı veya değil, istenmeyen bir bellek bölgesine giderek oradaki veriyi okumasına veya bozmasına sebep olabilir. İşletim sistemleri tabii ki iki farklı programın birbirinin verisine erişmesine izin vermez ama aynı programa ayrılmış belleğin içinde bile bu tür serbest bellek erişimleri güvenlik açıklarına ve çalışma zamanında ani hatalara sebep olur. Java'da referanslar saf bellek adresleri değildir, üzerlerinde nümerik işlem yapılamaz ve asıl bu yazı ile ilgili farklarına gelelim:
- Referansların işaret ettikleri nesnenin tipi bellidir. Java'da bir referans uygun olmayan bir tipteki nesneye işaret edemez. Java'yı güçlü tipli dil (tip sistemleri ile ilgili yazımıza buradan ulaşabilirsiniz) yapan özelliklerden biri referansların bu özelliğidir. (Örneğin C++ dilinde, "reinterpret_cast" ile, işaret edilen bellek bölgesini tamamen farklı bir tipteki değer olarak kullanmak, veya kullanmaya çalışmak, mümkündür)
- Bu yazımızla daha ilgili olan konu ise "null" değerinin standart olarak tanımlı olmasıdır. Referansların varsayılan değeri "null"dır, yani hiçbir nesneye işaret etmeyen referansların değeri "null" özel sabitidir. Ve null bir referans ile işlem yapılmaya çalışıldığında JVM; tüm Java geliştiricilerinin hayatlarında muhtemelen en çok gördüğü NullPointerException hatasını üretir, tutarlı ve anlaşılır bir şekilde hata raporlanır. (Örneğin C++'da NULL bir pointer ile işlem yapılmaya çalışıldığında programın nasıl davranması gerektiği tanımlı değildir ve SEGFAULT tarzı hatalar ile sık karşılaşılır)
Bu noktada bazı programlama dillerinin "null" güvenliği açısından daha korunaklı olduğunu söyleyebiliriz. Java'nın kütüphane seviyesinde "Optional" sınıfı ile sağlamaya çalıştığı null güvenliği örneğin Kotlin'de sentaks seviyesinde "nullable" tipler ile dilin ta kalbinde sağlanır. Ancak artık gelin konumuza geri dönelim.
Heap ve Dolaylılık
Dolaylılığın Maliyeti: Önbelleği Iskalamak ve Modern İşlemci Mimarisi
Günümüzde tipik bir bilgisayar içinde bulunan işlemcilerin bellek erişimi açısından kademeli bir mimarisi vardır. İşlemcilerin aritmetik ve lojik işlem yapma hızları üzerinde işlem yapılacak değerleri alıp getirmek için gereken bellek erişimine göre 1000 kat hızlı olabilmektedir. Bunun nedeni tabii ki saf fizik. Sonuçta elektronların işlemciden çıkıp anakart üzerinden geçerek belleğe ulaşması ve geri gelmesi ışık hızında bile olsa vakit alıyor. Buna karşı çözüm de işlemci ile belleği birbirine yakın tutmak.
Ancak ana belleğin tümünü işlemci içine almak hem karmaşıklık, hem ısı hem de maliyet açısından mantıklı bulunmuyor olsa gerek; donanım üreticileri tarafından işlemci içine giderek artan boyutlarda farklı seviyelerde (Level) önbellekler yerleştiriliyor. Üzerinde işlem yapılacak değer önce L1 önbellekten, yoksa sırası ile L2, L3 ve en son ana bellekten getirilmeye çalışılıyor.
Önbelleği ıskalamak (cache miss), yani kullanılacak değerin bir önbellekte bulunamaması her kademede giderek maliyeti artan bir şekilde daha sonraki bellekten okumaya sebep oluyor. Peki bu önbellekler nasıl doluyor ? Bu konuda temel prensip şu: bir değer bir önbellekte bulunamazsa (ki işin başında bu böyle, tüm önbellekler boş) bulunduğu yerden sadece o değer değil, o değerin içinde olduğu daha büyük bir bellek alanı topluca okunup getiriliyor.
Tam bu noktada daha önce Java Bellek Modelinde bahsettiğimiz stack ve heap bellek bölgelerinin başlangıçta ana bellekte olduğunu söylememiz gerek. Bir metot çalışırken o metoda ait stack bölgesi (frame) ana bellekten önbelleğe okunur. Pritimif değişkenlerin değerlerinin stack bölgesinde yan yana durduğunu düşündüğümüzde bir sonraki işlemde kullanılacak değerin büyük ihtimalle önbelleğe yüklenmiş olacağını umabiliriz. Ancak iş bir nesne ile çalışmaya geldiğinde o nesnenin "sadece referansı" stack bölgesinde olduğu için nesnenin kendisinin heap bölgesinden getirilmesi gerekiyor. Ve işte bu referans üzerinden dolaylı çalışma performansa hatrı sayılır şekilde negatif olarak yansıyabiliyor. Yansıyabiliyor diyoruz çünkü nesnenin içinde olduğu heap bölgesi daha önce başka bir işlem için en azından daha yüksek seviye bir önbelleğe alınmış olabilir. Öyle olmaması halinde; her önbellek ıskalaması, işlemcinin nesne verisinin heap bölgesinden getirilmesini beklemesine sebep oluyor.
Keşke JVM; nesneleri (en azından bazılarını) aynen primitif değerler gibi stack bölgesinde tutabilse, önbellek ıskalamalarını en aza indirip performansı arttırabilse değil mi ? Project Valhalla'yı sürdürenler de böyle düşünmüş olsa gerek ki işte karşımızda Primitif Nesneler ve Primitif Sınıflar.
Primitive Object ve Primitive Class
Önce gelin 1. kısımda bahsettiğimiz "Değer Nesneleri ve Sınıfları"nı hatırlayalım. Değer nesneleri ve sınıfları yeni bir özel nesne/sınıf türü. Kimliksizlik, değiştirilemezlik, senkronizasyona katılamama vb bazı kısıtlamalara uymaları gerekiyor ama bunun karşılığında bir takım optimizasyonlara imkan veriyorlar.
// tanımlama
primitive class Counter implements Serializable {
int counter;
Counter(int value) {
this.counter = value;
}
public int get() { return counter; }
public Counter increment(int delta) {
return new Counter(this.counter + delta);
}
}
// kullanım: değer nesneleri gibi
Counter counter = new Counter(1);
counter = counter.increment(1);
System.out.println(counter.get());
// eşitlik ve kimlik: değer nesneleri gibi
assert counter == new Counter(1);
// referanssızlık
Counter counter = null; // Derleme hatası
- Referanssızlık:Değer nesneleri alışılmış nesneler gibi stack bölgesinden bir referans ile işaret edilen ama yine de heap bölgesinde tutulan nesneler. Primitif nesneler ise referanssız, alışılmış primitif tipler gibi tamamıyla stack bölgesinde tutulabilen nesneler. Referansları yok, değişkenleri var.
- Null Olamama: Primitif nesnelerin bir referansı yok dolayısla referans tipine özel tanımlı bir sabit olan "null" değerini alamazlar.
- Varsayılan Değer (Default Value): Primitif tipli değişkenlere ilk değer atanmasa da hepsinin kendine özel bir varsayılan değeri vardır: primitifler için 0, referanslar için null. Peki primitif nesne değişkenlerinin varsayılan değerleri nedir ? Cevap aslında akla yatkın: bir nesne olsalardı alanlarının alacağı default değerler. Örnek verecek olursak yukaridaki "Counter" primitif sınıfı için varsayılan değer "counter" alanı "0" olan bir primitif nesne.
Aslında pritimif nesnelere referanssız olmaları nedeniyle alıştığımız manada nesne demek zor çünkü nesnelerin sık kullanılan bir diğer adı "referans tipleri". Yine de primitif nesnelerin bu şekilde isimlendirilmesi bir talihsizlik değil. Pritimif sınıflar hem gerçek manada stack bölgesinde tutulabilen primitif bir veri türü şeklinde hem de gerektiğinde stack dışında tutulabilecek alışılmış nesneler gibi kullanılabiliyor. Bu konunun detaylarına bu kısımda girmeyeceğiz ama bir ipucu verelim: alıştığımız primitif sarmalayıcı (wrapper) sınıflar!
Peki ne kazanıyoruz ?
Primitif nesnelerin; stack alanında referanssız, direk olarak tutulabilmesi sadece heap bellek bölgesine erişim dolaylılığını azaltmıyor, özellikle bir metodun diğerini çağırması sırasında performansı büyük oranda arttırıyor. Çünkü çağıran metodun geçtiği parametre stack içinde diğer metodun frame'i tarafından kullanılmak üzere hazır. Günümüzde varsayılan iç içe metot çağırma sayısının 7000 olduğu düşünülürse metot çağırma sırasında yaşanacak performans artışının ne kadar önemli olduğu ortaya çıkacaktır.
Primitif sınıf ve nesneler bir anlamda C struct'ları gibi. Bir yandan C#'ın "value types" (değer tipleri) türünü andırıyorlar ama ikisine de tam olarak benzemiyorlar. Hem bellek kullanımı hem de işlem performansı adına yepyeni seçenekler sunuyorlar.
Yorumlar
Yorum Gönder