Ana içeriğe atla

V for Java - Project Valhalla - 2. Kısım

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)

Java Bellek Modeli çok daha detaylı bir yazıyı gerektirir ancak biz bu yazıda Project Valhalla kapsamında özellike primitif nesne ve sınıflar için gerekli olan kadarından bahsedeceğiz. Bir önceki yazıda nesnelerin alanları için gereken alana ek bir başlık ile bellekte tutulduğundan bahsetmiştik ancak nerede tutulduklarını konuşmadık.
 
JVM; işletim sisteminden aldığı belleği farklı amaçlarla kullanılmak üzere farklı alt bölgelere ayırır. Biz burada konuyu anlama amacımıza hizmet etmeye yetecek 3 bölgeden bahsedeceğiz.
  • 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

 

 
Thread kavramı başka bir yazımızın konusu olsun ancak şimdilik bir thread'in "bir kod çalıştırma silsilesi" olduğunu söyleyelim. Java özelinde konuşursak her thread bir metot çalıştırarak başlar, daha doğrusu her thread bir metot çalıştırmak üzere başlatılır. Çalıştırılan bu başlangıç metodu başka metotları çağırdıkça mevcut thread çağrılan metotların kodlarını çalıştırmayı sürdürür. Bu metotlar silsilesi sonlandığında thread de sonlanır. Bir thread başlatıldığında JVM tarafından bu thread için özel bir stack bölgesi oluşturulur ve çalışmakta olan her metot kendi "frame" (çerçeve diye çevirmeyi deneyelim) adı verilen daha küçük bellek bölgesine sahip olur. Bir metot başka metodu çağırdıkça stack içine yeni frame'ler eklenir. Bir metot sonlanıp mevcut thread o metodu çağıran metoda döndüğünde sonlanan metodun frame bölgesi (ve dolayısıyla tüm içeriği) stack bölgesinden silinir.

Çalışmakta olan metodun tanımladığı "lokal değişkenlerin" değerleri JVM tarafından mevcut frame içine yüklenir. Stack bölgesinde tutulan lokal değişkenlerin iki temel çeşitte (tipte kelimesinden özellikle kaçınalım) olduğunu söyleyebiliriz:
  1. 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?)
  2. 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:

  1. 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)
  2. 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

Bahsettiğimiz üzere, referanslar stack bölgesinde tutulur ancak işaret ettikleri (null değil iseler) nesne stack bölgesinde değildir. Tüm nesneler JVM tarafından heap bölgesine yerleştirilir. Bir diğer tabirle, nesneler için heap bölgesinde yer ayrılır ancak yukarıdaki görselde bahsetmediğimiz "operand stack", yani çalıştırılabilir kodun üzerinde işlem yapacağı değerler de stack bölgesi içindedir.
 
Peki JVM, stack bölgesinde olmayan bu nesnelere nasıl ulaşıp üzerlerinde işlem yapmaktadır ? Çok ince bir soruymuş gibi sorulduğuna bakmayın, cevabı biliyorsunuz: referansı takip edip heap bölgesinden gerekli veriyi getirerek. Çok fazla derinlemesine inmeden bu yazı açısından şunu anlamamız yeterli: nesneler ile işlem yapabilmek için JVM önce ilgili adrese giderek heap bellek bölgesine erişmek zorundadır. Yani nesneleri (daha doğrusu bir nesne içindeki bir alanı) kullanan her işlem dolaylıdır ve "ek bir bellek erişimi" gerektirmektedir. Bu ek bellek erişimi Java ve JVM'in ilk tasarlandığı yıllarda büyük bir sorun değildi çünkü o yıllarda bellekler ile işlemcilerin çalışma hızları aşağı yukarı denkti. Yani örneğin toplama işlemi 1ns gerektiriyorsa (tabii ki gerçekte çok daha az gerektiriyor) toplanacak değerleri bellekten okuyup getirmek de 1ns sürüyordu. Günümüzde durum çok daha farklı, işlemcilerin çalışma hızları çok daha yüksek, toplama işlemi 1ns gerektiriyorsa bellek erişimi 1ms (yani 1000 kat daha yavaş) gerektirebiliyor. Buna ek olarak bir de işlemciler "çok çekirdekli". Tek bir işlemci içinde aynı anda işlem yapabilen 4, 8 veya daha çok sayıda çekirdek var.

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.
 
Primitif nesne ve sınıfları ise; değer nesne ve sınıflarına ek bir takım kısıtlamalara ve kurallara uyması gereken yeni bir tür. Yine, yeni bir keyword’ümüz var, hemen inceleyelim:


// 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ı



Tanımlanmaları ve kullanımları değer nesne ve sınıflarına oldukça benzemekle beraber primitif sınıf ve nesnelerin başlıca farkları:
  • 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.


Artık yeter

Project Valhalla ile ilgili bu 2. kısımda; Java Bellek Modeli, referanslar, önbellekler ve işlemciler derken yazıyı oldukça uzattık. Bu noktaya kadar okuyanlar için ne desek az. Son kısımda primitif tipler ile nesnelerin birleştirilmesinden bahsedeceğiz ve değer nesneleri ile primitif nesneleri alıştığımız nesnelere göre hangi durumlarda kullanabiliriz bir göz atacağız.

Görüşmek üzere.

Yorumlar

Bu blogdaki popüler yayınlar

Tip Sistemleri ve Programlama Dilleri

Tip Sistemi kavramı, günlük hayatlarının bir parçası olmasına rağmen bir çok yazılım profesyoneli için bile kulağa yabancı gelebilir. Tip Sistemleri; matematik ve mantık dallarındaki zengin bir konu olan "Tip Teorisinin" yazılım geliştirme ve programlama dillerine bir yansımasıdır. Tip sistemlerinin programlama dilleri için oldukça önemli ve belirleyici olduğunu söyleyebiliriz.  Bu yazıda herhangi bir programlama diline odaklanmadan teorik düzeyde tip sistemlerinden bahsetmekle yetineceğiz. Tip sistemleri; bir programlama dilindeki değişken, fonksiyon vb yapı taşlarınının kullanım ve birbirleri ile etkileşimlerini, belli kurallara bağlayarak, oluşabilecek belirsizlikleri ve hataları engellemeye yönelik mantıksal sistemlerdir. Havalı cümlemizi kurduğumuza göre daha anlaşılır olarak açıklamak gerekirse örneğin bir değişkenin hangi türde değerleri tutabileceği bir fonksiyonun hangi türde parametrelerle çağrılması gerektiği yine bir fonksiyonun bir değer üretip üretmediği ve üre

Uzaktan / Bulut İmza (Remote / Cloud Signature)

İmza, kişinin; o belgede yazanı onayladığını ve ona uyacağını gösteren işarettir. Bilgisayarlar hayatımıza girdiğinden beri belgeler bilgisayarlarda üretilir oldular ama sonuçta yazıcılardan çıkıp fiziksel olarak önümüze geldiler ve kalem mürekkep ile ıslak olarak imzalandılar. Günümüzde kurumlar ve devlet birimleri dijital dönüşüm ile gittikçe daha kağıtsız bir yapıya kavuşuyor. Belgeler bilgisayarlarda üretiliyor, hiç kağıda yazılmıyor ve tamamen elektronik ortamda kalıyorlar.  Bulut imza, Signature as a Service (Bulut Bilişim ile ilgili yazımıza buradan ulaşabilirsiniz) şeklinde kullanılmak üzere ve dijital dönüşüm yapbozundaki yasal/hukuki eksiği tamamlamak için geliyor. Bu büyük yeniliği anlamak için gelin şu anda elektronik ortamda imza nasıl atılıyor ve hangi zorlukları içeriyor bakalım. Sonra bulut imzayı tanıyalım ve ülkemizde teknik ve yasal mevzuat bakımından ne durumda olduğu ile yazımızı sonlandıralım. Elektronik ve Dijital İmza Elektronik imza; elektronik ortamda

V for Java - Project Valhalla - 1. Kısım

Project Valhalla OpenJDK tarafından yürütülmekte olan bir proje. Java'nın tip sistemi ile ilgili Java programlama dili ve JVM üzerinde yapılabilecek geliştirmelerin araştırıldığı ve adım adım sona yaklaşan bu proje bir takım ilginç yenilikler sunuyor. Bunları: Value objects / value classes (Değer nesneleri, değer sınıfları) Primitive objects / primitive classes (Primitif nesneler, primitif sınıflar) Primitif ve nesne tiplerinin birleştirilmesi olarak sıralayabiliriz. Tip sistemleri ile ilgili daha teorik bilgi için ilgili yazımıza göz atabilirsiniz. Güzel de, "value" nedir, "object" nedir biliyoruz ama "value object" neyin nesidir ? Gelin beraber bir göz atalım.     Primitifler, Sınıflar ve Kimlik (Identity) Java Tip Sistemi; 8 pritimif (byte, short, int, long, float, double, char ve boolean), nesne (object) ve dizi (array) olmak üzere 10 tanımlı tipten oluşur. Primitif tipler ile ifade edilemeyen daha karmaşık veri yapıları  (örneğin bir string yani