Java Virtual Machine Mimarisi

Deniz Arıkan
Akbank Teknoloji
Published in
12 min readMar 15, 2024

Derleme ve Yorumlama

İstediğimiz bir algoritmayı bir bilgisayarda çalıştırmak için çeşitli programlama dillerini kullanırız. İnsanların okuyabildiği (human-readable) programlama dillerinde geliştirilen kodlar direk olarak bilgisayarda çalıştırılamazlar; bu kodların önce bilgisayarların okuyabildiği (computer-readable) makine diline dönüştürülmeleri gerekir.

Bu işlem için 2 farklı yöntem kullanılır

· Derleme (Compilation)
· Yorumlama (Interpretation)

Derleme

Derleme (Compilation) işleminde program kodları bir derleyiciye verilerek makine kodları oluşturulur. Bu işlemden sonra çıkan makine kodları bilgisayar üzerinde çalıştırılır. Derleme işleminin bir kere yapılması yeterlidir.

Derleme ile çalışan dillere örnek olarak C, C++, Go, Fortran, Pascal ve COBOL verilebilir.

Yorumlama

Yorumlama (Interpretation) işleminde ise çalıştırılmak istenen kodlardan bir makine kodu oluşturulmaz. Burada kod runtime sırasında makine koduna çevrilerek istenilen komutlar çalıştırılır. Yorumlama işleminin ilgili program çalıştırıldığında her zaman yapılması gereklidir.

Yorumlama ile çalışan dillere örnek olarak PHP, Ruby, Python ve JavaScript verilebilir.

Java Virtual Machine

İlk sürümü 1996 yılında çıkan Java programlama dilinin en önemli özelliği yazılan ve derlenen kodların platformdan bağımsız olarak çalıştırılabiliyor olmasıdır. Bu özellik “Write once, Run everywhere” (WORA) ile ifade edilir.

Java, bahsedilen bu platformdan bağımsız çalışabilme özelliğini gerçekleştirmek için derleme ve yorumlama stratejilerini birleştirir ve derlenmiş Java bytecode’lar ile ilgili platform arasına yorumlama için Java Virtual Machine (JVM) adında yeni bir katman ekler.

Her platforma özel olan bu katmanın görevi Java bytecode’unu ilgili platform için gereken makine kodu şeklinde yorumlayarak çalıştırmaktır.

Java kod dosyaları JDK’in (Java Development Kit) in bir parçası olan javac komutu ile class dosyalarına dönüştürülürler.

Örneğin geliştirilen Test.java uygulamasını derlemek (bytecode’a dönüştürmek) için aşağıdaki komut çalıştırılır.

javac Test.java

class dosyalarının bytecode içeriği ise javap –c komutu ile görüntülenebilir.

javap –c Test.class

JVM(Java Virtual Machine) derlenerek bytecode’a çevrilmiş Java programlarının ortamdan bağımsız çalıştırılmasını sağlayan bir yapıdır.

Bu nedenle JVM, Java programlama dili ile yazılmış uygulamaların çalışmasını sağlayan JRE (Java Runtime Environment)’ın en önemli parçasıdır. Aynı Java kodunun (veya derlenmiş bytecode’unun) Windows, Unix, Linux gibi farklı platformlarda çalıştırılabilmesi bu platformlara özel geliştirilmiş JVM’ler sayesinde olur.

JRE ve onun bir parçası olan JVM’in üstünde ise Java Development Kit (JDK) bulunur.

Java Virtual Machine yapısı

JVM(Java Virtual Machine) en basit hali ile 3 parçadan oluşur. ClassLoader subsystem gereken Java sınıflarını JVM memory’ye yükler. Execution engine ise bu yüklenen sınıfları çalıştırır.

· Class Loader subsystem
· JVM Memory
· Execution engine

Bu 3 ana kısmın her birinde özelleşmiş işleri yapmaktan sorumlu alt kısımlar bulunmaktadır.

Class Loader subsystem

Class Loader’ın görevi uygulamanın çalışması için gereken class dosyalarını (bytecode) belleğe yüklemek, gerekli kontrolleri ve başlangıç işlemlerini yapmaktır.

Java’da sınıf yüklemesi yalnızca isteğe bağlı olarak dinamik bir şekilde gerçekleşir. Başlangıçta çalıştırılan uygulamanın CLASSPATH’indeki tüm sınıf dosyaları yüklenmez; bu işlem sadece ilgili class aktif olarak kullanılmak istendiğinde yapılır.

Yani Class Loader’ın uygulama başlangıcında gerekli yüklemeleri yapıp kenara çekilmesi gibi bir durum yoktur. Runtime sırasında sürekli çalışarak gelen class yükleme isteklerine göre ilgili sınıfları yükler. Bir sınıf yüklendikten sonra memory’den silinmez, uygulama boyunca kalır.

Örneğin bir class JVM’e yüklenirken Classloader ilgili sınıfı bulamazsa ClassNotFoundException hatasını atar. Java’nın sınıfın tanımını bulamamasının farklı nedenleri olabilir ancak en yaygın nedeni gereken jar kütüphanelerinin eksik olmasıdır. ClassNotFoundException, bir checked exception’dır ve genelde reflection ile (Class.forName() vb. yöntemlerle) class yüklenirken alınır.

NoClassDefFoundError ise fatal bir hatadır ve daha önce başarıyla derlenen bir class’a ait tanım bulunamadığı zaman atılır. Bu durum genellikle class’a ait statik bir blok çalıştırılırken veya sınıfın statik alanlarını initialize edilirken bir hata oluştuğunda ve dolayısıyla sınıfın initialize edilmesi başarısız olduğunda meydana gelir.

Class Loader alt sistemi sınıf yükleme işlemini 3 aşamada yapar.

· Loading
· Linking
· Initialization

Class Loader subsystem — Loading

Class Loader’ın Loading aşaması yüklenmek istenen sınıfın bytecode’unun (dosya sisteminde veya ağ üzerinde bulunan) bir kaynak dizinden okunmasından sorumludur. Bu işlem 3 farklı kısımda yapılır.

· Bootstrap Class Loader
· Extension/Platform Class Loader
· Application Class Loader

Bootstrap Class Loader $JAVA_HOME/jre/lib/* altındaki en temel JRE kütüphanelerinin yüklendiği JVM içine gömülü Class Loader’dır. Örneğin (JDK 9.0 öncesi) rt.jar kütüphanesi bu Class Loader tarafından yüklenir. Bu kütüphanelerin içinde java.lang, java.net, java.util, java.io gibi standart Java paketleri bulunur.

Bu kütüphaneler diğer bütün kütüphaneler tarafından kullanılmaktadır. Bu nedenle «Bootstrap Class Loader» diğerleri (Extension/Platform ve Application) ile kıyaslandığında önceliklidir.

Extension/Platform Class Loader sınıf yükleme önceliğide ikinci sıradadır ve $JAVA_HOME/jre/lib/ext/* altındaki Java kütüphanelerini yükler. java.ext.dirs sistem özelliği kullanılarak başka dizinler de okunabilir.

Bu kütüphaneler uygulamanın ihtiyaç duyacağı kütüphaneler tarafından kullanılabilir. Bu nedenle önem sırası olarak «Bootstrap Loader»dan sonra «Application Loader»dan önce gelir.

Java 9 ve sonraki sürümlerde, Extension Class Loader kaldırılmış ve yerine bu dizindeki modülleri yükleyen Platform Class Loader’lar eklenmiştir. Extension Class Loader önceki sürümlere uyumluluk için çalışmaya devam etmektedir.

Application Class Loader ise Extension Class Loader’ın altında yer alır. Bu kısım uygulamaya ait kütüphanelerin yüklenmesinden sorumludur. Çalışma komutundaki “-classpath” veya “-cp” parametreleri ile belirtilen dosyaları yükler.

Application Class Loader, System Class Loader olarak da ifade edilebilir.

Örneğin aşağıdaki komutlarda –cp veya –classpath olarak ifade edilen dizinlerden uygulama için gerekli kütüphaneler ve sınıflar yüklenir.

java -cp SampleApplication.jar
java MyApplication
java -classpath "Test.jar;lib/*" my.package.MainClass (Windows)
java -cp "Test.jar:lib/*" my.package.MainClass (Unix)

Örneğin aşağıdaki metot çalıştırıldığında ilgili classloader’ları console’a yazar.

public static void printClassLoaders() throws ClassNotFoundException {
System.out.println("loader this class:" + StaticTest.class.getClassLoader());
System.out.println("loader ArrayList:" + ArrayList.class.getClassLoader());
}

Çıktı olarak aşağıdaki satırlar yazılır. this class’a ait classloader’ın AppClassLoader, Java temel kütüphanesine ait ArrayList’e ait classloader’ın ise BootStrap ClassLoader olduğu (null olarak gösterilen) görülür.

loader this class:sun.misc.Launcher$AppClassLoader@73d16e93
loader ArrayList:null

Class Loader subsystem — Linking

Linking (Bağlama), tek bir çalıştırılabilir program oluşturmak için birden fazla dosya veya kitaplığın bir araya getirilmesi sürecini ifade eder. Bu süreç, kod veya verilere yapılan referansların çözümlenmesini ve gerekli tüm bileşenlerin uyumlu ve işlevsel bir program oluşturmak üzere birleştirilmesini içerir.

Bu terim sadece Java’ya ait değildir. Çok sayıda programlama dilinde Linking aşaması bulunmaktadır. Örneğin, C, C++ gibi diller Linking işlemini program çalışmaya başlamadan önce yaparlar. Buna statik linking denir.

Java, Linking işlemini dinamik olarak runtime sırasında gerçekleştirilir. Class Loader’ın Linking aşamasında aşağıdaki işlemler yapılır.

· Verify
· Prepare
· Resolve

Verify

Verify (Doğrulama) aşamasında yüklenen class dosyalarındaki bytecode’ların doğruluğu kontrol edilir. Bytecode’un formatında bir sorun olması, derleyici sürümlerinin uyumsuz olması gibi bir problem ile karşılaşılırsa java.lang.VerifyError hatası atılır.

Ve tabi ki eğer class yapısı bozulmuşsa ve bytecode dosyasının başında 0xCAFEBABE büyülü sayısı yazmıyorsa aşağıdaki hata alınabilir : )

java.lang.RuntimeException: java.lang.ClassFormatError: JVMCFRE076 bad magic number;

Prepare

Prepare (Hazırlama) aşamasında class’lar ve interface’ler içindeki static alanlar için memory’de yer ayrılır ve varsayılan değerleri atanır. Burada varsayılan değerlerden kasıt kodda geçen değerler değil Java’nın ilgili değişken tipi için belirlediği varsayılan değerdir. Kodda geçen değerler daha sonra atanacaktır.

Aşağıdaki değişken tipleri için ilgili atamalar yapılır.

class Test {
static boolean flag = true; // class variable
int j; // instance variable
}

Örneğin yukarıdaki kodda Prepare aşamasında JVM tarafından flag değişkenine false değeri atanır. Daha sonra kodda geçen true değeri atanacaktır.

Resolve

Doğrulama işleminden sonra bir class’a ait bileşenler, örneğin değişkenler, metot’lar veya instance’lar sembollere dönüştürülür. Bu semboller, ilgili bileşenlerine mantıksal referansı tutan String’lerdir.

JVM çalışırken Resolve (Çözümleme) aşamasında bu sınıfları ve bileşenlerini Class Loader’da bulmak için bu sembolik referansları kullanacaktır. Yani Resolve, bu sembolik referansları gerçek referanslara dönüştürme sürecidir.

Class Loader subsystem — Initialization

Daha önce Prepare (Hazırlama) aşamasında class’a ait static alanlara Java dilindeki varsayılan değerler atanmıştı. Initialization aşamasında class’a ait değişkenlere kodda belirtilen değerler atanır. Ayrıca class içindeki static bloklar çalıştırılır. Bu işlemler class’ın ilk aktif kullanımı (yani Class Loader’ın yüklemesi) ile olur.

class SampleClass {
// Static block
static
{
System.out.print("Static block");
}
}

JVM Memory

Java Virtual Machine’in kullandığı bellek 5 ana kısımdan oluşur.

JVM Memory — Heap Area

JVM belleğinin bir parçası olan Heap Area çalışan bütün thread’ler tarafından paylaşılır. JVM belleğinin bu kısmında runtime sırasında oluşturulan nesneler (class instance) bulunmaktadır. Stack memory’deki referanslar heap memory’deki gerçek nesneleri gösterir.

Heap memory’de yer kalmadığı zaman Garbage Collector mekanizması devreye girerek bellekte yer açar. GC algoritmasına göre Heap memory, Young Generation, Old Generation vb. bölümlere ayrılabilir.

· Her JVM için bir Heap bellek bulunur. Bu JVM içindeki thread’ler aynı Heap’i kullanır.
· JVM başlangıcında oluşturulur.
· Nesneler ve onlara ait değişkenler burada bulunur.
· Aynı anda çok sayıda thread buraya eriştiği için Heap’in kendisi thread-safe değildir.

Heap memory’nin boyutu aşağıdaki parametreler ile ayarlanabilir.

Bu örnekte Heap açılış boyutu 1 GB, en büyük boyutu ise 4 GB olarak ayarlanmıştır.

java -Xmx4G –Xms1G

JVM Memory — Stack Area

Stack Area ise, statik bellek ayarlanması ve bir thread’in çalıştırılması sırasında metot’ların yönetimi için kullanılır. Bir metot’a özgü primitive değişkenleri ve heap’deki nesnelere olan referansları içerir.

Bu belleğe erişim Son Giren İlk Çıkar (LIFO) şeklinde yapılır. Yeni bir metot çağrıldığında, ilgili thread’e ait stack kısmına primitive değişkenler ve nesnelere referanslar gibi o metot’a özgü değerleri içeren yeni bir blok (stack frame) eklenir.

Metot bittiğinde ise karşılık gelen stack alanı temizlenir ve akış çağıran metot’a geri döner. Böylelikle bir sonraki metot için Stack Area’da yer açılmış olur.

Aşağıdaki kodda önce main metotu başlatılır ve Stack’e eklenir. Bu metotun içinden processCustomers çağırılır, bu metotun içinden de for döngüsü ile processCustomer çalıştırılır. Sonra sırayla printCustomer ve sistem kütüphanesine ait println çalıştırılır. Bütün bu metotlar çalıştıkça stack’e eklenir, bittikçe de stack’ten silinir.

public static void main(String[] args) {
List<Customer> customerList = new ArrayList<Customer>();
new CustomerProcessor().processCustomers(customerList);
}

private void processCustomers(List<Customer> customerList) {
for (Iterator itr = customerList.iterator(); itr.hasNext();) {
Customer customer = (Customer) itr.next();
processCustomer(customer);
}
}

private void processCustomer(Customer customer) {
printCustomer(customer);
}

private void printCustomer(Customer customer) {
System.out.println(customer);
}

Stack Area içinde o sırada JVM’de çalışan her bir thread için ayrı stack’ler bulunur.

Java kodlarında yapılan recursive çağrılarda bir hata var ise bu Stack Area’nın dolmasına ve StackOverflowError alınmasına neden olabilir.

Örneğin aşağıdaki kod StackOverflowError hatası alır.

private static void calculate(int value) {
value = value - 1;
System.out.println(value);
calculate(value);
}

Stack memory boyutunu ayarlamak için –Xss parametresi kullanılır. Burada <heap boyutu> kısmına istenilen boyut yazılırken [birim] kısmına da gb için g, MB için m, KB için k yazılabilir.

-Xss<stack boyutu>[birim]

Örneğin Stack boyutunu 1024 KB yapmak için aşağıdaki parametre kullanılabilir.

java -Xss1024k

Stack ve Heap

Stack ve Heap belleklerinin birbirleriyle nasıl çalıştıklarını anlamak için aşağıdaki örneğe bakılabilir.

public class PersonPrinter {

public static void main(String[] args) {
int i=1;
Person p = new Person();
Car c = new Car();
new PersonPrinter().printPerson(p);
}

private void printPerson(Person p) {
p.setAge(35);
String str = p.toString();
System.out.println(str);
}

}

Bu kod parçası JVM tarafından çalıştırıldığında ilk olarak main metotuna girilir. Bu metot içindeki primitive değişken olan int i stack’de oluşturulur. Burada kullanılan Java nesneleri Car ve Person’ın da birer instance’ı Heap bellekte oluşturulur. Bu nesneleri gösteren referanslar ise stack memory’de main’e ait kısma eklenir.

main metotunun içinde printPerson metotu çalıştırılır. Bu metota ait yeni bir “Stack frame” Stack memory’ye eklenir. printPerson metotuna daha önce heap memory’de oluşturulmuş olan Person nesnesinin referansının (kendisi değil) bir kopyası verilir. Java “pass-by-value” çalışan bir dildir.

Aşağıdaki örnekte printPerson metotu içindeki p referansı üzerinde p.setAge(35) gibi bir metot çalıştırıldığında bu işlem Heap memory’deki ilgili nesnenin age özelliğini düzenler. Yani printPerson’dan çıkılıp main’e dönüldüğünde Person nesnesinin age özelliğinin 35 olduğu görülür.

Ancak, printPerson içinde p referansı null yapılsaydı veya new’lense idi main metotundaki Person nesnesini etkilemeyecekti. Bunun nedeni Java’nın “pass-by-value” olması ve printPerson çalıştırılırken main metotundaki orijinal referansın kendisinin değil bir kopyasının geçirilmiş olmasıdır.

Aşağıdaki satırlar printPerson içinde çalıştırıldığında Heap’deki asıl nesneyi etkilemez, yani main metot aynı nesne ile devam eder.

p = null;
p = new Person();

JVM Memory — Method Area

JVM’in çalışan bütün thread’ler arasında paylaşılan Method Area adında bir bölümü bulunmaktadır. Bu kısımda derlenmiş kodlar, class yapıları, class’lara ait constant, değişken ve metot yapısına ait bilgiler bulunmaktadır. Bu kısım metaspace (eski adıyla PermGen — Permanent Generation) olarak da bilinir.

Metaspace boyutu aşağıdaki parametreler ile ayarlanabilir. Örneğin bu kullanımda MetaSpace açılış boyutu 100 MB, en büyük boyutu ise 2 GB olarak ayarlanmıştır.

java -XX:MetaspaceSize=100M
java -XX:MaxMetaspaceSize=2G

JVM Memory — Program Counter Registers

Java runtime’da hangi komutun çalıştırılacağını takip etmek için Program Counter (PC) register’lar kullanılır. Her çalışan JVM thread’inin kendisine ait PC register’ı bulunmaktadır. Bir sonraki adımda çalıştırılacak olan komutun adresi burada tutulur.

Program Counter Registers, sadece thread sayısı kadar çalıştırılacak komut adresi tuttuğu için JMV belleği içinde çok küçük bir alana sahiptir.

JVM Memory — Native Method Stack

Native Method Stack, JVM stack ile benzer veri öğelerini bulundurur ve yerel (Java native olmayan) metotların çalıştırılmasına yardımcı olmak için kullanılır. Native Method Stack’in çalışması için bazı yerel program kodlarını Java uygulamalarına entegre etmemiz gerekir.

Execution Engine

Java Virtual Machine, çalıştırmak istediğimiz program için gerekli ortamı ve kaynakları sunan makine üzerinde çalışan sanal bir makine olarak düşünülebilir. Execution Engine de JVM’de kodların çalıştırılmasından sorumlu merkezi bileşenidir.

Bu bileşen, Class Loader tarafından JVM belleğine yüklenen uygulamaya ait bytecode komutlarını makine koduna çevirerek çalıştırır. Çalışan uygulamanın her bir thread’i Execution Engine’in farklı bir örneği olarak düşünülebilir.

Execution Engine aşağıdaki kısımlardan oluşur.

Execution Engine — Interpreter

Interpreter’ın görevi belleğe yüklenmiş olan bytecode’u okuyarak makine diline çevirmek ve sırayla çalıştırmaktır. Bu bileşenin dezavantajı bir bytecode komutunu her çalıştırmada okumanın ve çevirmenin getirdiği performans kaybıdır.

Örneğin, bir for veya while döngüsü içinde defalarca çalıştırılan bir kod her seferinde Interpreter tarafından bytecode’a çevrilir.

Bu performans kaybına engel olmak için JIT (Just In Time) derleyicisi geliştirilmiş ve Java’ya 1.1 sürümü ile birlikte eklenmiştir.

Execution Engine — JIT compiler

JVM Execution Engine’in önemli bir parçası olan Just In Time (JIT) derleyicisi çalıştırılmak istenen bytecode’ları derleyerek Java uygulamalarının performansını iyileştirir.

Just In Time (JIT) derleyicisi kodları derlerken uygun olan yerlerde gerekli optimizasyonları da yapar. JIT’in bir parçası olan HotSpot Profiler da çalışan kodlar ile ilgili bilgi toplayarak en fazla çalışan komutları belirler ve bir kod parçasının çalışma adedi belirli bir sayıyı geçtiğinde bu parçayı code cache’e ekler.

Bu işlem aşağıdaki sırayla yapılır.

· JVM interpreter her bytecode’u adım adım işler ve bayt kodunu makine kodu eşlemesini kullanarak makine koduyla yorumlar.
· JVM, bir kodun kaç kez çalıştırıldığını saymak için bir sayaç kullanarak sürekli kodun profilini çıkarır ve sayaç belirli bir değere ulaşırsa, optimizasyon için bu kodu derlemek ve kod önbelleğinde (code cache) saklamak için JIT derleyicisini kullanır.
· JVM daha sonra bu kodun zaten derlenmiş olup olmadığını kontrol eder. JVM, kod önbelleğinde önceden derlenmiş bir kod bulursa daha hızlı çalıştırma için derlenmiş kodu kullanır.

Bu sayede Interpreter ilgili kod parçasını her seferinde yorumlamak zorunda kalmaz ve code cache’de bulunan kodu direk çalıştırır.

private static void iterateMethod() {
System.out.println("Hello World!");
}

public static void main(String[] args) {
while (true) {
iterateMethod();
}
}

Örneğin yukarıdaki kod derlendikten sonra Interpreter tarafından satır satır çalıştırılırken iterateMethod’un çok kez çalıştığı Hotspot profiler tarafından görülür. Bu durumda, bu kod parçasının derlenmiş hali code cache’e eklenerek her seferinde yorumlanması yerine derlenmiş hali direk çalıştırılır ve performans kazanımı olur.

Execution Engine — Garbage Collector

Garbage Collection (GC), otomatik bellek yönetimi mekanizmasıdır. Bu işlem heap belleğe bakıp, kullanılan objelerin tespit edilmesi ve referans edilmeyenlerin silinmesi üzerine kuruludur. Kullanılmayan/referans edilmeyen nesnelerin kapladığı alan bellekte boşa çıkarılır ve bellekte boş yer açılmış olur.

Java GC arka planda otomatik olarak çalışır ve programcının GC ile ilgili herhangi bir kodu çalıştırmasına gerek yoktur. Ancak, Garbage collector’un nasıl çalıştığını bilmek daha etkin kodlama yapılmasına ve hataların giderilmesine yardımcı olur.

Garbage Collector hakkında ayrıntılı bilgiye aşağıdaki makaleden ulaşılabilir.

Java Virtual Machine Mimarisi Özet

Referanslar

https://kplnosmn94.medium.com/jvm-jre-ve-jdk-nedir-6cfee2727812

https://medium.com/@nipunthathsara/java-class-loading-df3b91bb0ee8

https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-2.html

https://www.baeldung.com/java-stack-heap

https://medium.com/@arvindcarpenter94/how-objects-and-references-are-stored-in-jvm-memory-areas-b6d3c5d74aad

https://medium.com/@lfoster49203/java-bytecode-and-class-loading-f0cdac74e0c4

https://subscription.packtpub.com/book/programming/9781800564909/2/ch02lvl1sec06/understanding-the-jvm-architecture

--

--