İçeriğe geç

Java Memory Model ve Yeniden Sıralama

İleri 10 dk Sık karşılaşılır

Ö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 B
x = 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ı.

Yazmak ile görünür olmak arasında bir cep var.
Adım adım oku
  1. A, x = 1 yazar; B, y = 1 yazar. Ama ikisi de önce kendi cebine, yani çekirdeğin store buffer'ına yazar.
  2. Notlar deftere inmeden ikisi de ortak deftere bakar. A, y'yi 0 görür; B, x'i 0 görür.
  3. Cepteki notlar deftere ancak sonra iner: x ve y artık 1.
  4. İki okuma da 0 döndü. Kimse yalan söylemedi; sadece yazmalar geç göründü.
  1. Bayt: İki thread de önce yazıyor, sonra okuyor. Biri mutlaka ötekinin yazdığını görür, değil mi?

  2. Sen: Mantıklı. En kötü ihtimalle biri 1 görür.

  3. Bayt: Ama ikisi de 0 gördü! Hem de programda tek bir hata yokken.

  4. 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=0

Bu 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

StoreBuffer.java
1// baslangic: x = 0, y = 0
2
3// Thread 1 // Thread 2
şu an çalışan satırx = 1; y = 1;
5r1 = y; r2 = x;

Debug

Adım 1/6

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
Java 21UTF-8LF4: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.

Hızlı kontrolİleri

Tek thread'li bir programda yeniden sıralama neden fark edilmez?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Kendin gör

Store buffer — “imkânsız” görünen sonuç neden çıkıyor

Tohum 3
int x = 0, y = 0;

Thread A          Thread B
--------          --------
x = 1;            y = 1;
r1 = y;           r2 = x;
Düz alanlar (int x, y)iki çekirdek0 / 10.000
  • 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
Hız
Adım 0

Ş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:

  1. Düz int, 10⁴ deneme. Anormallik çıkıyor. İlk kaçıncı denemede çıktığına bak.
  2. Deneme sayısını 10² yap. Çoğu tohumla hiç çıkmıyor. 100 deneme hiçbir şey kanıtlamadı.
  3. Aynı çekirdek anahtarını aç. Anormallik sıfır — ama kod değişmedi. Aynı program başka makinede bozulur.
  4. volatile inte geç. Anormallik imkânsız. Ama (1,1) hâlâ çıkıyor.
  5. synchronized blok. Şimdi (1,1) de imkânsız. İki satır arasındaki fark tam olarak burada.
Hızlı kontrolOrta

Java Memory Model'in asıl işi nedir?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Üç 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
Proje dosyaları

src/main/java/fees/ BrokenPublication.java Bozuk: yeni ücret tarifesinin referansı görünür olur ama alanları henüz yazılmamış olabilir. Okuyan thread ücreti 0 görür: bedava havale.

src/main/java/fees/BrokenPublication.java
public class BrokenPublication {
static class Tariff {
long eftFee; // kuruş; not final, not volatile
String version;
Tariff() {
eftFee = 650;
version = "2026-10";
}
}
static Tariff current; // plain field: no happens-before between writer and reader
static void reload() { // the admin publishes next month's tariff
current = new Tariff(); // the reference may become visible before eftFee/version are
}
static void chargeTransfer() {
Tariff t = current;
if (t != null) {
// Allowed by the JMM: t.eftFee == 0 or t.version == null here.
// In production that is a transfer charged nothing, silently.
System.out.println("fee " + t.eftFee + " (tariff " + t.version + ")");
}
}
}

src/main/java/fees/ FeeSchedule.java final alanlar: constructor bitince her thread doğru değerleri görür (this sızdırılmadıysa).

src/main/java/fees/FeeSchedule.java
// final fields: once the constructor finishes, any thread that sees a reference
// to this object sees these values — as long as `this` did not escape during construction.
public final class FeeSchedule {
private final String version;
private final BigDecimal eftFee;
private final Map<String, BigDecimal> feeBySegment;
public FeeSchedule(String version, BigDecimal eftFee, Map<String, BigDecimal> feeBySegment) {
this.version = version;
this.eftFee = eftFee; // BigDecimal is immutable itself
this.feeBySegment = Map.copyOf(feeBySegment); // the map itself must be immutable too
}
public String version() { return version; }
public BigDecimal eftFee() { return eftFee; }
public Map<String, BigDecimal> feeBySegment() { return feeBySegment; }
}

src/main/java/fees/ LazyBureauClient.java Double-checked locking ancak volatile ile doğrudur.

src/main/java/fees/LazyBureauClient.java
public class LazyBureauClient {
// The credit bureau client is expensive to build (TLS, client certificate), so it is
// built on first use. Without volatile, another thread may see a non-null reference
// to a half-constructed client. volatile makes the write happen-before the read.
private volatile HttpClient client;
public HttpClient get() {
HttpClient local = client; // one volatile read on the fast path
if (local == null) {
synchronized (this) {
local = client;
if (local == null) {
local = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build();
client = local;
}
}
}
return local;
}
}

src/main/java/fees/ CurrencyRegistry.java Holder idiom: sınıf yükleme zaten thread-safe; kilitsiz ve tembel.

src/main/java/fees/CurrencyRegistry.java
public final class CurrencyRegistry {
private final Map<String, Currency> byCode;
private CurrencyRegistry() {
byCode = Currency.getAvailableCurrencies().stream()
.collect(Collectors.toUnmodifiableMap(Currency::getCurrencyCode, c -> c));
}
// Holder idiom: Holder is loaded (and INSTANCE built) on first access to it.
// Class initialization is guaranteed to be thread-safe by the JVM.
private static final class Holder {
static final CurrencyRegistry INSTANCE = new CurrencyRegistry();
}
public static CurrencyRegistry instance() {
return Holder.INSTANCE;
}
public Optional<Currency> find(String code) {
return Optional.ofNullable(byCode.get(code));
}
}

src/test/java/fees/ PublicationStress.java jcstress ile bozuk sürümü test etmek: kuralı elle değil araçla doğrula.

src/test/java/fees/PublicationStress.java
// jcstress runs this millions of times on real hardware and reports which
// outcomes it saw. Reasoning about reordering by hand is not enough.
@JCStressTest
@Outcome(id = "650", expect = Expect.ACCEPTABLE, desc = "fully constructed")
@Outcome(id = "-1", expect = Expect.ACCEPTABLE, desc = "not yet published")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "published before the fee was written")
@State
public class PublicationStress {
BrokenPublication.Tariff current;
@Actor
public void reload() {
current = new BrokenPublication.Tariff();
}
@Actor
public void charge(J_Result r) {
BrokenPublication.Tariff t = current;
r.r1 = t == null ? -1 : t.eftFee;
}
}

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, sonraki lock’undan önce
  • Bir volatile yazma, onu okuyan her volatile okumadan önce
  • Thread.start(), o thread’in ilk komutundan önce
  • Bir thread’in son komutu, join()’in dönüşünden önce
  • Bir final alanı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.

Hızlı kontrolİleri

Her çift arasında happens-before kenarı var mı?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Sınıflandırılmamış

Kenar var — sıra garantili

JMM bu ilişkiyi tanımlar

    Kenar yok — garanti yok

    Çalışabilir ama garanti değildir

      `final` bir alanın JMM açısından özel bir garantisi var mıdır?

      Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

      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 A
      holder = new Holder(42);
      // Thread B
      if (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 şart

      final 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 final alan 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. ConcurrentHashMap ve AtomicReference bu işi zaten doğru yapıyor.

      Kendini sına

      Önce hızlı bir ısınma: puan yok, kayıt yok.

      Şimşek turu1/5

      Bir thread kendi yaptığı yazmaları her zaman doğru sırada görür.

      Soru 1/3İleri

      İki iş parçacığı aşağıdaki gibi çalışıyor. `r1 == 0 && r2 == 0` sonucu mümkün mü?

      Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.
      StoreBuffer.java
      1int x = 0, y = 0; // volatile DEĞİL
      2int r1, r2;
      3
      4// Thread A // Thread B
      5x = 1; y = 1;
      6r1 = y; r2 = x;
      Java 21UTF-8LF

      Bu çıktı garanti değildir — program non-deterministiktir, farklı çalıştırmada başka sonuç verebilir.

      Aklında kalacak üç şey

      1. 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. 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. 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.
      Sonraki kapı Havuzdaki bir thread, bir önceki kullanıcının bilgisini nasıl hatırlayabilir? ThreadLocal ve ScopedValue — Havuzdaki Thread Neyi Hatırlıyor? · 9 dk

      5 kart sonraki derste seni bekliyor

      0/5 kart bu dersten toplandı