Java ile Nesne Yönelimli Programlama · Ders Notu

Bölüm 10: İş Parçacıkları

Bir program aynı anda birden çok iş yürütebilir. Kelime işlemci yazarken yazım denetimi yapar, tarayıcı bir sayfayı indirirken öbür sekmeye tepki verir, bir sunucu yüzlerce isteği birlikte karşılar. Java bu işleri iş parçacığı denen bağımsız yürütme akışlarıyla yapar. Not önce iş parçacığını, süreci, eş zamanlılığı ve paralelliği tanıtır. Ardından Thread sınıfını, iş parçacığının yaşam döngüsünü, oluşturma ve başlatma yollarını ve iş parçacığını güvenle durdurmayı işler. Notun merkezinde paylaşılan verinin korunması vardır: yarış durumu, synchronized, volatile, açık kilitler, kilitlenme ve koşul beklemesi. Son kesimler java.util.concurrent paketinin hazır araçlarını, atomik sınıfları, kesmeyi, ThreadLocal'ı ve iş parçacığı havuzlarını tanıtır. Birden çok iş parçacığı içeren programların çıktısı çoğu zaman çalıştırmadan çalıştırmaya değişir. Notta bu tür her çıktı JDK 21 ile defalarca çalıştırılarak ölçüldü; hangi kısmın değişebileceği her örnekte belirtilir.

Öğrenme çıktıları

Kavramlar
İş parçacığı ile süreç, eş zamanlılık ile paralellik arasındaki farkı açıklamak.
Oluşturma
Runnable arayüzünü uygulayarak ya da Thread sınıfını genişleterek iş parçacığı yazmak ve start() ile başlatmak.
Yaşam döngüsü
Altı durumu ve sleep, join, wait, kilit beklemesi gibi geçişleri izlemek.
Senkronizasyon
Yarış durumunu tanımak; synchronized, volatile, Lock ve atomik sınıflarla önlemek.
Koşul bekleme
wait/notify ve Condition ile üretici–tüketici yazmak; kilitlenmeyi önlemek.
Hazır araçlar
CountDownLatch, CyclicBarrier, Exchanger, Semaphore, ThreadLocal ve iş parçacığı havuzlarını kullanmak.

10.1Temel kavramlar

10.1.1İş parçacığı

Şimdiye kadar yazılan bütün programlar tek bir akışla çalıştı. main metodunun ilk satırı çalışır, sonra ikincisi; bir metot çağrılırsa akış o metoda girer ve dönünce kaldığı yerden sürer. Herhangi bir anda programın “nerede olduğu” tek bir satırla anlatılabilir. Bu tek akış, iş parçacığının kendisidir.

Tanım 10.1

İş parçacığı (thread), bir programın içinde bağımsız ilerleyen yürütme akışıdır. Komutları sırayla çalıştırır; kendi çağrı yığını ve kendi program sayacı vardır. Java'da her iş parçacığı java.lang.Thread sınıfının bir nesnesiyle temsil edilir.

Her Java programı en az bir iş parçacığıyla başlar. JVM, main() metodunu çalıştırmak için bir iş parçacığı açar; bunun adı ana iş parçacığıdır (main thread). Program yeni iş parçacıkları açarsa bunlar ana iş parçacığıyla yan yana ilerler. Birden çok iş parçacığıyla çalışmaya çoklu iş parçacığı (multithreading) denir. Birden çok işin aynı zaman aralığında ilerlediği programlamanın adı da eş zamanlı programlamadır (concurrent programming).

süreç (JVM)heap: nesneler (ortak)mainkendi yığını, PCT1kendi yığını, PCT2kendi yığını, PC
Şekil 10.1: Bir süreçteki iş parçacıkları. Heap ortaktır; her iş parçacığının kendi yığını vardır.

Sunum durumu bir arı kovanına benzetir. Kovandaki her arı kendi işini yapar; bu, bir iş parçacığıdır. Ama bütün arılar aynı kovanı ve aynı petekleri kullanır; bu da ortak bellektir. İki arı aynı göze aynı anda bal koymaya kalkarsa sorun çıkar. Notun büyük kısmı bu çarpışmaların nasıl düzenleneceğini anlatır.

Birden çok iş parçacığına neden ihtiyaç duyulur? Üç neden öne çıkar:

  • Yanıt veren arayüz. Grafik arayüzlü bir programda uzun bir hesap, arayüzü çizen iş parçacığında yapılırsa pencere donar. Hesap ayrı bir iş parçacığına verilirse kullanıcı düğmelere basmayı sürdürebilir.
  • Çok çekirdekli işlemciyi kullanmak. Bugünkü işlemcilerin çoğunun 4, 8 ya da daha fazla çekirdeği vardır. Tek iş parçacıklı bir program bunlardan yalnız birini kullanır. İş parçalara bölünürse çekirdekler aynı anda çalışır ve hesap hızlanır.
  • Beklerken çalışmak. Ağdan veri gelmesi ya da diskten dosya okunması işlemciye göre çok yavaştır. Bekleyen iş parçacığı uyurken başka bir iş parçacığı işini sürdürür.

10.1.2Eş zamanlılık ve paralellik

Gündelik dilde “aynı anda” sözü iki farklı durumu anlatır. Teknik dilde bu iki durum ayrı adlar taşır.

Tanım 10.2

Eş zamanlılık (concurrency), birden çok görevin aynı zaman aralığında ilerlemesidir. Tek çekirdekte bile olur: işletim sistemi iş parçacıklarını çok kısa dilimlerle sırayla çalıştırır. Paralellik (parallelism) ise görevlerin gerçekten aynı anda, farklı çekirdeklerde çalışmasıdır ve birden çok çekirdek gerektirir.

Tek çekirdekeş zamanlılıkABABABABABİki çekirdekparalellikÇ1AÇ2Bzaman
Şekil 10.2: Solda tek çekirdek: A ve B zaman dilimleriyle sırayla çalışır. Sağda iki çekirdek: A ve B aynı anda çalışır.

Şekil 10.2'nin sol yarısında tek çekirdek vardır. A bir dilim çalışır, sonra B, sonra yine A. Dilimler milisaniyeler mertebesinde olduğu için kullanıcı iki işi aynı anda ilerliyor sanır. Bu eş zamanlılıktır, ama paralellik değildir: belirli bir anda işlemcide tek bir iş parçacığı vardır. Sağ yarıda iki çekirdek vardır ve A ile B gerçekten aynı anda çalışır. Paralellik eş zamanlılığın özel bir durumudur.

Hangi iş parçacığının ne zaman ve hangi çekirdekte çalışacağına JVM ve işletim sisteminin çizelgeleyicisi (scheduler) karar verir. Programcı bu sırayı bilemez ve denetleyemez. Aynı program bir çalıştırmada A'yı önce, ötekinde B'yi önce çalıştırabilir. Bu belirsizlik, çok iş parçacıklı programların en belirgin özelliğidir.

10.1.3Süreç ve iş parçacığı

Tanım 10.3

Süreç (process), kendi bellek alanı olan, çalışmakta olan programdır. İşletim sistemi her sürece ayrı bir adres alanı verir. Bir sürecin içinde bir ya da daha çok iş parçacığı çalışır; bu iş parçacıkları sürecin belleğini paylaşır.

Süreç: çalışan bir JVMHeap: nesneler ve dizilertüm iş parçacıkları ORTAK kullanırmainYığın (stack)yerel değişkenlerPC: sıradaki komutThread-0Yığın (stack)yerel değişkenlerPC: sıradaki komut
Şekil 10.3: Bir süreç: ortak heap, her iş parçacığına ayrı yığın ve program sayacı.
Tablo 10.1: Süreç ile iş parçacığının karşılaştırması.
Süreçİş parçacığı
BellekKendi adres alanıSürecin heap'ini paylaşır
Yığın—Her birinin kendi yığını
OluşturmaPahalıUcuz (hafif süreç)
İletişimDosya, soket, boruOrtak nesneler
RiskBirbirini bozamazOrtak veriyi bozabilir

Çalışan bir Java programı bir süreçtir; içinde birçok iş parçacığı çalışır. Tablo 10.1 iki kavramın farkını özetler. Heap'teki nesneler sürecin bütün iş parçacıklarına açıktır. İki iş parçacığı aynı nesnenin referansını tutuyorsa ikisi de o nesneyi okuyup değiştirebilir. Yerel değişkenler ise her iş parçacığının kendi yığınındadır ve paylaşılmaz. Bir metodu iki iş parçacığı aynı anda çalıştırsa bile metodun yerel değişkeni i'nin iki ayrı kopyası olur.

Bu ayrım iki yönlüdür. İyi yanı, iş parçacıkları arasında veri paylaşmanın çok kolay olmasıdır: ortak bir nesneye yazmak yeter. İki süreç ise birbirine ancak dosya, soket ya da boru gibi işletim sistemi araçlarıyla veri gönderebilir. Kötü yanı, birbirinden habersiz iki iş parçacığının ortak nesneyi aynı anda değiştirebilmesidir. Bir süreç ötekinin belleğini bozamaz; bir iş parçacığı ise aynı süreçteki her şeyi bozabilir.

İş parçacığı oluşturmak süreç oluşturmaktan çok daha ucuzdur, çünkü yeni bir adres alanı açılmaz; yalnız yeni bir yığın ayrılır. Bu yüzden iş parçacığına hafif süreç (lightweight process) da denir.

10.1.4Bağlam değiştirme

Bir bilgisayarda çoğu zaman çekirdek sayısından çok daha fazla iş parçacığı vardır. İşletim sistemi bunları zaman dilimleriyle sırayla çalıştırır. Bir iş parçacığının dilimi dolunca onun durumu saklanır, sıradakinin durumu yüklenir.

Tanım 10.4

Bağlam değiştirme (context switching), işlemcinin bir iş parçacığından ötekine geçmesidir. Geçişte çalışan iş parçacığının durumu (program sayacı, yazmaçlar, yığın işaretçisi) saklanır ve sıradakinin durumu yüklenir.

tek çekirdek:T1T2T3T1T2▲ bağlam değiştirme: kayıtları sakla, ötekini yükleHer geçiş zaman alır; iş parçacığı sayısı çekirdek sayısını çok aşarsa bu maliyet büyür.
Şekil 10.4: Tek çekirdekte iş parçacıkları sırayla çalışır; aralarda bağlam değiştirilir.

Bağlam değiştirmenin bir bedeli vardır. Her geçiş birkaç mikrosaniye sürer. Daha önemlisi, işlemcinin önbelleğindeki verinin bir kısmı yeni iş parçacığı için işe yaramaz ve yeniden bellekten okunması gerekir. Bu yüzden iş parçacığı sayısını artırmak her zaman hızlandırmaz. Binlerce iş parçacığı açan bir program, zamanının önemli bir kısmını geçişlere harcar ve yavaşlar.

Pratik bir kural vardır. Hesap ağırlıklı işlerde iş parçacığı sayısı yaklaşık çekirdek sayısı kadar tutulur. Çekirdek sayısı Runtime.getRuntime().availableProcessors() ile öğrenilir; notun örnekleri 12 mantıksal çekirdekli bir bilgisayarda çalıştırıldı. Bekleme ağırlıklı işlerde (ağ, dosya) iş parçacıklarının çoğu zaman uyuduğu için daha fazlası yararlı olabilir. Sayıyı denetim altında tutmak için iş parçacığı havuzu kullanılır.

10.1.5JVM ve iş parçacıkları

Java programları doğrudan işletim sisteminde değil, Java Sanal Makinesi (JVM, Java Virtual Machine) içinde çalışır. JVM, iş parçacıklarını bağımsız yürütme birimleri olarak yöneten sanal bir ortamdır. Günümüzün JVM'lerinde her Java iş parçacığı bir işletim sistemi iş parçacığına karşılık gelir; çizelgelemeyi işletim sistemi yapar.

Sınıf Yükleyici (Class Loader)JVM bellek alanlarıSınıf alanı(method area)HeapnesnelerYığın(stack)PC yazmacı(PC register)Yerel metotyığınıYürütme Motoru(yorumlayıcı, JIT, GC)Yerel Metot Arayüzü(JNI)YerelkütüphanelerOrtak: tüm iş parçacıkları paylaşırHer iş parçacığına ayrı
Şekil 10.5: JVM mimarisi. Sınıf alanı ve heap tek kopyadır; yığın, PC yazmacı ve yerel metot yığını her iş parçacığına ayrıdır.

Şekil 10.5 JVM'in bellek alanlarını gösterir. Bir iş parçacığı oluşturulduğunda JVM ona üç özel alan ayırır:

  • Yığın (stack): metot çağrılarının çerçevelerini, yerel değişkenleri ve parametreleri tutar.
  • PC yazmacı (program counter register): iş parçacığının o anda çalıştırdığı komutun adresini tutar.
  • Yerel metot yığını (native method stack): Java dışında, örneğin C ile yazılmış yerel metotların çağrıları içindir.

Sınıf alanı (sınıfların kodu ve static alanlar) ile heap (bütün nesneler) ise tek kopyadır ve bütün iş parçacıklarınca paylaşılır. static bir alan ve heap'teki bir nesne bu yüzden iki iş parçacığı arasında ortaktır; yerel değişkenler ortak değildir.

Sık yapılan hata

Yerel değişkenin de iş parçacıkları arasında paylaşıldığını sanmak. void say() { int i = 0; i++; } metodunu iki iş parçacığı aynı anda çalıştırırsa her biri kendi yığınında kendi i'sini artırır; ikisi de 1 bulur ve birbirini etkilemez. Sorun, iki iş parçacığının aynı nesnenin alanına ya da aynı static alana yazmasıyla başlar.

10.2Thread sınıfı

10.2.1Sınıf ve temel metotlar

Her iş parçacığı, java.lang paketindeki Thread sınıfının bir nesnesidir. Paket java.lang olduğu için import gerekmez. Thread nesnesi iş parçacığının yaşam döngüsünü yönetir: başlatır, bekletir, adını ve durumunu tutar.

İş parçacığı yazmanın bir yolu Thread'den türeyen bir alt sınıf yazmaktır. Alt sınıf run() metodunu geçersiz kılar (override) ve iş parçacığının yapacağı işi bu metoda yazar. İş parçacığı ise start() çağrılarak başlatılır. İki metodun ayrımı kritiktir: run() işin kendisidir, start() yeni akışı açan çağrıdır. Tablo 10.2 sınıfın sık kullanılan metotlarını özetler.

Tablo 10.2: Thread sınıfının temel metotları.
MetotGörevi
start()Yeni iş parçacığını başlatır; o da run()'ı çalıştırır
run()İş parçacığının yapacağı iş
sleep(ms)static: çalışan iş parçacığını belirli süre uyutur
join()O iş parçacığının bitmesini bekler
yield()static: yürütmeyi geçici olarak başkasına bırakır
interrupt()Kesme isteği gönderir
currentThread()static: şu an çalışan iş parçacığı
getName(), getState(), isAlive()Bilgi okuma

sleep, yield ve currentThread metotlarının static olması anlamlıdır. Bunlar belirli bir Thread nesnesine değil, o satırı çalıştıran iş parçacığına etki eder. Thread.sleep(100) yazan iş parçacığı kendisi uyur; başka bir iş parçacığını uyutmanın yolu yoktur.

10.2.2İş parçacığının nitelikleri

Bir Thread nesnesi, iş parçacığını tanımlayan birkaç nitelik taşır. Bunlar nesnenin alanlarıdır ve okumak ya da değiştirmek için get/set metotları vardır.

Çalıştırılabilir (Runnable)
İş parçacığının çalıştıracağı run() metodunu içeren nesne. İş parçacığının ana mantığı burada tanımlanır.
Ad (name)
main, Thread-0, Thread-1 gibi. Ad verilmezse JVM Thread- ön ekine artan bir numara ekler. Kayıt dosyalarında ve hata ayıklamada iş parçacığını tanımak için kullanılır: getName(), setName().
Kimlik (ID)
İş parçacığı oluşturulurken otomatik atanan tekil numara. Java 19'dan beri threadId() ile okunur; daha eski sürümlerde getId() kullanılırdı.
Grup (ThreadGroup)
İş parçacığının ait olduğu grup. İş parçacıklarını düzenlemek için kullanılır.
Arka plan (daemon)
Diğer iş parçacıklarına hizmet eden yardımcı iş parçacığı. JVM kapanırken bitmesi beklenmez.
Bağlam sınıf yükleyici (context class loader)
İş parçacığının sınıfları çalışma sırasında yüklerken kullandığı sınıf yükleyici.
Öncelik (priority)
MIN_PRIORITY (1) ile MAX_PRIORITY (10) arasında bir tam sayı; varsayılan NORM_PRIORITY (5).
Durum (state)
NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED değerlerinden biri.
Kesilme (interrupted)
Kesme isteği gelip gelmediğini gösteren bayrak; interrupt() ile kurulur.

Aşağıdaki kod parçası main içinde çalışır ve o anda çalışan iş parçacığının niteliklerini yazar.

Thread t = Thread.currentThread();   // çalışan
System.out.println("Ad      : " + t.getName());
// threadId(): Java 19+ (öncesinde getId())
System.out.println("Kimlik  : " + t.threadId());
System.out.println("Öncelik : " + t.getPriority());
String grup = t.getThreadGroup().getName();
System.out.println("Grup    : " + grup);
System.out.println("Daemon  : " + t.isDaemon());
System.out.println("Durum   : " + t.getState());
Ad      : main
Kimlik  : 1
Öncelik : 5
Grup    : main
Daemon  : false
Durum   : RUNNABLE

Thread.currentThread(), o satırı çalıştıran iş parçacığını döndürür. Kod main içinde olduğu için bu, ana iş parçacığıdır. Adı main'dir, kimliği 1'dir, önceliği varsayılan 5'tir. Grubu da main adını taşır. Ana iş parçacığı arka plan iş parçacığı değildir. Durumu RUNNABLE'dır, çünkü getState()'i çağıran iş parçacığı o anda çalışıyor. JDK 21 ile çalıştırılan program bu altı satırı her seferinde aynen yazdı.

Öncelik ve arka plan niteliği, iş parçacığını oluşturan iş parçacığından kalıtılır. main içinde oluşturulan bir iş parçacığının önceliği 5'tir ve arka plan iş parçacığı değildir. Bu değerler start()'tan önce değiştirilebilir.

Örnek 10.1

main içinde, başka iş parçacığı oluşturulmadan önce şu satırlar çalışıyor: Thread a = new Thread(() -> { }, "isci");, Thread b = new Thread(() -> { });, Thread c = new Thread(() -> { }); ve c.setPriority(Thread.MAX_PRIORITY);. a, b, c'nin adları ile b ve c'nin öncelikleri nedir?

Adlar isci, Thread-0 ve Thread-1'dir. Ad verilen iş parçacığı otomatik numara almaz; numara yalnız adsız iş parçacıklarına sırayla verilir. b'nin önceliği oluşturan iş parçacığından (main) kalıtılan 5'tir; c'ninki 10'dur. Üçü de hiç başlatılmadığı için durumları NEW'dur. Program JDK 21'de isci Thread-0 Thread-1 ve 5 10 false yazdı.

10.2.3İş parçacığı grupları

JVM başlarken iki iş parçacığı grubu oluşturur. system grubu JVM'in kendi iş parçacıklarını içerir: referansları yöneten Reference Handler, sonlandırıcı Finalizer, sinyalleri karşılayan Signal Dispatcher ve benzerleri. main grubu system'in alt grubudur; ana iş parçacığı ve programın oluşturduğu iş parçacıkları varsayılan olarak buraya aittir.

system grubuReference HandlerFinalizerSignal DispatcherAttach Listener …main grubumainThread-0, Thread-1 …kullanıcıJVM'in daemon iş parçacıkları
Şekil 10.6: İş parçacığı grupları: system grubu altında JVM iş parçacıkları ve main grubu.

Thread.getAllStackTraces(), canlı bütün iş parçacıklarını ve yığınlarını bir eşlem olarak döndürür. Anahtar kümesi dolaşılarak hepsi listelenir:

Set<Thread> hepsi =
        Thread.getAllStackTraces().keySet();
for (Thread t : hepsi) {
    System.out.println(t.getThreadGroup().getName()
            + "  " + t.getName()
            + "  daemon=" + t.isDaemon());
}
system  Notification Thread  daemon=true
main  main  daemon=false
system  Signal Dispatcher  daemon=true
system  Reference Handler  daemon=true
system  Attach Listener  daemon=true
InnocuousThreadGroup  Common-Cleaner  daemon=true
system  Finalizer  daemon=true

Bu çıktı JDK 21'de bir çalıştırmadan alındı. Listede yedi iş parçacığı vardır ve main dışındakilerin hepsi arka plan iş parçacığıdır. Common-Cleaner ayrı bir grupta, InnocuousThreadGroup'tadır. Satırların sırası eşlemin iç düzenine bağlıdır. On çalıştırmada sıra hep aynıydı, ama bu bir garanti değildir. Listede hangi JVM iş parçacıklarının bulunacağı da JVM sürümüne ve ayarlarına göre değişir.

10.2.4JVM başlarken çalışan iş parçacıkları

Sunum, JVM başladığında var olan dört temel iş parçacığını öne çıkarır:

Ana iş parçacığı (main thread)
JVM başladığında ilk çalışan iş parçacığıdır. main() metodu burada çalışır; uygulamanın temel mantığı burada yürür.
Çöp toplayıcı (garbage collector)
Bellek yönetiminden sorumludur. Referansı kalmamış nesneleri bulur ve temizler. HotSpot JVM'inde çöp toplayıcının iş parçacıkları JVM'in iç iş parçacıklarıdır; Java nesnesi olarak görünmezler, bu yüzden yukarıdaki listede yer almazlar.
Sinyal gönderici (Signal Dispatcher)
İşletim sisteminden gelen sinyalleri karşılar. Örneğin Ctrl+C ile gelen kapatma isteğinde JVM'in düzenli kapanmasını başlatır.
Sonlandırıcı (Finalizer)
Nesnelerin finalize() metodunu çağırır. finalize() Java 9'da kullanımdan kaldırıldı (deprecated), Java 18'den beri de ileride silinecek olarak işaretlidir. Yerine try-finally ya da AutoCloseable ile try-with-resources kullanılır.

10.3Yaşam döngüsü

10.3.1Altı durum

Bir iş parçacığı oluşturulduğu andan bittiği ana kadar farklı durumlardan geçer. getState() metodu o anki durumu Thread.State numaralandırmasının bir sabiti olarak döndürür. Java altı durum tanımlar.

Tablo 10.3: İş parçacığı durumları.
DurumAnlamıNasıl girilir?
NEW (yeni)Nesne oluşturuldu, henüz başlatılmadınew Thread(…); çıkış: start()
RUNNABLE (çalışabilir)Çalışıyor ya da işlemci sırası bekliyorstart() sonrası
BLOCKED (engellenmiş)synchronized blok ya da metoda girmek için monitör kilidini bekliyorKilit başkasındayken
WAITING (bekliyor)Süresiz bekliyor; başka bir iş parçacığı uyandırmalıObject.wait(), Thread.join(), LockSupport.park()
TIMED_WAITING (süreli bekliyor)Süre sınırlı bekliyorThread.sleep(ms), wait(ms), join(ms), parkNanos, parkUntil
TERMINATED (sonlandı)Yürütmeyi tamamladırun() bitince

Tablo 10.3 bir noktayı açıkça gösterir: Java'da ayrı bir “çalışıyor” (RUNNING) durumu yoktur. İşlemcide o anda çalışan iş parçacığı da, sırasını bekleyen iş parçacığı da RUNNABLE'dır. Hangisinin gerçekten işlemcide olduğunu çizelgeleyici bilir; JVM bu ayrımı programa göstermez.

Üç bekleme durumu birbirine karıştırılmamalıdır. BLOCKED yalnız bir monitör kilidini beklemektir: iş parçacığı bir synchronized bloğa girmek ister, ama kilit başka bir iş parçacığındadır. WAITING bir olayı süresiz beklemektir: başka bir iş parçacığının bitmesi (join) ya da uyandırması (notify) gerekir. TIMED_WAITING ise süreli beklemedir; süre dolunca iş parçacığı kendiliğinden döner. sleep bu yüzden her zaman TIMED_WAITING üretir.

10.3.2Durum diyagramı

NEWRUNNABLETERMINATEDBLOCKEDWAITINGTIMED_WAITINGstart()run() bitersynchronized kilidi bekler⇄ kilidi alırwait(), join()⇄ notify(), iş bitersleep(ms), wait(ms)⇄ süre dolar
Şekil 10.7: Durum diyagramı. NEW, start() ile RUNNABLE olur; RUNNABLE ile üç bekleme durumu arasında gidiş-dönüş vardır; run() bitince TERMINATED.

Şekil 10.7 geçişleri gösterir. Ana yol tek yönlüdür: NEW → RUNNABLE → TERMINATED. Geri dönüş yoktur. Biten bir iş parçacığı yeniden başlatılamaz; aynı işi bir kez daha yapmak için yeni bir Thread nesnesi gerekir. Üç bekleme durumunun her birinden ise yeniden RUNNABLE'a dönülür: kilit boşalınca, beklenen olay olunca ya da süre dolunca.

10.3.3Kod izleme: durumları gözlemlemek

Aşağıdaki program dört ayrı iş parçacığını farklı durumlara sokar ve durumlarını yazar. uyu(ms) ve katil(t), Thread.sleep ve t.join() çağrılarını try-catch ile saran yardımcı metotlardır. kilit ise static final Object türünde bir alandır.

public static void main(String[] args) {
    Thread uyuyan = new Thread(() -> uyu(1000));
    System.out.println("1) " + uyuyan.getState());
    uyuyan.start();
    uyu(100);
    System.out.println("2) " + uyuyan.getState());
    Thread bekleyen = new Thread(() -> katil(uyuyan));
    bekleyen.start();
    uyu(100);
    System.out.println("3) " + bekleyen.getState());
    Thread engellenen = new Thread(() -> {
        synchronized (kilit) { }
    });
    synchronized (kilit) {
        engellenen.start();
        uyu(100);
        System.out.println("4) " + engellenen.getState());
    }
    katil(bekleyen);
    System.out.println("5) " + uyuyan.getState());
}
Tablo 10.4: Durum gözlemi programının izlemesi.
AdımSatırDeğişkenler / durumYeni çıktı
12: Thread uyuyan = new Thread(() -> uyu(1000));——
23: System.out.println("1) " + uyuyan.getState());—1) NEW
34: uyuyan.start();——
45: uyu(100);——
56: System.out.println("2) " + uyuyan.getState());—2) TIMED_WAITING
68: bekleyen.start();——
710: System.out.println("3) " + bekleyen.getState());—3) WAITING
815: engellenen.start();——
917: System.out.println("4) " + engellenen.getState());—4) BLOCKED
1019: katil(bekleyen);——
1120: System.out.println("5) " + uyuyan.getState());—5) TERMINATED

1. ve 2. adımda uyuyan nesnesi oluşturulur ama başlatılmaz; durumu NEW'dur. 3. adımda start() çağrılır. Yeni iş parçacığı hemen uyu(1000)'e girer ve bir saniye uyur. Bu sırada main de 100 ms uyur. main uyandığında uyuyan hâlâ uykudadır: TIMED_WAITING.

6. adımda bekleyen başlatılır ve uyuyan.join()'i çağırır. join süresizdir; bekleyen, uyuyan bitene kadar WAITING durumunda kalır. 8. adımda main, kilit'in monitörünü alır ve ardından engellenen'i başlatır. engellenen aynı kilitle bir synchronized bloğa girmek ister, ama kilit main'dedir. main bloğun içinde 100 ms uyurken durumu yazar: BLOCKED.

mainRUNNABLEuyuyanTIMED_WAITINGbekleyenWAITINGengellenenBLOCKED
Şekil 10.8: 9. adım. main kilidi tutuyor ve uyuyor; uyuyan uykuda, bekleyen onu bekliyor, engellenen kilidi bekliyor.

Şekil 10.8 9. adımdaki tabloyu gösterir: dört iş parçacığı dört farklı durumdadır. Bir noktaya dikkat edilmelidir. main sleep içinde uyurken kilidi bırakmaz; engellenen bu yüzden içeri giremez. Blok bitince kilit bırakılır, engellenen girip hemen çıkar ve biter. main ardından bekleyen'i bekler. uyuyan bir saniyesini doldurup biter; bunun üzerine bekleyen de biter. Son satır uyuyan'ın durumunu yazar: TERMINATED.

Bu programın çıktısı çok iş parçacıklı olmasına rağmen sabittir. Bekleme süreleri, her gözlem anında iş parçacıklarının hangi durumda olacağını belirler: 100 ms, bir iş parçacığının başlayıp sleep'e ya da join'e girmesine yeter. Program 8 kez çalıştırıldı ve her seferinde aynı beş satırı yazdı. Yine de bu tür süre varsayımları ağır yüklü bir bilgisayarda bozulabilir. Süreler senkronizasyon için değil, yalnız gözlem için kullanılmıştır.

10.4Oluşturma ve başlatma

10.4.1Runnable arayüzü

Her iş parçacığı bir Runnable nesneyi çalıştırır. Runnable arayüzü java.lang paketindedir ve tek bir metot içerir:

package java.lang;

public interface Runnable {
    void run();
}

Bu arayüzü uygulayan sınıf run() metodunu yazar. Metodun gövdesi, iş parçacığının yapacağı iştir.

public class MyRunnable implements Runnable {
    @Override
    public void run() {
        // İş parçacığının yapması gereken görevler
    }
}

Runnable'ın tek soyut metodu olduğu için o bir fonksiyonel arayüzdür. Java 8'den beri böyle bir arayüzün yerine lambda ifadesi yazılabilir. Kısa görevler için ayrı bir sınıf yazmak gerekmez:

Runnable r = () -> System.out.println("merhaba");

Lambda, run() metodunun gövdesini doğrudan verir. Parametre listesi boştur, çünkü run() parametre almaz; dönüş tipi void olduğu için lambda değer döndürmez.

10.4.2İş parçacığını başlatmak

Bir Runnable nesnesi tek başına iş parçacığı değildir; yalnızca yapılacak işi anlatır. İşi yeni bir iş parçacığında çalıştırmak için nesne Thread yapıcısına verilir ve start() çağrılır.

public class Worker implements Runnable {
    // İş parçacığının görevleri burada tanımlanır
    public void run() {
        String ad = Thread.currentThread().getName();
        System.out.println("Worker çalışıyor: " + ad);
    }
    public static void main(String[] args) {
        // İş parçacığını başlatma
        Worker w = new Worker();
        Thread t = new Thread(w);
        t.start();
        System.out.println("main devam ediyor");
    }
}

Sunum bir çalıştırmanın çıktısını şöyle verir:

main devam ediyor
Worker çalışıyor: Thread-0

main, Worker nesnesini oluşturur ve onu yapıcıya vererek bir Thread oluşturur. t.start() yeni iş parçacığını açar ve hemen döner. Bundan sonra iki iş parçacığı vardır: main bir sonraki satıra geçip main devam ediyor yazar; Thread-0 ise run()'ı çalıştırıp kendi adını yazar. Ad otomatik verilmiştir: programın oluşturduğu ilk adsız iş parçacığı Thread-0'dır.

İki satırdan hangisinin önce yazılacağı belli değildir. start() döner dönmez main çalışmayı sürdürür; yeni iş parçacığının ne zaman işlemciye alınacağını çizelgeleyici belirler. Program JDK 21 ile 40 kez çalıştırıldı: hepsinde önce main devam ediyor satırı geldi. Ters sıra da geçerlidir ve başka bir bilgisayarda ya da yük altında görülebilir. Ölçülen dağılım bilgisayara ve yüke göre değişir; programın doğruluğu hiçbir sıraya dayanmamalıdır.

Sık yapılan hata

start() yerine run()'ı çağırmak. t.run() sıradan bir metot çağrısıdır: kod çağıranın iş parçacığında, yani main'de çalışır ve yeni iş parçacığı açılmaz. Program hata vermez, ama hiçbir şey paralel çalışmaz. Bu yüzden hata çoğu zaman fark edilmez.

10.4.3Kod izleme: iki yığın, iki akış

Aynı programı adım adım izlemek, start()'ın gerçekte ne yaptığını gösterir. İzleme, olası sıralardan birini, main'in önce yazdığı sırayı izler.

Tablo 10.5: Worker programının bir olası sırası.
AdımSatırDeğişkenler / durumYeni çıktı
19: Worker w = new Worker();w=Worker nesnesi, t=——
210: Thread t = new Thread(w);w=Worker nesnesi, t=Thread-0 (NEW)—
311: t.start();w=Worker nesnesi, t=Thread-0 (RUNNABLE)—
412: System.out.println("main devam ediyor");w=Worker nesnesi, t=Thread-0 (RUNNABLE)main devam ediyor
513: }t=Thread-0 (RUNNABLE)—
64: String ad = Thread.currentThread().getName();t=Thread-0 (RUNNABLE), ad="Thread-0"—
75: System.out.println("Worker çalışıyor: " + ad);t=Thread-0 (RUNNABLE), ad="Thread-0"Worker çalışıyor: Thread-0
86: }t=Thread-0 (TERMINATED)—

2. adımda Thread nesnesi oluşturulmuştur ve durumu NEW'dur. Ama henüz ayrı bir yürütme akışı yoktur; nesne heap'te bekleyen sıradan bir nesnedir. 3. adımda start() çağrılır. JVM yeni bir yığın açar ve run()'ı o yığında başlatır. start() ise run()'ın bitmesini beklemeden döner.

mainboşTERMINATEDThread-0run()RUNNABLE
Şekil 10.9: 6. adım. main() bitti, ama Thread-0 kendi yığınında run()'ı çalıştırıyor. ad onun yerel değişkenidir.

5. adımda main() metodu biter. Program ise bitmez. JVM, arka plan iş parçacığı olmayan bütün iş parçacıkları bitene kadar çalışmayı sürdürür. Şekil 10.9 bu anı gösterir: main'in yığını boşalmıştır, Thread-0'ın yığınında run() çerçevesi vardır. ad değişkeni bu çerçevededir ve başka hiçbir iş parçacığı onu göremez. 8. adımda run() biter ve Thread-0 TERMINATED olur. Çalışan iş parçacığı kalmadığı için JVM kapanır.

10.4.4Thread sınıfını genişletmek

İş parçacığı, Thread sınıfı genişletilerek de yazılabilir. Thread sınıfı zaten Runnable'ı uygular; alt sınıf yalnız run()'ı geçersiz kılar.

public class Worker2 extends Thread {
    // İş parçacığının görevleri burada tanımlanır
    public void run() {
        String ad = getName();   // Thread'den kalıtıldı
        System.out.println("Worker çalışıyor: " + ad);
    }
    public static void main(String[] args) {
        // İş parçacığını oluşturma ve başlatma
        Worker2 w = new Worker2();
        w.start();
        System.out.println("main devam ediyor");
    }
}
main devam ediyor
Worker çalışıyor: Thread-0

Bu yolda Runnable nesnesi ile Thread nesnesi aynıdır. Nesnenin kendisi bir Thread olduğu için w.start() doğrudan çağrılır. run() içinde de getName() ön eksiz yazılır, çünkü metot Thread'den kalıtılmıştır. Çıktının sırası yine değişebilir: 40 çalıştırmada hepsinde yine önce main yazdı.

10.4.5start() mı, run() mı?

start() ile run() arasındaki fark, aynı nesne üzerinde ikisini art arda çağırarak görülür.

public class StartRunFarki {
    public static void main(String[] args)
            throws InterruptedException {
        Thread t = new Thread(() -> {
            String ad = Thread.currentThread().getName();
            System.out.println("run() içinde: " + ad);
        });
        t.run();     // YANLIŞ: main çalıştırır
        t.start();   // DOĞRU: yeni iş parçacığı
        t.join();
    }
}
run() içinde: main
run() içinde: Thread-0

İlk satır t.run()'dan gelir. Bu çağrı, main içinde sıradan bir metot çağrısıdır; currentThread() main'i döndürür. İkinci satır t.start()'tan gelir: run() artık yeni iş parçacığında, Thread-0'da çalışır. Çıktının sırası sabittir: t.run() çağrısı bitmeden t.start() satırına gelinmez. join() ise main'i Thread-0 bitene kadar bekletir.

Tablo 10.6: run() ile start() çağrılarının farkı.
ÇağrıNe olur?
t.run()Sıradan bir metot çağrısı: aynı iş parçacığında (main) çalışır, yeni yığın yok, bitene kadar main bekler.
t.start()JVM yeni bir iş parçacığı ve yığın oluşturur; run() orada çalışır, start() hemen döner.
t.start() ikinci kezIllegalThreadStateException: bir Thread nesnesi yalnızca bir kez başlatılabilir.

Kural kısadır: iş parçacığının işi run() içine yazılır, iş parçacığı start() ile başlatılır. start() aynı nesnede ikinci kez çağrılırsa JDK 21 hem iş parçacığı çalışırken hem de bittikten sonra java.lang.IllegalThreadStateException fırlattı. Bunun nedeni durum diyagramındaki tek yönlü yoldur: start() yalnız NEW durumundaki bir iş parçacığında geçerlidir.

Örnek 10.2

Adı T olan bir iş parçacığı oluşturuluyor; işi "run: " + Thread.currentThread().getName() yazmaktır. Ardından sırasıyla t.run();, System.out.println("A " + t.getState());, t.start();, t.join(); ve System.out.println("B " + t.getState()); çalışıyor. Program ne yazdırır?

t.run() çağrısı main'de çalışır: run: main. İş parçacığı hâlâ hiç başlatılmamıştır: A NEW. start() sonrası run(), T'nin kendi yığınında çalışır: run: T. join() T bitene kadar bekler; son satır B TERMINATED'dır. join() sırayı sabitlediği için bu dört satır her çalıştırmada aynıdır; program beş kez çalıştırıldı ve hep aynı çıktıyı verdi.

10.4.6Tek kalıtım sorunu

Java bir sınıfın yalnızca tek bir sınıftan kalıtım almasına izin verir. Çalıştırılmak istenen sınıf zaten başka bir sınıftan türemişse Thread'i genişletemez. class Worker extends Ata, Thread yazmak derleme hatasıdır; javac virgülün olduğu yerde '{' expected hatası verir.

Çözüm Runnable arayüzündedir. Bir sınıfın uygulayabileceği arayüz sayısında sınır yoktur. Sınıf Ata'yı genişletir, Runnable'ı uygular ve gerekirse içinde Thread tipinde bir alan tutar.

Ata+ selamla(): void«interface»Runnable+ run(): voidWorker- thread: Thread+ run(): void+ start(): voidextendsimplements
Şekil 10.10: Worker, Ata sınıfını genişletir ve Runnable arayüzünü uygular; thread adlı bir Thread alanı vardır.
class Ata {
    public void selamla() {
        System.out.println("Ata sınıfından merhaba");
    }
}
class Worker extends Ata implements Runnable {
    private Thread thread;   // Thread alan olarak
    public void run() {
        String ad = Thread.currentThread().getName();
        System.out.println("run(): " + ad);
    }
    public void start() {
        thread = new Thread(this);
        thread.start();
    }
}
public class TekMirasOrnegi {
    public static void main(String[] args) {
        Worker w = new Worker();
        w.selamla();   // Ata'dan kalıtılan metot
        w.start();     // kendi start() metodumuz
    }
}
Ata sınıfından merhaba
run(): Thread-0

Worker hem Ata'nın metotlarını kalıtır hem de bir iş parçacığında çalışabilir. w.selamla() Ata'dan gelen metottur ve main'de çalışır. w.start() ise Thread'in değil, Worker'ın kendi yazdığı start() metodudur. Bu metot new Thread(this) ile kendini bir Thread'e verir ve onu başlatır. this, Runnable tipindendir; Thread yapıcısı bu yüzden onu kabul eder. Çıktıdaki iki satırın sırası sabittir: selamla(), iş parçacığı başlatılmadan önce biter.

Bu kalıp, iş ile iş parçacığını birbirinden ayırır. Worker “ne yapılacağını” anlatır, Thread nesnesi “nasıl çalıştırılacağını”. Aynı Runnable nesnesi ileride bir iş parçacığı havuzuna da verilebilir. Runnable'ın çoğu durumda tercih edilmesinin nedeni budur.

10.4.7Üç yolun karşılaştırması

Aşağıdaki program iş parçacığı oluşturmanın üç yolunu yan yana koyar. static class, başka bir sınıfın içinde tanımlanan sınıftır; dış sınıfın nesnesi olmadan kullanılabilir.

public class UcYol {
    static class Gorev implements Runnable {     // 1)
        public void run() {
            System.out.println("1) Runnable sınıfı");
        }
    }
    static class IsParcacigi extends Thread {    // 2)
        public void run() {
            System.out.println("2) Thread alt sınıfı");
        }
    }
    public static void main(String[] args)
            throws InterruptedException {
        Thread t1 = new Thread(new Gorev());
        Thread t2 = new IsParcacigi();
        // 3) Lambda (Java 8+)
        Thread t3 = new Thread(
                () -> System.out.println("3) Lambda"));
        t1.start(); t1.join();
        t2.start(); t2.join();
        t3.start(); t3.join();
    }
}
1) Runnable sınıfı
2) Thread alt sınıfı
3) Lambda

Her start()'tan hemen sonra join() geldiği için bir iş parçacığı bitmeden öteki başlamaz. Çıktı sırası bu yüzden sabittir. Elbette bu kullanım iş parçacıklarının bütün yararını ortadan kaldırır; program yalnız üç yolu göstermek için yazılmıştır.

Tablo 10.7: İş parçacığı oluşturmanın üç yolu.
YolNe zaman?
implements RunnableSınıf başka sınıftan türemişse; iş ile iş parçacığı ayrı tutulur
extends ThreadKısa, bağımsız örnekler; kalıtım hakkı harcanır
Lambda (Java 8+)Kısa görevler için en pratik yol

10.4.8Kod izleme: iki iş parçacığı

İki iş parçacığı gerçekten birlikte çalıştığında çıktı nasıl oluşur? Aşağıdaki programda A ve B adlı iki iş parçacığı aynı gorev() metodunu çalıştırır. Thread yapıcısının ikinci argümanı iş parçacığının adıdır.

public class IkiIsParcacigi {
    static void gorev() {
        String ad = Thread.currentThread().getName();
        for (int i = 1; i <= 2; i++) {
            System.out.println(ad + ": adım " + i);
        }
    }
    public static void main(String[] args)
            throws InterruptedException {
        Thread a = new Thread(() -> gorev(), "A");
        Thread b = new Thread(() -> gorev(), "B");
        a.start();
        b.start();
        a.join();
        b.join();
        System.out.println("main: bitti");
    }
}
Tablo 10.8: İki iş parçacığının bir olası sırası (sunumdaki sıra).
AdımSatırÇağrı yığınıDeğişkenler / durumYeni çıktı
112: a.start();main()a=RUNNABLE, b=NEW—
213: b.start();main()a=RUNNABLE, b=RUNNABLE—
314: a.join();main()a=RUNNABLE, b=RUNNABLE, main=WAITING—
45: System.out.println(ad + ": adım " + i);A: run() → gorev()A.i=1, B.i=—, main=WAITINGA: adım 1
55: System.out.println(ad + ": adım " + i);B: run() → gorev()A.i=1, B.i=1, main=WAITINGB: adım 1
65: System.out.println(ad + ": adım " + i);A: run() → gorev()A.i=2, B.i=1, main=WAITINGA: adım 2
75: System.out.println(ad + ": adım " + i);B: run() → gorev()A.i=2, B.i=2, main=WAITINGB: adım 2
816: System.out.println("main: bitti");main()A=TERMINATED, B=TERMINATED, main=RUNNABLEmain: bitti

1. ve 2. adımda main iki iş parçacığını başlatır. İkisi de RUNNABLE'dır, ama hangisinin ne zaman çalışacağı belli değildir. 3. adımda main, a.join() ile A'nın bitmesini beklemeye başlar ve WAITING olur. Artık çalışan A ve B'dir.

mainAB▶a▶b11zaman →
Şekil 10.11: 5. adım. Bağlam değişimi: şimdi B çalışıyor. B'nin i'si A'nınkinden ayrı bir yerel değişkendir.

4.–7. adımlarda A ve B sırayla birer satır yazar. Her bağlam değişiminde öteki iş parçacığı kaldığı yerden devam eder. A'nın i'si ile B'nin i'si ayrı yığınlardadır; birinin artması ötekini etkilemez. 8. adımda a.join() döner. B zaten bittiği için b.join() beklemeden döner ve main son satırı yazar.

Bu sıra olası sıralardan yalnız biridir. Program JDK 21 ile 60 kez çalıştırıldı; görülen çıktılar ve sayıları:

32 kez:  B: adım 1 · B: adım 2 · A: adım 1 · A: adım 2 · main: bitti
25 kez:  A: adım 1 · A: adım 2 · B: adım 1 · B: adım 2 · main: bitti
 3 kez:  B: adım 1 · A: adım 1 · A: adım 2 · B: adım 2 · main: bitti

Aynı iki iş parçacığı tek bir JVM içinde art arda başlatıldığında tablo değişti: 6.000 denemede A1 · A2 · B1 · B2 5.958 kez, B1 · B2 · A1 · A2 35 kez, A1 · B1 · A2 · B2 5 kez, A1 · B1 · B2 · A2 2 kez. Isınmış bir JVM'de yeni iş parçacığı çok çabuk başlar ve kısa işini öteki başlamadan bitirir. Sunumdaki sıra seyrek de olsa gerçekten görüldü. Kesin olan iki şey vardır. Birincisi, main: bitti her zaman en sondadır, çünkü iki join()'den sonra yazılır. İkincisi, aynı iş parçacığının iki satırı kendi aralarında sırasını korur: A: adım 2 hiçbir zaman A: adım 1'den önce gelmez. İki iş parçacığının satırları ise birbirine her biçimde karışabilir.

Örnek 10.3

main içinde sırasıyla şunlar çalışıyor: a'yı başlat, a.join(), b'yi başlat, "m" yaz, b.join(). a "a", b "b" yazıyor. Hangi çıktılar mümkündür?

a.join(), a bitmeden main'i ilerletmez; a her zaman ilk harftir. b başlatıldıktan sonra main'in m yazması ile b'nin çalışması yarışır. Mümkün çıktılar abm ve amb'dir; mab olamaz. b.join() yalnız programın b'den önce bitmesini engeller. Tek bir JVM içinde 300'er denemelik beş turda toplam 1.500 denemenin hepsinde amb çıktı; ayrı JVM'lerle yapılan 40 çalıştırmanın hepsinde de amb görüldü. Yeni iş parçacığı başlayana kadar main çoğu zaman satırını çoktan yazmıştır. Yine de abm geçerli bir sıradır; hiçbir sıra garanti edilmez.

10.5Yönetim ve durdurma

10.5.1sleep, join ve yield

Bir iş parçacığının akışını düzenleyen üç temel metot vardır.

sleep(ms)
Çalışan iş parçacığını belirli bir süre uyutur; iş parçacığı bu sürede TIMED_WAITING durumundadır. Uyuyan iş parçacığı tuttuğu kilitleri bırakmaz.
join()
Çağıran iş parçacığı, hedef iş parçacığı bitene kadar bekler. join(ms) biçimi en çok ms milisaniye bekler; süre dolunca hedef bitmemiş olsa bile döner.
yield()
Yürütmeyi geçici olarak başka bir iş parçacığına bırakma önerisidir. Çizelgeleyici bu öneriyi yok sayabilir; bu yüzden yield() ile hiçbir sıra garanti edilmez.

join()'in neden gerektiğini aşağıdaki program gösterir. hesap iş parçacığı 1'den 1.000.000'a kadar sayıları toplar ve sonucu static bir alana yazar.

public class JoinOrnegi {
    static long sonuc = 0;
    public static void main(String[] args)
            throws InterruptedException {
        Thread hesap = new Thread(() -> {
            long toplam = 0;
            for (int i = 1; i <= 1_000_000; i++) {
                toplam += i;
            }
            sonuc = toplam;
        });
        hesap.start();
        System.out.println("join öncesi : " + sonuc);
        hesap.join();   // hesap bitene kadar bekle
        System.out.println("join sonrası: " + sonuc);
    }
}
join öncesi : 0
join sonrası: 500000500000

start()'tan hemen sonra main sonuc'u okur. Hesap o sırada daha bitmemiştir; main başlangıç değerini, yani 0'ı görür. Program 30 kez çalıştırıldı ve ilk satır her seferinde 0 çıktı. Bu bir garanti değildir: yavaş başlayan bir main ya da çok hızlı bir hesap, ilk satırda da sonucu gösterebilirdi. join() sonrasında ise durum kesindir. Hesap bitmiştir ve yazdığı değer main'e görünür: 1 + 2 + … + 1.000.000 = 1.000.000 · 1.000.001 / 2 = 500.000.500.000.

Sonucun long tutulması önemlidir. Toplam yaklaşık 5 · 1011'dir; int'in üst sınırı olan 2.147.483.647'yi çok aşar.

join() ikinci bir güvence daha verir: hedef iş parçacığının bütün yazdıkları, join()'den dönen iş parçacığına görünür. sonuc'un volatile olmaması bu yüzden sorun değildir.

sleep ve join, kontrol edilen bir istisna olan InterruptedException'ı fırlatabilir. Bu yüzden çağrıldıkları yerde ya yakalanmaları ya da throws InterruptedException ile bildirilmeleri gerekir. Sunumdaki örneklerin çoğu main'i throws InterruptedException ile bildirir. Lambda içinde ise throws yazılamaz, çünkü run() hiçbir kontrol edilen istisna bildirmez; orada try-catch şarttır.

Sık yapılan hata

Thread.sleep(100); satırını try-catch olmadan, throws bildirmeyen bir metoda yazmak. Derleyici unreported exception InterruptedException; must be caught or declared to be thrown hatası verir. Çözüm try-catch ya da metot imzasına throws InterruptedException eklemektir.

10.5.2Arka plan iş parçacıkları

Tanım 10.5

Arka plan iş parçacığı (daemon thread), JVM'in kapanmasını engellemeyen yardımcı iş parçacığıdır. JVM, arka plan iş parçacığı olmayan bütün iş parçacıkları bitince kapanır; arka plan iş parçacıklarının bitmesini beklemez.

public class DaemonOrnegi {
    public static void main(String[] args)
            throws InterruptedException {
        Thread saat = new Thread(() -> {
            int s = 0;
            while (true) {
                System.out.println("tik " + s++);
                try {
                    Thread.sleep(300);
                } catch (InterruptedException e) {
                    return;
                }
            }
        });
        saat.setDaemon(true);   // start()'tan ÖNCE
        saat.start();
        Thread.sleep(1000);
        System.out.println("main bitti, JVM kapanıyor");
    }
}
tik 0
tik 1
tik 2
tik 3
main bitti, JVM kapanıyor

saat sonsuz bir döngüdedir ve her 300 ms'de bir satır yazar. setDaemon(true) onu arka plan iş parçacığı yapar. main bir saniye uyur, son satırını yazar ve biter. Geriye yalnız arka plan iş parçacığı kaldığı için JVM kapanır; saat'in döngüsü yarıda kalır.

Kaç tik yazılır? saat 0, 300, 600 ve 900 ms civarında yazar; dördüncü uykusu 1200 ms'de bitecektir. main 1000 ms'de uyanır. Bu yüzden beklenen çıktı dört tik'tir. Program 10 kez çalıştırıldı ve hepsinde dört tik satırı yazıldı. Zamanlamaya dayalı her çıktı gibi bu da garanti değildir; 900 ms ile 1000 ms arasındaki pay küçüktür.

setDaemon(true), start()'tan önce çağrılmalıdır. İş parçacığı başladıktan sonra çağrılırsa IllegalThreadStateException fırlar; bu davranış JDK 21 ile denendi. Arka plan iş parçacıkları kayıt tutma, önbellek temizleme ya da düzenli denetim gibi yardımcı işler içindir. Dosyaya yazan ya da yarıda kalmaması gereken bir iş arka plan iş parçacığına verilmez, çünkü JVM kapanırken finally blokları bile çalışmadan kesilebilir.

10.5.3İş parçacığını durdurmak

Bir iş parçacığı, run() metodu tamamlandığında durur. JVM ise iki durumda kapanır: System.exit() (içeride Runtime.exit()) çağrıldığında ya da arka plan iş parçacığı olmayan bütün iş parçacıkları bittiğinde.

Bir iş parçacığını dışarıdan durdurmanın doğru yolu iş birliğidir. Durdurulacak iş parçacığı düzenli aralıklarla bir bayrağı denetler; bayrak kalkınca işini düzgünce bitirip run()'dan çıkar. Bayrak volatile tanımlanır, böylece başka bir iş parçacığının yaptığı değişiklik görünür olur. İkinci yol interrupt() ile kesme isteği göndermektir.

Eski Java sürümlerinde Thread.stop() adlı bir metot vardı. Bu metot iş parçacığını işinin ortasında öldürürdü. İş parçacığının tuttuğu kilitler birden bırakılır ve koruduğu nesneler yarım güncellenmiş hâlde kalırdı. Örneğin bir hesaptan para çekilmiş ama öteki hesaba yatırılmamış olabilirdi. Metot uzun süre kullanımdan kaldırılmış olarak kaldı; Java 20'den beri de çalışmaz:

Thread t = new Thread(() -> { });
try {
    t.stop();
} catch (UnsupportedOperationException e) {
    System.out.println("stop(): desteklenmiyor");
}
stop(): desteklenmiyor

JDK 21'de stop() her zaman UnsupportedOperationException fırlatır. Aynı denemede suspend() ve resume() da aynı istisnayı fırlattı.

10.5.4Bayrakla durdurmak

class Worker extends Thread {
    private volatile boolean flag = false;
    public void finish() { flag = true; }
    public void run() {
        while (!flag) {
            System.out.println("çalışıyor...");
            try { Thread.sleep(100); }
            catch (InterruptedException e) { return; }
        }
        System.out.println("bayrak görüldü, bitiyor");
    }
}
public class BayrakDurdurma {
    public static void main(String[] args)
            throws InterruptedException {
        Worker w = new Worker();
        w.start();
        Thread.sleep(350);
        w.finish();   // bayrağı kaldır
        w.join();     // bitmesini bekle
        System.out.println("main: işçi durdu");
    }
}
çalışıyor...
çalışıyor...
çalışıyor...
çalışıyor...
bayrak görüldü, bitiyor
main: işçi durdu

finish() yalnız bayrağı kaldırır; iş parçacığını zorla durdurmaz. İşçi her turun başında !flag koşulunu denetler. Bayrağın kalktığını gördüğü ilk turda döngüden çıkar, son satırını yazar ve run()'dan kendisi döner. main ise join() ile işçinin bitmesini bekler.

Zamanlama şöyledir: işçi 0, 100, 200 ve 300 ms civarında yazar. main 350 ms'de uyanıp bayrağı kaldırır. İşçi 400 ms'de uyandığında bayrağı görür. Program 15 kez çalıştırıldı ve hepsinde dört çalışıyor... satırı görüldü. Sayı zamanlamaya bağlıdır; bayrak yönteminin doğruluğu ise sayıya bağlı değildir.

Bayrak volatile olmasaydı ne olurdu? İşçi bayrağı ilk okuduğu değerle önbellekte ya da bir yazmaçta tutabilir ve değişikliği hiç görmeyebilirdi. Bu örnekte döngüdeki sleep çağrısı sorunu çoğu zaman gizler; boş bir döngüde ise iş parçacığı gerçekten takılabilir.

10.5.5Referansla durdurmak

İş birliğiyle durdurmanın ikinci bir biçimi, bayrak yerine iş parçacığının kendisine referans tutmaktır.

class Worker implements Runnable {
    private volatile Thread thread;
    public void start() {
        thread = new Thread(this);
        thread.start();
    }
    public void finish() { thread = null; }
    public void run() {
        Thread ben = Thread.currentThread();
        while (thread == ben) {   // ... iş ...
        }
        System.out.println("run() bitti");
    }
}
public class ReferansDurdurma {
    public static void main(String[] args) throws Exception {
        Worker w = new Worker();
        w.start();
        Thread.sleep(200);
        w.finish();
        Thread.sleep(200);
        System.out.println("main: bitti");
    }
}
run() bitti
main: bitti

Sınıf Runnable'ı uygular ve çalışan Thread nesnesini volatile bir alanda tutar. Döngü, alan hâlâ kendi iş parçacığını gösterdikçe sürer. Thread.currentThread() ile karşılaştırma bunu denetler. finish() alanı null yapar; koşul bozulur ve run() biter. main 200 ms sonra durdurur, 200 ms daha bekler ve son satırını yazar. On çalıştırmada çıktı hep bu iki satırdı.

Bir ayrıntı: currentThread(), Thread'in static metodudur. Thread'i genişletmeyen bir sınıfta Thread. ön ekiyle yazılmak zorundadır. Ön ek unutulursa derleyici cannot find symbol hatası verir. Döngünün gövdesi boş olduğu için iş parçacığı bu 200 ms boyunca bir çekirdeği tam meşgul eder. Gerçek bir programda gövde bir iş yapar ya da kısa bir süre uyur.

10.6Senkronizasyon

10.6.1Sıra neden bozulur?

Birden çok iş parçacığı aynı veriyle çalıştığında işlemlerin doğru sırada yapılması üç nedenle güçtür.

Hız belirsizdir
Her iş parçacığının çalışma hızı ve sıklığı değişkendir. İşletim sistemleri öncelikleri göz ardı edebilir; sıra için önceliğe güvenilmez.
Değer bayatlayabilir
Bir iş parçacığı bir değişkeni okuduktan sonra başkası değeri güncelleyebilir. İlki güncel olmayan değerle çalışmayı sürdürür.
Komutlar yeniden sıralanabilir
Derleyici, JIT (just-in-time, çalışma anında) derleyicisi ve işlemci, hız için komutların sırasını değiştirebilir. Tek iş parçacığında bu fark edilmez; birden fazlasında beklenmeyen davranış doğurur.
T1T2oku x → 5x + 1 = 6 yaz (bayat!)x = 9 yazT2'nin 9'u kaybolur
Şekil 10.12: Bayat değer: T1 okuduktan sonra T2 yazar; T1 eski değerle devam eder.

Paylaşılan ve değişen veriye birden fazla iş parçacığı erişiyorsa erişim senkronize edilmelidir. Aksi hâlde sonuç, iş parçacıklarının hangi sırayla çalıştığına bağlı olur.

Tanım 10.6

Yarış durumu (race condition), bir programın sonucunun iş parçacıklarının çalışma sırasına bağlı olmasıdır. Senkronizasyon (synchronization) ise iş parçacıklarının paylaşılan veriye erişimini, sonuç sıraya bağlı kalmayacak biçimde düzenlemektir.

10.6.2Yarış durumu

En basit yarış örneği, iki iş parçacığının aynı sayacı artırmasıdır. Her iş parçacığı 100.000 kez sayac++ yapar; beklenen sonuç 200.000'dir.

public class YarisDurumu {
    static int sayac = 0;   // paylaşılan veri
    public static void main(String[] args)
            throws InterruptedException {
        Runnable arttir = () -> {
            for (int i = 0; i < 100_000; i++) {
                sayac++;   // oku, 1 ekle, yaz: atomik DEĞİL
            }
        };
        Thread t1 = new Thread(arttir);
        Thread t2 = new Thread(arttir);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("Beklenen: 200000");
        System.out.println("Bulunan : " + sayac);
    }
}

Sunumdaki üç çalıştırma 138001, 135352 ve 136776 buldu. Not için program JDK 21 ile 20 kez daha çalıştırıldı. Bulunan değerler:

136210, 145954, 155340, 149684, 132748, 125554, 126412, 156356, 126897, 144040, 137525, 127748, 117573, 164660, 129167, 134217, 140315, 129857, 128366, 133715
(en küçük 117573, en büyük 164660)

Her çalıştırma farklı bir sonuç verdi ve hiçbiri 200.000'e ulaşmadı. Artışların bir kısmı kaybolur. Kaybın miktarı tamamen çalıştırmaya göre değişir; bilgisayarın çekirdek sayısına, o anki yüke ve JIT'in kodu ne zaman derlediğine bağlıdır. Az sayıda artış yapan bir programda (örneğin 1.000) sonuç çoğu zaman doğru çıkar, çünkü iş parçacıkları hiç çakışmadan bitebilir. Hata bu yüzden rastgele görünür ve test ile yakalanması zordur.

10.6.3Kod izleme: sayac++ neden kaybolur?

sayac++ kaynak kodda tek bir işlem gibi görünür. İşlemcide ise üç adımdır: değeri bellekten oku, bir ekle, belleğe yaz.

// sayac++ işlemcide üç adımdır:
int yazmac = sayac;     // 1) oku
yazmac = yazmac + 1;    // 2) 1 ekle
sayac = yazmac;         // 3) yaz

İki iş parçacığı bu üç adımı aynı anda yaparsa adımlar birbirine karışabilir. Aşağıdaki izleme başlangıçta 0 olan sayacın iki artışla 1'de kaldığı bir sırayı gösterir.

Tablo 10.9: sayac++ adımlarının bir olası karışması. “Çağrı yığını” sütunu o anda çalışan iş parçacığını gösterir.
AdımSatırÇağrı yığınıDeğişkenler / durum
11: // sayac++ işlemcide üç adımdır:—sayac=0, t1.yazmac=—, t2.yazmac=—
22: int yazmac = sayac; // 1) okut1sayac=0, t1.yazmac=0, t2.yazmac=—
32: int yazmac = sayac; // 1) okut2sayac=0, t1.yazmac=0, t2.yazmac=0
43: yazmac = yazmac + 1; // 2) 1 eklet1sayac=0, t1.yazmac=1, t2.yazmac=0
54: sayac = yazmac; // 3) yazt1sayac=1, t1.yazmac=1, t2.yazmac=0
63: yazmac = yazmac + 1; // 2) 1 eklet2sayac=1, t1.yazmac=1, t2.yazmac=1
74: sayac = yazmac; // 3) yazt2sayac=1, t1.yazmac=1, t2.yazmac=1

2. adımda t1 sayacı okur: yazmacına 0 gelir. Tam bu noktada bir bağlam değişimi olur. 3. adımda t2 de sayacı okur; t1 henüz yazmadığı için o da 0 görür. 4. ve 5. adımlarda t1 kendi yazmacında 1 ekler ve belleğe 1 yazar.

belleksayac = 1t1yazmaç = 1t2yazmaç = 1yaz
Şekil 10.13: 7. adım. t2 kendi bayat değerine 1 ekleyip yazdı. İki artış yapıldı, ama sayac 1'dir.

6. ve 7. adımlarda t2 kendi yazmacındaki eski değere, yani 0'a 1 ekler ve 1 yazar. t1'in yazdığı 1'in üstüne yine 1 yazılmıştır. İki artış yapılmış, ama sonuç 1'dir: t1'in artışı kaybolmuştur (Şekil 10.13). Bu olaya kayıp güncelleme (lost update) denir.

Bu, olası sıralardan yalnızca biridir. Araya girme olmazsa, yani bir iş parçacığı üç adımını öteki başlamadan bitirirse sonuç doğru çıkar. 100.000'er artışlık programda bu karışma binlerce kez olur ve her seferinde bir artış kaybolur. İş Parçacığı Yarışı simülatörü bu adımları elle ya da rastgele sıralamakta ve kaybın nasıl oluştuğunu tek tek izlemekte kullanılabilir.

Örnek 10.4

Bir hesabın bakiyesi 500'dür. T1 yatir(100), T2 cek(30) çağırır; metotlar bakiye += m ve bakiye -= m satırlarından ibarettir ve senkronize değildir. Sıra şöyle olsun: T1 okur, T2 okur, T2 çıkarıp yazar, T1 ekleyip yazar. Bakiye sonunda kaç olur, doğrusu kaç olmalıydı?

Doğru sonuç 500 + 100 − 30 = 570'tir. T1 500 okur. T2 de 500 okur, 30 çıkarır ve 470 yazar. T1 bayat 500'e 100 ekler ve 600 yazar. Sonuç 600'dür: T2'nin para çekmesi kaybolmuştur. Son iki yazma ters sırada olsaydı sonuç 470 olurdu ve bu kez yatırılan para kaybolurdu. Çözüm, iki metodu da aynı nesnenin kilidiyle (synchronized) korumaktır.

10.6.4Senkronizasyon seçenekleri

Java iş parçacıklarını eşgüdümlemek için birçok araç sunar. Bunların her biri farklı bir soruna çözümdür:

  • join(): bir iş parçacığının bitmesini beklemek.
  • synchronized: nesnenin monitör kilidiyle karşılıklı dışlama.
  • Lock nesneleri (ReentrantLock, ReadWriteLock): açık kilitleme.
  • Bariyer, semafor, değiştirici: CyclicBarrier, Semaphore, Exchanger, CountDownLatch.
  • volatile değişkenler: okuma ve yazmaların bütün iş parçacıklarına görünmesi.
  • Atomik nesneler: AtomicInteger gibi bölünemez işlemler.

Senkronizasyona benzeyen ama senkronizasyon olmayan araçlar da vardır: öncelikler, sleep ile uyutma, yield ile yol verme ve zamanlayıcılar. Bunlar sırayı garanti etmez. “Biraz bekleyeyim, öbürü bitirmiş olur” düşüncesiyle yazılan kod, bir gün öbürünün bitirmediği bir anda yanlış sonuç üretir.

10.6.5synchronized ve monitör

Tanım 10.7

Monitör (monitor), her Java nesnesine bağlı, kilitlenip açılabilen iç kilit ve bekleme düzeneğidir. Monitörü aynı anda yalnızca bir iş parçacığı tutabilir. synchronized anahtar kelimesi bir nesnenin monitörünü alır; blok bitince monitör otomatik olarak bırakılır.

Monitörün çalışması şöyledir. t1 bir nesnenin monitörünü tutarken t2 de aynı monitörü isterse t2 bekler; durumu BLOCKED'dır. Monitör bırakılınca bekleyenler yarışır ve yalnızca biri monitörü alır. Blok normal biterse de, bloktan bir istisnayla çıkılırsa da monitör bırakılır. Böylece karşılıklı dışlama (mutual exclusion) sağlanır ve veri bütünlüğü korunur.

Tanım 10.8

Kritik bölge (critical section), paylaşılan veriye erişen ve aynı anda yalnızca bir iş parçacığının çalıştırması gereken kod parçasıdır. Karşılıklı dışlama, kritik bölgede aynı anda yalnızca bir iş parçacığının bulunmasıdır.

Söz dizimi kısadır. Parantez içine monitörü kullanılacak nesne yazılır:

synchronized (o) {
    // o'nun monitörü bu blokta bizde
}

Yarış örneğindeki sayaç artışı böyle bir blokla korunur:

Runnable r = () -> {
    for (int i = 0; i < 50_000; i++) {
        synchronized (o) {   // o'nun monitörünü al
            ortak++;
        }                    // monitör bırakılır
    }
};

İki iş parçacığı bu görevi 50.000'er kez çalıştırdığında sonuç her seferinde 100000'dir. Program 10 kez çalıştırıldı ve hep bu değeri verdi. Her ortak++, monitör tutulurken yapılır; bir iş parçacığının oku–ekle–yaz adımları bitmeden öteki bloğa giremez.

Monitörün işleyişi adım adım şöyledir:

// t1 ve t2 aynı kodu çalıştırır
synchronized (o) {
    ortak++;      // kritik bölge
}
  1. t1 ve t2 aynı anda synchronized (o) satırına gelir; monitör boştur.
  2. t1 kazanır ve monitörü alır. t2 giriş kümesinde (entry set) BLOCKED bekler.
  3. t1 bloktan çıkar; monitör otomatik bırakılır. Bekleyenler yarışır.
  4. t2 monitörü alır ve kritik bölgeye girer.
  5. t2 de çıkar. ortak++ iki kez, birbirine karışmadan yapılmıştır.
giriş kümesiBLOCKEDo nesnesimonitörsahibi: t1bittit2t1
Şekil 10.14: 2. adım. t1 monitörün sahibi; t2 giriş kümesinde BLOCKED bekliyor.

Hangi iş parçacığının önce gireceği belirsizdir. Önemli olan, kritik bölgenin hiçbir zaman iki iş parçacığınca aynı anda çalıştırılmamasıdır. Monitör kilitlemesinin doğru çalışması için bütün iş parçacıklarının aynı nesneyi kilitlemesi gerekir. Her iş parçacığı kendi nesnesinin monitörünü alırsa hiçbir koruma oluşmaz.

10.6.6Senkronize metotlar

synchronized bir metodun önüne de yazılabilir. Bu, metodun bütün gövdesini bir synchronized bloğa almakla aynıdır.

class C {
    synchronized void p() { ... }
    static synchronized void q() { ... }
    //...
}

Yukarıdaki tanımın eşdeğeri şudur:

class C {
    void p() { synchronized (this) { ... } }
    static void q() { synchronized (C.class) { ... } }
    //...
}

Hangi nesnenin kilitlendiği metodun türüne bağlıdır:

  • Örnek metot: this nesnesinin monitörü kilitlenir.
  • static metot: nesne olmadığı için sınıfın C.class nesnesinin monitörü kilitlenir.

Bu yüzden bir static synchronized metot ile bir örnek synchronized metot birbirini engellemez; farklı kilitler kullanırlar. Bu davranış JDK 21 ile denendi: başka bir iş parçacığı C.class'ın monitörünü 500 ms tutarken x.p() beklemeden çalıştı, C.q() ise monitör bırakılana kadar bekledi. Sunumdaki koddaki ... yerine gövde yazıldığında (örneğin n++, s++) iki biçim de derlenir ve aynı davranır.

Blok biçiminin bir üstünlüğü vardır: yalnız gerçekten gereken satırlar kilitlenir. Uzun bir metodun yalnız iki satırı paylaşılan veriye dokunuyorsa bütün metodu kilitlemek, öteki iş parçacıklarını boşuna bekletir.

Örnek 10.5

Sayac sınıfı Runnable'ı uygular; static int toplam alanı vardır ve run() metodu synchronized'dır. run(), toplam'ı 100.000 kez artırır. İki iş parçacığı new Thread(new Sayac()) ile iki ayrı nesneyi çalıştırıyor. Toplam neden 200.000'den küçük çıkabilir?

Her iş parçacığı kendi nesnesinin monitörünü (this) alır. İki farklı kilit birbirini engellemez; korunan veri ise ortak static toplam'dır. Program on kez çalıştırıldı ve bulunan toplamlar 111.143 ile 169.427 arasında değişti; hiçbiri 200.000 değildi. Düzeltme, herkesin aynı kilidi kullanmasıdır: döngüdeki artış synchronized (Sayac.class) bloğuna alınır. Düzeltilmiş program beş çalıştırmada da 200000 verdi. AtomicInteger da bir çözümdür.

10.6.7Yarışı önlemek

Yarış örneğindeki sayaç, metotları senkronize bir sınıfın içine taşınarak düzeltilir.

class Sayac {
    private int deger = 0;
    public synchronized void arttir() { deger++; }
    public synchronized int getDeger() { return deger; }
}

public class SenkronSayac {
    public static void main(String[] args)
            throws InterruptedException {
        Sayac sayac = new Sayac();
        Runnable gorev = () -> {
            for (int i = 0; i < 100_000; i++) {
                sayac.arttir();
            }
        };
        Thread t1 = new Thread(gorev);
        Thread t2 = new Thread(gorev);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("Sonuç: " + sayac.getDeger());
    }
}
Sonuç: 200000

arttir() artık synchronized'dır: oku–ekle–yaz adımları bölünmeden yapılır. Program 10 kez çalıştırıldı ve her seferinde Sonuç: 200000 yazdı. Okuyan getDeger() metodu da senkronizedir. Bu ilk bakışta gereksiz görünebilir, çünkü bir int'i okumak zaten tek adımdır. Ama okuma senkronize olmazsa okuyan iş parçacığının güncel değeri görmesi garanti değildir. Kilit bırakılırken yazılanlar, aynı kilidi sonradan alan iş parçacığına görünür hâle gelir.

Senkronizasyonun bir bedeli vardır. İş parçacıkları kritik bölgeye sırayla girer ve birbirini bekler; program biraz yavaşlar. Bu yüzden yalnız paylaşılan veri korunur. Bir iş parçacığının yalnız kendisinin kullandığı yerel değişkenler için kilide gerek yoktur.

10.6.8Karşılıklı dışlama: öncelikli kuyruk

Senkronize metotlar, bir veri yapısını bütünüyle iş parçacığı güvenli hâle getirmenin en kısa yoludur. Aşağıdaki öncelikli kuyruk her çağrıda en küçük öğeyi verir. Bütün metotlar aynı monitörü, this'i kullanır.

class OncelikliKuyruk {
    private int[] veri;
    private int boyut;
    public OncelikliKuyruk(int n) { veri = new int[n]; }
    public synchronized void ekle(int o) {
        if (boyut == veri.length)
            throw new HeapFullException();
        veri[boyut++] = o;
    }
    public synchronized int cikar() {   // en küçüğü
        int enk = 0;
        for (int i = 1; i < boyut; i++) {
            if (veri[i] < veri[enk]) enk = i;
        }
        int sonuc = veri[enk];
        veri[enk] = veri[--boyut];
        return sonuc;
    }
    public synchronized String toString() {
        return Arrays.toString(Arrays.copyOf(veri, boyut));
    }
}

HeapFullException sunumda tanımlanmamıştır; burada RuntimeException'dan türeyen ve "Kuyruk dolu" iletisini taşıyan bir sınıf olarak yazıldı. Kuyruk tek bir iş parçacığından şöyle kullanılır:

OncelikliKuyruk k =
        new OncelikliKuyruk(3);
k.ekle(5);
k.ekle(2);
k.ekle(8);
System.out.println(k);
int x = k.cikar();
System.out.println(x);
System.out.println(k);
k.ekle(1);
try {
    k.ekle(9);
} catch (HeapFullException e) {
    String m = e.getMessage();
    System.out.println(m);
}
[5, 2, 8]
2
[5, 8]
Kuyruk dolu

5, 2 ve 8 eklenince dizi [5, 2, 8] olur. cikar() en küçüğün yerini doğrusal aramayla bulur: 1. konumdaki 2. Bu öğe döndürülür, yerine son öğe (8) taşınır ve boyut bir azalır: [5, 8]. 1 eklenince kuyruk yeniden dolar. ekle(9) istisna fırlatır ve ileti yazdırılır.

Bu sınıfta senkronizasyon neden şarttır? ekle'deki veri[boyut++] = o satırı da oku–değiştir–yaz içerir. İki iş parçacığı aynı anda eklerse ikisi de aynı boyut'u okuyup aynı göze yazabilir; bir öğe kaybolur. cikar'ın ortasında bir ekle araya girerse en küçük öğenin yeri değişebilir. Bütün metotlar aynı monitörü kullandığından bir metot çalışırken öteki hiçbir metot başlayamaz. toString()'in de senkronize olması, yarım değişmiş bir diziyi yazdırmayı önler.

10.7Görünürlük

10.7.1volatile alanlar

Yarış durumu, iki iş parçacığının aynı anda yazmasından doğar. Daha sinsi bir sorun da vardır: bir iş parçacığının yazdığı değeri öteki hiç görmeyebilir. İşlemciler hız için değerleri çekirdeğe yakın önbelleklerde ya da yazmaçlarda (register) tutar. Derleyici de bir döngüde değişmediğini düşündüğü bir değişkeni bir kez okuyup yazmaçta tutabilir. Bu durumda başka bir çekirdekteki iş parçacığı ana belleğe yeni değeri yazsa bile ilk iş parçacığı eski değerle çalışmayı sürdürür.

Tanım 10.9

volatile anahtar kelimesiyle tanımlanan bir alan, her okunuşta ana bellekten okunur ve her yazılışta hemen bütün iş parçacıklarına görünür olur. volatile görünürlüğü garanti eder; birden çok adımlı işlemleri atomik yapmaz.

Çekirdek 1: mainönbellek: dur = trueÇekirdek 2: tyazmaç: dur = falseAna bellek: dur = trueyazartekrar okumaz(eski kopya)
Şekil 10.15: main yeni değeri ana belleğe yazar; t iş parçacığı değeri yazmacında eski hâliyle tuttuğu için değişikliği görmez.

Java dilinin kuralına göre long ve double dışındaki bütün değişkenlerin tek bir okunması ya da yazılması zaten atomiktir. 64 bitlik long ve double değerleri ise bazı JVM'lerde iki 32 bitlik yarım olarak yazılabilir. volatile long ve volatile double alanlarda bu yarım yazma olmaz; okuma ve yazma atomik hâle gelir.

Paylaşılan basit bir bayrak için volatile yeterlidir; synchronized ya da kilit gerekmez. Ama yalnız tek bir okuma ya da tek bir yazma atomiktir. x++ gibi oku–değiştir–yaz işlemleri volatile alanda da atomik değildir.

10.7.2Görünürlük sorunu

public class GorunurlukSorunu {
    static boolean dur = false;   // volatile DEĞİL!
    public static void main(String[] args)
            throws InterruptedException {
        Thread t = new Thread(() -> {
            long tur = 0;
            while (!dur) {
                tur++;
            }
            System.out.println("iş parçacığı durdu");
        });
        t.setDaemon(true);
        t.start();
        Thread.sleep(500);
        dur = true;
        System.out.println("main: dur = true");
        t.join(2000);   // en çok 2 sn bekle
        boolean canli = t.isAlive();
        System.out.println("hâlâ çalışıyor mu? " + canli);
    }
}

İş parçacığı boş bir döngüde dur'u denetler. main 500 ms sonra dur'u true yapar ve en çok iki saniye bekler. volatile olmadan sunumdaki çıktı şudur:

main: dur = true
hâlâ çalışıyor mu? true

İş parçacığı değişikliği hiç görmemiş ve iki saniye sonra hâlâ çalışıyor. Nedeni JIT derleyicisidir. Döngü birkaç bin kez döndükten sonra JIT onu makine koduna çevirir. Döngünün içinde dur'u değiştiren hiçbir şey olmadığı için dur bir kez okunur ve döngü while (true) gibi çalışır. Bu dönüşüm tek iş parçacıklı bir program için tamamen doğrudur; Java kuralları da volatile olmayan bir alanda buna izin verir.

Program JDK 21 ile 10 kez çalıştırıldı ve iş parçacığı her seferinde değişikliği görmedi; çıktı sunumdaki gibiydi. Alan static volatile boolean dur olarak tanımlandığında ise on çalıştırmanın hepsinde iş parçacığı durdu:

main: dur = true
iş parçacığı durdu
hâlâ çalışıyor mu? false

Bu çalıştırmalardan birinde iş parçacığı durdu satırı main: dur = true'dan önce yazıldı. Bu bir çelişki değildir: main önce alanı değiştirir, sonra yazar; iş parçacığı değişikliği bu iki adımın arasında görüp kendi satırını önce yazabilir.

Sonuç JVM'e, işlemciye ve JIT'in ne zaman devreye girdiğine göre değişebilir. Bir bilgisayarda sorun hiç görünmeyebilir, ötekinde her seferinde görünür. Doğruluk bu tür şansa bırakılmaz: başka bir iş parçacığının değiştirdiği bir bayrak her zaman volatile tanımlanır. Örnekteki setDaemon(true) çağrısının amacı da budur: takılan iş parçacığı programın kapanmasını engellemesin.

10.7.3volatile'ın sınırları

volatile, sayaç sorununu çözmez. Yarış örneğindeki sayaç volatile tanımlansa bile artışlar kaybolur:

static volatile int sayac = 0;
// main içinde:
Runnable r = () -> {
    for (int i = 0; i < 100_000; i++) sayac++;
};

Sunumdaki üç çalıştırma 163299, 164441 ve 199332 buldu. Not için yapılan 20 çalıştırmada bulunan değerler:

157591, 188201, 171134, 181949, 163135, 175082, 150206, 177784, 184817, 187816, 178180, 174558, 168901, 171061, 179658, 173836, 168144, 176917, 170584, 172321
(en küçük 150206, en büyük 188201)

Görünürlük sorunu çözülmüştür: her iş parçacığı her okumada güncel değeri görür. Ama iki iş parçacığı yine aynı değeri okuyup aynı sonucu yazabilir; okuma ile yazma arasına öteki iş parçacığı girebilir. Çözüm synchronized ya da AtomicInteger'dır.

volatile, final ile de birlikte kullanılamaz:

public class FinalVolatile {
    private final volatile int x = 5;
}
FinalVolatile.java:2: error: illegal combination of modifiers: final and volatile
    private final volatile int x = 5;
                               ^
1 error

İki anahtar kelime aynı soruna farklı yollardan çözüm getirir. Tablo 10.10 farkı özetler.

Tablo 10.10: final ve volatile.
finalvolatile
Bir kez atanır (yapıcıda); sonra değişmezDeğeri değişebilir
Yapıcı bitince herkes aynı değeri görürHer okuma güncel değeri döndürür

final alan hiç değişmediği için görünürlük sorunu yoktur; Java, yapıcı bittikten sonra bütün iş parçacıklarının final alanın doğru değerini göreceğini garanti eder. volatile ise yalnız değişen alanlar için anlamlıdır. İkisini birlikte yazmak çelişkili olduğu için derleyici reddeder.

10.7.4Java Bellek Modeli ve önce-olur ilişkisi

Bir iş parçacığının yazdığı değeri öteki ne zaman görür? Bu sorunun kesin cevabını Java Bellek Modeli (JMM, Java Memory Model) verir. Programcı işlemcinin önbelleklerini ve bellek bariyerlerini düşünmek zorunda kalmaz; bunun yerine önce-olur ilişkileriyle düşünür.

Tanım 10.10

A işlemi B işleminden önce-olur (happens-before) ise A'nın yazdığı her şey B'de kesinlikle görünür. Önce-olur ilişkisi geçişlidir: A, B'den ve B, C'den önce-olursa A da C'den önce-olur.

Tablo 10.11: Önce-olur ilişkisi kuran başlıca kurallar.
KuralA önce-olur B
KilitBir monitörün ya da kilidin bırakılması → aynı kilidin sonraki alınması
volatileBir volatile alana yazma → aynı alanın sonraki okunması
Başlatmat.start() çağrısından önceki her şey → t'nin içindeki işlemler
Bitirmet'nin bütün işlemleri → t.join()'den dönen iş parçacığı
Hazır yapılarBlockingQueue.put → aynı öğeyi alan take; Future'ın işi → get()

İş parçacıklı programlardaki görünürlük davranışı bu kurallarla açıklanır. join() örneğinde sonuc volatile değildi, ama main doğru değeri gördü: bitirme kuralı. Senkronize sayaçta getDeger()'in de senkronize olması kilit kuralını işletir. Bayrak örneğinde volatile kuralı işler. Bu ilişkilerden biri kurulmamışsa iki iş parçacığı arasında görünürlük garantisi yoktur.

Görünürlükle karıştırılmaması gereken bir başarım sorunu da vardır: yanlış paylaşım (false sharing). Farklı iş parçacıklarının birbirinden bağımsız değişkenleri aynı önbellek satırında (çoğunlukla 64 bayt) bulunur ve sık güncellenirse çekirdekler bu satırı birbirinden sürekli çeker. Sonuç yanlış olmaz, ama program yavaşlar. Bu bir doğruluk değil, başarım sorunudur.

10.8Açık kilitler ve kilitlenme

10.8.1Lock arayüzü

synchronized basit ve güvenlidir, ama esnek değildir. Kilit beklenirken vazgeçilemez, bekleme süreyle sınırlanamaz, beklemekte olan iş parçacığı kesilemez. Karmaşık senkronizasyon senaryoları için java.util.concurrent.locks paketi açık kilitler sunar.

Tanım 10.11

Açık kilit (explicit lock), Lock arayüzünü uygulayan bir nesnedir. Kilit lock() ile alınır ve unlock() ile açılır; ikisini de programcı çağırır. ReentrantLock ve ReentrantReadWriteLock bu arayüzün başlıca gerçeklemeleridir.

Açık kilitler synchronized'ın yapamadığı üç şeyi yapar: beklemeden denemek (tryLock()), süreli beklemek (tryLock(süre)) ve kesilebilir beklemek (lockInterruptibly()). Bunun karşılığında sorumluluk programcıdadır: kilidi açmak unutulmamalıdır. Kullanmak için java.util.concurrent.locks.* içe aktarılır.

public class Ornek {
    private final Lock kilit = new ReentrantLock();
    private int sayac = 0;
    public void calistir() {
        kilit.lock();
        try {
            // kritik bölge
            sayac++;
        } finally {
            kilit.unlock();
        }
    }
    public static void main(String[] args)
            throws InterruptedException {
        Ornek o = new Ornek();
        Runnable r = () -> {
            for (int i = 0; i < 100_000; i++) o.calistir();
        };
        Thread a = new Thread(r), b = new Thread(r);
        a.start(); b.start();
        a.join(); b.join();
        System.out.println("Sonuç: " + o.sayac);
    }
}
Sonuç: 200000

Program, yarış örneğinin açık kilitle yazılmış hâlidir. Her sayac++ kilit tutulurken yapılır. Program 10 kez çalıştırıldı ve her seferinde Sonuç: 200000 yazdı. Kilit nesnesi final bir alandadır; bütün iş parçacıkları aynı kilidi kullanır.

10.8.2Kilidi finally bloğunda açmak

Açık kilidin doğru kullanım kalıbı her zaman aynıdır:

public void calistir() {
    Lock kilit = ...;
    kilit.lock();
    try {
        // korunan kaynağa erişim
    } finally {
        kilit.unlock();
    }
}

lock(), try'dan hemen önce çağrılır. unlock() ise finally'nin içindedir. Kritik bölgede bir istisna oluşsa bile finally çalışır ve kilit bırakılır. lock()'un try'ın içine değil, önüne yazılmasının da nedeni vardır. lock() başarısız olursa finally'nin alınmamış bir kilidi açmaya çalışmasını istemeyiz.

kilit.lock();
islemYap();      // istisna fırlatırsa...
kilit.unlock();  // ...bu satıra hiç gelinmez

Bu yanlış biçimde islemYap() istisna fırlatırsa unlock() satırına hiç gelinmez. Kilit kilitli kalır ve onu isteyen her iş parçacığı sonsuza dek bekler. synchronized'da bu sorun yoktur: bloktan istisnayla çıkılsa da monitör kendiliğinden bırakılır.

Örnek 10.6

bol(a, b) metodu kilidi alıyor, a / b'yi yazıyor ve kilidi açıyor; finally yok. t1 bol(10, 0) çağırıyor; t1 bittikten sonra t2 bol(10, 2) çağırıyor. Ekranda ne görülür, t2 hangi durumda kalır?

t1'de 10 / 0 ArithmeticException fırlatır. t1 sonlanır, ama unlock()'a gelinmediği için kilit kilitli kalır. Ekranda yalnız t1'in yığın izi görünür; 5 hiç yazılmaz. t2 lock()'ta sonsuza dek bekler. Deneme programı t2'yi arka plan iş parçacığı yaparak ve 300 ms sonra durumunu yazdırarak asılı kalmadan bitirildi; üç çalıştırmada da durum WAITING çıktı. Lock beklemesi bir monitör beklemesi olmadığı için durum BLOCKED değil, WAITING'dir. unlock() finally'ye alınınca t2 5 yazıp bitti.

İkinci bir hata da alınmamış bir kilidi açmaktır:

Lock kilit = new ReentrantLock();
kilit.unlock();   // hiç alınmamış kilit
Exception in thread "main" java.lang.IllegalMonitorStateException
    at java.base/java.util.concurrent.locks.ReentrantLock$Sync.tryRelease(ReentrantLock.java:175)
    at java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer.release(AbstractQueuedSynchronizer.java:1059)
    at java.base/java.util.concurrent.locks.ReentrantLock.unlock(ReentrantLock.java:494)
    at AcmaHatasi.main(AcmaHatasi.java:5)

ReentrantLock, kilidin sahibini tutar. Sahibi olmayan bir iş parçacığı unlock() çağırırsa IllegalMonitorStateException fırlar. JDK 21'in yazdığı tam iletide istisnanın oluştuğu yer de görünür.

10.8.3tryLock()

tryLock() beklemeden kilit almayı dener. Kilit boşsa alır ve true döndürür; doluysa hiç beklemeden false döndürür. tryLock(3, TimeUnit.SECONDS) biçimi en çok üç saniye bekler. Süreli bekleme kesilebildiği için InterruptedException bildirilmelidir.

static void calistir()
        throws InterruptedException {
    if (kilit.tryLock()) {
        // alındı
        try { System.out.println("1: alındı"); }
        finally { kilit.unlock(); }
    } else {
        // alınamadı, başka bir şey yap
        System.out.println("1: alınamadı");
    }
    if (kilit.tryLock(3, TimeUnit.SECONDS)) {
        try { System.out.println("2: alındı"); }
        finally { kilit.unlock(); }
    } else {
        // 3 saniye içinde alınamadı
        System.out.println("2: alınamadı");
    }
}

Metot şöyle sınanır: main kilidi alır, yeni bir iş parçacığında calistir()'ı başlatır ve bir saniye sonra kilidi bırakır.

kilit.lock();   // kilidi main tutuyor
Thread t = new Thread(() -> {
    try { calistir(); }
    catch (InterruptedException e) { }
});
t.start();
Thread.sleep(1000);
System.out.println("main: kilidi bırakıyor");
kilit.unlock();
t.join();
1: alınamadı
main: kilidi bırakıyor
2: alındı

İlk denemede kilit main'dedir; tryLock() beklemeden false döndürür ve 1: alınamadı yazılır. İş parçacığı ardından en çok üç saniye beklemeye başlar. Bir saniye sonra main kilidi bırakır; bekleyen iş parçacığı kilidi hemen alır ve 2: alındı yazar. Çıktı beş çalıştırmada da aynıydı. main kilidi hiç bırakmasaydı ikinci deneme üç saniye sonra false dönerdi; bu durum ayrıca denendi ve 2: alınamadı (3 sn) görüldü.

10.8.4ReentrantLock ve yeniden giriş

ReentrantLock'un adı “yeniden girişe izin veren” anlamındadır. Kilidi tutan iş parçacığı aynı kilidi bir kez daha isterse beklemez; yalnız bir sayaç artar. Kilit, sayaç 0'a inince gerçekten bırakılır.

Tanım 10.12

Yeniden girişli kilit (reentrant lock), kendisini tutan iş parçacığının aynı kilidi beklemeden yeniden almasına izin veren kilittir. Kilit bir sahip ve bir tutma sayısı (hold count) saklar; her lock() sayıyı bir artırır, her unlock() bir azaltır.

Birden fazla kilitle iç içe kritik bölgelere de girilebilir:

ReentrantLock kilit1 = new ReentrantLock();
// true: adil (fair) kilit
ReentrantLock kilit2 = new ReentrantLock(true);
// ...
kilit1.lock();
try {
    // kritik bölgeye erişim
    kilit2.lock();
    try {
        // iç içe kritik bölgeye erişim
        System.out.println("iki kilit de alındı");
    } finally {  kilit2.unlock();  }
} finally {  kilit1.unlock();  }
System.out.println("adil mi? " + kilit2.isFair());
iki kilit de alındı
adil mi? true

new ReentrantLock(true) bir adil kilit (fair lock) oluşturur: kilit boşalınca en uzun bekleyen iş parçacığına verilir. Parametresiz yapıcı adil olmayan kilit verir; kilit1.isFair() false döndürür. Adil olmayan kilit daha hızlıdır, ama bir iş parçacığının uzun süre bekleyip durmasına izin verebilir. İki kilit iç içe alınırken kapatma sırası, açılış sırasının tersidir: önce kilit2, sonra kilit1.

synchronized da yeniden girişlidir. synchronized bir metot, aynı nesnenin başka bir synchronized metodunu çağırabilir; iş parçacığı kendi tuttuğu monitörü beklemez. Yeniden giriş olmasaydı böyle bir çağrı iş parçacığını kendi kendisiyle kilitlerdi.

Tutma sayısının nasıl değiştiğini aşağıdaki program gösterir.

import java.util.concurrent.locks.ReentrantLock;

public class YenidenGiris {
    static final ReentrantLock kilit =
            new ReentrantLock();
    static void yaz(String yer) {
        System.out.println(yer + kilit.getHoldCount());
    }
    static void dis() {
        kilit.lock();
        try { yaz("dis(): "); ic(); }
        finally { kilit.unlock(); }
    }
    static void ic() {
        kilit.lock();   // beklemeden girer
        try { yaz("ic():  "); }
        finally { kilit.unlock(); }
    }
    public static void main(String[] args) {
        dis();
        yaz("main:  ");
    }
}
Tablo 10.12: Yeniden giriş programının izlemesi.
AdımSatırÇağrı yığınıDeğişkenler / durumYeni çıktı
120: dis();main()sahibi=yok, sayı=0—
210: kilit.lock();main() → dis()sahibi=main, sayı=1—
311: try { yaz("dis(): "); ic(); }main() → dis() → yaz()sahibi=main, sayı=1dis(): 1
415: kilit.lock(); // beklemeden girermain() → dis() → ic()sahibi=main, sayı=2—
516: try { yaz("ic(): "); }main() → dis() → ic() → yaz()sahibi=main, sayı=2ic(): 2
617: finally { kilit.unlock(); }main() → dis() → ic()sahibi=main, sayı=1—
712: finally { kilit.unlock(); }main() → dis()sahibi=yok, sayı=0—
821: yaz("main: ");main() → yaz()sahibi=yok, sayı=0main: 0

2. adımda dis() kilidi alır: sahibi main, tutma sayısı 1. 4. adımda ic() aynı kilidi ister. Kilidin sahibi zaten main olduğu için beklemez; sayı 2 olur.

kilitsahibi: mainsayı = 2
Şekil 10.16: 4. adım. Kilidin sahibi main, tutma sayısı 2.

6. adımda ic()'nin finally'si unlock() çağırır. Sayı 1'e iner; kilit hâlâ main'dedir ve başka bir iş parçacığı onu alamaz. 7. adımda dis()'in finally'si sayıyı 0'a indirir ve kilit gerçekten bırakılır. main'deki son yaz çağrısı bu yüzden 0 yazar. Çıktı tek iş parçacıklı olduğu için sabittir. Kural açıktır: her lock() için bir unlock() gerekir. Fazladan bir unlock() IllegalMonitorStateException fırlatır; eksik bir unlock() ise kilidi sonsuza dek kilitli bırakır.

10.8.5ReadWriteLock

Birçok veri yapısı sık okunur, seyrek yazılır: bir ayar tablosu, bir önbellek, bir sözlük. Okumalar veriyi değiştirmediği için birbirini engellemelerine gerek yoktur. ReadWriteLock bu durumu iki ayrı kilitle karşılar.

Okuyucu-1Okuyucu-2Okuyucu-3Yazıcıokumalar paralelyazma tek başınayine paralelzaman →
Şekil 10.17: ReadWriteLock: okuyucular aynı anda okur; yazıcı yazarken kimse okuyamaz.
Okuma kilidi
Birden fazla iş parçacığı veriyi aynı anda okuyabilir; okurken birbirini engellemez.
Yazma kilidi
Bir iş parçacığı yazarken diğerlerinin okuması da yazması da engellenir; yazma güvenli ve atomik olur.
private final Map<String, String> tablo =
        new HashMap<>();
private final ReadWriteLock kilit =
        new ReentrantReadWriteLock();

public String oku(String anahtar) {
    kilit.readLock().lock();   // okuma kilidi al
    try {
        return tablo.get(anahtar);   // veriyi oku
    } finally {
        kilit.readLock().unlock();
    }
}
public void yaz(String anahtar, String deger) {
    kilit.writeLock().lock();  // yazma kilidi al
    try {
        tablo.put(anahtar, deger);   // veriyi yaz
    } finally {
        kilit.writeLock().unlock();
    }
}

ReentrantReadWriteLock, ReadWriteLock arayüzünün gerçeklemesidir. oku okuma kilidini, yaz yazma kilidini kullanır. Sınıfı iki okuyucuyla deneyen kod şudur:

AyarTablosu a = new AyarTablosu();
a.yaz("dil", "tr");
Runnable okuyucu = () -> System.out.println(
        Thread.currentThread().getName()
        + ": " + a.oku("dil"));
Thread o1 = new Thread(okuyucu, "Okuyucu-1");
Thread o2 = new Thread(okuyucu, "Okuyucu-2");
o1.start(); o2.start();
o1.join(); o2.join();
Okuyucu-2: tr
Okuyucu-1: tr

İki okuyucu okuma kilidini aynı anda tutabilir. Bu ayrıca denendi: bir iş parçacığı okuma kilidini tutarken ikinci bir iş parçacığının readLock().tryLock() çağrısı true, writeLock().tryLock() çağrısı false döndü. Satırların sırası ise değişir: 40 çalıştırmada 22 kez Okuyucu-1, 18 kez Okuyucu-2 önce yazdı.

10.8.6Açık ve dolaylı kilitleme

Tablo 10.13: Açık (Lock) ve dolaylı (synchronized) kilitleme.
Açık: LockDolaylı: synchronized
DenetimProgramcı lock()/unlock() çağırırOtomatik: blok girişinde alınır, çıkışta bırakılır
SınıflarLock arayüzü, ReentrantLock, ReentrantReadWriteLockAnahtar kelime; her nesnenin monitörü
Beklemeden denemetryLock(), tryLock(süre)Yok: kilit boşalana kadar bekler
Kesilebilir beklemelockInterruptibly()Yok
Adillik, koşullarnew ReentrantLock(true), birden çok ConditionYok; tek bekleme kümesi
Yeniden girişReentrantLock yeniden girişlidirHer zaman yeniden girişlidir
Riskunlock() unutulursa kilit sonsuza dek kalırUnutma riski yok
Ne zaman?Gelişmiş denetim gerektiğindeBasit senaryolar, daha okunur kod

Tablo 10.13 seçimi kolaylaştırır. Varsayılan seçim synchronized'dır: kısa, okunur ve hataya kapalıdır. Lock, tablodaki ek yeteneklerden biri gerçekten gerektiğinde kullanılır.

10.8.7Kilitlenme

Tanım 10.13

Kilitlenme (deadlock), iki ya da daha fazla iş parçacığının birbirinin tuttuğu kilidi sonsuza dek beklemesidir. Kilitler farklı iş parçacıklarınca farklı sırada alındığında oluşur.

static void calis(Object x, Object y, String ad) {
    synchronized (x) {        // 1. kilit
        uyu(100);
        synchronized (y) {    // 2. kilit
            System.out.println(ad + " bitti");
        }
    }
}
public static void main(String[] args)
        throws InterruptedException {
    Thread t1 = new Thread(() -> calis(a, b, "T1"));
    Thread t2 = new Thread(() -> calis(b, a, "T2"));
    t1.setDaemon(true);
    t2.setDaemon(true);
    t1.start();
    t2.start();
    Thread.sleep(500);
    String d = t1.getState() + " " + t2.getState();
    System.out.println(d);
    long[] k = ManagementFactory.getThreadMXBean()
            .findDeadlockedThreads();
    int n = (k == null) ? 0 : k.length;
    System.out.println("Kilitlenmiş: " + n);
}
T1T2abtutarbeklertutarbekler
Şekil 10.18: Kilitlenme döngüsü: T1 a'yı tutar ve b'yi bekler; T2 b'yi tutar ve a'yı bekler.
BLOCKED BLOCKED
Kilitlenmiş: 2

calis(x, y, ad), önce x'in sonra y'nin monitörünü alır. T1 calis(a, b), T2 ise ters sırayla calis(b, a) çağırır. uyu(ms), sleep'i try-catch ile saran yardımcıdır; iki iş parçacığının da ilk kilidi alıp ikinciyi istemeden önce beklemesini sağlar. 500 ms sonra main iki iş parçacığının durumunu yazar: ikisi de BLOCKED. ThreadMXBean.findDeadlockedThreads() kilitlenmiş iş parçacıklarının kimliklerini döndürür; sayı 2'dir.

İki iş parçacığı arka plan iş parçacığı yapılmıştır. Böylece main bitince JVM kapanır ve kilitlenme programı asılı bırakmaz. Program 10 kez çalıştırıldı ve her seferinde BLOCKED BLOCKED ve Kilitlenmiş: 2 görüldü. 100 ms'lik uyku yüzünden iki iş parçacığı neredeyse her zaman ilk kilitlerini birlikte alır. Uyku olmasaydı kilitlenme yalnız bazı çalıştırmalarda oluşurdu.

Kilitlenmenin nasıl kurulduğunu sunumdaki daha kısa sürüm üzerinde adım adım izleyelim. Bu sürümde iş parçacıkları arka plan iş parçacığı değildir; program gerçekten sonsuza dek bekler.

static final Object a = new Object(), b = new Object();
static void calis(Object x, Object y, String ad) {
    synchronized (x) {
        uyu(100);
        synchronized (y) {
            System.out.println(ad + " bitti");
        }
    }
}
public static void main(String[] args) {
    new Thread(() -> calis(a, b, "T1")).start();
    new Thread(() -> calis(b, a, "T2")).start();
}
Tablo 10.14: Kilitlenmenin adım adım oluşması.
AdımSatırÇağrı yığınıDeğişkenler / durum
11: static final Object a = new Object(), b = new Object();
10: public static void main(String[] args) {
main()a=Object, b=Object
211: new Thread(() -> calis(a, b, "T1")).start();
3: synchronized (x) {
T1: calis(a, b)T1: x=a, y=b
312: new Thread(() -> calis(b, a, "T2")).start();
3: synchronized (x) {
T2: calis(b, a)T2: x=b, y=a
44: uyu(100);T1: uyu(100) → T2: uyu(100)a=T1 tutuyor, b=T2 tutuyor
55: synchronized (y) {T1: calis(a, b)T1 istiyor=b
65: synchronized (y) {T1: calis(a, b) → T2: calis(b, a)T2 istiyor=a
76: System.out.println(ad + " bitti");T1: calis(a, b) → T2: calis(b, a)T1=BLOCKED, T2=BLOCKED
812: new Thread(() -> calis(b, a, "T2")).start();T1: calis(a, b) → T2: calis(a, b)T2 çağrısı=calis(a, b, "T2")

2. ve 3. adımlarda T1 a'yı, T2 b'yi alır. Şimdilik sorun yoktur. 4. adımda ikisi de 100 ms uyur; sleep tutulan kilidi bırakmaz. 5. adımda T1 uyanır ve iç blok için b'yi ister. b T2'dedir: T1 BLOCKED olur. 6. adımda T2 de a'yı ister; a T1'dedir. Her biri ötekinin bırakmasını bekler ve bir döngü oluşur.

kilit akilit bT1blockedT2blockedyeşil: kilidi tutuyorkırmızı: bekliyorDÖNGÜ
Şekil 10.19: 6. adım. Kaynak ayırma çizgesinde döngü kapandı: T1 → b → T2 → a → T1.

"bitti" satırına hiçbiri ulaşamaz. Bu program not için 6 saniyelik zaman aşımıyla üç kez çalıştırıldı. Üçünde de hiçbir satır yazılmadı ve süreç zaman aşımında dışarıdan sonlandırıldı. Kilitlenme dışarıdan bir müdahale olmadan asla çözülmez; JVM bunu kendiliğinden fark edip düzeltmez.

10.8.8Kilitlenmeyi önlemek

Kilitlenmenin en etkili ilacı tek bir değişikliktir: bütün iş parçacıkları kilitleri aynı sırayla alır.

Thread t1 = new Thread(() -> calis(a, b, "T1"));
Thread t2 = new Thread(() -> calis(a, b, "T2"));
T1 bitti
T2 bitti
TERMINATED TERMINATED
Kilitlenmiş: 0

Herkes önce a'yı, sonra b'yi alır. T2 a'yı bekler ve b'yi hiç tutmaz; döngü kurulamaz. T1 bitince kilitleri bırakır, T2 alır ve o da biter. Program 10 kez çalıştırıldı ve çıktı her seferinde bu dört satırdı.

Kilitlenmeyi önlemenin dört yolu şunlardır:

Sabit kilit sırası
Bütün iş parçacıkları kilitleri aynı sırayla alır; örneğin nesnelerin kimlik numarasına göre.
Süreli deneme
tryLock(süre) kilidi alamazsa iş parçacığı elindeki kilitleri bırakır ve daha sonra yeniden dener.
Kısa kritik bölge
Bir kilit tutulurken başka kilit istenmez ve uzun işler yapılmaz.
Teşhis
JDK'nın jstack aracı ya da ThreadMXBean.findDeadlockedThreads() kilitlenmiş iş parçacıklarını ve tuttukları kilitleri gösterir.
Sık yapılan hata

Kilitlenmenin “nasılsa olmuyor” diye göz ardı edilmesi. Uykusuz bir sürümde T1 a sonra b, T2 b sonra a alırsa çoğu çalıştırmada biri iki kilidi de alıp çıkar ve sorun görünmez. Not için iki iş parçacığı bu iki bloğu 1.000'er kez çalıştırdı ve deneme en çok 200 kez yinelendi. Üç ayrı çalıştırmanın üçünde de kilitlenme bu sınır dolmadan oluştu. Kilitlenmenin ne zaman ortaya çıkacağı çalıştırmaya göre değişir; olasılığı sıfır değilse er geç olur.

10.8.9Canlı kilit ve açlık

Kilitlenme tek ilerleme sorunu değildir. İki akrabası daha vardır.

Kilitlenmeherkes bloklu, hiçbir şey değişmezCanlı kilitherkes çalışıyor, ama ilerleme yokAçlıkbiri sürekli sıra bekliyor
Şekil 10.20: Kilitlenme, canlı kilit ve açlık.
Canlı kilit (livelock)
İş parçacıkları engellenmiş değildir; birbirine sürekli “yol vererek” durum değiştirirler, ama hiçbiri işini bitiremez. Dar bir koridorda karşılaşan iki kişinin aynı anda aynı yana kayması gibidir. Örnek: iki iş parçacığı tryLock başarısız olunca elindeki kilidi bırakır, aynı anda yeniden dener ve hep çakışır.
Açlık (starvation)
Bir iş parçacığı kaynağa uzun süre ya da hiç erişemez: kilidi hep başkaları kapar ya da uzun kritik bölgeler onu bekletir. Öncelik (setPriority) değerleri bu sorunu çözmek için güvenilir değildir.
Lock adil = new ReentrantLock(true);  // adil
if (kilit.tryLock(50, TimeUnit.MILLISECONDS)) {
    // ... iş, sonra kilit.unlock()
} else {
    Thread.sleep(rnd.nextInt(20)); // rastgele
}

Çareler kısadır. Adil kilit, kilidi en uzun bekleyene verir; bunun bedeli biraz daha yavaş çalışmadır. Yeniden denemeden önce rastgele bir süre beklemek iki iş parçacığının simetrisini bozar ve canlı kilidi önler; kodda rnd.nextInt(20) bu işi görür. Kritik bölgeleri kısa tutmak da herkese sıra gelmesini sağlar.

10.9Koşul senkronizasyonu

10.9.1Koşul beklemek

Karşılıklı dışlama, iki iş parçacığının aynı anda kritik bölgeye girmesini engeller. Bazı durumlarda bu yetmez: iş parçacığı kritik bölgeye girdiğinde yapacağı iş için uygun koşul henüz oluşmamış olabilir. Paylaşılan bir kuyruğu düşünelim. Tüketici kuyruktan öğe alır, ama kuyruk boşsa alacak bir şey yoktur. Üretici öğe ekler, ama kuyruk doluysa yer yoktur.

Tanım 10.14

Koşul senkronizasyonu (condition synchronization), ortak bir kaynağa yalnızca belirli bir koşul sağlandığında erişilmesini sağlar. İş parçacığı koşul sağlanana kadar bekler; koşulu değiştiren iş parçacığı bekleyeni uyandırır.

Kuyruğu güvenle güncellemek için nesne kilidi gerekir. Ayrıca koşul sağlanana kadar beklemek gerekir. Ama kilidi tutarken beklenemez. Tüketici kilidi tutarak kuyruğun dolmasını beklerse üretici kilidi hiç alamaz ve kuyruğa öğe ekleyemez; herkes takılır.

Akla ilk gelen çözüm, koşulu bir döngüde sürekli denetlemektir:

while (kuyruk.bosMu()) {
    // boşuna işlemci yakar; kilit tutuluyorsa
    // üretici hiç ekleyemez
}

Bu meşgul bekleme (busy waiting) iki yönden yanlıştır. Döngü boşuna bir çekirdeği tam meşgul eder. Kilit tutuluyorsa da üretici hiç ekleyemez ve döngü sonsuza dek sürer. Java'nın sunduğu üç doğru çözüm şunlardır:

  1. Object.wait() ve Object.notify() / notifyAll().
  2. Lock ile birlikte kullanılan Condition arayüzü.
  3. Senkronizasyon nesneleri (CountDownLatch, Semaphore …) ve hazır BlockingQueue sınıfları.

10.9.2wait() ve notify()

Her nesnenin monitörüne bağlı iki küme vardır. Giriş kümesi, monitörü almak isteyen iş parçacıklarını tutar. Bekleme kümesi (wait set) ise bir koşulun değişmesini bekleyen iş parçacıklarını tutar.

giriş kümesikilidi bekleyenlerBLOCKEDmonitör sahibi(tek iş parçacığı)bekleme kümesikoşul bekleyenlerWAITINGalırwait():bırakırnotify() / notifyAll(): uyanan, kilidi yeniden almak için sıraya girer
Şekil 10.21: Monitör: giriş kümesi, sahip ve bekleme kümesi. wait ile bekleme kümesine geçilir, notify ile giriş kümesine dönülür.
  • Kilidi tutan iş parçacığı wait() çağırır: bekleme kümesine girer, kilidi bırakır ve engellenir.
  • notify() bekleme kümesinden bir iş parçacığını, notifyAll() hepsini uyandırır. wait(ms) biçimi süre dolunca da döner.
  • Uyanan iş parçacığı önce kilidi yeniden almak zorundadır. Kilidi alınca wait()'ten döner ve kaldığı yerden devam eder.

İki kural vardır. Birincisi, wait ve notify yalnızca aynı nesnenin synchronized bloğu ya da metodu içinde çağrılır. Aksi hâlde JDK 21 java.lang.IllegalMonitorStateException: current thread is not owner fırlatır. İkincisi, koşul if ile değil while ile denetlenir. Uyanan iş parçacığı kilidi yeniden alana kadar başka bir iş parçacığı koşulu yine bozmuş olabilir. Ayrıca Java, hiçbir notify olmadan uyanmaya, yani sahte uyanmaya (spurious wakeup) izin verir. while döngüsü her uyanışta koşulu yeniden denetler.

Örnek 10.7

synchronized bir blok içinde kilit.wait() ile Thread.sleep(1000) arasındaki fark nedir?

wait() monitörü bırakır ve bekleme kümesine girer; notify ya da notifyAll ile uyanınca kilidi yeniden alıp devam eder. sleep() ise kilitle ilgilenmez: kilidi tutarak uyur ve o süre boyunca kilidi isteyen herkes BLOCKED bekler. Koşul beklerken kilidi tutmak, koşulu değiştirecek iş parçacığını içeri sokmaz; bu yüzden koşul beklemesi için wait() kullanılır.

10.9.3Engelleyen kuyruk

Koşul senkronizasyonunun klasik örneği üretici–tüketici problemidir (producer–consumer problem). Üretici iş parçacıkları paylaşılan bir tampona öğe koyar, tüketici iş parçacıkları alır. Tampon doluysa üretici, boşsa tüketici bekler. Aşağıdaki BlockingQueue sınıfı bu tamponu sabit boyutlu bir diziyle yazar.

class BlockingQueue {
    private Object[] veri;
    private int bas = 0;
    private int son = 0;
    private int buyukluk = 0;
    public BlockingQueue(int boyut) {
        veri = new Object[boyut];
    }
    public synchronized int getSize() {
        return buyukluk;
    }
    public synchronized void add(Object oge)
            throws InterruptedException {
        while (buyukluk == veri.length) {
            wait();
        }
        veri[son] = oge;
        son = (son + 1) % veri.length;
        buyukluk++;
        notifyAll();
    }
    public synchronized Object remove()
            throws InterruptedException {
        while (buyukluk == 0) {
            wait();
        }
        Object oge = veri[bas];
        bas = (bas + 1) % veri.length;
        buyukluk--;
        notifyAll();
        return oge;
    }
}

Dizi dairesel (circular) kullanılır. bas çıkarılacak öğenin, son eklenecek öğenin konumudur. Her ekleme ve çıkarmada konum bir artar; % veri.length sona gelince başa sarar. buyukluk kuyruktaki öğe sayısıdır.

add, kuyruk doluyken (buyukluk == veri.length) bekler; remove, kuyruk boşken bekler. Her ekleme ya da çıkarmadan sonra notifyAll() bekleyenleri uyandırır. Burada notifyAll() bilinçli bir seçimdir. Aynı bekleme kümesinde hem üreticiler hem tüketiciler bekleyebilir. notify() yanlış türden birini uyandırırsa o iş parçacığı koşulu bozuk bulup yeniden bekler ve doğru iş parçacığı hiç uyanmayabilir.

10.9.4Kod izleme: üretici ve tüketici

Kapasitesi 2 olan bir kuyrukta üreticinin 1, 2, 3 eklediği ve tüketicinin aldığı bir olası sırayı izleyelim. Satır numaraları aşağıdaki iki metodun numaralarıdır.

public synchronized void add(Object oge)
        throws InterruptedException {
    while (buyukluk == veri.length) {
        wait();
    }
    veri[son] = oge;
    son = (son + 1) % veri.length;
    buyukluk++;
    notifyAll();
}
public synchronized Object remove()
        throws InterruptedException {
    while (buyukluk == 0) {
        wait();
    }
    Object oge = veri[bas];
    bas = (bas + 1) % veri.length;
    buyukluk--;
    notifyAll();
    return oge;
}
Tablo 10.15: Üretici–tüketici izlemesi (kapasite 2, bir olası sıra).
AdımSatırDeğişkenler / durum
16: veri[son] = oge;
7: son = (son + 1) % veri.length;
8: buyukluk++;
bas=0, son=1, buyukluk=1
26: veri[son] = oge;
7: son = (son + 1) % veri.length;
8: buyukluk++;
bas=0, son=0, buyukluk=2
34: wait();bas=0, son=0, buyukluk=2
416: Object oge = veri[bas];
17: bas = (bas + 1) % veri.length;
18: buyukluk--;
bas=1, son=0, buyukluk=1
519: notifyAll();bas=1, son=0, buyukluk=1
63: while (buyukluk == veri.length) {
6: veri[son] = oge;
7: son = (son + 1) % veri.length;
8: buyukluk++;
bas=1, son=1, buyukluk=2
716: Object oge = veri[bas];
17: bas = (bas + 1) % veri.length;
18: buyukluk--;
bas=0, son=1, buyukluk=1
816: Object oge = veri[bas];
17: bas = (bas + 1) % veri.length;
18: buyukluk--;
bas=1, son=1, buyukluk=0
914: wait();bas=1, son=1, buyukluk=0

1. adımda add(1) yer bulur: 1, veri[0]'a yazılır ve son 1'e ilerler. 2. adımda add(2) 2'yi veri[1]'e yazar; son 2 % 2 = 0'a sarar. Kuyruk doludur. 3. adımda add(3) dolu kuyrukla karşılaşır ve wait() çağırır. Üretici bekleme kümesine girer ve kilidi bırakır.

veri[0]1veri[1]2bassonÜreticiWAITINGTüketiciçalışıyor
Şekil 10.22: 3. adım. Kuyruk dolu; üretici wait() ile bekleme kümesinde ve kilit boşta.

Kilit boşaldığı için 4. adımda tüketici içeri girebilir. remove() 1'i alır ve bas 1 olur. 5. adımda notifyAll() üreticiyi uyandırır. Ama kilit hâlâ tüketicidedir; üretici kilidi bekler. remove 1'i döndürüp kilidi bırakınca 6. adımda üretici kilidi alır. while koşulunu yeniden denetler: kuyruk artık dolu değildir. 3, veri[0]'a yazılır. 7. ve 8. adımlarda tüketici 2'yi ve 3'ü alır; kuyruk boşalır. 9. adımda bir sonraki remove() boş kuyrukla karşılaşır ve tüketici bekler. Üretici 4'ü ekleyip notifyAll() yapınca uyanacaktır.

Bu, olası sıralardan biridir; gerçek sıra çizelgeleyiciye bağlıdır. Ama hiçbir sırada kuyruk taşmaz ya da boşken okunmaz. Bu güvenceyi while ile wait birlikte verir.

Sunumdaki deneme programı kuyruğu iki iş parçacığıyla kullanır. Tüketici her öğeden önce 100 ms uyur; böylece üretici kuyruğu doldurup beklemek zorunda kalır.

BlockingQueue kuyruk = new BlockingQueue(2);
Thread uretici = new Thread(() -> {
    try {
        for (int i = 1; i <= 4; i++) {
            kuyruk.add(i);
            System.out.println("üretildi: " + i);
        }
    } catch (InterruptedException e) { }
});
Thread tuketici = new Thread(() -> {
    try {
        for (int i = 1; i <= 4; i++) {
            Thread.sleep(100);
            Object x = kuyruk.remove();
            System.out.println("tüketildi: " + x);
        }
    } catch (InterruptedException e) { }
});
uretici.start();
tuketici.start();
uretici.join();
tuketici.join();
System.out.println("kalan: " + kuyruk.getSize());
üretildi: 1
üretildi: 2
üretildi: 3
tüketildi: 1
üretildi: 4
tüketildi: 2
tüketildi: 3
tüketildi: 4
kalan: 0

Sunumdaki bu çıktıda üretildi: 3, tüketildi: 1'den önce görünür. Bu ilk bakışta tuhaftır: 3, ancak 1 çıkarıldıktan sonra eklenebilir. Açıklama, println'ın kilidin dışında olmasıdır. Tüketici remove'dan döner ve tam yazdıracakken çizelgeleyici üreticiyi çalıştırır; üretici 3'ü ekleyip kendi satırını önce yazar. Program 40 kez çalıştırıldı; görülen sıralar:

20 kez:  üretildi: 1 · üretildi: 2 · üretildi: 3 · tüketildi: 1 · tüketildi: 2 · üretildi: 4 · tüketildi: 3 · tüketildi: 4
20 kez:  üretildi: 1 · üretildi: 2 · üretildi: 3 · tüketildi: 1 · üretildi: 4 · tüketildi: 2 · tüketildi: 3 · tüketildi: 4

Bütün çalıştırmalarda son satır kalan: 0'dı ve her öğe tam bir kez üretilip tüketildi. Satırların sırası ise çalıştırmaya göre değişti.

10.9.5Condition arayüzü

wait ve notify monitör dünyasına aittir. Lock kullanan bir sınıfta aynı işi Condition arayüzü görür.

Tanım 10.15

Condition, bir Lock'a bağlı bekleme kümesidir ve kilit.newCondition() ile oluşturulur. await() kilidi bırakır ve iş parçacığını askıya alır; signal() bekleyen bir iş parçacığını, signalAll() hepsini uyandırır.

Condition'ın asıl üstünlüğü, bir kilide birden çok koşul bağlanabilmesidir. Üreticiler ve tüketiciler ayrı kümelerde bekler; her iş parçacığı yalnız ilgili olanı uyandırır.

class BlockingQueue {
    private final Lock kilit = new ReentrantLock();
    private final Condition doluDegil =
            kilit.newCondition();
    private final Condition bosDegil =
            kilit.newCondition();
    private Object[] veri;
    private int bas = 0;
    private int son = 0;
    private int buyukluk = 0;
    public BlockingQueue(int boyut) {
        veri = new Object[boyut];
    }
    // add() ve remove() metotları
}
public void add(Object oge)
        throws InterruptedException {
    kilit.lock();
    try {
        while (buyukluk == veri.length)
            doluDegil.await();
        veri[son] = oge;
        son = (son + 1) % veri.length;
        buyukluk++;
        bosDegil.signal();
    } finally {
        kilit.unlock();
    }
}
public Object remove()
        throws InterruptedException {
    kilit.lock();
    try {
        while (buyukluk == 0)
            bosDegil.await();
        Object oge = veri[bas];
        bas = (bas + 1) % veri.length;
        buyukluk--;
        doluDegil.signal();
        return oge;
    } finally {
        kilit.unlock();
    }
}

Ekleyen yalnız bosDegil koşulunu, çıkaran yalnız doluDegil koşulunu uyarır. bosDegil'de yalnız tüketiciler, doluDegil'de yalnız üreticiler bekler. Gereksiz uyandırma olmadığı için signalAll() yerine signal() yeterlidir. Kilit finally'de açılır; await da wait gibi bir while döngüsü içindedir.

Sunum, sınıfı bir üretici ve main ile dener: üretici 1'den 4'e kadar ekler, main dört kez çıkarıp aralarında boşlukla yazar.

1 2 3 4 

Kuyruk ilk giren ilk çıkar (FIFO) ilkesiyle çalıştığı için bu çıktı sıra açısından her zaman aynıdır. Program on kez çalıştırıldı ve hep 1 2 3 4 yazdı.

10.9.6Hazır çözüm: ArrayBlockingQueue

Kendi engelleyen kuyruğumuzu yazmak, düzeneği anlamak için yararlıdır. Gerçek projelerde ise java.util.concurrent paketindeki hazır BlockingQueue gerçeklemeleri kullanılır: ArrayBlockingQueue, LinkedBlockingQueue ve benzerleri.

BlockingQueue<Integer> kuyruk =
        new ArrayBlockingQueue<>(2);
Thread uretici = new Thread(() -> {
    try {
        // put(): kuyruk doluysa bekler
        for (int i = 1; i <= 4; i++) kuyruk.put(i);
    } catch (InterruptedException e) { }
});
uretici.start();
for (int i = 1; i <= 4; i++) {
    // take(): kuyruk boşsa bekler
    System.out.print(kuyruk.take() + " ");
}
System.out.println();
uretici.join();
1 2 3 4 

put() kuyruk doluysa, take() boşsa bekler. Bekleme ve uyandırma sınıfın içinde yapılır; programcı kilit ya da koşul yazmaz. Sonuç on çalıştırmada da 1 2 3 4 idi. Tablo 10.16 üç yolu karşılaştırır.

Tablo 10.16: Koşul beklemenin üç yolu.
YolBeklemeUyandırma
synchronizedwait()notify(), notifyAll()
Lock + Conditionawait()signal(), signalAll()
ArrayBlockingQueueput(), take() içindeotomatik
Örnek 10.8

Kapasitesi 2 olan bir ArrayBlockingQueue<Integer> ile üretici iş parçacığı 1'den 5'e kadar sayıları, ardından bitiş işareti olarak −1'i koysun. main −1 gelene kadar alıp toplasın. Çıktı nedir?

Üretici for (int i = 1; i <= 5; i++) q.put(i); ve q.put(-1); çalıştırır. main bir döngüde int v = q.take(); yapar, v < 0 ise döngüden çıkar, değilse toplam += v yapar. Kuyruk FIFO olduğu için −1 her zaman en son gelir ve beş sayının hepsi toplanır: Toplam: 15. Kapasitenin 2 olması yalnız üreticinin daha sık beklemesine yol açar; sonucu değiştirmez. Program beş kez çalıştırıldı ve hep Toplam: 15 yazdı.

10.10Senkronizasyon nesneleri

10.10.1Dört hazır araç

java.util.concurrent paketi, iş parçacıkları arasında sık görülen eşgüdüm biçimleri için hazır sınıflar sunar. Her biri belirli bir senaryoya çözümdür.

Latch: 3 → 0210sıfırda kapı açılırBarrier: hepsi gelsinsonra birlikteExchanger: takasABSemaphore: 2 izinT1T2T3üçüncü bekler
Şekil 10.23: CountDownLatch, CyclicBarrier, Exchanger ve Semaphore.
CountDownLatch (geri sayım mandalı)
“N olay olsun, sonra devam et.” Bekleyenler sayaç sıfırlanana kadar durur. Tek kullanımlıktır.
CyclicBarrier (döngüsel bariyer)
“Hepimiz buluşma noktasına gelelim, sonra birlikte devam edelim.” Yeniden kullanılabilir.
Exchanger (değiştirici)
İki iş parçacığı bir noktada buluşur ve veri takası yapar.
Semaphore (semafor)
N tane izin: aynı anda en çok N iş parçacığı kaynağa girebilir.

10.10.2CountDownLatch

Geri sayım mandalı, belirli sayıda olayı bekleyen iş parçacıklarını eşgüdümler. Yapıcıya bir başlangıç sayısı verilir. Her countDown() sayacı bir azaltır. await() çağıran iş parçacığı sayaç 0 olana kadar bekler.

public static void main(String[] args)
        throws Exception {
    int sayac = 3;
    CountDownLatch mandal = new CountDownLatch(sayac);
    for (int i = 0; i < sayac; i++) {
        int no = i + 1;   // lambda için kopya
        new Thread(() -> {
            System.out.println(no + ". olay gerçekleşti");
            // Olay gerçekleştiğinde sayaç azaltılır.
            mandal.countDown();
        }).start();
    }
    // iş parçacıklarının tamamlanması beklenir.
    mandal.await();
    System.out.println("Tüm olaylar tamamlandı!");
}

Döngüdeki int no = i + 1; satırı önemlidir. Lambda içinden yalnız final ya da etkin olarak final (bir kez atanıp değişmeyen) yerel değişkenlere erişilebilir. Döngü değişkeni i her turda değiştiği için doğrudan kullanılamaz; javac şu hatayı verir: local variables referenced from a lambda expression must be final or effectively final. Her turda yeni oluşturulan no ise hiç değişmez.

Tablo 10.17: CountDownLatch programının bir çalıştırması (sunumdaki sıra).
AdımSatırDeğişkenler / durumYeni çıktı
14: CountDownLatch mandal = new CountDownLatch(sayac);sayaç=3—
211: }).start();sayaç=3—
314: mandal.await();sayaç=3—
48: System.out.println(no + ". olay gerçekleşti");
10: mandal.countDown();
sayaç=21. olay gerçekleşti
58: System.out.println(no + ". olay gerçekleşti");
10: mandal.countDown();
sayaç=13. olay gerçekleşti
68: System.out.println(no + ". olay gerçekleşti");
10: mandal.countDown();
sayaç=02. olay gerçekleşti
715: System.out.println("Tüm olaylar tamamlandı!");sayaç=0Tüm olaylar tamamlandı!

Üç iş parçacığı başlatılır ve main await()'te bekler. Her iş parçacığı satırını yazar ve sayacı bir azaltır. Sunumdaki çalıştırmada sıra 1, 3, 2'dir. Son countDown() sayacı 0'a indirince mandal açılır ve main uyanır.

sayaç0123olaylarmaindevam
Şekil 10.24: 6. adım. Son olay gerçekleşti, sayaç 0; mandal açıldı ve main uyanıyor.

Tüm olaylar tamamlandı! satırı her zaman en sondadır; olay satırlarının sırası ise değişir. Program 60 kez çalıştırıldı; görülen sıralar:

13 kez:  1 · 3 · 2
13 kez:  1 · 2 · 3
12 kez:  2 · 3 · 1
 9 kez:  3 · 2 · 1
 7 kez:  3 · 1 · 2
 6 kez:  2 · 1 · 3

Mandal tek kullanımlıktır. Sayaç hiç artmaz ve yeniden kurulamaz. Sayaç 0'dayken countDown() çağrısı etkisizdir; sayaç eksiye inmez. Açılmış bir mandalda await() beklemeden döner.

10.10.3CyclicBarrier

Döngüsel bariyer, bir grup iş parçacığını bir buluşma noktasında bekletir. Yapıcıya parti denen beklenecek iş parçacığı sayısı ve isteğe bağlı bir bariyer eylemi verilir.

public static void main(String[] args) {
    int parti = 3;
    Runnable eylem = () -> {
        System.out.println("Parti tamamlandı!");
    };
    CyclicBarrier bariyer =
            new CyclicBarrier(parti, eylem);
    for (int i = 0; i < parti; i++) {
        int no = i + 1;
        new Thread(() -> {
            try {
                System.out.println(no + " bekliyor");
                // iş parçacıklarını bekle
                bariyer.await();
                System.out.println(no + " devam ediyor");
            } catch (Exception e) {
                e.printStackTrace();
            }
        }).start();
    }
}
1 bekliyor
2 bekliyor
3 bekliyor
Parti tamamlandı!
3 devam ediyor
1 devam ediyor
2 devam ediyor

Her iş parçacığı await() çağırınca bekler. Partinin son üyesi de await() çağırınca bariyer eylemi bir kez çalışır, ardından herkes devam eder. Eylemi partiyi tamamlayan son iş parçacığı çalıştırır; bu ayrıca denendi. Bu çıktı da bir çalıştırmadır. Parti tamamlandı! satırı her zaman üç bekliyor satırından sonra, üç devam ediyor satırından öncedir. Her grubun kendi içindeki sırası ise değişir: 60 çalıştırmada 8 farklı çıktı görüldü.

Bariyerin “döngüsel” olması, parti dolup herkes geçtikten sonra aynı bariyerin yeniden kullanılabilmesidir. Bir oyunda her turun sonunda oyuncuların birbirini beklemesi bunun tipik örneğidir. Bekleyenlerden biri istisna alırsa, zaman aşımına uğrarsa ya da kesilirse bariyer kırılır; öteki bekleyenler BrokenBarrierException alır.

10.10.4Exchanger

Exchanger, iki iş parçacığının veri alışverişi için kullanılır. Bir kilitten çok bir iletişim nesnesidir.

static void takas(Exchanger<String> ex, String veri, int ms) {
    try {
        System.out.println("gönderiliyor: " + veri);
        Thread.sleep(ms);
        String alinan = ex.exchange(veri);   // karşıyı bekle
        System.out.println(veri + " sahibi aldı: " + alinan);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}
public static void main(String[] args) {
    Exchanger<String> exchanger = new Exchanger<>();
    // İlk iş parçacığı "Veri 1" gönderir, 2 sn sonra
    new Thread(() -> takas(exchanger, "Veri 1", 2000)).start();
    // İkinci iş parçacığı "Veri 2" gönderir, 1 sn sonra
    new Thread(() -> takas(exchanger, "Veri 2", 1000)).start();
gönderiliyor: Veri 2
gönderiliyor: Veri 1
Veri 1 sahibi aldı: Veri 2
Veri 2 sahibi aldı: Veri 1

exchange(veri), karşı taraf da exchange çağırana kadar bekler. Veri 2'nin sahibi bir saniye sonra exchange'e gelir ve bir saniye daha bekler. Veri 1'in sahibi iki saniyede gelince buluşurlar ve her biri ötekinin verisini alır. İlk iki satırın sırası iki iş parçacığının başlama sırasına bağlıdır; son iki satır da buluşma anında yarışır. On çalıştırmada 4 farklı çıktı görüldü.

10.10.5Semaphore

Tanım 10.16

Semafor (semaphore), aynı anda en çok N iş parçacığının bir kaynağa erişmesine izin veren sayaçtır. acquire() bir izin alır; izin yoksa bekler. release() bir izni geri verir.

public static void main(String[] args) {
    int izin = 3;
    Semaphore semaphore = new Semaphore(izin);
    for (int i = 0; i < 5; i++) {
        int no = i + 1;
        new Thread(() -> {
            try {
                semaphore.acquire();   // izin al
                System.out.println(no + " girdi");
                Thread.sleep(2000);
                System.out.println(no + " çıkıyor");
                semaphore.release();   // izni bırak
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }
}
Tablo 10.18: Üç izinli semafor ve beş iş parçacığı (sunumdaki çalıştırma).
AdımSatırDeğişkenler / durumYeni çıktı
13: Semaphore semaphore = new Semaphore(izin);izin=3—
216: }).start();izin=3—
38: semaphore.acquire(); // izin al
9: System.out.println(no + " girdi");
izin=21 girdi
48: semaphore.acquire(); // izin al
9: System.out.println(no + " girdi");
izin=13 girdi
58: semaphore.acquire(); // izin al
9: System.out.println(no + " girdi");
izin=02 girdi
68: semaphore.acquire(); // izin alizin=0—
711: System.out.println(no + " çıkıyor");
12: semaphore.release(); // izni bırak
izin=12 çıkıyor
811: System.out.println(no + " çıkıyor");
12: semaphore.release(); // izni bırak
izin=21 çıkıyor
98: semaphore.acquire(); // izin al
9: System.out.println(no + " girdi");
izin=04 girdi ⏎ 5 girdi
1011: System.out.println(no + " çıkıyor");
12: semaphore.release(); // izni bırak
izin=33 çıkıyor ⏎ 4 çıkıyor ⏎ 5 çıkıyor

Beş iş parçacığı başlatılır. İlk üçü birer izin alıp girer; sunumdaki çalıştırmada bunlar 1, 3 ve 2'dir. İzin kalmadığı için 4 ve 5 acquire()'da bekler.

izinler0 / 31içeride2içeride3içeride4bekliyor5bekliyor
Şekil 10.25: 6. adım. Üç izin de kullanımda; 4 ve 5 acquire()'da bekliyor.

İki saniye sonra içerdekiler çıkar ve izinlerini bırakır. Bekleyen 4 ve 5 izinleri alıp girer ve iki saniye sonra onlar da çıkar. Sonunda üç izin geri dönmüştür. İçeride aynı anda hiçbir zaman üçten fazla iş parçacığı bulunmaz. Hangi iş parçacıklarının önce gireceği ve satırların sırası ise değişir: 12 çalıştırmada 12 farklı çıktı görüldü. Bütün çalıştırmalarda ilk üç satır üç girdi satırıydı ve dördüncü giriş ancak bir çıkıştan sonra geldi. İlk girenler her zaman 1, 2 ve 3 değildi; iki çalıştırmada 4 de ilk üçün arasındaydı. Çıktı satırları kilidin dışında yazıldığı için bazen bir girdi satırı, izni bırakan iş parçacığının çıkıyor satırından bile önce göründü.

new Semaphore(n, true) adil bir semafor oluşturur: izinler bekleme sırasına göre dağıtılır. Semaforun iki özelliği şaşırtıcıdır. Semafor izinlerin sahibini tutmaz; izni almamış bir iş parçacığı da release() çağırabilir. Başlangıç sayısı da bir üst sınır değildir: fazladan her release() izin sayısını artırır.

Örnek 10.9

Tek bir iş parçacığında şunlar çalışıyor: Semaphore s = new Semaphore(2);, s.acquire();, x1 = s.tryAcquire();, x2 = s.tryAcquire();, ardından üç kez s.release();. Sonra CountDownLatch m = new CountDownLatch(2); ve üç kez m.countDown();. x1, x2, s.availablePermits(), m.getCount() ve m.await(10, TimeUnit.MILLISECONDS) nedir?

Semafor 2 izinle başlar. acquire() bir izin alır: 1. tryAcquire() kalan izni alır ve true döner: 0. İkinci tryAcquire() izin bulamaz ve beklemeden false döner; acquire() olsaydı sonsuza dek beklerdi. Üç release() izni 3'e çıkarır, başlangıçtaki 2'yi aşar. Mandal iki countDown() ile 0'a iner; üçüncüsü etkisizdir. Açık mandalda await beklemeden true döner. Program true false 3 ve 0 true yazdı.

10.11Diğer araçlar

10.11.1Atomik sınıflar

Tek bir sayacı korumak için kilit kullanmak, sinek avlamak için top atmaya benzer. java.util.concurrent.atomic paketi kilitsiz ve iş parçacığı güvenli sayaçlar sunar.

Tanım 10.17

Atomik işlem (atomic operation), bölünmeden, tek adımda tamamlanan işlemdir; başka bir iş parçacığı işlemin ortasına giremez. AtomicInteger, AtomicLong, AtomicBoolean gibi sınıflar değerleri üzerinde atomik işlemler sunar.

public class AtomikSayac {
    public static void main(String[] args)
            throws InterruptedException {
        AtomicInteger sayac = new AtomicInteger(0);
        Runnable r = () -> {
            for (int i = 0; i < 100_000; i++) {
                sayac.incrementAndGet();   // atomik ++sayac
            }
        };
        Thread a = new Thread(r), b = new Thread(r);
        a.start(); b.start();
        a.join(); b.join();
        System.out.println("Sonuç: " + sayac.get());
        boolean b1 = sayac.compareAndSet(200000, 0);
        System.out.println(b1 + " " + sayac.get());
        boolean b2 = sayac.compareAndSet(5, 10);
        System.out.println(b2 + " " + sayac.get());
    }
}
Sonuç: 200000
true 0
false 0

incrementAndGet(), ++x'in atomik karşılığıdır: değeri bir artırır ve yeni değeri döndürür. Program 10 kez çalıştırıldı ve her seferinde Sonuç: 200000 buldu. Ardından iki compareAndSet çağrısı vardır. Değer 200000 olduğu için ilki tutar, 0 yazar ve true döner. İkincisi 5 bekler, ama değer 0'dır; hiçbir şey yazmaz ve false döner.

Tanım 10.18

Karşılaştır ve değiştir (compare-and-set, CAS): compareAndSet(beklenen, yeni), değer hâlâ beklenen ise yeni'yi yazar ve true döndürür. Değer farklıysa ona dokunmaz ve false döndürür. Karşılaştırma ve yazma tek bir atomik işlemdir; işlemci bunu tek bir komutla yapar.

Kabaca, bir atomik nesne bir volatile alan ile atomik bir compareAndSet işleminin birleşimidir. volatile görünürlüğü, CAS ise bölünmezliği sağlar.

10.11.2Kod izleme: compareAndSet

Kilit yoksa incrementAndGet nasıl güvenli olabilir? Aşağıdaki metot onun mantığını CAS ile yazar.

AtomicInteger sayac = new AtomicInteger(5);

int artir() {                  // incrementAndGet'in mantığı
    while (true) {
        int eski = sayac.get();
        int yeni = eski + 1;
        if (sayac.compareAndSet(eski, yeni))
            return yeni;
        // başarısız: araya biri girdi, tekrar dene
    }
}
Tablo 10.19: İki iş parçacığının aynı anda artir() çağırması.
AdımSatırÇağrı yığınıDeğişkenler / durum
11: AtomicInteger sayac = new AtomicInteger(5);—sayac=5
25: int eski = sayac.get();T1: artir()T1.eski=5
35: int eski = sayac.get();T1: artir() → T2: artir()T1.eski=5, T2.eski=5
46: int yeni = eski + 1;T1: artir() → T2: artir()T1.yeni=6, T2.yeni=6
57: if (sayac.compareAndSet(eski, yeni))
8: return yeni;
T1: artir()sayac=6, T1 → 6
67: if (sayac.compareAndSet(eski, yeni))T2: artir()sayac=6, T2 CAS(5, 6)=false
75: int eski = sayac.get();T2: artir()T2.eski=6
86: int yeni = eski + 1;T2: artir()T2.yeni=7
97: if (sayac.compareAndSet(eski, yeni))
8: return yeni;
T2: artir()sayac=7

2. ve 3. adımlarda T1 ve T2 güncel değeri okur; ikisi de 5 görür. Kilit olmadığı için ikisi de çalışmayı sürdürür. 4. adımda ikisi de yerel olarak 6 hesaplar. Sıradan bir sayac++ olsaydı ikisi de 6 yazar ve bir artış kaybolurdu.

belleksayac6T1eski = –yeni = –T2eski = 5yeni = 6CAS başarısız
Şekil 10.26: 6. adım. T1'in CAS'ı başarılı oldu, değer 6. T2'nin compareAndSet(5, 6) çağrısı başarısız: beklenen 5, değer 6.

5. adımda T1 compareAndSet(5, 6) çağırır. Değer hâlâ 5 olduğu için 6 yazılır ve true döner. 6. adımda T2 de compareAndSet(5, 6) çağırır. Değer artık 6'dır, beklenen 5 değildir; hiçbir şey yazılmaz ve false döner. T2 kaybolmaz, döngüye döner: 7. adımda güncel değeri, 6'yı yeniden okur ve 7 hesaplar. 9. adımda CAS bu kez başarılıdır: sayac = 7. İki artış da sayılmıştır. Kimse kilit beklemedi; yalnız T2 bir tur fazla döndü.

Bu metot iki iş parçacığıyla 100.000'er kez çağrıldığında sayaç 5 + 200.000 = 200.005'e ulaştı; beş çalıştırmada da sonuç aynıydı. Kilitsiz yöntemin üstünlüğü, iş parçacıklarının hiç uyumamasıdır. Çakışma az olduğunda bu kilitten daha hızlıdır; çok yoğun çakışmada ise iş parçacıkları boşuna tur atar.

10.11.3Kesme

Bir iş parçacığını durdurmanın ikinci iş birlikçi yolu kesmedir. t.interrupt(), t'ye bir kesme isteği gönderir; t'yi zorla durdurmaz.

Tanım 10.19

Kesme (interrupt), bir iş parçacığına “işini bırak” isteği göndermektir. interrupt() hedefin kesme bayrağını kurar. Hedef sleep, wait ya da join içinde bekliyorsa bekleme hemen kesilir ve InterruptedException fırlar; bu sırada bayrak temizlenir.

Bayrağı okumanın iki yolu vardır. Thread.interrupted() static bir metottur; çalışan iş parçacığının bayrağını okur ve temizler. isInterrupted() ise bir örnek metodudur ve bayrağı yalnız okur.

public class KesmeOrnegi {
    public static void main(String[] args)
            throws InterruptedException {
        Thread isci = new Thread(() -> {
            try {
                while (!Thread.interrupted()) {
                    // İş parçacığının çalışma mantığı
                    System.out.println("çalışıyor...");
                    Thread.sleep(400);
                }
            } catch (InterruptedException e) {
                System.out.println("İş parçacığı iptal edildi!");
            }
        });
        isci.start();
        Thread.sleep(1000);
        isci.interrupt();   // sleep() içindeyken kes
        isci.join();
        System.out.println("main: bitti");
    }
}
çalışıyor...
çalışıyor...
çalışıyor...
İş parçacığı iptal edildi!
main: bitti

İşçi, bayrak kurulmadıkça döngüde kalır. Her turda bir satır yazar ve 400 ms uyur. main bir saniye sonra interrupt() çağırır.

Tablo 10.20: Kesme programının izlemesi.
AdımSatırDeğişkenler / durumYeni çıktı
11: Thread isci = new Thread(() -> {
11: isci.start();
isci=RUNNABLE—
23: while (!Thread.interrupted()) {
4: System.out.println("çalışıyor...");
5: Thread.sleep(400);
isci=TIMED_WAITINGçalışıyor...
312: Thread.sleep(1000);main=TIMED_WAITING—
43: while (!Thread.interrupted()) {
4: System.out.println("çalışıyor...");
5: Thread.sleep(400);
zaman=≈400 msçalışıyor...
53: while (!Thread.interrupted()) {
4: System.out.println("çalışıyor...");
5: Thread.sleep(400);
zaman=≈800 msçalışıyor...
613: isci.interrupt(); // isci sleep() içindezaman=≈1000 ms—
77: } catch (InterruptedException e) {
8: System.out.println("iptal edildi!");
e=InterruptedExceptioniptal edildi!
814: isci.join();isci=TERMINATED—
915: System.out.println("main: bitti");—main: bitti

İşçi 0, yaklaşık 400 ve 800 ms'de yazar. Üçüncü uykusu 1200 ms'de bitecektir. main 1000 ms'de uyanır ve interrupt() çağırır. 7. adımda işçi sleep içindedir; uyku hemen kesilir, InterruptedException fırlar ve bayrak yeniden false olur.

iscimainsleepsleepsleepsleep(1000)04008001000kesme bayrağı: false
Şekil 10.27: 7. adım. interrupt() işçiyi sleep içinde yakaladı; uyku kesildi ve catch çalıştı.

catch bloğu iletisini yazar ve run() biter. main join() ile işçinin bitmesini bekler ve son satırını yazar. Program on kez çalıştırıldı ve hep üç çalışıyor... satırı yazdı. Kesme bir istektir: iş parçacığı bayrağı ya da istisnayı kendisi ele alır ve çıkar.

Örnek 10.10

Bir iş parçacığı Thread.sleep(5000) içindeyken main ona interrupt() gönderiyor. catch içinde Thread.currentThread().isInterrupted() yazdırılıyor. Ardından main kendi bayrağını Thread.currentThread().interrupt() ile kurup iki kez Thread.interrupted() yazdırıyor. Çıktı nedir?

sleep InterruptedException fırlatırken bayrağı temizler; catch içinde isInterrupted() false'tur ve kesildi false yazılır. main'in ilk Thread.interrupted() çağrısı kurulu bayrağı okur ve temizler: true. İkincisi temizlenmiş bayrağı okur: false. Program üç çalıştırmada da kesildi false ve true false yazdı.

Sık yapılan hata

InterruptedException'ı boş bir catch ile yutmak. İstisna fırlarken bayrak temizlenmiştir. catch hiçbir şey yapmazsa kesme isteği kaybolur ve iş parçacığı çalışmayı sürdürür. Doğrusu döngüden çıkmak ya da isteği yukarıya iletmek için catch içinde Thread.currentThread().interrupt() ile bayrağı yeniden kurmaktır.

10.11.4Askıya alma

Eski Java'da suspend() bir iş parçacığını dondurur, resume() devam ettirirdi. suspend() iş parçacığını tuttuğu kilitlerle birlikte dondurduğu için kilitlenmeye çok açıktı; JDK 21'de iki metot da UnsupportedOperationException fırlatır. Aynı iş wait() ve notifyAll() ile güvenle yapılır.

class Gorev implements Runnable {
    private boolean duraklatildi = false;
    public synchronized void duraklat() {
        duraklatildi = true;
    }
    public synchronized void devamEt() {
        duraklatildi = false;
        notifyAll();   // bekleyeni uyandır
    }
    public void run() {
        try {
            for (int i = 1; i <= 5; i++) {
                synchronized (this) {
                    while (duraklatildi) wait();
                }
                System.out.println("adım " + i);
                Thread.sleep(200);
            }
        } catch (InterruptedException e) { }
    }
}

Görev her adımın başında duraklatildi bayrağını kilit altında denetler. Bayrak kalkmışsa wait() ile bekler. devamEt() bayrağı indirir ve notifyAll() ile bekleyeni uyandırır. Bayrak her iki metotta da aynı monitörle korunur; ayrıca volatile olmasına gerek yoktur.

Gorev g = new Gorev();
Thread t = new Thread(g);
t.start();
Thread.sleep(500);
g.duraklat();
System.out.println("main: duraklatıldı");
Thread.sleep(1000);
System.out.println("main: devam");
g.devamEt();
t.join();
adım 1
adım 2
adım 3
main: duraklatıldı
main: devam
adım 4
adım 5

Görev 0, 200 ve 400 ms'de adım yazar. main 500 ms'de duraklatır. Görev 600 ms'de uyanınca bayrağı görür ve bekler. Bir saniye boyunca hiçbir adım yazılmaz. main devam ettirince kalan iki adım yazılır. Program on kez çalıştırıldı ve hep bu yedi satırı yazdı. Duraklatma, görev bir adımın ortasındayken değil, adımlar arasında devreye girer; bu yüzden hiçbir nesne yarım kalmaz.

10.11.5ThreadLocal

Yarışın en kesin çaresi paylaşmamaktır. ThreadLocal, her iş parçacığına aynı değişkenin ayrı bir kopyasını verir.

static final ThreadLocal<String> deger =
        new ThreadLocal<>();
public static void main(String[] args)
        throws InterruptedException {
    Thread thread1 = new Thread(() -> {
        // İlk iş parçacığı
        deger.set("Değer 1");
        System.out.println("thread1: " + deger.get());
    });
    Thread thread2 = new Thread(() -> {
        // İkinci iş parçacığı
        deger.set("Değer 2");
        System.out.println("thread2: " + deger.get());
    });
    thread1.start();
    thread2.start();
    thread1.join();
    thread2.join();
    System.out.println("main: " + deger.get());
thread1: Değer 1
thread2: Değer 2
main: null

İki iş parçacığı aynı deger nesnesine yazar, ama her biri kendi kopyasına yazar ve kendi kopyasını okur. Kopyalar birbirinden bağımsızdır. main hiç set etmediği için kendi kopyası boştur ve null görür. İlk iki satırın sırası değişir: 40 çalıştırmada 20 kez thread1, 20 kez thread2 önce yazdı. main: null satırı ise hep sondaydı.

Her iş parçacığına ayrı bir başlangıç değeri vermek için ThreadLocal.withInitial(() -> 0) (Java 8+) yazılır ya da initialValue() metodu geçersiz kılınır. Tipik kullanım, iş parçacığı güvenli olmayan bir nesnenin, örneğin bir tarih biçimleyicinin, her iş parçacığına ayrı bir kopyasını vermektir.

Örnek 10.11

ThreadLocal<Integer> t = ThreadLocal.withInitial(() -> 0); ardından main'de t.set(5). Yeni bir x iş parçacığı t.set(t.get() + 1) yapıp "x: " + t.get() yazıyor; main x'i bekleyip "main: " + t.get() yazıyor. Çıktı nedir?

main'in 5'i x'e geçmez. x ilk get()'te withInitial'daki 0 ile başlar ve 1 yapar: x: 1. x'in yazdığı da main'in kopyasını etkilemez: main: 5.

10.11.6İş parçacığı havuzları

Her görev için yeni bir iş parçacığı açmak iki sorun doğurur. İş parçacığı oluşturmak ucuz değildir: yığın ayrılır, işletim sistemi kaydı yapılır. Daha önemlisi sayı denetimsiz büyür: bin istek gelirse bin iş parçacığı açılır.

Tanım 10.20

İş parçacığı havuzu (thread pool), kullanılacak iş parçacıklarını önceden oluşturan ve görevleri bunlara dağıtan yapıdır. Görevler havuza gönderilir (submit); boştaki bir işçi görevi alır, bitirince sıradakine geçer. Boş işçi yoksa görev kuyrukta bekler.

submit(görev)görev kuyruğuG1G2G3G4iş parçacığı havuzu (2)pool-1-thread-1pool-1-thread-2iş parçacıkları biten görevden sonra yenisini alır
Şekil 10.28: Görevler kuyruğa eklenir; havuzdaki iki iş parçacığı sırayla görev alır.

Havuz iş parçacıklarını yeniden kullanır ve sayılarını sınırlar. Java'da havuzları ExecutorService arayüzü yönetir. Executors.newFixedThreadPool(n) sabit n işçili hazır bir havuz verir. Ayrıntılı ayar gerektiğinde havuzu oluşturan ve yöneten ThreadPoolExecutor sınıfı doğrudan kullanılır.

public class HavuzOrnegi {
    static void gorev(int no) {
        String ad = Thread.currentThread().getName();
        System.out.println("Görev " + no + " -> " + ad);
    }
    public static void main(String[] args) throws Exception {
        ExecutorService havuz = Executors.newFixedThreadPool(2);
        for (int i = 1; i <= 4; i++) {
            int no = i;
            havuz.submit(() -> gorev(no));
        }
        // Callable: değer döndüren görev
        Future<Integer> f = havuz.submit(() -> 6 * 7);
        System.out.println("Callable sonucu: " + f.get());
        havuz.shutdown();   // yeni görev kabul etme
        havuz.awaitTermination(5, TimeUnit.SECONDS);
        System.out.println("Havuz kapandı");
    }
}

Sunumdaki ilk çalıştırma şudur:

Görev 1 -> pool-1-thread-1
Görev 2 -> pool-1-thread-2
Görev 3 -> pool-1-thread-2
Görev 4 -> pool-1-thread-2
Callable sonucu: 42
Havuz kapandı

Dört görev iki işçiye dağılır. Görevlerin hangi işçiye düştüğü ve satırların sırası değişir; sunum ikinci bir çalıştırmada son iki satırı Görev 3 -> pool-1-thread-1 ve Görev 4 -> pool-1-thread-2 olarak verir. Not için 40 çalıştırmada 6 farklı çıktı görüldü. Değişmeyenler de vardır: işçi adları hep pool-1-thread-1 ve pool-1-thread-2'dir; havuz üçüncü bir iş parçacığı açmaz. Son iki satır da hep aynıdır.

Runnable değer döndürmez. Değer döndüren görevler için Callable arayüzü vardır; lambda () -> 6 * 7 bir Callable<Integer>'dır. submit hemen bir Future döndürür. Future, ileride hazır olacak sonucu temsil eder. get() sonuç hazır olana kadar bekler.

shutdown(), havuzun yeni görev almayı bırakmasını ister; kuyruktaki görevler yine de bitirilir. awaitTermination bütün görevlerin bitmesini en çok verilen süre kadar bekler. shutdown() unutulursa havuzun işçileri arka plan iş parçacığı olmadığı için program açık kalır.

Java 21 yeni bir seçenek getirdi: Executors.newVirtualThreadPerTaskExecutor() her göreve ayrı bir sanal iş parçacığı (virtual thread) verir. Sanal iş parçacıklarını işletim sistemi değil JVM yönetir; milyonlarcası açılabilir. Bekleme ağırlıklı, çok sayıda kısa görev için uygundurlar. Bir denemede böyle bir görevin içinde isVirtual() ve isDaemon() ikisi de true döndü.

10.11.7Kod izleme: havuz ve Future

Havuzun zaman içinde nasıl çalıştığını görmek için görevler uyuyarak uzun iş taklidi yapar. is(ms, d), ms milisaniye uyuyup d'yi döndürür.

static int is(int ms, int d) throws Exception {
    Thread.sleep(ms);   // uzun süren iş yerine
    return d;
}
// main içinde:
ExecutorService havuz = Executors.newFixedThreadPool(2);
Future<Integer> f1 = havuz.submit(() -> is(300, 10));
Future<Integer> f2 = havuz.submit(() -> is(100, 20));
Future<Integer> f3 = havuz.submit(() -> is(100, 30));
System.out.println(f1.isDone() + " " + f3.isDone());
int toplam = f1.get() + f2.get() + f3.get();
System.out.println("toplam = " + toplam);
System.out.println(f2.isDone() + " " + f3.isDone());
havuz.shutdown();
Tablo 10.21: İki işçili havuzda üç görevin izlemesi (süreler yaklaşık).
AdımSatırDeğişkenler / durumYeni çıktı
16: ExecutorService havuz = Executors.newFixedThreadPool(2);havuz=2 işçi—
27: Future<Integer> f1 = havuz.submit(() -> is(300, 10));f1=Future (bekliyor)—
38: Future<Integer> f2 = havuz.submit(() -> is(100, 20));f2=Future—
49: Future<Integer> f3 = havuz.submit(() -> is(100, 30));f3=Future—
510: System.out.println(f1.isDone() + " " + f3.isDone());f1.isDone()=falsefalse false
611: int toplam = f1.get() + f2.get() + f3.get();zaman=≈100 ms—
711: int toplam = f1.get() + f2.get() + f3.get();zaman=≈200 ms—
811: int toplam = f1.get() + f2.get() + f3.get();toplam=10+20+30=60—
912: System.out.println("toplam = " + toplam);
13: System.out.println(f2.isDone() + " " + f3.isDone());
toplam=60toplam = 60 ⏎ true true
1014: havuz.shutdown();havuz=kapanıyor—

Havuzun iki işçisi vardır. f1 işçi 1'de, f2 işçi 2'de başlar. İki işçi de meşgul olduğu için f3 kuyrukta bekler; yeni iş parçacığı açılmaz. 5. adımda hiçbir görev bitmemiştir; isDone() beklemeden false döner. 6. adımda f1.get() sonuç hazır olana kadar main'i durdurur. Bu sırada, yaklaşık 100 ms'de f2 biter ve işçi 2 kuyruktan f3'ü alır.

kuyrukişçi 1işçi 2f1f2f30100200300main: f1.get() bekliyorbitti: f2
Şekil 10.29: 6. adım. main f1.get()'te bekliyor; f2 bitti ve işçi 2 kuyruktaki f3'ü aldı.

Yaklaşık 200 ms'de f3 de biter; main hâlâ f1'i bekler. 300 ms'de f1 hazır olur. f2 ve f3 zaten bittiği için onların get()'leri beklemeden döner: toplam 10 + 20 + 30 = 60. Görevler tek iş parçacığında sırayla çalışsaydı 300 + 100 + 100 = 500 ms sürerdi; havuzla toplam süre yaklaşık 300 ms'dir. Program on kez çalıştırıldı; çıktı hep false false, toplam = 60, true true idi ve ölçülen süre her seferinde 300 ile 350 ms arasındaydı. Görevlerin hangi işçiye düştüğü ise çalıştırmadan çalıştırmaya değişebilir.

10.11.8CompletableFuture ve eş zamanlı koleksiyonlar

CompletableFuture<Integer> cf = CompletableFuture
    .supplyAsync(() -> 6)
    .thenApply(x -> x * 7)              // 42
    .thenCombine(CompletableFuture
        .supplyAsync(() -> 8), Integer::sum);
System.out.println(cf.get());           // 50

Map<String, Integer> say =
    new ConcurrentHashMap<>();
String[] k = {"elma", "armut", "elma"};
Arrays.stream(k).parallel()
    .forEach(w -> say.merge(w, 1, Integer::sum));
System.out.println(say);  // {elma=2, armut=1}

Bu kod iki farklı aracı gösterir ve on çalıştırmada da 50 ile {elma=2, armut=1} yazdı.

CompletableFuture
get() ile beklemek yerine işlemleri zincirler; her adım öncekinin bitmesiyle arka planda çalışır. Kodda 6 üretilir, 7 ile çarpılır (42) ve ayrı bir görevin ürettiği 8 ile toplanır: 50.
ConcurrentHashMap
İş parçacığı güvenli eşlemdir. merge ve compute metotları atomiktir. Paralel akış, dizinin öğelerini farklı iş parçacıklarında işler; merge sayımın kaybolmamasını sağlar.
CopyOnWriteArrayList
Her yazmada iç diziyi kopyalar. Çok okunan, az yazılan listeler için uygundur.
ForkJoinPool
Böl ve yönet görevleri (RecursiveTask) içindir. Boştaki işçi başkasının kuyruğundan iş çalar (work stealing). Paralel akışlar (parallelStream()) bu havuzu kullanır.

Paralel akış her zaman hızlı değildir. Küçük veride iş parçacıklarını eşgüdümleme maliyeti kazancı aşar. Paylaşılan değiştirilebilir bir değişkene yazan bir paralel akış ise sonucu bozar. Paralelleştirme kararı ölçümle verilir.

10.11.9İş parçacığı güvenliği

Tanım 10.21

Bir sınıf, birden çok iş parçacığından aynı anda kullanıldığında da doğru davranıyorsa iş parçacığı güvenlidir (thread-safe).

İş parçacığı güvenliğine dört yoldan ulaşılır:

Değiştirilemez nesneler
Oluşturulduktan sonra değişmeyen nesneler (String, bütün alanları final olan sınıflar, Java 16+ record) senkronizasyonsuz paylaşılabilir. Değişmeyen veri yarışmaz.
Paylaşmamak
Yerel değişkenler her iş parçacığının kendi yığınındadır; ThreadLocal da her iş parçacığına kopya verir.
Senkronizasyon
Paylaşılan değişen veriye her erişim aynı kilitle korunur: synchronized ya da Lock.
Hazır yapılar
AtomicInteger, ConcurrentHashMap, CopyOnWriteArrayList, BlockingQueue.

Koleksiyon çerçevesinin sıradan sınıfları, ArrayList ve HashMap, iş parçacığı güvenli değildir. İki iş parçacığı aynı ArrayList'e 10.000'er öğe eklediğinde not için yapılan on çalıştırmanın hiçbirinde boyut 20.000 çıkmadı; bulunan değerler 10.004 ile 14.121 arasındaydı. Dört çalıştırmada iş parçacıklarından biri, iç dizi büyütülürken ArrayIndexOutOfBoundsException ile öldü. Bu sınıflar ya bir kilitle korunur ya da yerlerine eş zamanlı karşılıkları kullanılır.

10.12Çözümlü problemler

Örnek 10.12 (Problem 1)

Çıktı nedir? Her çalıştırmada aynı mı?

Thread t = new Thread(() -> System.out.print("B"));
System.out.print("A");
t.start();
t.join();
System.out.print("C");

ABC, her zaman. A, iş parçacığı başlatılmadan önce yazılır. B yeni iş parçacığında yazılır; join(), B yazılıp iş parçacığı bitmeden main'i C'ye geçirmez. On çalıştırmada da çıktı ABC idi.

Örnek 10.13 (Problem 2)

Çıktı nedir?

Thread t = new Thread(() -> System.out.print("X"));
System.out.print(t.getState() + " ");
t.run();
System.out.print(" " + t.getState());

NEW X NEW. run() sıradan bir çağrıdır: X'i main yazar, iş parçacığı hiç başlamaz ve NEW kalır.

Örnek 10.14 (Problem 3)

“İki iş parçacığı” örneğinin olası çıktıları hangileridir? a) A1 A2 B1 B2 main; b) B1 A1 A2 B2 main; c) A1 main B1 A2 B2; d) A2 A1 B1 B2 main. (Kısaltma: A1, A: adım 1 satırıdır.)

a ve b. c olamaz, çünkü main iki join()'den sonra yazar. d olamaz, çünkü aynı iş parçacığının adımları kendi içinde sırasını korur. a, not için yapılan ölçümlerde en sık görülen sıraydı. b'nin aynası olan A1 B1 B2 A2 de tek JVM içindeki 6.000 denemede iki kez görüldü.

Örnek 10.15 (Problem 4)

Her durumda iş parçacığının durumu nedir? a) sleep(500) içinde; b) dolu bir synchronized bloğa girerken; c) wait() sonrası; d) run() bitince.

a) TIMED_WAITING; b) BLOCKED; c) WAITING; d) TERMINATED. c'de iş parçacığı bekleme kümesindedir. notify gelip kilidi yeniden almaya çalışırken ise BLOCKED olur.

Örnek 10.16 (Problem 5)

Aşağıdaki sınıf iş parçacığı güvenli midir?

class Hesap {
    private int bakiye = 0;
    void yatir(int m) { bakiye += m; }
    int getBakiye() { return bakiye; }
}

Hayır: bakiye += m oku–ekle–yaz'dır. İki iş parçacığı 100.000'er kez yatir(1) çağırdığında on çalıştırmanın hiçbirinde bakiye 200.000 olmadı; sonuçlar 126.397 ile 171.138 arasında değişti. Çözüm: metotlar synchronized olur ya da alan AtomicInteger yapılıp addAndGet(m) kullanılır.

Örnek 10.17 (Problem 6)

İki iş parçacığı bunları çalıştırıyor. Ne olabilir?

// T1                    // T2
synchronized (a) {       synchronized (b) {
  synchronized (b) { }     synchronized (a) { }
}                        }

Kilitlenme: T1 a'yı, T2 b'yi alırsa ikisi de ötekini sonsuza dek bekler. Uyku olmadığı için kilitlenme her çalıştırmada olmaz; bu yüzden tehlikelidir. Bloklar birer döngüde binlerce kez çalıştırıldığında kilitlenme üç denemenin üçünde de görüldü. Çözüm: iki iş parçacığı da önce a'yı, sonra b'yi alır.

Örnek 10.18 (Alıştırma: iki yarım toplam)

1..1000 sayılarının toplamı iki iş parçacığıyla hesaplanacak: biri ilk yarıyı, öteki ikinci yarıyı toplar; main ikisini bekleyip toplamı yazar.

Thread sol = new Thread(() -> {
    for (int i = 0; i < 500; i++) parca[0] += dizi[i];
});
Thread sag = new Thread(() -> {
    for (int i = 500; i < 1000; i++) parca[1] += dizi[i];
});
sol.start(); sag.start();
sol.join(); sag.join();

Her iş parçacığı kendi gözüne yazar; iki iş parçacığı aynı göze hiç yazmadığı için yarış yoktur. join() sonrası okuma güvenlidir. dizi[i] = i + 1 iken parçalar 125.250 ve 375.250'dir; toplam 500.500 = 1000 · 1001 / 2.

Örnek 10.19 (Alıştırma: çift sayıları saymak)

1..1000 arasındaki çift sayılar dört iş parçacığıyla, her biri 250 sayı alarak, ortak bir sayaçta sayılacak.

Sayaç AtomicInteger cift olur. Her iş parçacığı for (int i = bas; i < bas + 250; i++) if (i % 2 == 0) cift.incrementAndGet(); çalıştırır; bas değerleri 1, 251, 501 ve 751'dir. Bütün join()'lerden sonra çıktı Çift sayı: 500'dür. Sayaç sıradan bir int olsaydı sonuç yarış yüzünden 500'den küçük çıkabilirdi.

Örnek 10.20 (Alıştırma: karelerin toplamı)

1..5 sayılarının kareleri 3 iş parçacıklı bir havuzda Callable görevleriyle hesaplanacak ve Future'lardan toplanacak.

ExecutorService havuz = Executors.newFixedThreadPool(3);
List<Future<Long>> sonuclar = new ArrayList<>();
for (int i = 1; i <= 5; i++) {
    int n = i;
    sonuclar.add(havuz.submit(() -> kare(n)));
}
long toplam = 0;
for (Future<Long> f : sonuclar) toplam += f.get();
havuz.shutdown();
System.out.println("Karelerin toplamı: " + toplam);

Yardımcı metot static long kare(int n) { return (long) n * n; }'dir. Future'lar gönderilme sırasıyla listede durur; get() her birinin sonucunu bekler. Çıktı Karelerin toplamı: 55'tir (1 + 4 + 9 + 16 + 25).

Örnek 10.21 (Alıştırma: saat)

Her saniye tik yazan bir iş parçacığı yazılacak; main 3,5 saniye sonra onu interrupt() ile durduracak.

Kesme örneğindeki kalıp kullanılır: döngüde while (!Thread.interrupted()), gövdede tik yazıp sleep(1000); catch'ten sonra saat durdu. main sleep(3500), interrupt() ve join() çalıştırır. İş parçacığı 0, 1, 2 ve 3. saniyelerde yazar; 3,5. saniyede uykusu kesilir. Çıktı dört tik ve saat durdu'dur; beş çalıştırmada da böyleydi.

10.13Sık yapılan hatalar

Aşağıdaki iki tablo, sunumdaki sık yapılan hataları toplar. Derleyici iletileri JDK 21'in javac'ıyla, çalışma zamanı belirtileri programlar çalıştırılarak doğrulandı.

Tablo 10.22: İş parçacığı oluşturma ve yönetme hataları.
HataBelirtiDoğrusu
t.run() çağırmakYeni iş parçacığı yok; kod main'de sırayla çalışırt.start()
start()'ı iki kez çağırmakIllegalThreadStateExceptionHer çalıştırma için yeni Thread
Thread.sleep(100); yalnız başınaerror: unreported exception InterruptedException; must be caught or declared to be throwntry-catch ya da throws InterruptedException
Lambda içinde döngü değişkeni ierror: local variables referenced from a lambda expression must be final or effectively finalint no = i; kopyası
Fırlatılmayan istisnayı yakalamak: try { while (!Thread.interrupted()) { } } catch (InterruptedException e)error: exception InterruptedException is never thrown in body of corresponding try statementGövdede sleep/wait olmalı ya da catch kaldırılmalı
Sınıf Thread'i genişletmiyorken currentThread()error: cannot find symbolThread.currentThread()
join()'u unutmakmain, sonuç hazır olmadan okur (ör. 0)Sonucu okumadan önce join()
Tablo 10.23: Senkronizasyon hataları.
HataBelirtiDoğrusu
wait()/notify()'ı synchronized dışında çağırmakIllegalMonitorStateException: current thread is not ownerAynı nesne üzerinde synchronized içinde
Koşulu if ile beklemekUyanınca koşul yine bozuk olabilir; taşan ya da boşken okunan kuyrukwhile (koşul) wait();
unlock()'u finally dışında yazmakİstisnada kilit hiç açılmaz; herkes sonsuza dek beklerlock(); try { … } finally { unlock(); }
Alınmamış kilidi açmakIllegalMonitorStateExceptionHer lock() için bir unlock()
volatile sayaçta sayac++Artışlar kaybolur (ör. 163299)AtomicInteger ya da synchronized
final volatile alanerror: illegal combination of modifiers: final and volatileYalnız biri kullanılır
stop(), suspend() kullanmakJava 20+: UnsupportedOperationExceptionBayrak, interrupt(), wait/notify
InterruptedException'ı boş catch ile yutmakKesme isteği kaybolur, iş parçacığı durmazDöngüden çıkmak ya da Thread.currentThread().interrupt()
Kilitleri farklı sırada almakKilitlenme: program donarHerkes aynı sırayla alır

Bu hataların bir kısmını derleyici yakalar; geri kalanı ancak çalışma sırasında, çoğu zaman da yalnız bazı çalıştırmalarda ortaya çıkar. Yarış ve kilitlenme hataları yükün yüksek olduğu bir anda, örneğin bir sunucuda yoğun saatte görünür hâle gelir. Bu yüzden çok iş parçacıklı kodda “denedim, çalıştı” bir kanıt değildir. Paylaşılan her değişken için hangi kilidin onu koruduğu açıkça bilinmelidir.

10.14Özet

  • İş parçacığı, bir süreç içinde bağımsız ilerleyen yürütme akışıdır. Aynı sürecin iş parçacıkları heap'i ve sınıf alanını paylaşır; her birinin kendi yığını, PC yazmacı ve yerel değişkenleri vardır.
  • Eş zamanlılık, görevlerin aynı zaman aralığında ilerlemesidir ve tek çekirdekte bile olur. Paralellik, görevlerin farklı çekirdeklerde gerçekten aynı anda çalışmasıdır.
  • Hangi iş parçacığının ne zaman çalışacağına çizelgeleyici karar verir. Birden çok iş parçacıklı programların çıktı sırası bu yüzden çalıştırmaya göre değişebilir.
  • İş parçacığı Runnable'ı uygulayarak, Thread'i genişleterek ya da lambda ile yazılır. İş run()'a yazılır; iş parçacığı start() ile başlatılır. run()'ı doğrudan çağırmak yeni iş parçacığı açmaz.
  • Altı durum vardır: NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED. Biten iş parçacığı yeniden başlatılamaz.
  • join() bir iş parçacığının bitmesini bekler ve onun yazdıklarını görünür kılar. sleep kilidi bırakmaz.
  • Arka plan iş parçacıkları JVM'in kapanmasını engellemez; setDaemon(true), start()'tan önce çağrılır.
  • İş parçacığı iş birliğiyle durdurulur: volatile bir bayrak ya da interrupt(). stop() Java 20'den beri çalışmaz.
  • Yarış durumu, paylaşılan veride bölünebilir işlemlerden doğar. sayac++ oku–ekle–yaz adımlarından oluşur ve araya giren iş parçacığı artışları kaybettirir.
  • synchronized, bir nesnenin monitörünü alarak karşılıklı dışlama sağlar. Örnek metot this'i, static metot Sınıf.class'ı kilitler. Koruma ancak herkes aynı kilidi kullanırsa işe yarar.
  • volatile görünürlüğü sağlar, atomikliği sağlamaz. Önce-olur ilişkileri (kilit, volatile, start, join) bir iş parçacığının yazdığının ötekine ne zaman görüneceğini belirler.
  • Lock açık kilittir: tryLock, süreli ve kesilebilir bekleme, adillik ve birden çok Condition sunar. unlock() her zaman finally'dedir. ReentrantLock ve synchronized yeniden girişlidir.
  • ReadWriteLock, okuyucuların aynı anda okumasına izin verir, yazıcıyı tek başına bırakır.
  • Kilitlenme, kilitlerin farklı sırada alınmasıyla oluşur. Sabit kilit sırası, süreli deneme ve kısa kritik bölgeler onu önler.
  • Koşul beklemesi için wait/notifyAll ya da Condition'ın await/signal'ı kullanılır; koşul while ile denetlenir. Hazır ArrayBlockingQueue üretici–tüketici problemini kendisi çözer.
  • CountDownLatch olayları sayar, CyclicBarrier iş parçacıklarını buluşturur, Exchanger takas yaptırır, Semaphore eş zamanlı girişi sınırlar.
  • Atomik sınıflar compareAndSet ile kilitsiz ve güvenli güncelleme yapar. ThreadLocal her iş parçacığına ayrı kopya verir.
  • İş parçacığı havuzları iş parçacıklarını yeniden kullanır ve sayılarını sınırlar. Callable değer döndürür, Future.get() sonucu bekler, shutdown() unutulmaz.
  • İş parçacığı güvenliğinin yolları: değiştirilemez nesneler, paylaşmamak, senkronizasyon ve hazır eş zamanlı yapılar. ArrayList ve HashMap güvenli değildir.

10.15Kavram listesi

10.16Kendini Dene

  1. Bir static metot int toplam = 0; tanımlayıp bir döngüde 1.000 kez toplam++ yapıyor ve sonucu yazıyor. A ve B adlı iki iş parçacığı bu metodu çalıştırırsa her biri ne yazar? Yarış olur mu?

    Cevap

    İkisi de 1000 yazar. toplam yerel bir değişkendir ve her iş parçacığının kendi yığınındadır; iki kopya birbirini görmez. Yarış ancak paylaşılan bir alana (nesne alanı ya da static alan) yazılınca olur.

  2. Bir sınıfta static synchronized void s() { ortak++; } ve synchronized void o() { ortak++; } var; ortak bir static int'tir. Bir iş parçacığı 100.000 kez s(), öteki aynı nesne üzerinde 100.000 kez o() çağırıyor. Sonuç her zaman 200.000 müdür?

    Cevap

    Hayır. s() sınıfın .class nesnesini, o() this'i kilitler. İki farklı kilit birbirini engellemez ve ortak++ iki iş parçacığından aynı anda çalışabilir. Beş denemede sonuç her seferinde 200.000'den küçük çıktı (180.586 ile 198.518 arası). Çözüm iki metodun da aynı kilidi kullanmasıdır.

  3. ReentrantLock k üzerinde main iki kez lock(), bir kez unlock() çağırıyor. Ardından başka bir iş parçacığı k.tryLock() çağırıyor. Ne döner? main'de k.getHoldCount() kaçtır?

    Cevap

    tryLock() false döner; getHoldCount() 1'dir. Tutma sayısı 2'den 1'e inmiştir ve kilit hâlâ main'dedir. Kilit ancak ikinci unlock() ile bırakılır.

  4. CountDownLatch m = new CountDownLatch(3); üzerinde iki kez countDown() çağrılıyor. m.await(100, TimeUnit.MILLISECONDS) ne döndürür? m.getCount() kaçtır?

    Cevap

    false ve 1. Sayaç 0'a inmediği için mandal açılmaz; süreli await 100 ms bekler ve süre dolunca false döndürür. Süresiz await() çağrılsaydı iş parçacığı üçüncü olay gelene kadar beklerdi.

  5. Executors.newFixedThreadPool(1) ile oluşturulan havuza, her biri kendi numarasını ve iş parçacığının adını yazan üç görev gönderiliyor. Çıktının sırası ve iş parçacığı adları nedir?

    Cevap

    Havuzda tek işçi olduğu için görevler kuyruk sırasıyla, birbiri ardına çalışır: 1, 2, 3. Üçü de pool-1-thread-1'de çalışır; havuz yeni iş parçacığı açmaz. Tek işçili havuz bu yüzden görevlerin sırasını korur. Beş denemede çıktı hep aynıydı.

  6. AtomicInteger a = new AtomicInteger(10); ardından a.compareAndSet(10, 20), a.compareAndSet(10, 30) ve a.getAndIncrement() çağrılıyor. Üç çağrı ne döndürür, a'nın son değeri nedir?

    Cevap

    true, false, 20; son değer 21. İlk CAS 10'u bulur ve 20 yazar. İkincisi 10 bekler, ama değer 20'dir; dokunmaz. getAndIncrement() önce eski değeri (20) döndürür, sonra artırır.

  7. main önce Thread.currentThread().interrupt() çağırıyor, sonra Thread.sleep(1000)'i bir try-catch içinde çalıştırıyor. sleep bir saniye sürer mi? catch içinde isInterrupted() nedir?

    Cevap

    Sürmez. Kesme bayrağı kurulu bir iş parçacığı sleep'e girerse InterruptedException hemen fırlar. Denemede istisna 100 ms'den kısa sürede geldi. İstisna fırlarken bayrak temizlenir; isInterrupted() false'tur.

  8. Üç iş parçacığı üç kilidi şöyle alıyor: T1 önce a sonra b; T2 önce b sonra c; T3 önce c sonra a. Kilitlenme olabilir mi? Olabilirse hangi kural bunu önler?

    Cevap

    Olabilir. Üçü de ilk kilidini alırsa T1 b'yi, T2 c'yi, T3 de a'yı bekler. Beklenen her kilit bir sonraki iş parçacığındadır ve T1 → T2 → T3 → T1 döngüsü kurulur. Sabit bir sıra, örneğin a < b < c konur ve her iş parçacığı kilitlerini bu sırayla alır. T3 bu durumda önce a'yı, sonra c'yi alır ve döngü kurulamaz.

  9. Engelleyen kuyruk örneğinde notifyAll() yerine notify() yazılırsa ne gibi bir sorun çıkabilir?

    Cevap

    Aynı bekleme kümesinde hem üreticiler hem tüketiciler bekleyebilir. notify() tek bir iş parçacığını rastgele seçer. Bir tüketici öğe aldıktan sonra yine bir tüketiciyi uyandırırsa, o tüketici kuyruğu boş bulup yeniden bekler. Bekleyen üretici ise hiç uyanmaz ve program takılabilir. notifyAll() herkesi uyandırır; ilgisiz olanlar while koşulunu bozuk bulup yeniden bekler. Condition ile üreticilere ve tüketicilere ayrı küme verilirse signal() yeterli olur.

10.17İleri okuma

  • Oracle, The Java Tutorials, “Lesson: Concurrency”: süreçler ve iş parçacıkları, senkronizasyon, canlılık sorunları, korumalı bloklar ve yüksek düzeyli eş zamanlılık nesneleri üzerine resmî ve ücretsiz anlatımdır.
  • B. Goetz ve diğerleri, Java Concurrency in Practice: iş parçacığı güvenliği, görünürlük, kilitler ve havuzlar üzerine alanın temel kitabıdır; örnekleri bugün de geçerlidir.
  • J. Bloch, Effective Java (3. baskı), Bölüm 11, Madde 78–84: paylaşılan değişen veriye erişimi senkronize etmeyi, wait/notify yerine hazır araçları tercih etmeyi ve aşırı senkronizasyondan kaçınmayı gerekçeleriyle anlatır.
  • J. Gosling ve diğerleri, The Java Language Specification, Java SE 21 Edition, Bölüm 17 “Threads and Locks”: önce-olur ilişkisinin ve volatile'ın kesin tanımıdır.

10.18Kaynaklar

  1. Oracle, The Java Tutorials: Lesson: Concurrency (Processes and Threads, Synchronization, Liveness, Guarded Blocks, High Level Concurrency Objects).
  2. J. Gosling ve diğerleri, The Java Language Specification, Java SE 21 Edition, Bölüm 17: Threads and Locks.
  3. Oracle, Java SE 21 API Documentation: java.lang.Thread, java.util.concurrent, java.util.concurrent.locks, java.util.concurrent.atomic.
  4. B. Goetz ve diğerleri, Java Concurrency in Practice, Addison-Wesley, 2006.
  5. P. Deitel ve H. Deitel, Java: How to Program, 11. baskı, Pearson (Bölüm 23: Concurrency).
  6. C. S. Horstmann, Core Java, Volume I: Fundamentals, 12. baskı, Pearson (Bölüm 12: Concurrency).
  7. K. Sierra, B. Bates ve T. Gee, Head First Java, 3. baskı, O'Reilly (iş parçacıkları ve eş zamanlılık bölümleri).
  8. J. Bloch, Effective Java, 3. baskı, Addison-Wesley (Bölüm 11: Concurrency, Madde 78–84).

İlgili sayfalar: Bölüm 10 sunumu · Çalışma soruları · İş Parçacığı Yarışı simülatörü · Terim sözlüğü · © Sercan Külcü. Tüm hakları saklıdır.