Concurrency: thread'ler, kilitler ve ConcurrentHashMap
Java Memory Model'den virtual thread'lere kadar görünürlük, atomiklik, kilitler, ConcurrentHashMap, thread pool'lar, CompletableFuture ve deadlock'u mekanizmasıyla ve production sonuçlarıyla öğretir.
Bu bölümde
Concurrency, backend mülakatlarında en çok eleyen konudur, çünkü "çalışıyor gibi görünen" kod ile doğru kod arasındaki farkı sadece mekanizmayı bilen görür. Kod okuma sorularında volatile bir sayacın, containsKey + put yapan bir cache'in ya da ters sırada kilit alan bir transfer metodunun ne zaman bozulacağı sorulur. Kod yazma sorularında üç servisi CompletableFuture ile paralel çağırıp timeout ve fallback eklemen beklenir. Soru-cevapta ise ConcurrentHashMap'in iç yapısı, thread pool boyutlandırma ve virtual thread'lerin neyi çözüp neyi çözmediği konuşulur.
Bu bölüm önce temeli (Java Memory Model, happens-before, volatile, synchronized, CAS), sonra araçları (Lock'lar, atomik sınıflar, ConcurrentHashMap, executor'lar, CompletableFuture), son olarak canlılık problemlerini (deadlock) ve Java 21+ dünyasını (virtual thread'ler, structured concurrency) anlatır.
Konu anlatımı
Üç ayrı problem: atomiklik, görünürlük, sıralama
Çok thread'li kodda bozulan şey üç ayrı başlık altında incelenir. Mülakatta bunları birbirinden ayırabilmek, cevabın kalitesini belirleyen ilk şeydir.
- Atomiklik (atomicity): Birden fazla adımdan oluşan bir işlemin araya başka bir thread girmeden tek parça gibi gerçekleşmesi.
count++atomik değildir. - Görünürlük (visibility): Bir thread'in yazdığı değerin diğer thread tarafından ne zaman görüleceği. Modern CPU'larda her çekirdeğin kendi cache'i ve store buffer'ı vardır; JIT derleyici bir değeri register'da tutabilir. Senkronizasyon olmadan bir thread'in yazdığını diğeri hiçbir zaman görmeyebilir.
- Sıralama (ordering): Derleyici, JIT ve CPU, tek thread'in gözünden sonucu değiştirmediği sürece komutları yeniden sıralayabilir. Başka bir thread bu ara durumları görebilir.
Görünürlük probleminin klasik örneği, durdurma bayrağıdır:
class PriceSyncWorker {
private boolean running = true; // volatile DEĞİL
void stop() { running = false; } // başka thread çağırır
void loop() {
while (running) {
syncNextBatch();
}
}
}
syncNextBatch JIT tarafından inline edilip running'e dokunmadığı anlaşılırsa, JIT döngüyü if (running) while (true) {...} biçimine dönüştürebilir (hoisting). stop() çağrılsa bile döngü bitmez. Bu bir derleyici hatası değildir; Java Memory Model buna izin verir. running'i volatile yapmak bu optimizasyonu yasaklar.
Java Memory Model ve happens-before
Java Memory Model (JMM, JLS Bölüm 17), "bir thread'in yazdığı değer başka bir thread'e ne zaman görünür olmak zorundadır?" sorusunu happens-before ilişkisiyle cevaplar. Kural: A yazma işlemi B okuma işlemiyle happens-before ilişkisindeyse, B, A'nın yazdığını (ya da daha sonrasını) görmek zorundadır. İlişki yoksa garanti de yoktur.
Happens-before ilişkisi kuran kurallar:
- Program sırası: Aynı thread içinde, önce gelen işlem sonrakinden önce olur.
- Monitor kilidi: Bir monitörün
unlock'u, aynı monitörün sonrakilock'undan önce olur.synchronizedbloğundan çıkarken yapılan her yazma, aynı kilidi sonra alan thread'e görünür. - volatile: Bir
volatilealana yazma, aynı alanın sonraki okumasından önce olur. - Thread başlatma:
Thread.start()çağrısından önceki işlemler, başlatılan thread'in ilk işleminden önce olur. - Thread sonlanma: Bir thread'in tüm işlemleri, başka bir thread'in onun bittiğini
join()ile tespit etmesinden önce olur. - Geçişlilik (transitivity): A → B ve B → C ise A → C.
java.util.concurrent bu garantileri genişletir: bir nesneyi concurrent bir koleksiyona koymadan önceki işlemler, onu başka thread'de okuyan işlemlerden önce olur; bir Runnable'ı executor'a submit etmeden önceki işlemler onun çalışmasından önce olur; Future'ın temsil ettiği hesap, get() ile sonucu alan thread'in sonraki işlemlerinden önce olur; Lock.unlock, Semaphore.release, CountDownLatch.countDown gibi "bırakma" işlemleri karşılık gelen "alma" işlemlerinden önce olur.
Data race, aynı değişkene en az biri yazma olan, happens-before ile sıralanmamış iki erişimdir. JMM'nin temel garantisi şudur: data race içermeyen (doğru senkronize edilmiş) bir program, sanki tüm işlemler tek bir global sırada çalışıyormuş gibi davranır (sequential consistency). Data race varsa, sezgiye aykırı her türlü sonuç mümkündür.
Pratik çıkarım: "Bu kod çalışıyor" demek bir şey kanıtlamaz. x86 görece güçlü bir bellek modeline sahiptir ve bazı hatalar orada hiç görünmez, ARM tabanlı makinelerde (Graviton sunucular, Apple Silicon geliştirici makineleri) ortaya çıkar. Doğruluğu test sonucuna değil, happens-before argümanına dayandır.
volatile: görünürlük var, atomiklik yok
volatile iki şey sağlar:
- Görünürlük: Her okuma ana bellekteki güncel değeri görür; değer register'da önbelleğe alınamaz.
- Sıralama:
volatileyazmadan önceki işlemler yazmanın sonrasına,volatileokumadan sonraki işlemler okumanın öncesine taşınamaz. Bu sayedevolatilebir bayrak, kendisinden önce yazılmış diğer alanları da "yayınlar".
Sağlamadığı şey atomikliktir. totalViews++ (alan long ise) bytecode'da şuna derlenir:
aload_0
dup
getfield totalViews // 1) oku
lconst_1
ladd // 2) 1 ekle
putfield totalViews // 3) yaz
İki thread aynı anda getfield ile 41'i okur, ikisi de 42 hesaplar, ikisi de 42 yazar. İki artış yapıldı, değer bir arttı: lost update. volatile sadece her getfield'ın güncel değeri görmesini sağlar; okuma ile yazma arasına başka thread'in girmesini engellemez.
volatile'ın küçük ama önemli bir garantisi daha vardır: JLS 17.7'ye göre volatile olmayan long ve double alanlara yazma, iki ayrı 32 bitlik yazma olarak ele alınabilir (word tearing). volatile long yazmaları ise her zaman atomiktir. 64 bit JVM'lerde pratikte nadiren görülür, ama spesifikasyon garanti etmez.
volatile doğru araç olduğunda:
- Tek yazarlı, çok okurlu bayraklar (
running,shutdownRequested). - Immutable bir nesnenin referansını yayınlamak: "fiyat tablosu snapshot'ı" gibi bir nesne arka planda yeniden hesaplanıp
volatilereferansa atanır; okurlar her zaman tutarlı bir snapshot görür. - Double-checked locking'deki tekil instance alanı (aşağıda).
Durum başka bir alana ya da kendi eski değerine bağlıysa (x = x + 1, if (x == null) x = ...) volatile yetmez.
synchronized: monitor, reentrancy, wait/notify
Her Java nesnesinin bir monitor'ü vardır. synchronized blok, bytecode'da monitorenter/monitorexit komutlarına; synchronized metot ise metot üzerindeki ACC_SYNCHRONIZED bayrağına derlenir. synchronized iki garantiyi birlikte verir:
- Karşılıklı dışlama (mutual exclusion): Aynı monitörü aynı anda tek thread tutar.
- Görünürlük: Bloktan çıkarken yapılan yazmalar, aynı monitörü sonra alan thread'e görünür.
Bu yüzden "sadece yazan metodu synchronized yaptım, okuyan metodu yapmadım" eksik bir çözümdür: okuyan taraf happens-before ilişkisine girmediği için güncel değeri görmeyebilir.
Reentrancy: Monitörü tutan thread aynı monitörü tekrar alabilir (sayaç artar). synchronized bir metot aynı nesnenin başka bir synchronized metodunu çağırdığında kendini kilitlemez.
Neyin üzerinde kilitlenir?
synchronizedinstance metodu →this;static synchronized→Classnesnesi.- Dışarıdan erişilebilen bir nesne üzerinde kilitlenmek (public
this, birStringliteral,Integergibi kutulanmış değerler) başka kodun aynı kilidi almasına ve beklenmedik çekişme ya da deadlock'a yol açar.private final Object lock = new Object()güvenli tercihtir. - JPA entity'si üzerinde
synchronizedneredeyse her zaman yanlıştır: her istek kendi persistence context'inde aynı satır için farklı bir Java nesnesi yükler. İki thread farklı nesneleri kilitler ve hiçbir şeyi korumaz.
wait/notify: wait() monitörü bırakıp bekler, notify/notifyAll bekleyeni uyandırır. İkisi de monitör tutulurken çağrılmalıdır, aksi halde IllegalMonitorStateException. wait her zaman bir while döngüsü içinde koşulu yeniden kontrol ederek çağrılır, çünkü spurious wakeup (sebepsiz uyanma) mümkündür ve uyandığında koşul başka bir thread tarafından tekrar bozulmuş olabilir. Uygulama kodunda wait/notify yerine BlockingQueue, CountDownLatch, Condition gibi yüksek seviyeli araçlar tercih edilir.
Performans notu: HotSpot, çekişme yokken synchronized'ı ucuz tutmak için hafif kilitleme teknikleri kullanır; çekişme olunca monitör "şişirilir" (inflation) ve thread'ler OS seviyesinde park edilir. Biased locking JDK 15'te varsayılan olarak kapatıldı (JEP 374). Çekişmesiz synchronized modern JVM'lerde birkaç nanosaniyedir; asıl maliyet çekişmedir.
ReentrantLock ve diğer Lock'lar
java.util.concurrent.locks.ReentrantLock, synchronized ile aynı karşılıklı dışlama ve görünürlük garantisini verir (AQS, yani AbstractQueuedSynchronizer üzerine kurulu), ek olarak:
| Yetenek | synchronized | ReentrantLock |
|---|---|---|
| Zaman aşımlı deneme | Yok | tryLock(timeout, unit) |
| Hemen deneme | Yok | tryLock() |
| Beklerken interrupt edilebilme | Yok | lockInterruptibly() |
| Adil sıra (fairness) | Yok | new ReentrantLock(true) |
| Birden fazla bekleme koşulu | Tek (wait/notify) | newCondition() ile çok |
| Bırakma | Otomatik (exception'da da) | Elle, finally içinde zorunlu |
| İzlenebilirlik | Thread dump | Thread dump + getQueueLength(), isLocked() |
private final ReentrantLock lock = new ReentrantLock();
boolean reserve(long productId, int qty) throws InterruptedException {
if (!lock.tryLock(50, TimeUnit.MILLISECONDS)) {
return false; // çekişme yüksek: bekleyip thread'i tıkamak yerine vazgeç
}
try {
return doReserve(productId, qty);
} finally {
lock.unlock(); // finally olmadan exception'da kilit sonsuza kadar tutulur
}
}
Fair lock, en uzun bekleyene sırayı garanti eder ama throughput'u belirgin biçimde düşürür (her bırakmada thread uyandırma maliyeti). Varsayılan non-fair'dir; fairness'i sadece starvation gerçek bir sorunsa aç.
Okuma ağırlıklı durumlar:
ReentrantReadWriteLock: çok okur aynı anda, yazar tek başına. Okuma kısa ve çekişme düşükse düz kilitten daha yavaş olabilir; okuma pahalı ve yazma nadirse kazandırır.StampedLock(Java 8+): optimistic read sunar. Kilit almadan okursun, sonravalidate(stamp)ile arada yazma olup olmadığını kontrol edersin; olduysa gerçek okuma kilidiyle tekrar okursun. Reentrant değildir veConditiondesteklemez; dikkatli kullanılmazsa kendi kendine deadlock yaratmak kolaydır.
Çoğu backend kodunda doğru cevap ne ReadWriteLock ne StampedLock'tur: okuma ağırlıklı paylaşılan veri için immutable snapshot + volatile referans (ya da AtomicReference) daha basit ve daha hızlıdır.
Atomik sınıflar, CAS ve LongAdder
AtomicInteger, AtomicLong, AtomicReference gibi sınıflar CAS (compare-and-swap) üzerine kuruludur: "bellekteki değer hâlâ beklediğim değerse yenisini yaz, değilse yazma ve bana söyle". CAS tek bir CPU komutudur (x86'da LOCK CMPXCHG) ve kilitsizdir.
Karmaşık bir güncelleme CAS döngüsüyle yapılır:
record PriceSnapshot(BigDecimal price, long version) {}
private final AtomicReference<PriceSnapshot> current =
new AtomicReference<>(new PriceSnapshot(BigDecimal.ZERO, 0));
void applyDiscount(BigDecimal percent) {
current.updateAndGet(s -> new PriceSnapshot(
s.price().multiply(BigDecimal.ONE.subtract(percent)), s.version() + 1));
// İçeride: oku -> fonksiyonu uygula -> CAS dene -> başarısızsa baştan
}
updateAndGet'e verilen fonksiyon çekişme altında birden çok kez çalışabilir; bu yüzden yan etkisiz olmalıdır (log yazma, event gönderme yok).
ABA problemi: Değer A iken okursun, başka thread onu B'ye sonra tekrar A'ya çevirir, senin CAS'in başarılı olur ama arada olanları kaçırmışsındır. Immutable nesne referanslarıyla çalışırken nadiren sorun olur; sorun olduğunda AtomicStampedReference değere bir sürüm numarası ekler.
AtomicLong ile LongAdder farkı. AtomicLong.incrementAndGet() tüm thread'leri tek bir bellek konumuna yazdırır. x86'da HotSpot bu işlemi genelde tek bir LOCK XADD komutuna indirir, yani CAS döngüsü gibi "başarısız olup tekrar dene" yoktur; ama tüm çekirdekler aynı cache line'ın sahipliği için yarışır ve cache line çekirdekler arasında sürekli taşınır. Yüksek çekişmede asıl maliyet budur. updateAndGet, accumulateAndGet gibi fonksiyon alan metotlar ise gerçek CAS döngüsüdür ve çekişmede tekrar sayısı artar.
LongAdder (Java 8+) sayacı birden çok hücreye (cell) böler. Çekişme yokken tek bir base alanına yazar; çekişme algılanınca hücre dizisini oluşturur ve her thread (thread'e özgü bir hash ile) farklı bir hücreyi artırır. Hücre dizisi 2'nin kuvvetleri halinde, kabaca çekirdek sayısına ulaşana kadar büyür ve hücreler false sharing'i önlemek için padding'le (@Contended) ayrılır. sum() base ile tüm hücreleri toplar.
AtomicLong | LongAdder | |
|---|---|---|
| Yazma (yüksek çekişme) | Tek cache line'da yarış | Hücrelere dağılır, ölçeklenir |
| Okuma | Tek okuma, kesin | sum() tüm hücreleri toplar; eşzamanlı yazma varken anlık snapshot değildir |
| Bellek | 8 byte | Çekişmede hücre dizisi |
| Uygun kullanım | Kesin değere göre karar (id üretimi, "stok tam 0 mı?") | Metrik, görüntülenme sayacı, istatistik |
LongAccumulator aynı yapıyı keyfi bir birleştirme fonksiyonuyla (max, min) sunar.
Check-then-act ve read-modify-write yarışları
Race condition'ların büyük çoğunluğu iki desenden birine uyar:
- Check-then-act: "Kontrol et, sonra ona göre davran."
if (!map.containsKey(k)) map.put(k, v), lazy initialization, "stok yeterli mi? yeterliyse düş". - Read-modify-write: "Oku, hesapla, yaz."
count++,map.put(k, map.get(k) + 1).
Kontrol ile eylem arasında başka bir thread araya girer ve kontrolün sonucu artık geçerli değildir. Flash sale'de bunun adı overselling'dir:
// Tek pod'da bile yanlış
public boolean decrease(long productId, int qty) {
Integer current = stock.get(productId); // (1) oku
if (current != null && current >= qty) { // (2) kontrol et
stock.put(productId, current - qty); // (3) yaz
return true;
}
return false;
}
Stokta 1 ürün varken iki thread (1)'de 1 okur, ikisi de (2)'den geçer, ikisi de 0 yazar, iki sipariş onaylanır. stock'u ConcurrentHashMap yapmak bunu çözmez: CHM get ve put'u ayrı ayrı thread-safe yapar, ama üçlüyü atomik yapmaz. "Thread-safe parçaları birleştirmek thread-safe bir bütün vermez" ilkesinin en sık görülen örneği budur.
Doğru çözüm, kontrolü ve eylemi tek bir atomik işlemde birleştirmektir. Tek JVM'de:
public boolean decrease(long productId, int qty) {
if (qty <= 0) throw new IllegalArgumentException("qty must be positive");
AtomicBoolean decreased = new AtomicBoolean(false); // lambda'dan sonucu taşımak için kutu
stock.computeIfPresent(productId, (id, current) -> {
if (current < qty) return current; // değiştirmeden bırak (null dönseydik key silinirdi)
decreased.set(true);
return current - qty;
});
return decreased.get();
}
computeIfPresent bu key'in bulunduğu bin'i (bucket) kilitler, mevcut değeri kendisi okur, lambda'yı çağırır, sonucu yazar ve kilidi ancak bundan sonra bırakır. Okuma ve yazma aynı kilidin içindedir. Farklı ürünler farklı bin'lerde olduğu için birbirini beklemez; synchronized(this) tüm ürünleri tek kilide bağlardı.
Bu çözüm sadece tek JVM için doğrudur. Servis 10 pod'da çalışıyorsa her pod'un kendi map'i vardır; doğru yer veritabanıdır (UPDATE stock SET quantity = quantity - :qty WHERE product_id = :id AND quantity >= :qty ve etkilenen satır sayısına bakmak) ya da hot SKU için Redis + Lua (bkz. Bölüm 8 ve 13). Mülakatta JVM içi çözümü verdikten sonra bu sınırı kendiliğinden söylemek senior sinyalidir.
Concurrency kodu nasıl test edilir? Race condition'ı yakalamak için thread'lerin gerçekten aynı anda başlaması gerekir. CountDownLatch bir "başlangıç kapısı" olarak kullanılır:
static int countSuccesses(int requests, int threads, BooleanSupplier task) throws Exception {
AtomicInteger successes = new AtomicInteger();
CountDownLatch startGate = new CountDownLatch(1);
try (ExecutorService executor = Executors.newFixedThreadPool(threads)) { // close(): Java 19+
List<Future<?>> futures = new ArrayList<>();
for (int i = 0; i < requests; i++) {
futures.add(executor.submit(() -> {
startGate.await(); // herkes kapıda bekler
if (task.getAsBoolean()) successes.incrementAndGet();
return null;
}));
}
startGate.countDown(); // hepsini aynı anda serbest bırak
for (Future<?> f : futures) f.get(); // beklenmeyen exception testi patlatsın
}
return successes.get();
}
@RepeatedTest(5)
void neverOversells() throws Exception {
service.loadStock(1L, 100);
assertThat(countSuccesses(1_000, 64, () -> service.decrease(1L, 1))).isEqualTo(100);
}
1000 istek, 100 stok: tam olarak 100 başarı. Test geçmesi doğruluğu kanıtlamaz (race'ler olasılıksaldır) ama bozuk versiyon bu testte genelde hemen yakalanır. Daha sistematik doğrulama için OpenJDK'nın jcstress aracı vardır.
ConcurrentHashMap içeride nasıl çalışır
Java 7 ve öncesi: Map, varsayılan 16 segment'e bölünmüştü; her segment bir ReentrantLock idi. En fazla 16 yazar paralel çalışabiliyordu.
Java 8+: Segment yapısı kaldırıldı. Map, volatile erişilen bir Node[] tablosudur (ilk yazmada tembel olarak oluşturulur). Bir key'in bin'i spread(hashCode) & (n - 1) ile bulunur; spread, hash'in üst bitlerini alt bitlere karıştırarak kötü dağılmış hashCode'ların etkisini azaltır.
Yazma yolu (putVal):
- Tablo yoksa CAS ile oluştur.
- Bin boşsa: yeni node'u CAS ile yerleştir. Kilit yok.
- Bin bir
ForwardingNodeise (resize sürüyor): taşımaya yardım et, sonra tekrar dene. - Bin doluysa: bin'in ilk node'u üzerinde
synchronizedal, listede ya da ağaçta key'i bul, güncelle ya da sona ekle. - Bin'deki eleman sayısı 8'e (
TREEIFY_THRESHOLD) ulaştıysa ve tablo en az 64 (MIN_TREEIFY_CAPACITY) ise bağlı listeyi kırmızı-siyah ağaca (TreeBin) çevir; tablo 64'ten küçükse ağaç yerine tabloyu büyüt. Eleman sayısı 6'ya düşerse liste geri gelir. - Sayacı artır; gerekirse resize başlat.
Kilit kapsamı tek bir bin'dir: farklı bin'lere yazan thread'ler birbirini hiç beklemez.
Okuma yolu (get): Hiç kilit almaz. Tablo elemanları ve node'ların val/next alanları volatile semantikle okunur; yazar node'u tam kurduktan sonra yayınladığı için okur yarım kurulmuş bir node görmez. TreeBin içinde yazar ağacı yeniden düzenlerken okur bloklanmaz, ağacın yanında tutulan bağlı liste üzerinden doğrusal arama yapar. Okumalar bu yüzden çekirdek sayısıyla ölçeklenir.
Resize: Tablo iki katına çıkarken eski tablonun bin'leri parça parça yeni tabloya taşınır. Birden çok thread aynı anda farklı parçaları taşır; taşınmış bin'lere ForwardingNode konur, böylece o bin'e gelen okuma yeni tabloya yönlendirilir, yazma ise taşımaya yardım eder.
Sayaç: size() için baseCount + CounterCell[] kullanılır; LongAdder ile aynı fikir. Her put tek bir sayaca yazsaydı CHM'nin ölçeklenmesi o sayaçta tıkanırdı.
Null yok: CHM null key ve null value kabul etmez. get(k) null dönerse "key yok" anlamına gelmek zorundadır. HashMap'te "key var, değeri null" durumunu containsKey ile ayırabilirsin; eşzamanlı ortamda get ile containsKey arasında başka thread map'i değiştirebileceği için bu ayrım race'li olurdu. Tasarım bu belirsizliği baştan kaldırır.
Weakly consistent iterator: CHM iterator'ları ConcurrentModificationException fırlatmaz. Oluşturuldukları andaki ya da sonraki bir durumu yansıtırlar; iterasyon sırasında yapılan değişiklikleri görebilir de görmeyebilir de. Bir iterator'ı aynı anda tek thread kullanmalıdır.
Toplu durum metotları yaklaşıktır: Javadoc açıkça söyler: size, isEmpty ve containsValue sonuçları map eşzamanlı güncellenirken yalnızca geçici bir durumu yansıtır; izleme ve tahmin için yeterlidir, program akışını kontrol etmek için değil. size() int döner ve Integer.MAX_VALUE'da doyar; mappingCount() long döner ve büyük map'lerde tercih edilir. İkisi de eşzamanlı yazma altında tahmindir.
Atomik bileşik işlemler: putIfAbsent, computeIfAbsent, computeIfPresent, compute, merge, replace(k, old, new), remove(k, v) key bazında atomiktir.
ConcurrentHashMap<Long, Long> views = new ConcurrentHashMap<>();
// Yanlış: check-then-act + read-modify-write
if (!views.containsKey(id)) views.put(id, 0L);
views.put(id, views.get(id) + 1);
// Doğru: tek atomik çağrı
views.merge(id, 1L, Long::sum);
// Çok yüksek çekişmeli sayaç: bin kilidini sadece ilk oluşturmada al
ConcurrentHashMap<Long, LongAdder> hotViews = new ConcurrentHashMap<>();
hotViews.computeIfAbsent(id, k -> new LongAdder()).increment();
compute* içinde uzun iş yapmanın tehlikesi. computeIfAbsent, key yoksa bin'e bir ReservationNode yerleştirip onun üzerinde synchronized alır ve mapping fonksiyonu kilit tutulurken çalışır. Javadoc'un uyarısı: hesaplama sürerken diğer thread'lerin bazı güncelleme işlemleri bloklanabilir, bu yüzden hesaplama kısa ve basit olmalı ve bu map'teki başka eşlemeleri güncellememelidir. Sonuçları:
- Fonksiyonun içinde 80 ms'lik bir HTTP çağrısı varsa, o bin'e düşen başka key'lere yazmak isteyen thread'ler de 80 ms bekler. Tablo küçükse ya da hash'ler çakışıyorsa bu, alakasız ürünlerin yavaşlaması demektir.
- Fonksiyon içinden aynı map'i değiştirmek (ör. recursive
computeIfAbsent) Java 9+'ta çoğu durumdaIllegalStateException: Recursive updateile yakalanır; Java 8'de belirli koşullarda sonsuz döngüye girebiliyordu. Bu tespit garanti değildir, kurala uymak gerekir. - Java 8'de
computeIfAbsent, key zaten mevcutken bile bin kilidini alabiliyordu (JDK-8161372); sonraki sürümlerde bu yol iyileştirildi. Java 8 kod tabanlarında sıcak key'ler için önceget()ile bakmak yaygın bir optimizasyondu.
Uzun süren yükleme için doğru desen, map'e değerin kendisini değil future'ını koymaktır (aşağıda q1 çözümünde) ya da Caffeine gibi bunu zaten doğru yapan bir cache kullanmaktır.
Diğer araçlar: thread-safe set için ConcurrentHashMap.newKeySet(); Java 8+ toplu işlemler (forEach, reduce, search) bir parallelismThreshold parametresiyle common pool'da paralel çalışabilir. Collections.synchronizedMap ise her işlemde (okumalar dahil) tek bir kilit alır ve iterasyon için çağıranın elle synchronized (map) yazmasını gerektirir; yük altında ölçeklenmez.
ExecutorService ve thread pool boyutlandırma
Platform thread'i bir OS thread'idir: oluşturması pahalıdır, stack için adres alanı ayırır (64 bit Linux'ta varsayılan 1 MB rezerve edilir, gerçek bellek kullanıldıkça ayrılır) ve context switch maliyeti vardır. Bu yüzden platform thread'leri havuzlanır.
ThreadPoolExecutor'ın görev kabul kuralı, çoğu mülakatçının sorduğu ve çoğu adayın yanlış bildiği şeydir:
- Çalışan thread sayısı
corePoolSize'dan azsa yeni thread aç. - Değilse görevi kuyruğa koy.
- Kuyruk doluysa ve thread sayısı
maximumPoolSize'dan azsa yeni thread aç. - O da doluysa reddet (
RejectedExecutionHandler).
Yani maximumPoolSize'a ancak kuyruk dolunca ulaşılır. Sınırsız bir kuyrukla maximumPoolSize hiçbir işe yaramaz.
Executors factory'lerinin tuzakları:
| Factory | İçeride | Risk |
|---|---|---|
newFixedThreadPool(n) | n thread + sınırsız LinkedBlockingQueue | Yük artınca kuyruk sonsuza kadar büyür: önce latency, sonra OutOfMemoryError |
newCachedThreadPool() | 0..Integer.MAX_VALUE thread + SynchronousQueue | Yük altında binlerce thread açar: bellek ve context switch patlaması |
newSingleThreadExecutor() | 1 thread + sınırsız kuyruk | Fixed ile aynı risk |
newVirtualThreadPerTaskExecutor() (Java 21+) | Görev başına bir virtual thread | Havuz yok; eşzamanlılık sınırı ayrıca konmalı |
Production'da sınırları açıkça yazılmış bir executor kur:
ExecutorService pricingIo = new ThreadPoolExecutor(
32, 32, // core = max: sabit boyut
60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(500), // sınırlı kuyruk: backpressure
Thread.ofPlatform().name("pricing-io-", 0).factory(), // Java 21+; thread dump'ta okunur isimler
new ThreadPoolExecutor.CallerRunsPolicy()); // doluysa çağıran thread çalıştırır: doğal yavaşlatma
Reddetme politikaları: AbortPolicy (varsayılan, RejectedExecutionException), CallerRunsPolicy (çağıranı yavaşlatarak backpressure uygular; ama çağıran bir Tomcat thread'iyse onu da tıkar), DiscardPolicy ve DiscardOldestPolicy (sessizce iş kaybeder; metrik ve log olmadan kullanma).
Boyutlandırma. Brian Goetz'in Java Concurrency in Practice'teki formülü:
thread sayısı = çekirdek sayısı × hedef CPU kullanımı × (1 + bekleme süresi / hesaplama süresi)
- CPU-yoğun iş (şifreleme, JSON üretimi, sıkıştırma): bekleme ≈ 0 → çekirdek sayısı kadar (belki +1). Fazlası sadece context switch üretir.
- I/O-yoğun iş: 8 çekirdek, her çağrı 100 ms bekleyip 5 ms CPU harcıyorsa 8 × 1 × (1 + 100/5) = 168 thread.
İkinci bir bakış açısı Little Yasası'dır: sistemdeki eşzamanlı iş = gelen istek hızı × ortalama süre. Saniyede 500 fiyat çağrısı yapıyorsan ve her biri 200 ms sürüyorsa, ortalama 500 × 0,2 = 100 çağrı aynı anda devam eder; havuzun en az bu kadar thread'e ihtiyacı vardır, üstüne patlamalar için pay bırakılır.
Formüller başlangıç noktasıdır; asıl sınır genelde downstream'dir. Havuzu 200'e çıkarmak, 20 bağlantılı bir DB havuzunun önünde 180 thread'in beklemesi demektir. Bu yüzden:
- Bulkhead: Her dış bağımlılık için ayrı, sınırlı havuz (fiyat servisi yavaşladığında stok çağrıları etkilenmesin).
- Gözlemlenebilirlik: Aktif thread, kuyruk uzunluğu, reddedilen görev sayısı, görev bekleme süresi metrik olarak izlenmeli (Micrometer
ExecutorServiceMetricsbunları yayınlar). Kuyruk sürekli doluysa boyut değil downstream sorunludur. - Kapanış: Uygulama kapanırken
shutdown()+awaitTermination(...); Java 19+'taExecutorServiceAutoCloseable'dır veclose()bunu yapar.
Referans değerler: Spring Boot'un gömülü Tomcat'i varsayılan olarak en fazla 200 istek thread'i kullanır; HikariCP'nin varsayılan maximumPoolSize değeri 10'dur. Spring Boot'un @Async için auto-configure ettiği ThreadPoolTaskExecutor varsayılan olarak 8 core thread ve sınırsız kuyrukla gelir; yani yük altında @Async işleri sessizce kuyrukta birikir. spring.task.execution.pool.* ile sınırla.
CompletableFuture
CompletableFuture (Java 8+), asenkron hesapları birleştirmek için bir API'dir. Temel metotlar:
| Amaç | Metot |
|---|---|
| Başlat | supplyAsync(supplier, executor), runAsync(runnable, executor) |
| Sonucu dönüştür (senkron fonksiyon) | thenApply |
| Sonucu tüket | thenAccept, thenRun |
| Future dönen adımı zincirle (düzleştir) | thenCompose (Stream'deki flatMap gibi) |
| İki bağımsız sonucu birleştir | thenCombine |
| Hepsini / ilkini bekle | allOf (CompletableFuture<Void>), anyOf (CompletableFuture<Object>) |
| Hata yönetimi | exceptionally, handle, whenComplete; Java 12+ exceptionallyCompose |
| Zaman aşımı (Java 9+) | orTimeout, completeOnTimeout |
| Sonucu al | join() (unchecked CompletionException), get() (checked ExecutionException) |
thenApply ile thenCompose farkı: thenApply(f)'te f düz bir değer döner. f kendisi bir CompletableFuture dönüyorsa thenApply sonucu CompletableFuture<CompletableFuture<T>> olur; thenCompose bunu düzleştirir:
CompletableFuture<Order> order = orderClient.getAsync(orderId);
CompletableFuture<Shipment> shipment = order.thenCompose(o -> cargoClient.trackAsync(o.trackingNo()));
Hangi thread'de çalışır? Bu, CompletableFuture'ın en çok yanlış bilinen tarafıdır.
- Executor verilmemiş
*AsyncmetotlarForkJoinPool.commonPool()'da çalışır (common pool'un paralelliği 2'den azsa her görev için yeni bir thread açılır). Common pool çekirdek sayısından bir eksik thread'lidir ve parallel stream'lerle paylaşılır; içine bloklayan I/O vermek tüm uygulamayı yavaşlatır. I/O için her zaman executor ver. *Asyncolmayan bağımlı adımlar (thenApply,thenAccept...) önceki aşamayı tamamlayan thread'de ya da future zaten tamamlanmışsa bu metodu çağıran thread'de çalışır. YanisupplyAsync(..., ioExecutor).thenApply(heavyMapping)içindeki ağır dönüşüm I/O thread'inde çalışır; future çok hızlı tamamlanmışsa çağıran istek thread'inde çalışır.thenApplyAsync(f)executor'suz verilirse yine common pool'a gider;thenApplyAsync(f, executor)kontrolü sana verir.- Zaman aşımı tetiklendiğinde future'ı JDK'nın dahili zamanlayıcı thread'i tamamlar; ardından gelen senkron adımlar o thread'de çalışabilir. Zaman aşımından sonraki ağır işi
*Async(..., executor)ile kendi havuzuna taşı.
Hata yönetimi:
exceptionally(fn): sadece hata durumunda çalışır, yerine geçecek değeri döner.handle((result, ex) -> ...): her durumda çalışır, sonucu ya da hatayı yeni bir değere çevirir.whenComplete((result, ex) -> ...): her durumda çalışır ama sonucu değiştirmez (log, metrik için).
Bir aşamada fırlayan exception sonraki aşamalara CompletionException ile sarılı olarak iletilir; exceptionally içinde gelen ex genelde sarılıdır. Asıl nedeni bulmak için açman gerekir:
static Throwable unwrap(Throwable t) {
return (t instanceof CompletionException || t instanceof ExecutionException) && t.getCause() != null
? t.getCause() : t;
}
Zaman aşımı ve iptal: orTimeout(300, ms) future'ı TimeoutException ile tamamlar; completeOnTimeout(value, 300, ms) verilen değerle tamamlar. İkisi de alttaki işi durdurmaz. Javadoc'a göre CompletableFuture'ın kendisini tamamlayan hesap üzerinde doğrudan kontrolü yoktur: cancel(true) sadece future'ı CancellationException ile tamamlar, çalışan thread'i interrupt etmez. HTTP çağrısı arka planda sürer, executor thread'ini ve bir bağlantıyı tutar. Gerçek koruma için HTTP client'ın kendisinde connect ve read timeout tanımlanmalıdır; orTimeout ise çağıranın daha fazla beklememesini sağlar.
Context propagation: MDC (log korelasyon id'si), trace context ve Spring Security context thread'e bağlıdır (ThreadLocal) ve başka thread'e kendiliğinden geçmez. Micrometer Context Propagation ile executor'ı sarmak ya da TaskDecorator kullanmak gerekir; aksi halde asenkron adımların log'ları trace'ten kopar.
Deadlock ve diğer canlılık sorunları
Deadlock, iki ya da daha fazla thread'in birbirinin tuttuğu kaynağı sonsuza kadar beklemesidir. Oluşması için dört Coffman koşulunun aynı anda sağlanması gerekir:
- Mutual exclusion: Kaynak aynı anda tek thread tarafından tutulabilir.
- Hold and wait: Thread bir kaynağı tutarken başkasını bekler.
- No preemption: Tutulan kaynak zorla alınamaz.
- Circular wait: Thread'ler döngüsel bir bekleme zinciri oluşturur.
Birini kırmak deadlock'u imkansız kılar. Pratikte kırılanlar:
- Circular wait → kilit sırası (lock ordering). Tüm kilitler aynı global sırayla (ör. id'ye göre) alınırsa döngü oluşamaz.
- No preemption / hold and wait →
tryLock(timeout). Alınamayan kilitte elindekileri bırak, rastgele bir backoff ile tekrar dene. - Hold and wait → tek seferde al. Gereken tüm kaynakları tek bir kilit ya da tek bir atomik işlemle al.
synchronized'ın zaman aşımı yoktur; deadlock'a giren thread'ler sonsuza kadar BLOCKED kalır, istek havuzu tükenir ve servis yavaş yavaş cevap veremez hale gelir. Havuz henüz tamamen tükenmediyse ya da Actuator ayrı bir management portunda (ayrı thread havuzuyla) çalışıyorsa liveness probe geçmeye ve pod "sağlıklı" görünmeye devam edebilir.
Tespit: jcmd <pid> Thread.print ya da jstack <pid> ile alınan thread dump'ta JVM döngüyü kendisi bulur ve Found one Java-level deadlock: başlığı altında hangi thread'in hangi kilidi tutup hangisini beklediğini yazar. Programatik olarak ThreadMXBean.findDeadlockedThreads() monitörleri ve ReentrantLock gibi ownable synchronizer'ları kapsar; bunu periyodik çalıştırıp metrik ya da alarm üretmek mümkündür.
Diğer canlılık sorunları:
- Livelock: Thread'ler bloklanmaz ama sürekli birbirine yol verip geri çekildikleri için ilerleyemezler.
tryLock+ sabit backoff ile ortaya çıkabilir; rastgele (jitter'lı) backoff çözer. - Starvation: Bir thread kaynağa hiç ulaşamaz (non-fair kilitte sürekli başkalarının öne geçmesi, düşük öncelik).
- Thread pool deadlock: Bir havuzdaki görev, aynı havuza submit ettiği başka bir görevi
get()ile bekler. Havuz doluysa iç görev hiç çalışmaz. Aynı problemin Spring'deki karşılığı: dış transaction bir bağlantıyı tutarkenREQUIRES_NEWikinci bir bağlantı ister; havuz boyutu kadar eşzamanlı istek gelirse hepsi birbirini bekler.
Veritabanında da aynı mantık geçerlidir: iki transaction satırları ters sırada kilitlerse PostgreSQL deadlock'u tespit eder (varsayılan deadlock_timeout 1 saniye sonra kontrol eder) ve birini deadlock detected (SQLSTATE 40P01) ile abort eder. Uygulama bu hatayı retry etmelidir; kalıcı çözüm satırları her zaman aynı sırada kilitlemektir (bkz. Bölüm 10).
Virtual threads (Java 21+)
Model. Virtual thread (JEP 444, Java 21'de final), JVM'in yönettiği hafif bir thread'dir. Az sayıda platform thread'i, yani carrier, üzerine "mount" edilerek çalışır. Carrier'lar varsayılan olarak çekirdek sayısı kadar paralelliğe sahip, FIFO modunda çalışan özel bir ForkJoinPool'dur (common pool değil). Virtual thread bloklayan bir işleme (soket okuma, BlockingQueue.take, Thread.sleep, ReentrantLock bekleme) geldiğinde JDK onun stack frame'lerini heap'e kopyalar (unmount) ve carrier'ı serbest bırakır; işlem hazır olduğunda virtual thread herhangi bir carrier'a yeniden mount edilir. JDK'nın bloklayan API'lerinin çoğu bunu destekleyecek şekilde yeniden yazıldı.
Neyi çözer: Thread-per-request modelini korurken on binlerce eşzamanlı I/O-bound isteği taşımak. Kod bloklayan, sıralı, okunur ve debug edilebilir kalır; reactive programlamanın (WebFlux, callback zincirleri) karmaşıklığına girmeden benzer eşzamanlılık kapasitesi elde edilir. Virtual thread'ler ucuz olduğu için her görev için yenisi açılır.
Neyi çözmez:
- CPU-yoğun iş: Virtual thread hızı değil eşzamanlılık kapasitesini artırır. Darboğaz çekirdek sayısıysa, 10.000 virtual thread 8 çekirdeği paylaşmak zorundadır; ek olarak zamanlama maliyeti getirir.
- Downstream kapasitesi: 10.000 virtual thread, 20 bağlantılı Hikari havuzunun önünde yine sıraya girer. Darboğaz thread'den DB'ye kayar; hatta platform thread havuzunun doğal sınırlayıcı etkisi kalktığı için DB'ye ve dış servislere daha fazla yük biner.
- Kötü tasarlanmış kod: Bir çağrı 5 saniye sürüyorsa virtual thread ile de 5 saniye sürer.
Havuzlama. Oracle'ın virtual thread rehberi açıkça söyler: havuzlar kıt kaynakları paylaşmak için tasarlanmıştır; virtual thread'ler kıt değildir ve asla havuzlanmamalıdır. Eşzamanlılığı sınırlamak gerekiyorsa (downstream'i korumak için) Semaphore kullanılır:
private final Semaphore pricingPermits = new Semaphore(50); // fiyat servisine en fazla 50 eşzamanlı çağrı
Price fetch(long productId) throws InterruptedException {
pricingPermits.acquire();
try {
return pricingClient.fetch(productId); // bloklayan HTTP; virtual thread unmount olur
} finally {
pricingPermits.release();
}
}
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Price>> futures = productIds.stream()
.map(id -> executor.submit(() -> fetch(id)))
.toList();
// ...
}
Pinning. Virtual thread'in bloklandığında carrier'dan ayrılamaması durumudur. Java 21'de iki durumda olur:
- Virtual thread bir
synchronizedblok ya da metot içindeyken bloklanırsa (Object.wait()dahil). - Bir
nativemetot ya da Foreign Function çalıştırırken.
Pinning kodu yanlış yapmaz ama ölçeklenmeyi bozar: synchronized içinde JDBC çağrısı yapan 8 virtual thread, 8 carrier'ın hepsini tutar ve diğer binlerce virtual thread bekler. JDK 21 ile 23 arasında öneri, sık ve uzun süren pinning'in olduğu yerlerde synchronized'ı ReentrantLock ile değiştirmekti; bazı kütüphaneler (ör. JDBC sürücüleri, HTTP client'lar) bu dönemde içlerindeki synchronized kullanımlarını bu yüzden güncelledi.
JDK 24 (JEP 491): Virtual thread'ler artık monitörleri carrier'dan bağımsız olarak alıp tutabilir ve bırakabilir; synchronized içinde bloklanma ve Object.wait() artık pinning yaratmaz. Pinning yalnızca native kod ya da FFM üzerinden çağrılan kodun Java'ya geri çağrı yapıp orada bloklandığı durumlarda ve birkaç nadir JVM içi durumda kalır. JDK 21'deki -Djdk.tracePinnedThreads seçeneği JDK 24'te kaldırıldı (artık etkisi yok); kalan pinning durumları JFR'ın jdk.VirtualThreadPinned event'iyle izlenir ve bu event JDK 24'te pinning nedenini de raporlar. Java 25 LTS'ye geçiş, virtual thread kullanan bir ekip için bu yüzden önemli bir kazanımdır.
ThreadLocal maliyeti. Virtual thread'ler ThreadLocal'ı destekler, ama iki kullanım tarzı ters teper:
- Pahalı nesneyi ThreadLocal'da önbelleğe almak (
SimpleDateFormat, büyük buffer'lar): Bu desen thread'lerin havuzda yeniden kullanılmasına dayanır. Virtual thread'ler havuzlanmadığı için her görev nesneyi yeniden oluşturur; kazanç yok, maliyet var.DateTimeFormattergibi immutable, paylaşılabilir nesneler kullan. - Milyon thread × ThreadLocal verisi: Her virtual thread kendi kopyasını taşır; büyük nesneler bellek tüketimini çarpar.
InheritableThreadLocalher yeni thread'e kopyalanır.
Java 25'te final olan Scoped Values (JEP 506), bir metodun çağırdığı kodla ve alt thread'lerle sınırlı süre için değiştirilemez veri paylaşmanın daha ucuz ve güvenli yoludur: alt thread'ler bağlamı kopyalamadan devralır.
Spring Boot. Spring Boot 3.2+ ile spring.threads.virtual.enabled=true (Java 21+) şunları değiştirir:
- Tomcat/Jetty istek işleme virtual thread'lerde çalışır.
@Asyncve diğer asenkron işler için auto-configure edilen executor, virtual thread kullanan birSimpleAsyncTaskExecutorolur;@SchedulediçinSimpleAsyncTaskScheduler. Thread havuzu ayarları (spring.task.execution.pool.*) artık etkisizdir.- Messaging listener container'ları gibi diğer entegrasyonlar da (sürüme göre değişen kapsamda) virtual thread kullanır.
Spring Boot dokümantasyonunun iki uyarısı önemlidir: virtual thread'ler daemon thread'lerdir ve JVM tüm thread'leri daemon olduğunda kapanır; sadece @Scheduled işlerle ayakta kalan bir uygulamada spring.main.keep-alive=true gerekir. Ayrıca güncel Spring Boot dokümantasyonu en iyi deneyim için Java 24+'ı şiddetle önerir; bunun sebebi JEP 491'dir.
Gözlemlenebilirlik. Binlerce virtual thread klasik jstack çıktısında okunmaz; jcmd <pid> Thread.dump_to_file -format=json <dosya> virtual thread'leri de içeren yapılandırılmış bir dump üretir. Pinning için JFR jdk.VirtualThreadPinned event'i izlenir. Geçişten önce ve sonra p99 latency, DB havuzu bekleme süresi (hikaricp.connections.pending) ve downstream hata oranları karşılaştırılmalıdır.
Structured concurrency
Bir isteğin içinde birkaç alt görevi paralel çalıştırdığında CompletableFuture ya da ExecutorService kullanırsan, alt görevlerin yaşam süresi kodun yapısına bağlı değildir: biri hata verdiğinde diğerleri çalışmaya devam eder, ana thread interrupt edilirse alt görevler sızar, thread dump'ta hangi görevin hangi isteğe ait olduğu görünmez. Structured concurrency, alt görevleri bir kapsam (scope) içine bağlar: kapsam bitmeden hiçbir alt görev onu aşamaz; biri başarısız olunca diğerleri iptal edilir.
Sürüm durumu (Ekim 2026 itibarıyla):
- JDK 19-20: incubator (JEP 428, 437).
- JDK 21-24: preview (JEP 453, 462, 480, 499).
- JDK 25: beşinci preview (JEP 505); public constructor'lar yerine
StructuredTaskScope.open(...)factory'leri veJoinerkavramı geldi. - JDK 26: altıncı preview (JEP 525).
- JDK 27: yedinci preview (JEP 533);
join()'in fırlattığı exception modeli değişti (FailedExceptionyerineExecutionException, ek bir tip parametresi). - JDK 28 (Mart 2027): JEP 543 ile JDK 27'deki API'nin değişmeden final olması hedefleniyor.
Yani Java 21 ve 25 LTS'de hâlâ --enable-preview gerektirir ve API sürümler arasında değişti. Production'da kullanmadan önce hedef JDK sürümünün API'sine bakmak gerekir. JDK 25 API'siyle örnek:
// JDK 25, --enable-preview
ProductView load(long id) throws InterruptedException {
try (var scope = StructuredTaskScope.open()) { // varsayılan: biri başarısızsa hepsi iptal
Subtask<Price> price = scope.fork(() -> priceClient.get(id));
Subtask<Stock> stock = scope.fork(() -> stockClient.get(id));
scope.join(); // ikisini birden bekler; hata varsa fırlatır
return new ProductView(price.get(), stock.get(), Campaign.NONE);
} // close(): kalan alt görevleri iptal edip bekler
}
Her fork varsayılan olarak yeni bir virtual thread başlatır. CompletableFuture versiyonuna göre fark: fiyat çağrısı hata verirse stok çağrısı interrupt edilerek iptal edilir (CompletableFuture'da arka planda sürerdi), ana thread interrupt edilirse alt görevler de iptal olur ve thread dump'ta alt görevler ebeveyn kapsamın altında görünür.
Quiz'deki sorular: çözüm ve nedenleri
q1: Sayaç ve fiyat cache'i: 64 thread altında ne bozulur?
Senaryo: Tek pod, 64 Tomcat thread'i.
private volatile long totalViews = 0;
private final Map<Long, Price> priceCache = new ConcurrentHashMap<>();
public void onView(long productId) { totalViews++; }
public Price price(long productId) {
if (!priceCache.containsKey(productId)) {
priceCache.put(productId, pricingClient.fetch(productId)); // ~80 ms HTTP
}
return priceCache.get(productId);
}
Doğru cevaplar: b, c, d.
- b)
totalViews++oku-artır-yaz adımlarıdır; artışlar kaybolur. Doğru.getfield,ladd,putfieldüç ayrı bytecode adımıdır; iki thread aynı değeri okuyup aynı sonucu yazarsa bir artış kaybolur.volatileher okumanın güncel değeri görmesini sağlar ama okuma ile yazma arasına girilmesini engellemez. - c)
AtomicLongya daLongAdderile düzeltilir; çok yazılıp az okunan sayaçtaLongAdderdaha az çekişme yaratır. Doğru.LongAdderyazmaları hücrelere dağıtır;AtomicLong'da tüm çekirdekler tek bir cache line için yarışır. - d) Aynı ürün için birçok thread aynı anda
fetchçağırabilir (check-then-act, cache stampede). Doğru.containsKeyileputarasında 80 ms'lik bir boşluk var. Popüler bir ürün için ilk anda gelen 50 isteğin hepsicontainsKey'denfalsealır ve fiyat servisine 50 çağrı gider.
Yanlışlar:
- a)
volatilesayesinde++atomiktir. Yanlış:volatilegörünürlük ve sıralama verir, atomiklik vermez. - e)
computeIfAbsent(productId, pricingClient::fetch)her şeyi çözer, içinde 80 ms HTTP sorun değildir. Yanlış:computeIfAbsentatomiktir ve stampede'i engeller, ama mapping fonksiyonu bin kilidi tutulurken çalışır. 80 ms boyunca aynı bin'e düşen başka ürünlere yazmak isteyen thread'ler de bekler; HTTP hata verirse ya da yavaşlarsa bu bekleme yayılır. Javadoc hesaplamanın kısa ve basit olmasını ister.
Quiz açıklamasında küçük bir düzeltme: Açıklama, AtomicLong'un yük altında "CAS başarısızlıkları" yaşadığını söylüyor. incrementAndGet için x86'da HotSpot genelde tek bir LOCK XADD komutu üretir; başarısız olup tekrar deneme yoktur. Maliyetin asıl kaynağı tek cache line üzerindeki çekişmedir (cache line'ın çekirdekler arasında sürekli taşınması). CAS tekrar sorunu updateAndGet/accumulateAndGet gibi gerçek CAS döngüsü kullanan metotlarda ve x86 dışı mimarilerde geçerlidir. Sonuç (yüksek çekişmede LongAdder daha iyi) değişmez.
Cache tarafında üç ayrı problem var: stampede (aynı key için paralel yükleme), sınırsız büyüme (map hiç temizlenmiyor; milyonlarca ürün heap'i doldurur) ve bayatlama (TTL yok; fiyat değişse de eski fiyat sonsuza kadar döner, e-ticarette yanlış fiyat göstermek ciddi bir hatadır).
Daha iyi çözüm:
private final LongAdder totalViews = new LongAdder();
public void onView(long productId) { totalViews.increment(); }
// Elle stampede koruması: map'e değeri değil future'ı koy
private final ConcurrentHashMap<Long, CompletableFuture<Price>> cache = new ConcurrentHashMap<>();
public CompletableFuture<Price> price(long productId) {
CompletableFuture<Price> f = cache.computeIfAbsent(productId,
id -> CompletableFuture.supplyAsync(() -> pricingClient.fetch(id), pricingIo));
// Başarısız future cache'te kalmasın: sadece kendi future'ımızı sil
f.whenComplete((p, ex) -> { if (ex != null) cache.remove(productId, f); });
return f;
}
Lambda sadece bir future oluşturup döndüğü için bin kilidi mikrosaniyeler içinde bırakılır; aynı ürünü isteyen herkes aynı future'ı bekler. remove(key, value) iki argümanlı versiyondur: arada başka bir thread yeni bir future koyduysa onu silmez. Bu versiyonda hâlâ TTL ve boyut sınırı yoktur. Production cevabı Caffeine'dir: AsyncLoadingCache aynı key için tek yükleme yapar, maximumSize ve expireAfterWrite ile boyutu ve süreyi sınırlar, refreshAfterWrite ile eski değeri dönerken arka planda yeniler, başarısız yüklemeleri cache'te tutmaz.
q4: Virtual thread'lere geçiyoruz: hangileri doğru?
Senaryo: Sipariş servisi Java 21 ve Spring Boot 3.3'e yükseltiliyor; her istekte PostgreSQL (Hikari: 20) ve iki dış HTTP servisi var.
Doğru cevaplar: b, d, f.
- b) Bloklayan I/O sırasında virtual thread carrier'ı bırakır; binlerce eşzamanlı isteği az OS thread'iyle taşır. Doğru. Bloklama noktasında stack heap'e alınır (unmount), carrier başka bir virtual thread'i çalıştırır.
- d) JDK 21'de
synchronizediçinde bloklayan I/O pinning yaratabilir; JDK 24 (JEP 491) bunu büyük ölçüde çözdü. Doğru. JDK 21-23'te pinning nedeniyle eşzamanlılık carrier sayısına düşebilir; bu sürümlerde sık ve uzun pinning olan yerlerdeReentrantLocktercih edilir. JDK 24'te kalan pinning native/FFM çağrılarıyla sınırlıdır. - f) Spring Boot 3.2+ ile
spring.threads.virtual.enabled=trueTomcat istek işleyicilerini virtual thread'de çalıştırır. Doğru.@Asyncexecutor'ı ve@Scheduledscheduler'ı da virtual thread tabanlı olur ve havuz ayarları etkisizleşir.
Yanlışlar:
- a) CPU-yoğun işleri daha hızlı yapar. Yanlış: darboğaz çekirdek sayısıdır; virtual thread hız değil eşzamanlılık kapasitesi verir.
- c) Pahalı oldukları için havuzlanmalı. Yanlış: ucuzdurlar ve asla havuzlanmazlar; eşzamanlılık
Semaphoreile sınırlanır. - e) DB connection pool sorunu kendiliğinden çözülür. Yanlış: binlerce virtual thread 20 bağlantı için sıraya girer; darboğaz DB'ye kayar ve DB'ye daha fazla yük biner.
Quiz açıklamasına ekler: "Platform thread ~1 MB stack" ifadesi, 64 bit Linux'ta varsayılan olarak rezerve edilen adres alanıdır; gerçek bellek kullanıldıkça ayrılır. Ayrıca virtual thread'ler daemon olduğu için sadece @Scheduled işlerle ayakta duran bir serviste spring.main.keep-alive=true gerekir.
Production karşılığı: Geçişin asıl riski darboğazın yer değiştirmesidir. Platform thread havuzu (Tomcat 200) farkında olmadan bir rate limiter görevi görüyordu; virtual thread'lerle 2.000 eşzamanlı istek, 20 bağlantılı havuzda Hikari'nin connectionTimeout'una (varsayılan 30 saniye) kadar bekleyip hata alabilir ve dış servislere 10 kat yük gidebilir. Geçişten önce downstream başına Semaphore bulkhead'leri, HTTP client timeout'ları ve DB havuzu metrikleri hazır olmalıdır.
j17: ConcurrentHashMap içeride nasıl çalışır?
Senaryo: 200 istek thread'inin aynı anda yazdığı ürün görüntülenme map'i için HashMap, synchronizedMap ve CHM arasında seçim.
Doğru cevaplar: a, b, c, f.
- a) Java 8+ CHM boş bucket'a CAS ile, dolu bucket'a ilk node üzerinde
synchronizedile yazar. Doğru. Kilit kapsamı tek bin'dir; Java 7'deki 16 segment sınırı kalktı. - b)
get()çoğu durumda kilit almaz; volatile okumalarla güncel değeri görür. Doğru; daha da kesin söylemek gerekirsegethiçbir durumda bloklanmaz. Ağaç yeniden düzenlenirken bile okur bağlı liste üzerinden doğrusal arama yapar. - c) Null key ve null value kabul etmez. Doğru.
getnull dönerse anlamı tek olmalı ("key yok"); eşzamanlı ortamdacontainsKeyile ayırmak race'li olurdu. - f) Çok çakışan bin 8 elemanda (tablo en az 64 iken)
TreeBin'e döner. Doğru. KötühashCodeya da hash flooding saldırısında arama O(n)'den O(log n)'e iner. Tablo 64'ten küçükse ağaç yerine resize yapılır.
Yanlışlar:
- d)
size()her zaman anlık ve kesin değeri verir. Yanlış: Javadoc'a göre eşzamanlı güncelleme altında geçici bir durumu yansıtır; izleme için yeterli, program kontrolü için değil. Büyük map'temappingCount(). - e) Iteration sırasında değişiklik
ConcurrentModificationExceptionfırlatır. Yanlış: iterator'lar weakly consistent'tir, exception fırlatmaz. - g)
synchronizedMapCHM kadar iyi ölçeklenir. Yanlış: tek kilit; okumalar dahil her işlem sıraya girer.
Ana kavram ve production karşılığı: CHM'yi seçmek yetmez; bileşik işlemleri merge/compute* ile atomik yazmak gerekir. 200 thread'in yazdığı görüntülenme sayacı için en iyi yapı ConcurrentHashMap<Long, LongAdder> + computeIfAbsent(id, k -> new LongAdder()).increment(): bin kilidi sadece bir ürünün ilk görüntülenmesinde alınır, sonraki artışlar LongAdder'ın hücrelerine dağılır. Sayaçlar periyodik olarak DB'ye ya da bir event'e boşaltılıyorsa, boşaltma ile artış arasındaki yarış için sumThenReset()'in de eşzamanlı yazmalar altında kesin olmadığını hesaba kat.
j18: Ürün sayfası: üç servisi paralel çağır
Senaryo: Fiyat, stok ve kampanya servisleri 100-200 ms sürüyor. Fiyat ya da stok alınamazsa sayfa hata dönmeli; kampanya yavaş ya da hatalıysa sayfa Campaign.NONE ile açılmalı; her çağrı en fazla 300 ms beklenmeli.
Çözüm:
public ProductView load(long productId) {
CompletableFuture<Price> price = CompletableFuture
.supplyAsync(() -> priceClient.get(productId), ioExecutor)
.orTimeout(300, TimeUnit.MILLISECONDS);
CompletableFuture<Stock> stock = CompletableFuture
.supplyAsync(() -> stockClient.get(productId), ioExecutor)
.orTimeout(300, TimeUnit.MILLISECONDS);
CompletableFuture<Campaign> campaign = CompletableFuture
.supplyAsync(() -> campaignClient.get(productId), ioExecutor)
.completeOnTimeout(Campaign.NONE, 300, TimeUnit.MILLISECONDS)
.exceptionally(ex -> {
log.warn("Kampanya alınamadı, kampanyasız devam: productId={}", productId, ex);
return Campaign.NONE;
});
try {
return price.thenCombine(stock, PriceAndStock::new)
.thenCombine(campaign, (ps, c) -> new ProductView(ps.price(), ps.stock(), c))
.join();
} catch (CompletionException e) {
throw new ProductPageException(productId, e.getCause());
}
}
private record PriceAndStock(Price price, Stock stock) {}
Quiz'in model çözümü allOf(price, stock, campaign).thenApply(ignored -> new ProductView(price.join(), stock.join(), campaign.join())).join() kullanır; bu da doğrudur ve üç ya da daha fazla future'da daha okunurdur (allOf tamamlandıktan sonra içerideki join() çağrıları bloklamaz). thenCombine tiplidir ve ara join() çağrılarını gizler. İki yaklaşımda da toplam süre en yavaş çağrı kadardır (üst sınır ~300 ms).
Neden böyle:
- Ayrı I/O executor: Executor verilmezse üç bloklayan çağrı common pool'a gider; common pool küçük ve parallel stream'lerle paylaşımlıdır.
- Fiyat/stok için
orTimeout, kampanya içincompleteOnTimeout(NONE)+exceptionally: Zorunlu veriler hata ile, opsiyonel veri fallback ile tamamlanır.exceptionallyiçinde hatayı loglamak ya da metrikle saymak gerekir; aksi halde kampanya servisi günlerce çökük kalsa kimse fark etmez. CompletionException'ı açmak:join()asıl hatayı sarar;getCause()ile alınıp anlamlı bir domain exception'a çevrilir. Global exception handler bunu 503 ya da 504'e eşleyebilir.
Sorunun ikinci kısmı: orTimeout alttaki HTTP çağrısını durdurur mu? Hayır. Sadece future'ı TimeoutException ile tamamlar. Executor thread'i HTTP cevabını beklemeye devam eder, thread ve bağlantı tutulur. cancel(true) da çalışan thread'i interrupt etmez. Gerçek koruma HTTP client'ta connect ve read timeout'tur (RestClient/WebClient ya da JDK HttpClient seviyesinde); orTimeout, client timeout'undan biraz büyük ya da eşit seçilir ki iki katman tutarlı olsun.
Yanlış ya da eksik yaklaşımlar: supplyAsync'e executor vermemek; üç çağrıyı sırayla get() ile beklemek (paralellik kaybolur); kampanya hatasını handle ile yakalayıp loglamamak; get() kullanıp checked exception'ları catch (Exception e) {} ile yutmak; timeout'u sadece orTimeout ile verip HTTP client timeout'unu unutmak.
Java 21+ alternatifi: Virtual thread'lerle her çağrı bloklayan kod olarak yazılabilir. Structured concurrency ise (Java 25'te preview) "fiyat hata verirse stok çağrısını iptal et" davranışını gerçekten sağlar; CompletableFuture'da diğer çağrılar arka planda sürer.
j19: Cüzdanlar arası transfer donuyor
Senaryo:
public void transfer(Wallet from, Wallet to, BigDecimal amount) {
synchronized (from) {
synchronized (to) {
from.withdraw(amount);
to.deposit(amount);
}
}
}
// Thread 1: transfer(ali, ayse, 100)
// Thread 2: transfer(ayse, ali, 50)
Ne oluyor: Klasik deadlock. Thread 1 ali'nin monitörünü alıp ayse'ninkini bekler; Thread 2 ayse'ninkini alıp ali'ninkini bekler. Dört Coffman koşulu da sağlanıyor. synchronized'ın zaman aşımı olmadığı için ikisi de sonsuza kadar BLOCKED kalır; bu iki cüzdana dokunan her yeni istek de aynı kilitlerde birikir ve istek havuzu tükenir.
Tespit: jcmd <pid> Thread.print ya da jstack <pid>: dump'ın sonunda Found one Java-level deadlock: başlığı, hangi thread'in hangi nesneyi tuttuğunu ve hangisini beklediğini gösterir. Programatik olarak ThreadMXBean.findDeadlockedThreads().
JVM içi düzeltme: kilit sırası.
public void transfer(Wallet from, Wallet to, BigDecimal amount) {
if (from.id() == to.id()) throw new IllegalArgumentException("Aynı cüzdana transfer");
Wallet first = from.id() < to.id() ? from : to;
Wallet second = first == from ? to : from;
synchronized (first) {
synchronized (second) {
from.withdraw(amount);
to.deposit(amount);
}
}
}
Alternatif: ReentrantLock.tryLock(timeout); alınamazsa elindeki kilidi bırak, rastgele (jitter'lı) bir backoff sonrası tekrar dene. Sabit backoff livelock'a yol açabilir.
Asıl doğru cevap: Para transferi JVM kilidiyle yapılmaz.
- Servis birden fazla pod'da çalışır;
synchronizedsadece tek JVM'i korur. - Quiz'in değinmediği bir nokta: Tek pod'da bile,
Walletbir JPA entity'siyse her istek kendi persistence context'inde ayrı bir Java nesnesi yükler.synchronized (from)iki farklı isteğin aynı satırı temsil eden farklı nesnelerini kilitler ve hiçbir şeyi korumaz. Kilit sırası çözümü de ancak aynı cüzdan için her yerde aynı nesne kullanılıyorsa (bellekte tutulan bir domain modeli) anlamlıdır. - Doğru yer veritabanıdır: tek bir transaction içinde iki satırı id sırasıyla
SELECT ... FOR UPDATEile kilitlemek ya da koşullu atomikUPDATE wallet SET balance = balance - :x WHERE id = :from AND balance >= :xkullanmak. Satırlar ters sırada kilitlenirse PostgreSQL deadlock'u tespit eder ve bir transaction'ı40P01ile abort eder; uygulama retry etmelidir. Bankacılıkta buna ek olarak transfer isteği idempotency key ile korunur ki retry çift transfer yaratmasın (satır kilitleri için bkz. Bölüm 10, idempotency için bkz. Bölüm 13).
Ana kavram: Deadlock'u önlemenin en pratik yolu döngüsel beklemeyi kilit sırasıyla kırmaktır; ama mülakatta "bu kilit neyi koruyor, kaç JVM var, nesne kimliği sabit mi?" sorularını sormak, sorunun asıl çözümüne götürür.
Üç seviyede cevap
Soru: "volatile ne işe yarar? volatile bir sayaç thread-safe midir?"
seviye: Orta etiketler: [concurrency, volatile, memory-model] kaynak: Tahmini
volatile, değişkenin ana bellekten okunmasını sağlar, cache'lenmez. Böylece tüm thread'ler aynı değeri görür. Sayaç volatile olursa thread-safe olur, çünkü her thread güncel değeri okur.
volatile görünürlük garantisi verir: bir thread'in yazdığı değer diğer thread'lere hemen görünür ve volatile yazma/okuma etrafında yeniden sıralama sınırlanır. Ama atomiklik vermez. count++ oku, artır, yaz olmak üzere üç adımdır; iki thread aynı değeri okuyup aynı sonucu yazabilir ve artış kaybolur. Sayaç için AtomicLong ya da yüksek çekişmede LongAdder kullanırım. volatile'ı durdurma bayrağı gibi tek yazarlı durumlarda kullanırım.
volatile, JMM'de happens-before kuran bir araç: volatile yazma, aynı alanın sonraki okumasından önce olur ve yazmadan önceki tüm işlemleri de görünür kılar. Bu yüzden immutable bir snapshot'ı ya da durdurma bayrağını yayınlamak için doğru araçtır; JIT'in döngü koşulunu register'a alması gibi optimizasyonları da yasaklar. Atomiklik vermez: count++ bytecode'da getfield, ladd, putfield'dır ve araya girilebilir. Ayrıca volatile olmayan long/double yazmalarının JLS'e göre iki 32 bitlik yazma olabileceğini, volatile'ın bunu da kapattığını bilirim.
Sayaç için seçim okuma deseninden çıkar: kesin değere göre karar vereceksem AtomicLong; metrik gibi çok yazılıp seyrek okunan sayaçta LongAdder, çünkü tek cache line üzerindeki çekişmeyi hücrelere dağıtır ve sum() zaten anlık snapshot gerektirmeyen yerde kullanılır. Durum birden fazla alana bağlıysa (if (x == null) x = ...) ne volatile ne tek bir atomic yeter; ya kilit ya immutable nesne + AtomicReference.compareAndSet. Bu tür hataların x86'da gizlenip ARM'da (Graviton) ortaya çıkabileceğini bilerek doğruluğu testle değil happens-before argümanıyla kurarım; gerektiğinde jcstress ile doğrularım.
Junior cevap görünürlüğü yaklaşık doğru anlatıyor ama "volatile sayaç thread-safe'tir" yanlış inancını taşıyor; bu tam olarak q1'in a seçeneğidir ve production'da sessiz veri kaybı demektir. Mid cevap görünürlük ile atomikliği ayırıyor, doğru alternatifleri ve kullanım alanını veriyor. Senior cevap mekanizmayı JMM terimleriyle (happens-before, yayınlama, yeniden sıralama, word tearing) anlatıyor, aracı okuma desenine göre seçiyor, çok alanlı invariant'larda volatile ve tek atomic'in de yetmediğini söylüyor ve doğrulama yöntemini (happens-before argümanı, jcstress, mimari farkı) ekliyor.
Takip sorusu: "Double-checked locking'de instance alanını neden volatile yapmak gerekir? volatile olmazsa ne görülebilir?"
Soru: "ConcurrentHashMap nasıl çalışır? HashMap ve Collections.synchronizedMap'ten farkı nedir?"
seviye: Orta etiketler: [concurrency, concurrenthashmap] kaynak: Tahmini
HashMap thread-safe değil, ConcurrentHashMap thread-safe. synchronizedMap de thread-safe ama daha yavaş. ConcurrentHashMap map'i segmentlere bölüp her segmenti ayrı kilitler, bu yüzden daha hızlıdır. ConcurrentHashMap kullanınca race condition olmaz.
Java 8'den beri segment yok: boş bin'e CAS ile, dolu bin'e o bin'in ilk node'u üzerinde synchronized ile yazılır; okumalar kilitsizdir. Farklı bin'lere yazan thread'ler birbirini beklemez. Çakışan bin 8 elemanda ağaca döner. Null kabul etmez, iterator'ları weakly consistent'tir, size yaklaşıktır. synchronizedMap tek kilit kullanır, okumalar dahil her şey sıraya girer. CHM kullanmak tek başına yetmez; containsKey + put hâlâ race'tir, bileşik işlemler için merge, computeIfAbsent kullanırım.
Java 8+ CHM, volatile erişilen bir node tablosu: boş bin'e CAS, dolu bin'e ilk node üzerinde synchronized, get hiçbir zaman bloklanmaz. Resize'da birden çok thread taşımaya yardım eder ve taşınmış bin'ler ForwardingNode ile işaretlenir; sayaç LongAdder benzeri hücrelerle tutulur, bu yüzden size() eşzamanlı yazmada tahmindir. Treeify 8 elemanda ama tablo 64'ten küçükse önce resize; bu, kötü hashCode ya da hash flooding'e karşı O(log n) garantisi verir. Null'ı reddetmesi, eşzamanlı ortamda "yok" ile "null değer" ayrımının race'li olmasından gelir.
Pratikte asıl soru "hangi map" değil "bileşik işlemi nasıl atomik yaparım". merge, compute* key bazında atomiktir ama fonksiyon bin kilidi tutulurken çalışır; içine I/O koyarsam aynı bin'deki alakasız key'leri de bloklarım ve aynı map'e geri yazarsam Java 9+'ta "Recursive update" alırım. Yavaş yükleme gereken yerde map'e future koyarım ya da doğrudan Caffeine kullanırım, çünkü boyut sınırı, TTL ve stampede koruması zaten orada. Yüksek çekişmeli sayaçta ConcurrentHashMap<K, LongAdder>. Son olarak CHM sadece tek JVM'i korur; stok gibi çok pod'lu tutarlılık gereken state'i oraya koymam.
Junior cevap Java 7 bilgisini (segment) güncel sanıyor ve "CHM kullanınca race olmaz" diyor; bu, check-then-act hatalarının kaynağı olan yaygın bir yanlış inanç. Mid cevap Java 8+ yapısını, null/iterator/size davranışını ve bileşik işlem gereksinimini doğru anlatıyor. Senior cevap iç yapıyı (resize yardımı, sayaç, treeify koşulu) tasarım gerekçeleriyle veriyor, compute içinde uzun iş yapmanın somut sonuçlarını söylüyor, alternatif olarak Caffeine'i ve future desenini öneriyor ve CHM'nin dağıtık tutarlılık sağlamadığını belirterek kapsamı doğru çiziyor.
Takip sorusu: "computeIfAbsent içinde 80 ms'lik bir HTTP çağrısı yapan bir cache gördün. Neyi, neden değiştirirsin?"
Soru: "Bir thread pool'un boyutunu nasıl belirlersin?"
seviye: İleri etiketler: [concurrency, thread-pool] kaynak: Tahmini
Genelde çekirdek sayısının iki katı kadar thread veririm. Ya da Executors.newFixedThreadPool(100) gibi yeterince büyük bir sayı veririm, daha çok thread daha çok iş demek.
İşin türüne bakarım. CPU-yoğun işte çekirdek sayısı kadar thread yeter, fazlası context switch üretir. I/O-yoğun işte thread'ler çoğu zaman beklediği için daha fazla thread gerekir; Goetz'in formülüyle çekirdek × (1 + bekleme/hesaplama). newFixedThreadPool sınırsız kuyruk kullandığı için ThreadPoolExecutor'ı sınırlı kuyruk ve reddetme politikasıyla kendim kurarım. ThreadPoolExecutor'ın max thread'e ancak kuyruk dolunca çıktığını bilirim.
Önce iş tipini ve hedefi netleştiririm: hangi downstream'i çağırıyoruz, beklenen istek hızı ve latency ne? Little Yasası'yla gereken eşzamanlılığı hesaplarım: saniyede 500 çağrı × 200 ms = ortalama 100 eşzamanlı çağrı, patlamalar için pay eklerim. CPU-yoğun iş için çekirdek sayısı; I/O için Goetz formülü başlangıç noktasıdır. Ama sınır çoğu zaman thread değil downstream'dir: 20 bağlantılı DB havuzunun önüne 200 thread koymak sadece kuyruğu yer değiştirir. Bu yüzden her bağımlılık için ayrı ve sınırlı bir havuz (bulkhead), sınırlı kuyruk, bilinçli bir reddetme politikası (CallerRunsPolicy'nin Tomcat thread'ini de tıkayacağını bilerek) ve isimlendirilmiş thread'ler kurarım.
Boyutu ölçümle ayarlarım: aktif thread, kuyruk uzunluğu, görev bekleme süresi, reddetme sayısı ve downstream latency'si Micrometer ile izlenir; kuyruk sürekli doluysa havuzu büyütmek yerine downstream'e bakarım. Java 21+ ve I/O ağırlıklı bir serviste platform thread havuzu yerine virtual thread + Semaphore ile eşzamanlılığı doğrudan downstream kapasitesine göre sınırlamayı tercih ederim; o zaman soru "kaç thread" değil "downstream başına kaç eşzamanlı çağrı" olur.
Junior cevap ezber bir oran ve "daha çok thread daha çok iş" yanlış inancıyla geliyor; sınırsız kuyruk ve downstream etkisinden habersiz. Mid cevap iş tipine göre ayrım yapıyor, formülü ve ThreadPoolExecutor'ın kuyruk-önce davranışını biliyor. Senior cevap netleştirme sorusuyla başlıyor, Little Yasası ile somut hesap yapıyor, darboğazın downstream'de olduğunu, bulkhead'i, reddetme politikasının yan etkisini ve izlenecek metrikleri söylüyor; virtual thread dünyasında sorunun nasıl değiştiğini de biliyor.
Takip sorusu: "Kuyruğun sürekli dolu olduğunu ve CPU'nun %20'de olduğunu görüyorsun. Ne yaparsın?"
Soru: "Servisimizi virtual thread'lere geçirmeli miyiz?"
seviye: İleri etiketler: [virtual-threads, concurrency] kaynak: Tahmini
Evet, virtual thread'ler çok hafif ve hızlı. Milyonlarca thread açabiliriz, bu yüzden performans artar. Spring Boot'ta tek bir property ile açılıyor, hemen geçmeliyiz.
Servis I/O-bound ise, yani zamanının çoğunu DB ve HTTP beklerken geçiriyorsa, virtual thread'ler eşzamanlılık kapasitesini artırır, çünkü bloklama sırasında carrier thread'i bırakırlar. CPU-bound işi hızlandırmazlar. Havuzlanmazlar; eşzamanlılığı Semaphore ile sınırlarım. Java 21'de synchronized içinde bloklama pinning yaratır, bu yüzden kütüphanelerin ve kodun buna uygun olduğunu kontrol ederim. DB havuzu darboğazı devam eder.
Önce bugünkü darboğazı ölçerim: thread havuzu doluyken CPU boştaysa ve istekler çoğunlukla I/O bekliyorsa virtual thread kazandırır; CPU doluysa ya da darboğaz DB ise kazandırmaz, hatta Tomcat'in 200 thread'lik doğal sınırı kalktığı için DB'ye ve dış servislere daha fazla yük gider. Bu yüzden geçişten önce downstream başına Semaphore bulkhead'leri, HTTP client timeout'ları ve Hikari bekleme metriklerini hazırlarım.
Sürüm önemli: Java 21-23'te synchronized içinde bloklama pinning yaratır; kendi kodumuzdaki sıcak synchronized bloklarını ve kütüphane sürümlerini JFR jdk.VirtualThreadPinned ile kontrol ederim. JDK 24'te JEP 491 bunu çözdüğü için hedefim Java 25 LTS olur; Spring Boot dokümanı da 24+'ı öneriyor. ThreadLocal'da pahalı nesne cache'leyen kodu temizlerim, MDC/trace propagation'ı doğrularım, sadece @Scheduled ile ayakta duran servislerde spring.main.keep-alive=true eklerim. Geçişi bir pod'da ya da canary ile yapıp p99 latency, throughput, DB pending connection ve hata oranlarını karşılaştırırım. Reactive bir stack'imiz varsa onu sırf bu yüzden geri almam; yeni ve bloklayan kod tabanları için ise virtual thread, reactive'den daha basit bir yoldur.
Junior cevap "hafif = hızlı" yanılgısıyla ve ölçmeden geçiş öneriyor; darboğazın kaymasından ve pinning'den habersiz. Mid cevap I/O-bound/CPU-bound ayrımını, havuzlamamayı, pinning'i ve DB darboğazını biliyor. Senior cevap kararı ölçüme bağlıyor, geçişin yan etkisini (doğal rate limiter'ın kalkması) ve önlemlerini sayıyor, sürüm farkını (JEP 491, Java 25 hedefi) ve gözlemlenebilirliği (JFR event'i, Hikari metrikleri, canary) ekliyor, reactive ile karşılaştırmada da dogmatik değil.
Takip sorusu: "Geçişten sonra p99 latency arttı ve DB'de aktif bağlantı sayısı sürekli tavanda. Ne olmuş olabilir, ne yaparsın?"
Soru: "Deadlock nedir, nasıl tespit eder ve önlersin?"
seviye: Orta etiketler: [concurrency, deadlock] kaynak: Tahmini
Deadlock, iki thread'in birbirini sonsuza kadar beklemesidir. Önlemek için synchronized'ı az kullanırım ya da timeout koyarım. Olursa uygulamayı yeniden başlatırım.
İki thread kilitleri ters sırada alırsa oluşur: biri A'yı tutup B'yi, diğeri B'yi tutup A'yı bekler. Dört Coffman koşulu gerekir: karşılıklı dışlama, tut ve bekle, zorla alınamama, döngüsel bekleme. Thread dump'ta Found one Java-level deadlock olarak görünür; jstack ya da jcmd Thread.print ile alırım. Kilitleri her zaman aynı sırayla alarak (ör. id'ye göre) ya da ReentrantLock.tryLock(timeout) ile önlerim.
Coffman koşullarından birini kırmak yeterli; pratikte döngüsel beklemeyi global kilit sırasıyla kırarım, kilitlerin sırasını kod incelemesinde görünür kılarım. tryLock + jitter'lı backoff ikinci seçenek ama livelock riskini ve karmaşıklığını getirir. En iyisi kilit kapsamını küçültmek ya da kilidi tamamen kaldırmak: tek bir atomik işlem, immutable veri, ya da işi tek bir thread'e/partition'a bağlamak.
Tespitte thread dump'ı birkaç saniye arayla iki üç kez alırım; deadlock'ta JVM döngüyü kendisi raporlar, ama aynı kilitte uzun süre BLOCKED kalan thread'ler deadlock olmadan da çekişmeye işaret eder. ThreadMXBean.findDeadlockedThreads() ile periyodik kontrol edip alarm üretmek mümkün; liveness probe havuz tamamen tükenene kadar geçebileceği için pod'un sağlıklı görünebileceğini bilirim. Ayrıca deadlock'un JVM dışı biçimlerini de düşünürüm: aynı havuza submit edip beklemek (thread pool deadlock), REQUIRES_NEW ile bağlantı havuzunun tükenmesi ve DB satır kilitlerinde ters sıra; sonuncusunda PostgreSQL 40P01 ile birini abort eder ve uygulamanın retry etmesi gerekir. Para transferi gibi çok pod'lu bir işte zaten JVM kilidi değil DB transaction'ı ve satır kilidi kullanırım.
Junior cevap tanımı biliyor ama çözümü belirsiz ("az kullanırım") ve restart'ı çözüm sanıyor; tespit yöntemi yok. Mid cevap mekanizmayı, Coffman koşullarını, tespit aracını ve iki standart çözümü veriyor. Senior cevap çözümleri trade-off'larıyla sıralıyor (livelock riski), kilidi kaldırmayı birinci seçenek olarak görüyor, tespitte deadlock ile çekişmeyi ayırıyor, alarm üretmeyi ve health check yanılgısını söylüyor, deadlock'un havuz ve DB seviyesindeki karşılıklarını da kapsıyor.
Takip sorusu: "Transfer servisinin DB'de satırları id sırasıyla kilitlediğinden emin olmak için ne yaparsın, 40P01 gelirse retry'ı nasıl güvenli hale getirirsin?"
Sorulmadı ama sorulabilir
Güvenli yayınlama, immutability ve double-checked locking
Bir nesneyi başka thread'lere "yayınlamak" (bir alana atamak, bir koleksiyona koymak) happens-before olmadan yapılırsa, okuyan thread nesnenin referansını görüp alanlarını yarım kurulmuş görebilir. Güvenli yayınlama yolları: volatile alan ya da AtomicReference, final alan, kilit ile korunan alan, concurrent koleksiyon, static initializer.
final alanlar (JLS 17.5): constructor sırasında this dışarı sızmadıysa, nesnenin referansını gören her thread final alanların constructor'da atanan değerlerini görür. Record'lar ve tüm alanları final olan sınıflar bu yüzden senkronizasyonsuz paylaşılabilir.
Double-checked locking ancak volatile ile doğrudur:
private static volatile PricingRules instance;
static PricingRules get() {
PricingRules local = instance;
if (local == null) {
synchronized (PricingRules.class) {
local = instance;
if (local == null) {
instance = local = load();
}
}
}
return local;
}
volatile olmadan, instance = new PricingRules() atamasının referans yazması constructor'daki alan yazmalarından önceye sıralanabilir; kilit almadan ilk if'i okuyan başka bir thread yarım kurulmuş nesneyi görür. Daha basit alternatif, JVM'in sınıf başlatma garantisine dayanan holder idiom'dur:
static PricingRules get() { return Holder.INSTANCE; }
private static final class Holder { static final PricingRules INSTANCE = load(); }
Spring uygulamalarında bu desene nadiren ihtiyaç olur; singleton bean'ler container tarafından güvenli yayınlanır.
Mülakat özeti: "Paylaşılan durumu mümkünse immutable yaparım; değişmesi gerekiyorsa yeni bir immutable snapshot'ı volatile ya da AtomicReference ile yayınlarım."
ThreadLocal, MDC ve thread pool'larda sızıntı
ThreadLocal değeri thread'e bağlıdır. Havuzlu thread'ler yeniden kullanıldığı için bir görevin bıraktığı değer sonraki görevde görünür: bir isteğin kullanıcı bilgisi, tenant id'si ya da MDC korelasyon id'si başka bir isteğin log'larına karışır. Değer büyükse ve havuz uzun yaşıyorsa bellek sızıntısıdır (uygulama sunucularında redeploy sırasında classloader sızıntısının klasik sebebi).
Kurallar:
- Değeri koyan kod
finallyiçinderemove()çağırır. - Spring Security context ve MDC asenkron sınırlarda (
@Async,CompletableFuture, executor) otomatik taşınmaz;TaskDecoratorya da Micrometer Context Propagation ile açıkça taşınır ve görev bitince temizlenir. - Virtual thread'lerde havuz yok, sızıntı riski azalır ama thread sayısı × ThreadLocal boyutu bellek maliyeti artar; Java 25+'ta Scoped Values daha uygun bir alternatiftir.
Mülakat özeti: "ThreadLocal'ı finally'de temizlerim; havuzlu thread'lerde temizlenmeyen değer bir sonraki isteğe sızar, asenkron sınırlarda context'i decorator ile açıkça taşırım."
CountDownLatch, CyclicBarrier ve Semaphore
CountDownLatch(n): n kezcountDown()çağrılıncaawait()edenler serbest kalır. Tek kullanımlıktır. Kullanım: N görev bitene kadar beklemek, testlerde "başlangıç kapısı".CyclicBarrier(n): n thread birbirini bekler, hepsi gelince birlikte devam eder; yeniden kullanılabilir ve bariyerde bir aksiyon çalıştırabilir. Faz faz ilerleyen hesaplarda kullanılır; backend kodunda nadirdir.Semaphore(n): Aynı anda en fazla n izin. Bulkhead'in en basit hali: dış servise eşzamanlı çağrı sınırı. Virtual thread dünyasında havuz boyutunun yerini alır.
private final Semaphore cargoApi = new Semaphore(20);
Optional<TrackingInfo> track(String trackingNo) throws InterruptedException {
if (!cargoApi.tryAcquire(100, TimeUnit.MILLISECONDS)) {
return Optional.empty(); // sınır dolu: bekleyip birikmek yerine hızlı başarısız ol
}
try {
return Optional.of(cargoClient.track(trackingNo));
} finally {
cargoApi.release();
}
}
Mülakat özeti: "Latch bir kerelik bekleme, barrier karşılıklı buluşma, semaphore eşzamanlılık sınırıdır; downstream koruması için semaphore'u tryAcquire + timeout ile kullanırım."
BlockingQueue ile producer-consumer ve backpressure
BlockingQueue (ArrayBlockingQueue, LinkedBlockingQueue), üretici ve tüketici thread'lerini ayırmanın standart yoludur: put kuyruk doluysa bekler, take boşsa bekler. Sınırlı bir kuyruk, üreticinin tüketiciden hızlı olduğu durumda üreticiyi yavaşlatır; buna backpressure denir. Sınırsız kuyruk ise sorunu sadece belleğe erteler.
BlockingQueue<OrderEvent> buffer = new ArrayBlockingQueue<>(10_000);
// Üretici: kuyruk doluysa en fazla 50 ms bekle, sonra reddet/metrik
if (!buffer.offer(event, 50, TimeUnit.MILLISECONDS)) {
droppedEvents.increment();
}
// Tüketici: toplu çek, toplu yaz
List<OrderEvent> batch = new ArrayList<>(500);
batch.add(buffer.take());
buffer.drainTo(batch, 499);
repository.saveAll(batch);
Bellek içi kuyruk pod çökünce kaybolur; kaybolmaması gereken event'ler (sipariş, ödeme) için doğru yer Kafka ya da outbox tablosudur.
Mülakat özeti: "Üretici ve tüketiciyi sınırlı bir BlockingQueue ile ayırırım; sınır backpressure sağlar, kalıcılık gerekiyorsa bellek kuyruğu yerine Kafka ya da outbox kullanırım."
InterruptedException'ı doğru ele almak
Java'da thread'i zorla durdurmanın güvenli yolu yoktur; interrupt işbirlikçi bir iptal isteğidir. Bloklayan metotlar (sleep, wait, BlockingQueue.take, Future.get) interrupt'ı InterruptedException ile bildirir ve bunu yaparken thread'in interrupt bayrağını temizler.
// Yanlış: iptal isteği kaybolur, executor shutdownNow() ile duramaz
try { queue.take(); } catch (InterruptedException e) { log.warn("interrupted"); }
// Doğru: ya yukarı fırlat ya da bayrağı geri koy ve çık
try {
return queue.take();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("İşlem iptal edildi", e);
}
Bu, uygulama kapanışında (shutdownNow), structured concurrency'de alt görev iptalinde ve Future.cancel(true)'da iptalin çalışmasının ön şartıdır.
Mülakat özeti: "InterruptedException'ı yutmam; ya yukarı iletirim ya da Thread.currentThread().interrupt() ile bayrağı geri koyup görevi sonlandırırım."
False sharing
CPU'lar belleği 64 byte'lık cache line'lar halinde taşır. İki farklı thread'in sık yazdığı iki farklı değişken aynı cache line'a düşerse, her yazma diğer çekirdeğin cache line'ını geçersiz kılar; değişkenler mantıken bağımsız olsa da donanım seviyesinde çekişirler. Belirtisi: thread sayısı arttıkça ölçeklenmeyen, hatta yavaşlayan sayaçlar. LongAdder hücreleri ve ConcurrentHashMap'in sayaç hücreleri bu yüzden @Contended ile padding'lenir (JDK içi anotasyon jdk.internal.vm.annotation.Contended; uygulama kodunda kullanmak için --add-exports ve -XX:-RestrictContended gerekir). Uygulama kodunda genelde çözüm, thread başına yerel biriktirip seyrek birleştirmektir.
Mülakat özeti: "Bağımsız iki sıcak değişken aynı cache line'daysa çekirdekler onu birbirinden kapar; LongAdder bu yüzden hücrelerini padding'ler."
Sık yapılan hatalar
volatilesayacı thread-safe sanmak →++getfield/ladd/putfield olarak üç adımdır, artışlar kaybolur →AtomicLongya daLongAdder.ConcurrentHashMapkullanınca race kalmadığını düşünmek →get+put,containsKey+putayrı ayrı atomiktir, birlikte değil →merge,compute*,putIfAbsent.computeIfAbsentiçinde HTTP/DB çağrısı → bin kilidi süre boyunca tutulur, aynı bin'deki başka key'ler bekler → map'e future koy ya da CaffeineAsyncLoadingCache.- Sınırsız cache map'i → heap dolar ve bayat veri sonsuza kadar döner → boyut sınırı ve TTL olan bir cache (Caffeine).
Executors.newFixedThreadPoolile sınırsız kuyruk → yük altında latency ve sonraOutOfMemoryError→ sınırlı kuyrukluThreadPoolExecutor, bilinçli reddetme politikası, metrikler.maximumPoolSize'ı büyütüp thread sayısının artacağını sanmak → max'a ancak kuyruk dolunca çıkılır → kuyruk kapasitesini ve core boyutunu birlikte tasarla.supplyAsync'e executor vermemek → bloklayan I/O common pool'u tıkar, parallel stream'ler de yavaşlar → I/O için ayrı, sınırlı executor.orTimeout'un HTTP çağrısını iptal ettiğini sanmak → sadece future tamamlanır, thread ve bağlantı tutulmaya devam eder → HTTP client'ta connect/read timeout.exceptionallyile hatayı loglamadan yutmak → bağımlılık günlerce çökük kalır, kimse fark etmez → fallback'te log ve metrik.- Kilitleri değişken sırada almak → deadlock → global kilit sırası (id),
tryLock+ jitter'lı backoff ya da tek atomik işlem. - JPA entity'si ya da herhangi bir istek-başı nesne üzerinde
synchronized→ her istek farklı nesneyi kilitler, hiçbir şey korunmaz; çok pod'da zaten anlamsız → DB satır kilidi ya da koşullu atomikUPDATE. - Virtual thread'leri havuzlamak → kazancı yok eder → görev başına yeni virtual thread, eşzamanlılık sınırı için
Semaphore. - Java 21-23'te virtual thread +
synchronizediçinde bloklayan I/O → pinning, carrier'lar tükenir → JFR ile tespit, sıcak noktalardaReentrantLockya da JDK 24+. - Virtual thread'e geçince DB havuzu sorununun çözüleceğini sanmak → darboğaz DB'ye kayar, bağlantı bekleme timeout'ları artar → downstream başına bulkhead, havuz metrikleri.
InterruptedException'ı yutmak → iptal ve kapanış çalışmaz → bayrağı geri koy ya da exception'ı yukarı ilet.ThreadLocal'ı temizlememek → havuzlu thread'lerde veri bir sonraki isteğe sızar →finally { tl.remove(); }.
Hızlı tekrar
S: Çok thread'li kodda bozulan üç ayrı şey nedir?
C: Atomiklik (adımların araya girilmeden tek parça yapılması), görünürlük (yazılan değerin diğer thread'e ne zaman görüneceği) ve sıralama (derleyici/CPU'nun komutları yeniden sıralaması).
S: Hangi işlemler happens-before ilişkisi kurar?
C: Aynı thread'de program sırası, monitor unlock → sonraki lock, volatile yazma → sonraki okuma, Thread.start → thread'in ilk işlemi, thread'in işlemleri → join dönüşü; ve geçişlilik. java.util.concurrent bunu concurrent koleksiyon, executor submit, Future.get ve synchronizer'larla genişletir.
S: volatile neden count++'ı atomik yapmaz?
C: ++ bytecode'da üç adımdır (getfield, ladd, putfield). volatile her okumanın güncel değeri görmesini sağlar ama okuma ile yazma arasına başka thread'in girmesini engellemez.
S: LongAdder neden yüksek çekişmede AtomicLong'dan iyidir ve ne zaman kullanılmaz?
C: Yazmaları padding'li hücrelere dağıtır, tek cache line üzerindeki çekişmeyi kaldırır. sum() eşzamanlı yazmada anlık snapshot değildir; kesin değere göre karar verilen yerde (stok 0 mı?) kullanılmaz.
S: Java 8+ ConcurrentHashMap yazarken nasıl kilitler?
C: Boş bin'e CAS ile node yerleştirir; dolu bin'de o bin'in ilk node'u üzerinde synchronized alır. get hiç kilit almaz.
S: ConcurrentHashMap'te bin ne zaman ağaca döner?
C: Bin'deki eleman sayısı 8'e ulaştığında ve tablo en az 64 olduğunda TreeBin'e döner; tablo küçükse önce resize yapılır. 6'ya düşünce listeye geri döner.
S: computeIfAbsent içine neden uzun iş (HTTP, DB) konmaz?
C: Mapping fonksiyonu bin kilidi tutulurken çalışır; aynı bin'e düşen başka key'lere yazanlar da bekler. Javadoc hesaplamanın kısa ve basit olmasını, map'teki başka eşlemelerin güncellenmemesini ister.
S: ThreadPoolExecutor yeni görevi hangi sırayla değerlendirir?
C: Core'dan az thread varsa yeni thread; yoksa kuyruğa; kuyruk doluysa ve max'tan az thread varsa yeni thread; o da doluysa reddet.
S: thenApply ile thenCompose farkı nedir?
C: thenApply düz değer dönen fonksiyonla dönüştürür; thenCompose future dönen fonksiyonu düzleştirerek zincirler (iç içe CompletableFuture'ı önler).
S: Executor verilmemiş supplyAsync nerede çalışır, orTimeout alttaki çağrıyı durdurur mu?
C: ForkJoinPool.commonPool()'da çalışır. orTimeout sadece future'ı TimeoutException ile tamamlar; alttaki iş ve thread devam eder, gerçek koruma client timeout'udur.
S: Deadlock için gereken dört Coffman koşulu nedir ve pratikte hangisi kırılır?
C: Mutual exclusion, hold and wait, no preemption, circular wait. Pratikte circular wait global kilit sırasıyla kırılır; tryLock ile no preemption/hold and wait de kırılabilir.
S: Java 21'de virtual thread'ler hangi durumlarda pin'lenir ve JDK 24 neyi değiştirdi?
C: synchronized içinde bloklanırken ve native metot/foreign function çalıştırırken. JDK 24'te JEP 491 ile synchronized (ve Object.wait) artık pinning yaratmıyor; native/FFM durumları kalıyor.
S: Virtual thread'ler neyi çözmez?
C: CPU-yoğun işi hızlandırmaz, DB havuzu ve dış servis kapasitesini artırmaz. Havuzlanmazlar; eşzamanlılık Semaphore ile sınırlanır.
S: Spring Boot'ta virtual thread'leri nasıl açarsın ve hangi yan etkiye dikkat edersin?
C: Spring Boot 3.2+ ve Java 21+ ile spring.threads.virtual.enabled=true. Virtual thread'ler daemon olduğu için sadece @Scheduled ile ayakta duran uygulamada spring.main.keep-alive=true gerekir; havuz ayarları etkisizleşir.
S: Structured concurrency'nin Ekim 2026 itibarıyla durumu nedir?
C: Java 21-27 arasında preview (Java 25'te beşinci, 27'de yedinci preview); JEP 543 ile JDK 28'de (Mart 2027) final olması hedefleniyor. API sürümler arasında değişti.
Kaynaklar
- JLS Bölüm 17: Threads and Locks (Java SE 21): Happens-before, data race, final alan semantiği ve
long/doubleyazmalarının atomikliği için birincil kaynak. - java.util.concurrent paket özeti (Java 21): "Memory Consistency Properties" bölümü, concurrent koleksiyonların ve executor'ların verdiği happens-before garantilerini listeler.
- ConcurrentHashMap (Java 21 Javadoc): Weakly consistent iterator'lar, toplu metotların yaklaşıklığı ve
compute*uyarıları. - CompletableFuture (Java 21 Javadoc): Varsayılan executor, bağımlı adımların hangi thread'de çalıştığı, iptal ve exception sarma kuralları.
- Virtual Threads (Java 21 Core Libraries rehberi): Pinning, havuzlamama, Semaphore ile sınırlama ve ThreadLocal tavsiyeleri.
- JEP 444: Virtual Threads: Virtual thread'lerin tasarım gerekçesi ve thread-per-request önerisi.
- JEP 491: Synchronize Virtual Threads without Pinning: JDK 24'te
synchronizedpinning'inin kaldırılması, kalan pinning durumları ve JFR event'i. - JEP 525: Structured Concurrency (Sixth Preview): StructuredTaskScope ve Joiner API'sinin JEP geçmişiyle birlikte güncel tasarımı.
- Spring Boot: SpringApplication, Virtual threads:
spring.threads.virtual.enabled, daemon thread uyarısı vespring.main.keep-alive. - Close Encounters of The Java Memory Model Kind (Aleksey Shipilëv): JMM hakkındaki yanlış sezgileri örneklerle çürüten, mülakat öncesi okunacak en iyi yazı.
- Java Concurrency in Practice (Brian Goetz ve diğerleri): Thread pool boyutlandırma, güvenli yayınlama ve kilit sırası konularının klasik kaynağı.