JVM Nasıl Çalışır Yazı Serisi – Java Hotspot, Assembly Kod, Hafıza Bariyerleri ve Volatile Analizi

Java siniflari java derleyicisi javac (compiler) tarafından bytekoduna dönüştürülür.

public class HelloWorld {

	public static void main(String[] args) {
		System.out.println("Hello World");
	}
}

Yukarida yer alan HelloWorld sinifinin bytekod olarak derlenmis seklini asagida görmekteyiz:

public class HelloWorld extends java.lang.Object{
public HelloWorld();
  Code:
   0:	aload_0
   1:	invokespecial	#1; //Method java/lang/Object."<init>":()V
   4:	return
 
public static void main(java.lang.String[]);
  Code:
   0:	getstatic	#2; //Field java/lang/System.out:Ljava/io/PrintStream;
   3:	ldc	#3; //String Hello World
   5:	invokevirtual	#4; //Method java/io/PrintStream.println:(Ljava/lang/String;)V
   8:	return
}

Derlenmis bir java sinifinin bytekod ciktisini su sekilde alabiliriz:

javap -classpath .  -c HelloWorld

Oluşan bytekod dogrudan işlemci (CPU) üzerinde koşturulamaz, cünkü bytekod islemcinin anlayacagi bir yapida degildir. Bytekodu yorumlayan birimin adi java sanal makinedir (jvm – java virtual machine). Kosturmak istedigimiz derlenmis bir java sinifi classloader olarak tanimlanan sinif yükleyicileri tarafindan hafizaya yüklenir. Bu islemi örnegin su sekilde baslatabiliriz:

java -classpath .  HelloWorld

Java komutu sanal makineyi baslatir ve sanal makinenin bünyesindeki sinif yükleyicileri devreye girerek, derlenmis ve bir class dosyasi halindeki HelloWorld sinifini hafizaya yükler. HelloWorld sinifi bünyesindeki kodlarin sanal makine tarafindan kosturulabilmeleri icin bu sinif bünyesinde main() isminde bir metodun bulunmasi gerekmektedir. Bu metod uygulamanin giris noktasidir ve sanal makina bu metodun basindan itibaren komutlari kosturmaya baslar.

Sanal makine bytekodlari iki sekilde kosturur. Bunlardan ilkinde bytekod yorumlanir. Ikincisinde ise bytekod mikroislemcinin anlayacagi sekilde derlenir. Bytekodun yorumlanmasi durumunda sanal makine bytekodun karsiligi olan islemci kodunu (microcode) tespit eder ve bu islemci kodunun kendi bünyesindeki derlenmis halini islemci üzerinde kosturur. Bytekodun yorumlanmasi java uygulamalarinin daha yavas calisir durumda olmalari dejavantajini beraberinde getirmektedir. Uygulamanin performansini artirmak icin bytekodun islemci koduna derlenmesi gerekmektedir. Bytekod derleme görevini sanal makine bünyesinde JIT (just in time) Hotspot derleyicisi (compiler) üstlenmektedir. Sikca ihtiyac duyulan kod birimleri JIT tarafindan derlenir ve dogrudan islemci üzerinde kosturulur. JIT olmadan sanal makine sadece bir bytekod yorumlayıcısıdır (interpreter). Hotspot çok sık kullanılan kod bölümlerini, mikroişlemci üzerinde daha hızlı koşturulabilmeleri için doğrudan mikroislemci koduna dönüştürür. Bu işlemi yapabilmesi için belli bir süre kodu analiz etmesi (profiling) gerekmektedir. Bu sebepten dolayı Java uygulamaları belli bir ısınma aşamasından sonra daha hızlı çalışmaya başlarlar, çünkü bytekodun belli bir kısmı ya da hepsi JIT tarafından Assembly oradan da islemi koduna dönüştürülmüştür.

JIT tarafından oluşturulan Assembly kodunun çıktısını alabilmek için bir Hotspot Disassembler plugin kullanmamız gerekmektedir. Kenai base-hsdis projesi bünyesinde böyle bir plugin yer almaktaktadır. Ben bu yazımdaki örnekler için Linux altında 64 bit JDK7 (jdk1.7.0_45) kullandım. Kullandığım disassembler plugin linux-hsdis-amd64.so ismini taşıyor. Bu dosyanın libhsdis-amd64.so ismini taşıyacak şekilde jdk1.7.0_45/jre/lib/amd64/server dizinine kopyalanması gerekiyor. Bu işlem ardından çalışan bir Java uygulamasının makine kodu çıktısını alabiliriz.

Önce koşturmak istediğimiz Java koduna bir göz atalım. Main sınıfı bünyesinde volatile olan volatileCounter ve counter değişkenleri yer almaktadır. count() metodu bünyesinde bir for döngüsünde bu değişkenlerin değerleri artırılmaktadır. For döngüsü volatileCounter değişkeni 100000 değerine ulaştığında son bulmaktadır. Hotspot JIT kod 100000 sefer koşturulduğundan dolayı islemci makine koduna dönüştürmektedir. Bu uygulamanın çok sıkca kullanılan alanlarının (hotspot; sıcak alan anlamında) makine koduna dönüştürülerek, daha da hızlı koşturulabilmeleri için gerekli bir işlemdir. Hotspot sadece sıkca koşturulan kodları makine koduna dönüştürür. Sıkça kullanılmayan kod bloklarının JVM tarafından yorumlanma hızı yeterlidir. Bu tür kodlar için makine kodunun oluşturulması çok maliyetli bir işlemdir. Elde edilecek kazanç çok az olacağından, sıkça kullanılmayan kod blokları makine koduna dönüştürülmez.

Eğer count() metodunda bir for döngüsü kullanılmasaydı, JIT tarafından daha sonra inceleyecegimiz assembly kodu oluşturulmazdı. Bunu sağlayan döngünün 100000 adet olmasıdır. JIT hangi kod bloğunun ne kadar koşturulduğunu takip ettiği için hangi kod birimi için makine kodu oluşturacağına karar verebilmektedir.

public class Main {

	private volatile int volatileCounter;
	private int counter;

	public static void main(final String[] args) {

		new Main().count();
	}

	private void count() {

		for (; this.volatileCounter < 100000;) {
			this.volatileCounter++;

			synchronized (this) {
				this.counter++;
			}
		}
	}
}

Java derleyicisi (javac) tarafından oluşturulan bytekodunu aşağıdaki resimde görmekteyiz.

Şimdi gelelim Hotspot JIT tarafından oluşturulan assembly koduna. Assembly kodunu görebilmek için java sanal makineyi aşağıdaki şekilde çalıştırmamız gerekiyor:

java -cp . -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly Main

Aşağıda oluşan Assembly kodu yer almaktadır:

1   0x00007f206906015c: jne    0x00007f2069060202  ;*monitorenter
                                                ; - Main::count@16 (line 16)
2   0x00007f2069060162: incl   0x10(%r13)
3   0x00007f2069060166: mov    $0x7,%r10d
4   0x00007f206906016c: and    0x0(%r13),%r10
5   0x00007f2069060170: cmp    $0x5,%r10
6   0x00007f2069060174: jne    0x00007f2069060226  ;*monitorexit
                                                ; - Main::count@28 (line 16)
7   0x00007f206906017a: mov    0xc(%r13),%r11d    ; OopMap{rbp=NarrowOop r13=Oop r14=Oop off=222}
                                                ;*if_icmplt
                                                ; - Main::count@41 (line 13)
8   0x00007f206906017e: test   %eax,0xa91ee7c(%rip)        # 0x00007f207397f000
                                                ;   {poll}
9   0x00007f2069060184: cmp    $0x186a0,%r11d
10  0x00007f206906018b: jge    0x00007f20690602ef  ;*aload_0
                                                ; - Main::count@3 (line 14)
11  0x00007f2069060191: mov    0xc(%r13),%r11d
12  0x00007f2069060195: inc    %r11d
13  0x00007f2069060198: mov    %r11d,0xc(%r13)
14  0x00007f206906019c: lock addl $0x0,(%rsp)     ;*putfield volatileCounter
                                                ; - Main::count@10 (line 14)
15  0x00007f20690601a1: mov    0x0(%r13),%rax
16  0x00007f20690601a5: mov    %rax,%r10
17  0x00007f20690601a8: and    $0x7,%r10
18  0x00007f20690601ac: cmp    $0x5,%r10
19  0x00007f20690601b0: jne    0x00007f2069060106
20  0x00007f20690601b6: mov    0xb0(%r12,%rbp,8),%r10
21  0x00007f20690601be: mov    %r10,%r11
22  0x00007f20690601c1: or     %r15,%r11
23  0x00007f20690601c4: mov    %r11,%r8
24  0x00007f20690601c7: xor    %rax,%r8
25  0x00007f20690601ca: test   $0xffffffffffffff87,%r8
26  0x00007f20690601d1: je     0x00007f2069060162
27  0x00007f20690601d3: test   $0x7,%r8
28  0x00007f20690601da: jne    0x00007f2069060100
29  0x00007f20690601e0: test   $0x300,%r8
30  0x00007f20690601e7: jne    0x00007f20690601f6
31  0x00007f20690601e9: and    $0x37f,%rax
32  0x00007f20690601f0: mov    %rax,%r11
33  0x00007f20690601f3: or     %r15,%r11
34  0x00007f20690601f6: lock cmpxchg %r11,0x0(%r13)
35  0x00007f20690601fc: je     0x00007f2069060162
36  0x00007f2069060202: mov    %r14,0x8(%rsp)
37  0x00007f2069060207: mov    %r13,(%rsp)
38  0x00007f206906020b: mov    %r14,%rsi
39  0x00007f206906020e: lea    0x10(%rsp),%rdx
40  0x00007f2069060213: callq  0x00007f206905e320  ; OopMap{rbp=NarrowOop [0]=Oop [8]=Oop off=376}
                                                ;*monitorenter
                                                ; - Main::count@16 (line 16)
                                                ;   {runtime_call}
                                                

Java kodu sequentially consistent değildir yani Java bytekodun sahip olduğu sıraya göre satır satır koşturulma mecburiyeti yoktur. Hem Java derleyicisi (JIT) hem de mikroişlemci performansı artırmak için birbirine bağımlı olmayan kod satırlarının yerlerini değiştirerek işlem yapabilirler. Bu aslında oluşan java kodunun programcının yazdığı şekilde koşturulmadığı anlamına gelmektedir. Paralel çalışan programlarda bunun çok ilginç bir yan etkisi mevcut: bir threadin bir değişken üzerinde yaptığı değişikliği başka bir çekirdek üzerinde koşan başka bir thread göremeyebilir. Örneğin t1 (thread 1) a isimli değişkenin değerini bir artırdı ise ve t2 a belli bir değere sahip iken bir for döngüsünü terk etmek istiyorsa, t2 belki bu for döngüsünden hiçbir zaman çıkamayabilir, çünkü a üzerinde t1 tarafından yapılan değişiklikleri göremeyebilir. Bunun sebebi t1 in üzerinde çalıştığı çekirdeğin (core) a üzerinde yaptığı değişiklikleri kendi ön belleginde (L1/L2 cache) tutmasıdır. Mikroişlemciler yüksek performansta çalışabilmek için hazıfa alanları üzerinde yaptıkları değişiklikleri ilk etapta kendi ön belleklerinde tutarlar. Gerek duymadıkça da bu değişiklikleri diğer çekirdeklerle paylaşmazlar. Her çekirdek sahip olduğu önbelliği tüm hafiza alanıymış (RAM) gibi gördüğü için kendi performansını artırmak adına kod sırasını değiştirebilir. Bu belli bir sıraya bağımlı olan diğer threadlerin düşünüldükleri şekilde çalışmalarını engelleyebilir. Bu genelde paralel programlarda program hatası olarak dışarıya yansır.

Bu tür sorunları aşmanın bir yolu volatile tipinde değişkenler kullanmaktır. Volatile tipinde olan değişkenler üzerinde işlem yapıldığında mikroişlemci kodu programcının yazdığı sırada koşturmaya zorlanır. Bunu gerçekleştirmek için hafıza bariyerleri (memory barrier) kullanılır. Mikroişlemci bir hazıfa bariyeri ile karşılaştığında bir çekirdeğin sahip olduğu önbellekteki değişiklikleri doğrudan hafızaya geri yazar (write back) ve bu hazıfa alanını (cache line) kendi önbelleklerinde tutan diğer çekirdeklere mesaj göndererek bu hafıza alanını silmelerini (cache invalidate) talep eder. Böylece herhangi bir çekirdek üzerinde koşan bir threadin yaptığı değişiklik hemen hafızaya, oradan da diğer çekirdeklerin önbelleklerine yansır. Bu t1 tarafından a üzerinde yapılan bir değişikliğin t2 tarafından anında görülmesi anlamına gelmektedir. Bunun gerçekleşmesi için a isimli değişkenin volatile olması gerekmektedir.

Main sınıfında yer alan this.volatileCounter++; satırı ile bahsettiğim hafıza bariyerinin kullanımı gerekmektedir. JVM bunu sağlamak için Assemby kodunun 14. satırında mikroişlemci için lock addl komutunu kullanmaktadır. 13. satırda yer alan mov komutuyla %r11d registerinde yer alan volatileCounter isimli değişkenin değeri doğrudan hafızaya (RAM) aktarılır. Hafiza alanının adresi %r13 registerinde yer almaktadır. 14. satırda yer alan lock addl ile tüm mikroişlemci bünyesinde global bir hafıza transaksiyonu gerçekleştirilir. Atomik olan bu işlem ile tüm çekirdeklerin üzerinde değişiklik yapılan hafiza alanını önbelleklerinden silmeleri ve yeniden yüklemelerini sağlanır. Böylece diğer threadler yapılan değişiklikleri anında görmüş olurlar.

Java kodunu sequentially consistent yapmanın diğer bir yolu synchronized kelimesinin kullanımıdır. Synchronized kullanılması durumunda mikroişlemci değişikliğe uğrayan değeri önbellekten alıp hafızaya geri aktararak, diğer çekirdeklerin kendi önbelleklerini tazelemelerini sağlar.

Main sınıfında yer alan counter isimli değişken volatile olmadığı için üzerinde yapılan değişiklikler diğer çekirdeklere yansımaz. Bunu sağlamak için count() metodunda değer atamasını synchronized bloğunda yaptım. Bu JVM tarafından tekrar bir hafıza bariyeri kullanımı gerektiren bir işlemdir. JIT makina kodunun 34. satırında lock cmpxchg ile gerekli hafıza bariyerini oluşturmaktadır. Bu üzerinde işlem yapılan çekirdeğin counter isimli değişkenin değerini tekrar hafızaya geri aktarmasını ve diğer çekirdeklerin bu değeri tekrar hafızadan kendi önbelleklerine çekmelerini sağlamaktadır.


EOF (End Of Fun)
Özcan Acar

JVM Nasıl Çalışır Yazı Serisi – Java Hotspot, Assembly Kod, Hafıza Bariyerleri ve Volatile Analizi” hakkında 2 yorum

Yorumlar kapalı.