Java Memory Model ve Yeniden Sıralama
Önce şunu oku: Race Condition ve Görünürlük
30 saniyede özet
Bilgisayar yazdığın satırları her zaman yazdığın sırayla görünür kılmaz. Bu yüzden iki thread'in de ötekinin yazdığını görmediği, programa bakınca imkânsız görünen bir sonuç gerçekten çıkabilir.
İki arkadaş aynı anda birbirine not bırakıyor ve ikisi de boş kâğıt buluyor. Bilgisayarda da olur; şu programa bak:
int x = 0, y = 0; // düz alanlar, volatile değil
// Thread A // Thread Bx = 1; y = 1;r1 = y; r2 = x;İki thread de önce yazıp sonra okuyor. İkisinin birden 0 okuması mümkün mü? Cevabı göster
Mantık “imkânsız” diyor: yazmalardan biri önce olmalı, sonra çalışan onu görmeli. Ama cevap evet, mümkün — ve nedeni dersin tamamı.
Adım adım oku
- A, x = 1 yazar; B, y = 1 yazar. Ama ikisi de önce kendi cebine, yani çekirdeğin store buffer'ına yazar.
- Notlar deftere inmeden ikisi de ortak deftere bakar. A, y'yi 0 görür; B, x'i 0 görür.
- Cepteki notlar deftere ancak sonra iner: x ve y artık 1.
- İki okuma da 0 döndü. Kimse yalan söylemedi; sadece yazmalar geç göründü.
-
Bayt: İki thread de önce yazıyor, sonra okuyor. Biri mutlaka ötekinin yazdığını görür, değil mi?
-
Sen: Mantıklı. En kötü ihtimalle biri 1 görür.
-
Bayt: Ama ikisi de 0 gördü! Hem de programda tek bir hata yokken.
-
Bayt: Her çekirdek önce kendi cebine yazıyor. Not deftere geçmeden öteki bakarsa... boş kâğıt.
Bu nasıl mümkün?
Her çekirdeğin bir store bufferCPU'nun yazmaları ana belleğe göndermeden önce beklettiği küçük kuyruk. İki çekirdeğin birbirinin yazmasını gecikmeli görmesinin donanım sebebi budur.Sözlükte gör →’ı vardır. x = 1 önce oraya düşer, ana belleğe sonra iner.
Bu sırada aynı çekirdek r1 = y okumasını yapar: kendi buffer’ında y yoktur, ana bellekte hâlâ 0 görür. İki çekirdek de aynısını yaparsa ikisi de 0 okur — yazmalar kaybolmadı, sadece geç.
Çekirdek A Çekirdek B[store buffer: x=1] [store buffer: y=1] ↓ y'yi oku ↓ x'i oku ana bellek: x=0, y=0 → r1=0, r2=0Bu bir görünürlük sorunu değil, sıralama sorunudur.
Kafam karıştı, daha basit anlat
Her çekirdek yazdığını önce kendi cebine koyar, ortak panoya sonra asar. Tam o arada öteki çekirdek panoya bakarsa, eski değeri görür.
Satır satır: iki thread, iki sıfır
İkisi de 0 nasıl okur
// baslangic: x = 0, y = 0 // Thread 1 // Thread 2x = 1; y = 1;r1 = y; r2 = x;Debug
thread 1 x = 1 yazılır — ama CPU bunu doğrudan ana belleğe değil, kendi store buffer'ına koyar.
- ana bellek x
- = 0
- T1 store buffer
- = x=1
Sol/sağ ok tuşlarıyla da gezebilirsin.
Kafam karıştı, daha basit anlat
İki thread de önce kendi değerini yazıp sonra ötekini okuyor. Yazılar cepte bekliyorsa, ikisi de ötekinin eski değerini, yani 0 okur.
Tek thread'li bir programda yeniden sıralama neden fark edilmez?
Kendin gör
Store buffer — “imkânsız” görünen sonuç neden çıkıyor
Tohum 3int x = 0, y = 0; Thread A Thread B -------- -------- x = 1; y = 1; r1 = y; r2 = x;
- r1=1, r2=1 — ikisi de diğerinin yazmasını gördü0
- r1=1, r2=0 — yalnızca A gördü0
- r1=0, r2=1 — yalnızca B gördü0
- r1=0, r2=0 — ikisi de görmedi0
Şu an ne oldu?
İki iş parçacığı, iki değişken
A önce x’e yazıp y’yi okuyor, B önce y’ye yazıp x’i okuyor. Programa bakınca ikisinin de 0 okuması mümkün değilmiş gibi görünüyor.
Aklında kalsın: Kaynak kodun sırası, işlemcinin ve JIT’in uygulamak zorunda olduğu sıra değildir. Tek garanti, tek bir iş parçacığının kendi sonucunu doğru görmesidir.
Olay günlüğü (0)
Henüz olay yok. Oynat veya adımla.
Sırayla dene:
Düz int, 10⁴ deneme. Anormallik çıkıyor. İlk kaçıncı denemede çıktığına bak.- Deneme sayısını 10² yap. Çoğu tohumla hiç çıkmıyor. 100 deneme hiçbir şey kanıtlamadı.
Aynı çekirdekanahtarını aç. Anormallik sıfır — ama kod değişmedi. Aynı program başka makinede bozulur.volatile inte geç. Anormallik imkânsız. Ama (1,1) hâlâ çıkıyor.synchronized blok. Şimdi (1,1) de imkânsız. İki satır arasındaki fark tam olarak burada.
Java Memory Model'in asıl işi nedir?
Üç ayar, üç farklı koruma
| (1,1) | (1,0) / (0,1) | (0,0) | |
|---|---|---|---|
| Düz int | ✅ | ✅ | ✅ anormallik |
| volatile | ✅ | ✅ | ❌ imkânsız |
| synchronized | ❌ imkânsız | ✅ | ❌ imkânsız |
- volatileAlanın her okumasının gerçekten yapılmasını ve her yazmasının diğer thread'lere görünmesini garanti eden belirteç. Görünürlük sağlar, atomiklik sağlamaz.Sözlükte gör → erişimleri sıralı tutarlı yapar ama kimin önce çalışacağını belirlemez — (1,1) hâlâ mümkün.
- synchronized ayrıca karşılıklı dışlama verir. Bloklar sırayla çalışır, önce çalışan 0 okur — (1,1) de kalkar.
Aşağıdaki örnek bir bankanın ücret servisinden: yönetici yeni ay için ücret tarifesini yayınlıyor, yüzlerce thread o anda havale ücreti okuyor. Tarifeyi güvenle yayınlamanın dört yolu ve bozuk sürümü yakalayan bir jcstress testi.
Derinleş · Ücret tarifesini güvenle yayınlamak: dört yol 5 dosya · ~111 satır · ilk okumada atlayabilirsin
happens-before bir zaman ilişkisi değildir. “A, B’den önce oldu” demez; “A’nın etkileri B tarafından görülmek zorunda” der.
Garanti veren başlıca kenarlar:
- Aynı iş parçacığında program sırası
- Bir monitörün
unlock’u, sonrakilock’undan önce - Bir
volatileyazma, onu okuyan hervolatileokumadan önce Thread.start(), o thread’in ilk komutundan önce- Bir thread’in son komutu,
join()’in dönüşünden önce - Bir
finalalanın constructor’daki ataması, nesne güvenli yayımlandıysa okumadan önce
İki işlem arasında bir happens-before kenarı yoksa hiçbir sıra garantisi yoktur. Yukarıdaki programda A’nın yazması ile B’nin okuması arasında hiçbir kenar yok — bu yüzden anormallik yasal.
Kafam karıştı, daha basit anlat
volatile yazıları hemen panoya astırır ve sırayı korur. synchronized buna ek olarak bir kapı koyar: içeride aynı anda yalnızca bir thread olur.
Her çift arasında happens-before kenarı var mı?
`final` bir alanın JMM açısından özel bir garantisi var mıdır?
Tuzaklar
Aynı yeniden sıralamaDerleyicinin, JIT'in veya CPU'nun komutları yazdığın sıradan farklı çalıştırması. Tek thread'de sonucu değiştirmez; başka bir thread bunu görebilir.Sözlükte gör →, çok daha sinsi bir hatayı üretir:
class Holder { int value; Holder(int v) { value = v; } }
// Thread Aholder = new Holder(42);
// Thread Bif (holder != null) { use(holder.value); // 0 okuyabilir!}new tek bir işlem değildir: bellek ayır, constructor’ı çalıştır, referansı ata. Son iki adım yeniden sıralanabilir. B, referansı atanmış ama henüz kurulmamış bir nesne görebilir.
Bu, kırık double-checked locking’in tam olarak nedenidir. Düzeltmesi tek kelimedir:
private static volatile Holder instance; // volatile şartfinal alanlar da özel bir garanti taşır: nesne constructor içinden kaçmadıysa, onu gören her thread final alanları kurulmuş hâliyle görür. Değiştirilemez nesnelerin güvenle paylaşılabilmesinin sebebi budur.
“Test ettim, çalışıyor” neden savunma değil. Simülatördeki 2. ve 3. adımlar bu cümleyi çürütüyor:
- Anormallik binde birkaç olabilir; 100 deneme onu göstermez.
- Tek çekirdekte hiç çıkmaz. Dizüstünde yeşil, üretimde kırmızı.
- JIT ısındıktan sonra farklı optimize eder; ilk çalıştırmalar hatayı gizler.
Bu yüzden eşzamanlılık kodu testle değil, modelle doğrulanır: hangi happens-before kenarı bu iki işlemi sıralıyor? Cevap veremiyorsan garanti yok demektir.
Pratikte:
- Paylaşılan değiştirilebilir durumdan kaçın. Değiştirilemez nesne ve
finalalan sorunu kökten kaldırır. - Kaçamıyorsan bir kenar kur.
volatile,synchronized,java.util.concurrent— üçü de happens-before üretir. - Kendi kilitsiz algoritmanı yazma.
ConcurrentHashMapveAtomicReferencebu işi zaten doğru yapıyor.
Kendini sına
Önce hızlı bir ısınma: puan yok, kayıt yok.
Bir thread kendi yaptığı yazmaları her zaman doğru sırada görür.
İki iş parçacığı aşağıdaki gibi çalışıyor. `r1 == 0 && r2 == 0` sonucu mümkün mü?
Aklında kalacak üç şey
- 1 Kodun yazıldığı sıra bir garanti değildir. Java'nın tek sözü, bir thread'in kendi yaptığını doğru sırada görmesidir.
- 2 volatile ile synchronized farklı şeyleri engeller: volatile iki sıfırı imkânsız yapar ama ikisinin de 1 görmesine izin verir; synchronized ikisini de kaldırır.
- 3 'Denedim, çalışıyor' kanıt değildir: bu hata binde birkaç kez çıkar, tek çekirdekte hiç çıkmayabilir. Eşzamanlılık hataları modelle gösterilir.
5 kart sonraki derste seni bekliyor