Java Memory Model (JMM)
Bir değişkene değer atadın, başka bir thread o değişkeni okudu — ama eski değeri gördü. Kod doğru görünüyor, tek thread'de çalışıyor, ama multi-thread'de bozuluyor. Test'te geçiyor, production'da patlıyor. Hafta sonları çalışıyor, pazartesi çalışmıyor.
Bu tür hayalet bug'ların sebebi Java Memory Model (JMM). JMM, multi-threaded bir ortamda bir thread'in yaptığı değişikliklerin diğer thread'ler tarafından ne zaman ve nasıl görüleceğini tanımlayan kurallar bütünüdür.
Bunu şöyle düşün: Bir ofiste çalışıyorsun. Herkesin masasında bir not defteri var (CPU cache). Ofiste bir de ortak beyaz tahta var (ana bellek / RAM). Sen bir bilgiyi not defterine yazarsın — ama beyaz tahtaya ne zaman aktarırsın? Karşıdaki arkadaşın beyaz tahtayı ne zaman kontrol eder? İşte JMM tam olarak bu koordinasyonu tanımlar.
Bu ders, Java'nın en karmaşık ama en kritik konularından biri. Eğer multi-threaded kod yazıyorsan — ki modern Java'da neredeyse kaçınılmaz — bu kuralları bilmek zorundasın.
Sorun: Bellek Görünürlüğü
Modern CPU'lar performans için cache kullanır. Her CPU çekirdeğinin kendi L1/L2 cache'i vardır. Bir thread bir değişkeni değiştirdiğinde, bu değişiklik önce cache'e yazılır. Ana belleğe (RAM) ne zaman yansıyacağı belirsizdir.
class VisibilityProblem {
private boolean running = true;
void stop() {
running = false; // Thread-1 bunu yazar
}
void run() {
while (running) { // Thread-2 bunu okur
// iş yap
}
System.out.println("Stopped!");
}
}Thread-1 running = false yapar. Thread-2 hâlâ running == true görüyor ve döngüden çıkamıyor. Neden? Thread-1'in değişikliği kendi cache'inde kaldı, Thread-2'nin cache'ine henüz yansımadı.
Bu bellek görünürlüğü (memory visibility) problemidir. JMM, bu sorunu çözmek için happens-before kurallarını tanımlar.
Happens-Before İlişkisi
JMM'nin temel kavramı happens-before'dur. Eğer A işlemi B işleminden happens-before ise, A'nın yaptığı tüm değişiklikler B tarafından garantili olarak görülür.
Happens-before bir zaman kavramı değil, bir görünürlük garantisidir. "A, B'den önce oldu" değil, "A'nın etkileri B'ye görünür" demek.
Temel Happens-Before Kuralları
1. Program Order Rule: Aynı thread içinde, bir ifade kendisinden sonraki ifadeden happens-before'dur. Tek thread'de her şey sıralı görünür.
2. Monitor Lock Rule: Bir lock'un serbest bırakılması (unlock), aynı lock'un sonraki edinilmesinden (lock) happens-before'dur.
synchronized (lock) {
x = 42; // (A)
} // unlock happens-before...
// Başka thread:
synchronized (lock) { // ...bu lock
int r = x; // (B) — 42 görmeyi garanti eder
}3. Volatile Variable Rule: Bir volatile değişkene yazma, aynı değişkenin sonraki okunmasından happens-before'dur.
4. Thread Start Rule: thread.start() çağrısı, başlatılan thread'deki tüm işlemlerden happens-before'dur.
5. Thread Join Rule: Bir thread'deki tüm işlemler, o thread'e yapılan join() çağrısından happens-before'dur.
6. Transitivity: Eğer A happens-before B ve B happens-before C ise, A happens-before C'dir.
// Thread Start Rule örneği
int value = 0;
value = 42; // (A)
Thread t = new Thread(() -> {
System.out.println(value); // (B) — 42 görmeyi garanti eder
});
t.start(); // A happens-before B (Thread Start Rule)volatile Keyword Derinlemesine
volatile, bir değişkenin her okunmasının ana bellekten yapılmasını ve her yazmanın ana belleğe yansımasını garanti eder. CPU cache'inden okuma/yazma yapmaz.
class TaskRunner {
private volatile boolean running = true;
void stop() {
running = false; // Ana belleğe yazılır
}
void run() {
while (running) { // Her iterasyonda ana bellekten okunur
// iş yap
}
System.out.println("Stopped!"); // Artık çıkabilir
}
}volatile eklediğimizde, stop() çağrıldığında run() method'undaki döngü kesinlikle sona erer. Çünkü volatile yazma, volatile okumadan happens-before'dur.
volatile Ne Yapar, Ne Yapmaz?
Yapar:
Görünürlük garantisi — değişiklik diğer thread'lere anında görünür
Reordering'i engeller — volatile okuma/yazma etrafında belirli sıralama garantileri verir
Yapmaz:
Atomiklik sağlamaz!
count++işlemi volatile olsa bile thread-safe değildir
private volatile int count = 0;
// BU THREAD-SAFE DEĞİL!
void increment() {
count++; // Okuma → artırma → yazma: 3 ayrı işlem
// İki thread aynı anda okuyabilir, ikisi de aynı değeri artırır
}count++ aslında üç adımdır: oku (count'un değerini al), artır (1 ekle), yaz (yeni değeri ata). volatile sadece her adımın görünür olmasını sağlar — ama iki thread arasında interleave olabilir.
volatile Ne Zaman Kullanılır?
Flag değişkenleri:
boolean running,boolean cancelledgibi. Bir thread yazar, diğeri okur.Bağımsız durum: Değişkenin yeni değeri eski değerine bağlı değilse (yani
x = 5amax++değil).Double-checked locking pattern'inde (aşağıda göreceğiz).
synchronized'ın Memory Semantics
synchronized sadece mutual exclusion (karşılıklı dışlama) sağlamaz — bellek görünürlüğü de sağlar. Bu genellikle gözden kaçan bir detaydır.
Bir thread synchronized bloğuna girdiğinde, cache'ini geçersiz kılar — tüm değişkenlerin güncel değerlerini ana bellekten okur. Bloktan çıktığında, tüm değişiklikleri ana belleğe flush eder.
class SharedState {
private int x = 0;
private int y = 0;
// Thread-1
synchronized void write() {
x = 1;
y = 2;
} // Bloktan çıkarken x=1, y=2 ana belleğe yazılır
// Thread-2
synchronized void read() {
// Bloğa girerken ana bellekten güncel değerleri okur
System.out.println(x + " " + y); // 1 2 görmeyi garanti eder
// (aynı lock kullanıldığı sürece)
}
}⚠️ Dikkat: Görünürlük garantisi sadece aynı lock kullanıldığında geçerlidir. İki farklı lock kullanırsan, happens-before ilişkisi oluşmaz.
private final Object lock1 = new Object();
private final Object lock2 = new Object();
// BU YANLIŞ — farklı lock'lar, görünürlük garantisi yok!
synchronized (lock1) { x = 42; } // Thread-1
synchronized (lock2) { int r = x; } // Thread-2 — x'in 42 olacağı garanti değil!final Field Semantics
final field'lar constructor'da atanır ve sonra değişmez. JMM, final field'lar için özel bir garanti verir: constructor tamamlandığında, final field'ların değeri tüm thread'lere görünürdür — ek senkronizasyona gerek yoktur.
class ImmutableConfig {
private final String host;
private final int port;
private final List<String> servers;
ImmutableConfig(String host, int port, List<String> servers) {
this.host = host;
this.port = port;
this.servers = List.copyOf(servers); // Defensive copy
}
// Constructor tamamlandığında tüm final field'lar
// diğer thread'lere güvenle görünür
}Bu garanti bir koşula bağlıdır: this referansı constructor'dan kaçmamalıdır (constructor leaking).
// YANLIŞ — this constructor'dan kaçıyor!
class Broken {
private final int value;
Broken() {
// Henüz constructor bitmeden this'i dışarıya veriyoruz
EventBus.register(this); // Başka thread bu nesneyi görebilir
this.value = 42; // ...ama value henüz atanmamış!
}
}Bu durumda başka bir thread value'yu 0 olarak görebilir — final bile olsa. Constructor bitene kadar this'i hiçbir yere verme.
Double-Checked Locking
Singleton pattern'inin lazy initialization versiyonunda klasik bir tuzak var. Önce yanlış versiyonu görelim:
// YANLIŞ — race condition ve görünürlük problemi var!
class Singleton {
private static Singleton instance;
static Singleton getInstance() {
if (instance == null) { // 1. kontrol (lock'sız)
synchronized (Singleton.class) {
if (instance == null) { // 2. kontrol (lock'lu)
instance = new Singleton(); // SORUN BURADA!
}
}
}
return instance;
}
}Sorun nerede? instance = new Singleton() aslında üç adımdır:
Bellek ayır
Constructor'ı çalıştır (field'ları initialize et)
Referansı
instance'a ata
JVM bu adımları yeniden sıralayabilir (reordering). Sıra 1→3→2 olabilir. Bu durumda Thread-A adım 3'ü yapar (instance artık null değil), Thread-B ilk if kontrolünde instance != null görür ve yarı initialize edilmiş bir nesne alır. Felaket.
Doğru Versiyon: volatile ile
// DOĞRU — volatile reordering'i engeller
class Singleton {
private static volatile Singleton instance;
static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
// volatile yazma, constructor tamamlanmadan
// referansın atanmasını engeller
}
}
}
return instance;
}
}volatile, instance'a yazma işleminin constructor tamamlanmadan gerçekleşmesini engeller. Böylece başka bir thread instance != null gördüğünde, nesnenin tam olarak initialize edildiğinden emin olabilir.
Daha İyi Alternatifler
Aslında double-checked locking'e gerek yok. Java'da daha temiz yollar var:
// Holder idiom — JVM class loading garantisi kullanır
class Singleton {
private Singleton() {}
private static class Holder {
static final Singleton INSTANCE = new Singleton();
}
static Singleton getInstance() {
return Holder.INSTANCE;
}
}Holder sınıfı ilk getInstance() çağrısına kadar yüklenmez. JVM, class loading'in thread-safe olduğunu garanti eder. Hem lazy hem safe — ve volatile veya synchronized bile gerekmez.
// Enum singleton — en basit ve en güvenli
enum Singleton {
INSTANCE;
void doSomething() {
// ...
}
}Enum singleton serialization-safe ve reflection-safe'tir. Joshua Bloch (Effective Java yazarı) bu yöntemi önerir.
Atomic Sınıflar ve Memory Ordering
volatile görünürlük sağlar ama atomiklik sağlamaz. synchronized her ikisini de sağlar ama ağırdır. Arada bir yol: atomic sınıflar.
java.util.concurrent.atomic paketi, lock kullanmadan atomic operasyonlar sunar. İç mekanizmada CAS (Compare-And-Swap) donanım talimatı kullanılır.
import java.util.concurrent.atomic.AtomicInteger;
class Counter {
private final AtomicInteger count = new AtomicInteger(0);
void increment() {
count.incrementAndGet(); // Atomic: oku + artır + yaz tek işlemde
}
int get() {
return count.get();
}
}AtomicInteger.incrementAndGet() tek bir atomic operasyondur — iki thread aynı anda çağırsa bile kayıp olmaz.
CAS Nasıl Çalışır?
// CAS mantığı (pseudocode)
boolean compareAndSwap(expectedValue, newValue) {
if (currentValue == expectedValue) {
currentValue = newValue;
return true; // Başarılı
}
return false; // Başka thread araya girdi, tekrar dene
}incrementAndGet() şöyle çalışır:
Mevcut değeri oku (diyelim 5)
CAS: "Eğer hâlâ 5 ise, 6 yap"
Başka thread araya girdiyse (değer artık 5 değilse), başa dön ve tekrar dene
Bu "spin" yaklaşımı, düşük çekişme (low contention) durumunda synchronized'dan çok daha hızlıdır. Çünkü lock yok — sadece donanım düzeyinde bir karşılaştırma.
Diğer Atomic Sınıflar
import java.util.concurrent.atomic.*;
AtomicInteger counter = new AtomicInteger(0);
AtomicLong timestamp = new AtomicLong(0L);
AtomicBoolean flag = new AtomicBoolean(false);
AtomicReference<User> currentUser = new AtomicReference<>(null);
// Yaygın operasyonlar
counter.incrementAndGet(); // ++counter (atomic)
counter.decrementAndGet(); // --counter (atomic)
counter.addAndGet(5); // counter += 5 (atomic)
counter.compareAndSet(10, 20); // if counter==10 then counter=20
flag.compareAndSet(false, true); // Sadece false ise true yap
currentUser.set(new User("Ali"));
User user = currentUser.get();
currentUser.compareAndSet(user, new User("Veli"));AtomicReference ile Lock-Free Veri Yapıları
import java.util.concurrent.atomic.AtomicReference;
// Lock-free stack (basit örnek)
class LockFreeStack<T> {
private final AtomicReference<Node<T>> top = new AtomicReference<>(null);
void push(T value) {
Node<T> newNode = new Node<>(value);
Node<T> currentTop;
do {
currentTop = top.get();
newNode.next = currentTop;
} while (!top.compareAndSet(currentTop, newNode));
// CAS başarısız olursa (başka thread push/pop yaptıysa) tekrar dene
}
T pop() {
Node<T> currentTop;
Node<T> newTop;
do {
currentTop = top.get();
if (currentTop == null) return null;
newTop = currentTop.next;
} while (!top.compareAndSet(currentTop, newTop));
return currentTop.value;
}
private static class Node<T> {
final T value;
Node<T> next;
Node(T value) { this.value = value; }
}
}Bu stack tamamen lock-free — hiçbir thread'in diğerini beklememesi hedeflenir. Yüksek çekişme durumunda CAS retry'ları artabilir ama deadlock riski sıfırdır.
LongAdder: Yüksek Çekişme için
Eğer çok sayıda thread aynı counter'ı güncelliyorsa, AtomicLong bile yavaşlayabilir (çok fazla CAS retry). Bu durumda LongAdder daha iyidir:
import java.util.concurrent.atomic.LongAdder;
LongAdder adder = new LongAdder();
// Farklı thread'ler
adder.increment(); // Her thread kendi hücresini günceller
adder.add(5);
// Toplam değeri almak gerektiğinde
long total = adder.sum(); // Tüm hücreleri toplarLongAdder dahili olarak birden fazla hücre (cell) kullanır. Her thread kendi hücresini günceller — çekişme azalır. sum() çağrıldığında hücreler toplanır. Yazma hızlı, okuma biraz daha yavaş — ama çoğu sayaç senaryosunda yazma daha sıktır.
VarHandle (Java 9+)
VarHandle, volatile ve atomic erişim modellerini herhangi bir field üzerinde kullanmanı sağlar. java.lang.invoke.VarHandle sınıfı, düşük seviye bellek erişim kontrolü sunar.
Neden VarHandle?
AtomicInteger kullanışlı ama her field için ayrı bir wrapper nesnesi oluşturuyorsun. Performans-kritik kodda bu overhead kabul edilemez. VarHandle ile mevcut bir int field'ı üzerinde atomic operasyonlar yapabilirsin.
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
class SpinLock {
private volatile int locked = 0;
private static final VarHandle LOCKED;
static {
try {
LOCKED = MethodHandles.lookup()
.findVarHandle(SpinLock.class, "locked", int.class);
} catch (Exception e) {
throw new ExceptionInInitializerError(e);
}
}
void lock() {
while (!LOCKED.compareAndSet(this, 0, 1)) {
Thread.onSpinWait(); // CPU ipucu: spin bekliyorum
}
}
void unlock() {
LOCKED.setRelease(this, 0); // Release semantics ile yaz
}
}Erişim Modları
VarHandle farklı erişim modları (access modes) sunar:
// Plain: Normal okuma/yazma — sıralama garantisi yok
int val = (int) LOCKED.get(this);
LOCKED.set(this, 42);
// Opaque: Görünürlük garantili ama sıralama yok
int val = (int) LOCKED.getOpaque(this);
LOCKED.setOpaque(this, 42);
// Acquire/Release: Tek yönlü sıralama
int val = (int) LOCKED.getAcquire(this); // Sonraki okumaları sırasız yapmaz
LOCKED.setRelease(this, 42); // Önceki yazmaları sırasız yapmaz
// Volatile: Tam sıralama (volatile ile aynı)
int val = (int) LOCKED.getVolatile(this);
LOCKED.setVolatile(this, 42);Bu modlar C++ memory ordering'den esinlenmiştir:
Release/Acquire: Çoğu senaryoda yeterli, volatile'dan daha performanslıVolatile: En güçlü garanti ama en pahalıOpaque: Sadece görünürlük gerektiğinde (sıralama önemsizse)
💡 İpucu: VarHandle çoğu uygulama geliştiricisi için gerekli değildir. Framework ve kütüphane yazarları, ya da ultra-performans gereken kodlar için tasarlanmıştır. Günlük kullanımda AtomicInteger, volatile ve synchronized yeterlidir.
Reordering: Derleyici ve CPU Hileleri
JVM ve CPU, performans için talimatları yeniden sıralayabilir (reordering). Tek thread'de bu görünmez — program aynı sonucu üretir. Ama multi-thread'de beklenmedik sonuçlara yol açar.
// Başlangıç: x = 0, y = 0
// Thread-1 // Thread-2
x = 1; int r1 = y;
y = 1; int r2 = x;Beklenen: r1 == 1 ise r2 == 1 olmalı (çünkü x, y'den önce yazıldı). Ama reordering yüzünden r1 == 1, r2 == 0 mümkün! Thread-1'de y = 1 işlemi x = 1'den önce görünebilir.
volatile veya synchronized kullanmak reordering'i kısıtlar. JMM, happens-before ilişkisi olan işlemlerin reorder edilmesini yasaklar.
// volatile ile reordering engellenir
private volatile boolean ready = false;
private int data = 0;
// Thread-1
data = 42; // (A)
ready = true; // (B) — volatile yazma
// Thread-2
if (ready) { // (C) — volatile okuma → B happens-before C
int r = data; // (D) — 42 görmeyi garanti eder
// A happens-before B (program order)
// B happens-before C (volatile rule)
// A happens-before D (transitivity)
}Bu pattern'e "piggybacking" denir: data volatile değil ama ready volatile olduğu için, ready = true yazılmadan önceki tüm değişiklikler (data = 42 dahil) ready == true okuduktan sonra görünür.
Pratik: Thread-Safe Lazy Cache
Öğrendiklerimizi birleştiren pratik bir örnek — thread-safe lazy initialization pattern:
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
class ComputeCache<K, V> {
private final Map<K, V> cache = new ConcurrentHashMap<>();
private final Function<K, V> computeFunction;
ComputeCache(Function<K, V> computeFunction) {
this.computeFunction = computeFunction;
}
V get(K key) {
// computeIfAbsent atomik bir operasyondur
// Key yoksa compute eder, varsa mevcut değeri döner
return cache.computeIfAbsent(key, computeFunction);
}
void invalidate(K key) {
cache.remove(key);
}
int size() {
return cache.size();
}
}
class Main {
public static void main(String[] args) {
ComputeCache<String, Integer> lengthCache =
new ComputeCache<>(key -> {
System.out.println("Computing for: " + key);
return key.length(); // Pahalı hesaplama simülasyonu
});
// İlk çağrı — hesaplar
System.out.println(lengthCache.get("hello")); // Computing for: hello → 5
// İkinci çağrı — cache'den döner
System.out.println(lengthCache.get("hello")); // 5 (hesaplama yok)
System.out.println(lengthCache.get("world")); // Computing for: world → 5
}
}Bu örnekte ConcurrentHashMap.computeIfAbsent() thread-safe'tir — birden fazla thread aynı key için aynı anda çağırsa bile hesaplama sadece bir kez yapılır. İç mekanizmada segment bazlı locking ve CAS kullanılır.
Yaygın Hatalar ve Anti-Pattern'ler
1. Kısmi Senkronizasyon
// YANLIŞ — sadece yazma senkronize, okuma değil
class Counter {
private int count = 0;
synchronized void increment() { count++; } // Senkronize
int getCount() { return count; } // Senkronize DEĞİL!
}Getter senkronize değilse, okuma yapan thread eski değeri görebilir. Ya her iki method'u da senkronize yap, ya da AtomicInteger kullan.
2. Lock Nesnesi Değişikliği
// YANLIŞ — lock referansı değişiyor!
private Object lock = new Object();
void reset() {
lock = new Object(); // Artık farklı lock!
// Eski lock'la senkronize olan thread'ler korumasız kalır
}Lock nesnesi her zaman final olmalı: private final Object lock = new Object();
3. volatile Array/Collection Yanılgısı
private volatile int[] data = new int[10];
data[0] = 42; // Bu volatile yazma DEĞİL!
// Sadece data referansı volatile, elemanları değil
data = new int[]{42}; // BU volatile yazma — referans değiştiği içinvolatile referans üzerindedir, içerik üzerinde değil. Eleman düzeyinde thread-safety için AtomicIntegerArray kullan.
Özet
Java Memory Model (JMM), multi-threaded ortamda bir thread'in değişikliklerinin diğer thread'lere ne zaman görünür olacağını tanımlar.
Happens-before ilişkisi görünürlük garantisidir —
synchronized,volatile,Thread.start/joingibi mekanizmalar bu ilişkiyi kurar.volatile görünürlük sağlar ve reordering'i engeller, ama atomiklik sağlamaz. Flag değişkenleri ve double-checked locking için kullan.
synchronized hem mutual exclusion hem bellek görünürlüğü sağlar. Aynı lock kullanmak şarttır.
Atomic sınıflar (AtomicInteger, AtomicReference) CAS tabanlı lock-free operasyonlar sunar. Düşük çekişmede
synchronized'dan hızlıdır.VarHandle (Java 9+) düşük seviye bellek erişim kontrolü sağlar — framework yazarları için. Günlük kodda
volatileve atomic sınıflar yeterlidir.
AI Asistan
Sorularını yanıtlamaya hazır