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
Runnablearayüzünü uygulayarak ya daThreadsınıfını genişleterek iş parçacığı yazmak vestart()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,Lockve atomik sınıflarla önlemek. - Koşul bekleme
wait/notifyveConditionile üretici–tüketici yazmak; kilitlenmeyi önlemek.- Hazır araçlar
CountDownLatch,CyclicBarrier,Exchanger,Semaphore,ThreadLocalve 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.
İş 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).
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.
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.
Ş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ığı
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ç | İş parçacığı | |
|---|---|---|
| Bellek | Kendi adres alanı | Sürecin heap'ini paylaşır |
| Yığın | — | Her birinin kendi yığını |
| Oluşturma | Pahalı | Ucuz (hafif süreç) |
| İletişim | Dosya, soket, boru | Ortak nesneler |
| Risk | Birbirini bozamaz | Ortak 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.
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.
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.
Ş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.
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.
| Metot | Gö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-1gibi. Ad verilmezse JVMThread-ö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ümlerdegetId()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) ileMAX_PRIORITY(10) arasında bir tam sayı; varsayılanNORM_PRIORITY(5).- Durum (state)
NEW,RUNNABLE,BLOCKED,WAITING,TIMED_WAITING,TERMINATEDdeğ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 : RUNNABLEThread.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.
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 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=trueBu çı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. Yerinetry-finallyya daAutoCloseableile 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.
| Durum | Anlamı | 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ı bekliyor | start() sonrası |
BLOCKED (engellenmiş) | synchronized blok ya da metoda girmek için monitör kilidini bekliyor | Kilit 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ı bekliyor | Thread.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ı
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());
}| Adım | Satır | Değişkenler / durum | Yeni çıktı |
|---|---|---|---|
| 1 | 2: Thread uyuyan = new Thread(() -> uyu(1000)); | — | — |
| 2 | 3: System.out.println("1) " + uyuyan.getState()); | — | 1) NEW |
| 3 | 4: uyuyan.start(); | — | — |
| 4 | 5: uyu(100); | — | — |
| 5 | 6: System.out.println("2) " + uyuyan.getState()); | — | 2) TIMED_WAITING |
| 6 | 8: bekleyen.start(); | — | — |
| 7 | 10: System.out.println("3) " + bekleyen.getState()); | — | 3) WAITING |
| 8 | 15: engellenen.start(); | — | — |
| 9 | 17: System.out.println("4) " + engellenen.getState()); | — | 4) BLOCKED |
| 10 | 19: katil(bekleyen); | — | — |
| 11 | 20: 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.
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-0main, 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.
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.
| Adım | Satır | Değişkenler / durum | Yeni çıktı |
|---|---|---|---|
| 1 | 9: Worker w = new Worker(); | w=Worker nesnesi, t=— | — |
| 2 | 10: Thread t = new Thread(w); | w=Worker nesnesi, t=Thread-0 (NEW) | — |
| 3 | 11: t.start(); | w=Worker nesnesi, t=Thread-0 (RUNNABLE) | — |
| 4 | 12: System.out.println("main devam ediyor"); | w=Worker nesnesi, t=Thread-0 (RUNNABLE) | main devam ediyor |
| 5 | 13: } | t=Thread-0 (RUNNABLE) | — |
| 6 | 4: String ad = Thread.currentThread().getName(); | t=Thread-0 (RUNNABLE), ad="Thread-0" | — |
| 7 | 5: System.out.println("Worker çalışıyor: " + ad); | t=Thread-0 (RUNNABLE), ad="Thread-0" | Worker çalışıyor: Thread-0 |
| 8 | 6: } | 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.
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-0Bu 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.
| Ç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 kez | IllegalThreadStateException: 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.
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.
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-0Worker 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) LambdaHer 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.
| Yol | Ne zaman? |
|---|---|
implements Runnable | Sınıf başka sınıftan türemişse; iş ile iş parçacığı ayrı tutulur |
extends Thread | Kı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");
}
}| Adım | Satır | Çağrı yığını | Değişkenler / durum | Yeni çıktı |
|---|---|---|---|---|
| 1 | 12: a.start(); | main() | a=RUNNABLE, b=NEW | — |
| 2 | 13: b.start(); | main() | a=RUNNABLE, b=RUNNABLE | — |
| 3 | 14: a.join(); | main() | a=RUNNABLE, b=RUNNABLE, main=WAITING | — |
| 4 | 5: System.out.println(ad + ": adım " + i); | A: run() → gorev() | A.i=1, B.i=—, main=WAITING | A: adım 1 |
| 5 | 5: System.out.println(ad + ": adım " + i); | B: run() → gorev() | A.i=1, B.i=1, main=WAITING | B: adım 1 |
| 6 | 5: System.out.println(ad + ": adım " + i); | A: run() → gorev() | A.i=2, B.i=1, main=WAITING | A: adım 2 |
| 7 | 5: System.out.println(ad + ": adım " + i); | B: run() → gorev() | A.i=2, B.i=2, main=WAITING | B: adım 2 |
| 8 | 16: System.out.println("main: bitti"); | main() | A=TERMINATED, B=TERMINATED, main=RUNNABLE | main: 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.
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: bittiAynı 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.
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_WAITINGdurumundadı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 çokmsmilisaniye 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ı: 500000500000start()'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.
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ı
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ıyorsaat 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(): desteklenmiyorJDK 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 durdufinish() 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: bittiSı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.
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.
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.
| Adım | Satır | Çağrı yığını | Değişkenler / durum |
|---|---|---|---|
| 1 | 1: // sayac++ işlemcide üç adımdır: | — | sayac=0, t1.yazmac=—, t2.yazmac=— |
| 2 | 2: int yazmac = sayac; // 1) oku | t1 | sayac=0, t1.yazmac=0, t2.yazmac=— |
| 3 | 2: int yazmac = sayac; // 1) oku | t2 | sayac=0, t1.yazmac=0, t2.yazmac=0 |
| 4 | 3: yazmac = yazmac + 1; // 2) 1 ekle | t1 | sayac=0, t1.yazmac=1, t2.yazmac=0 |
| 5 | 4: sayac = yazmac; // 3) yaz | t1 | sayac=1, t1.yazmac=1, t2.yazmac=0 |
| 6 | 3: yazmac = yazmac + 1; // 2) 1 ekle | t2 | sayac=1, t1.yazmac=1, t2.yazmac=1 |
| 7 | 4: sayac = yazmac; // 3) yaz | t2 | sayac=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.
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.
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.Locknesneleri (ReentrantLock,ReadWriteLock): açık kilitleme.- Bariyer, semafor, değiştirici:
CyclicBarrier,Semaphore,Exchanger,CountDownLatch. volatiledeğişkenler: okuma ve yazmaların bütün iş parçacıklarına görünmesi.- Atomik nesneler:
AtomicIntegergibi 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
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.
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
}t1vet2aynı andasynchronized (o)satırına gelir; monitör boştur.t1kazanır ve monitörü alır.t2giriş kümesinde (entry set)BLOCKEDbekler.t1bloktan çıkar; monitör otomatik bırakılır. Bekleyenler yarışır.t2monitörü alır ve kritik bölgeye girer.t2de çıkar.ortak++iki kez, birbirine karışmadan yapılmıştır.
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:
thisnesnesinin monitörü kilitlenir. staticmetot: nesne olmadığı için sınıfınC.classnesnesinin 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.
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ç: 200000arttir() 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 dolu5, 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.
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.
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? falseBu ç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.
final | volatile |
|---|---|
| Bir kez atanır (yapıcıda); sonra değişmez | Değeri değişebilir |
| Yapıcı bitince herkes aynı değeri görür | Her 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.
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.
| Kural | A önce-olur B |
|---|---|
| Kilit | Bir monitörün ya da kilidin bırakılması → aynı kilidin sonraki alınması |
volatile | Bir volatile alana yazma → aynı alanın sonraki okunması |
| Başlatma | t.start() çağrısından önceki her şey → t'nin içindeki işlemler |
| Bitirme | t'nin bütün işlemleri → t.join()'den dönen iş parçacığı |
| Hazır yapılar | BlockingQueue.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.
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ç: 200000Program, 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ç gelinmezBu 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.
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ış kilitException 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.
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? truenew 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: ");
}
}| Adım | Satır | Çağrı yığını | Değişkenler / durum | Yeni çıktı |
|---|---|---|---|---|
| 1 | 20: dis(); | main() | sahibi=yok, sayı=0 | — |
| 2 | 10: kilit.lock(); | main() → dis() | sahibi=main, sayı=1 | — |
| 3 | 11: try { yaz("dis(): "); ic(); } | main() → dis() → yaz() | sahibi=main, sayı=1 | dis(): 1 |
| 4 | 15: kilit.lock(); // beklemeden girer | main() → dis() → ic() | sahibi=main, sayı=2 | — |
| 5 | 16: try { yaz("ic(): "); } | main() → dis() → ic() → yaz() | sahibi=main, sayı=2 | ic(): 2 |
| 6 | 17: finally { kilit.unlock(); } | main() → dis() → ic() | sahibi=main, sayı=1 | — |
| 7 | 12: finally { kilit.unlock(); } | main() → dis() | sahibi=yok, sayı=0 | — |
| 8 | 21: yaz("main: "); | main() → yaz() | sahibi=yok, sayı=0 | main: 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.
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.
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
Açık: Lock | Dolaylı: synchronized | |
|---|---|---|
| Denetim | Programcı lock()/unlock() çağırır | Otomatik: blok girişinde alınır, çıkışta bırakılır |
| Sınıflar | Lock arayüzü, ReentrantLock, ReentrantReadWriteLock | Anahtar kelime; her nesnenin monitörü |
| Beklemeden deneme | tryLock(), tryLock(süre) | Yok: kilit boşalana kadar bekler |
| Kesilebilir bekleme | lockInterruptibly() | Yok |
| Adillik, koşullar | new ReentrantLock(true), birden çok Condition | Yok; tek bekleme kümesi |
| Yeniden giriş | ReentrantLock yeniden girişlidir | Her zaman yeniden girişlidir |
| Risk | unlock() unutulursa kilit sonsuza dek kalır | Unutma riski yok |
| Ne zaman? | Gelişmiş denetim gerektiğinde | Basit 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
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);
}a'yı tutar ve b'yi bekler; T2 b'yi tutar ve a'yı bekler.BLOCKED BLOCKED
Kilitlenmiş: 2calis(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();
}| Adım | Satır | Çağrı yığını | Değişkenler / durum |
|---|---|---|---|
| 1 | 1: static final Object a = new Object(), b = new Object();10: public static void main(String[] args) { | main() | a=Object, b=Object |
| 2 | 11: new Thread(() -> calis(a, b, "T1")).start();3: synchronized (x) { | T1: calis(a, b) | T1: x=a, y=b |
| 3 | 12: new Thread(() -> calis(b, a, "T2")).start();3: synchronized (x) { | T2: calis(b, a) | T2: x=b, y=a |
| 4 | 4: uyu(100); | T1: uyu(100) → T2: uyu(100) | a=T1 tutuyor, b=T2 tutuyor |
| 5 | 5: synchronized (y) { | T1: calis(a, b) | T1 istiyor=b |
| 6 | 5: synchronized (y) { | T1: calis(a, b) → T2: calis(b, a) | T2 istiyor=a |
| 7 | 6: System.out.println(ad + " bitti"); | T1: calis(a, b) → T2: calis(b, a) | T1=BLOCKED, T2=BLOCKED |
| 8 | 12: 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.
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ş: 0Herkes ö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
jstackaracı ya daThreadMXBean.findDeadlockedThreads()kilitlenmiş iş parçacıklarını ve tuttukları kilitleri gösterir.
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.
- 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ığı
tryLockbaş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.
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:
Object.wait()veObject.notify()/notifyAll().Lockile birlikte kullanılanConditionarayüzü.- Senkronizasyon nesneleri (
CountDownLatch,Semaphore…) ve hazırBlockingQueuesı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.
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.
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;
}| Adım | Satır | Değişkenler / durum |
|---|---|---|
| 1 | 6: veri[son] = oge;7: son = (son + 1) % veri.length;8: buyukluk++; | bas=0, son=1, buyukluk=1 |
| 2 | 6: veri[son] = oge;7: son = (son + 1) % veri.length;8: buyukluk++; | bas=0, son=0, buyukluk=2 |
| 3 | 4: wait(); | bas=0, son=0, buyukluk=2 |
| 4 | 16: Object oge = veri[bas];17: bas = (bas + 1) % veri.length;18: buyukluk--; | bas=1, son=0, buyukluk=1 |
| 5 | 19: notifyAll(); | bas=1, son=0, buyukluk=1 |
| 6 | 3: while (buyukluk == veri.length) {6: veri[son] = oge;7: son = (son + 1) % veri.length;8: buyukluk++; | bas=1, son=1, buyukluk=2 |
| 7 | 16: Object oge = veri[bas];17: bas = (bas + 1) % veri.length;18: buyukluk--; | bas=0, son=1, buyukluk=1 |
| 8 | 16: Object oge = veri[bas];17: bas = (bas + 1) % veri.length;18: buyukluk--; | bas=1, son=1, buyukluk=0 |
| 9 | 14: 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.
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: 0Sunumdaki 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: 4Bü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.
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.
| Yol | Bekleme | Uyandırma |
|---|---|---|
synchronized | wait() | notify(), notifyAll() |
Lock + Condition | await() | signal(), signalAll() |
ArrayBlockingQueue | put(), take() içinde | otomatik |
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.
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.
| Adım | Satır | Değişkenler / durum | Yeni çıktı |
|---|---|---|---|
| 1 | 4: CountDownLatch mandal = new CountDownLatch(sayac); | sayaç=3 | — |
| 2 | 11: }).start(); | sayaç=3 | — |
| 3 | 14: mandal.await(); | sayaç=3 | — |
| 4 | 8: System.out.println(no + ". olay gerçekleşti");10: mandal.countDown(); | sayaç=2 | 1. olay gerçekleşti |
| 5 | 8: System.out.println(no + ". olay gerçekleşti");10: mandal.countDown(); | sayaç=1 | 3. olay gerçekleşti |
| 6 | 8: System.out.println(no + ". olay gerçekleşti");10: mandal.countDown(); | sayaç=0 | 2. olay gerçekleşti |
| 7 | 15: System.out.println("Tüm olaylar tamamlandı!"); | sayaç=0 | Tü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.
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 · 3Mandal 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 ediyorHer 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 1exchange(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
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();
}
}| Adım | Satır | Değişkenler / durum | Yeni çıktı |
|---|---|---|---|
| 1 | 3: Semaphore semaphore = new Semaphore(izin); | izin=3 | — |
| 2 | 16: }).start(); | izin=3 | — |
| 3 | 8: semaphore.acquire(); // izin al9: System.out.println(no + " girdi"); | izin=2 | 1 girdi |
| 4 | 8: semaphore.acquire(); // izin al9: System.out.println(no + " girdi"); | izin=1 | 3 girdi |
| 5 | 8: semaphore.acquire(); // izin al9: System.out.println(no + " girdi"); | izin=0 | 2 girdi |
| 6 | 8: semaphore.acquire(); // izin al | izin=0 | — |
| 7 | 11: System.out.println(no + " çıkıyor");12: semaphore.release(); // izni bırak | izin=1 | 2 çıkıyor |
| 8 | 11: System.out.println(no + " çıkıyor");12: semaphore.release(); // izni bırak | izin=2 | 1 çıkıyor |
| 9 | 8: semaphore.acquire(); // izin al9: System.out.println(no + " girdi"); | izin=0 | 4 girdi ⏎ 5 girdi |
| 10 | 11: System.out.println(no + " çıkıyor");12: semaphore.release(); // izni bırak | izin=3 | 3 çı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.
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.
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.
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 0incrementAndGet(), ++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.
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
}
}| Adım | Satır | Çağrı yığını | Değişkenler / durum |
|---|---|---|---|
| 1 | 1: AtomicInteger sayac = new AtomicInteger(5); | — | sayac=5 |
| 2 | 5: int eski = sayac.get(); | T1: artir() | T1.eski=5 |
| 3 | 5: int eski = sayac.get(); | T1: artir() → T2: artir() | T1.eski=5, T2.eski=5 |
| 4 | 6: int yeni = eski + 1; | T1: artir() → T2: artir() | T1.yeni=6, T2.yeni=6 |
| 5 | 7: if (sayac.compareAndSet(eski, yeni))8: return yeni; | T1: artir() | sayac=6, T1 → 6 |
| 6 | 7: if (sayac.compareAndSet(eski, yeni)) | T2: artir() | sayac=6, T2 CAS(5, 6)=false |
| 7 | 5: int eski = sayac.get(); | T2: artir() | T2.eski=6 |
| 8 | 6: int yeni = eski + 1; | T2: artir() | T2.yeni=7 |
| 9 | 7: 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.
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.
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.
| Adım | Satır | Değişkenler / durum | Yeni çıktı |
|---|---|---|---|
| 1 | 1: Thread isci = new Thread(() -> {11: isci.start(); | isci=RUNNABLE | — |
| 2 | 3: while (!Thread.interrupted()) {4: System.out.println("çalışıyor...");5: Thread.sleep(400); | isci=TIMED_WAITING | çalışıyor... |
| 3 | 12: Thread.sleep(1000); | main=TIMED_WAITING | — |
| 4 | 3: while (!Thread.interrupted()) {4: System.out.println("çalışıyor...");5: Thread.sleep(400); | zaman=≈400 ms | çalışıyor... |
| 5 | 3: while (!Thread.interrupted()) {4: System.out.println("çalışıyor...");5: Thread.sleep(400); | zaman=≈800 ms | çalışıyor... |
| 6 | 13: isci.interrupt(); // isci sleep() içinde | zaman=≈1000 ms | — |
| 7 | 7: } catch (InterruptedException e) {8: System.out.println("iptal edildi!"); | e=InterruptedException | iptal edildi! |
| 8 | 14: isci.join(); | isci=TERMINATED | — |
| 9 | 15: 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.
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.
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ı.
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 5Gö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.
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.
İş 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.
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();| Adım | Satır | Değişkenler / durum | Yeni çıktı |
|---|---|---|---|
| 1 | 6: ExecutorService havuz = Executors.newFixedThreadPool(2); | havuz=2 işçi | — |
| 2 | 7: Future<Integer> f1 = havuz.submit(() -> is(300, 10)); | f1=Future (bekliyor) | — |
| 3 | 8: Future<Integer> f2 = havuz.submit(() -> is(100, 20)); | f2=Future | — |
| 4 | 9: Future<Integer> f3 = havuz.submit(() -> is(100, 30)); | f3=Future | — |
| 5 | 10: System.out.println(f1.isDone() + " " + f3.isDone()); | f1.isDone()=false | false false |
| 6 | 11: int toplam = f1.get() + f2.get() + f3.get(); | zaman=≈100 ms | — |
| 7 | 11: int toplam = f1.get() + f2.get() + f3.get(); | zaman=≈200 ms | — |
| 8 | 11: int toplam = f1.get() + f2.get() + f3.get(); | toplam=10+20+30=60 | — |
| 9 | 12: System.out.println("toplam = " + toplam);13: System.out.println(f2.isDone() + " " + f3.isDone()); | toplam=60 | toplam = 60 ⏎ true true |
| 10 | 14: 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.
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ı.
CompletableFutureget()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.
mergevecomputemetotları atomiktir. Paralel akış, dizinin öğelerini farklı iş parçacıklarında işler;mergesayı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
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ıfinalolan 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;
ThreadLocalda her iş parçacığına kopya verir. - Senkronizasyon
- Paylaşılan değişen veriye her erişim aynı kilitle korunur:
synchronizedya daLock. - 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
Çı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.
Çı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.
“İ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ü.
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.
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.
İ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.
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.
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.
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).
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ı.
| Hata | Belirti | Doğrusu |
|---|---|---|
t.run() çağırmak | Yeni iş parçacığı yok; kod main'de sırayla çalışır | t.start() |
start()'ı iki kez çağırmak | IllegalThreadStateException | Her çalıştırma için yeni Thread |
Thread.sleep(100); yalnız başına | error: unreported exception InterruptedException; must be caught or declared to be thrown | try-catch ya da throws InterruptedException |
Lambda içinde döngü değişkeni i | error: local variables referenced from a lambda expression must be final or effectively final | int 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 statement | Gövdede sleep/wait olmalı ya da catch kaldırılmalı |
Sınıf Thread'i genişletmiyorken currentThread() | error: cannot find symbol | Thread.currentThread() |
join()'u unutmak | main, sonuç hazır olmadan okur (ör. 0) | Sonucu okumadan önce join() |
| Hata | Belirti | Doğrusu |
|---|---|---|
wait()/notify()'ı synchronized dışında çağırmak | IllegalMonitorStateException: current thread is not owner | Aynı nesne üzerinde synchronized içinde |
Koşulu if ile beklemek | Uyanınca koşul yine bozuk olabilir; taşan ya da boşken okunan kuyruk | while (koşul) wait(); |
unlock()'u finally dışında yazmak | İstisnada kilit hiç açılmaz; herkes sonsuza dek bekler | lock(); try { … } finally { unlock(); } |
| Alınmamış kilidi açmak | IllegalMonitorStateException | Her lock() için bir unlock() |
volatile sayaçta sayac++ | Artışlar kaybolur (ör. 163299) | AtomicInteger ya da synchronized |
final volatile alan | error: illegal combination of modifiers: final and volatile | Yalnız biri kullanılır |
stop(), suspend() kullanmak | Java 20+: UnsupportedOperationException | Bayrak, interrupt(), wait/notify |
InterruptedException'ı boş catch ile yutmak | Kesme isteği kaybolur, iş parçacığı durmaz | Döngüden çıkmak ya da Thread.currentThread().interrupt() |
| Kilitleri farklı sırada almak | Kilitlenme: program donar | Herkes 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.sleepkilidi 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:
volatilebir bayrak ya dainterrupt().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 metotthis'i,staticmetotSınıf.class'ı kilitler. Koruma ancak herkes aynı kilidi kullanırsa işe yarar.volatilegö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.Lockaçık kilittir:tryLock, süreli ve kesilebilir bekleme, adillik ve birden çokConditionsunar.unlock()her zamanfinally'dedir.ReentrantLockvesynchronizedyeniden 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/notifyAllya daCondition'ınawait/signal'ı kullanılır; koşulwhileile denetlenir. HazırArrayBlockingQueueüretici–tüketici problemini kendisi çözer. CountDownLatcholayları sayar,CyclicBarrieriş parçacıklarını buluşturur,Exchangertakas yaptırır,Semaphoreeş zamanlı girişi sınırlar.- Atomik sınıflar
compareAndSetile kilitsiz ve güvenli güncelleme yapar.ThreadLocalher 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.
Callabledeğ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.
ArrayListveHashMapgüvenli değildir.
10.15Kavram listesi
- iş parçacığı thread
- süreç process
- eş zamanlılık / paralellik concurrency / parallelism
- iş parçacığı durumları thread states
- arka plan iş parçacığı daemon thread
- Java Sanal Makinesi (JVM) Java Virtual Machine
- fonksiyonel arayüz functional interface
- lambda ifadesi lambda expression
- yarış durumu race condition
- senkronizasyon synchronization
- monitör monitor
- karşılıklı dışlama mutual exclusion
- kilit lock
- kilitlenme deadlock
- üretici–tüketici problemi producer–consumer problem
- semafor semaphore
- atomik işlem atomic operation
- iş parçacığı havuzu thread pool
- iş parçacığı güvenliği thread safety
- değiştirilemez immutable
- bağlam değiştirme context switching
- çizelgeleyici scheduler
- kritik bölge critical section
- görünürlük,
volatilevisibility - önce-olur ilişkisi happens-before
- yeniden girişli kilit reentrant lock
- canlı kilit / açlık livelock / starvation
- bekleme kümesi wait set
- karşılaştır ve değiştir compare-and-set
- kesme interrupt
10.16Kendini Dene
Bir
staticmetotint toplam = 0;tanımlayıp bir döngüde 1.000 keztoplam++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.
toplamyerel 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 dastaticalan) yazılınca olur.Bir sınıfta
static synchronized void s() { ortak++; }vesynchronized void o() { ortak++; }var;ortakbirstatic int'tir. Bir iş parçacığı 100.000 kezs(), öteki aynı nesne üzerinde 100.000 kezo()çağırıyor. Sonuç her zaman 200.000 müdür?Cevap
Hayır.
s()sınıfın.classnesnesini,o()this'i kilitler. İki farklı kilit birbirini engellemez veortak++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.ReentrantLock küzerindemainiki kezlock(), bir kezunlock()çağırıyor. Ardından başka bir iş parçacığık.tryLock()çağırıyor. Ne döner?main'dek.getHoldCount()kaçtır?Cevap
tryLock()falsedöner;getHoldCount()1'dir. Tutma sayısı 2'den 1'e inmiştir ve kilit hâlâmain'dedir. Kilit ancak ikinciunlock()ile bırakılır.CountDownLatch m = new CountDownLatch(3);üzerinde iki kezcountDown()çağrılıyor.m.await(100, TimeUnit.MILLISECONDS)ne döndürür?m.getCount()kaçtır?Cevap
falseve 1. Sayaç 0'a inmediği için mandal açılmaz; süreliawait100 ms bekler ve süre doluncafalsedöndürür. Süresizawait()çağrılsaydı iş parçacığı üçüncü olay gelene kadar beklerdi.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ı.AtomicInteger a = new AtomicInteger(10);ardındana.compareAndSet(10, 20),a.compareAndSet(10, 30)vea.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.mainönceThread.currentThread().interrupt()çağırıyor, sonraThread.sleep(1000)'i birtry-catchiçinde çalıştırıyor.sleepbir saniye sürer mi?catchiçindeisInterrupted()nedir?Cevap
Sürmez. Kesme bayrağı kurulu bir iş parçacığı
sleep'e girerseInterruptedExceptionhemen fırlar. Denemede istisna 100 ms'den kısa sürede geldi. İstisna fırlarken bayrak temizlenir;isInterrupted()false'tur.Üç iş parçacığı üç kilidi şöyle alıyor: T1 önce
asonrab; T2 öncebsonrac; T3 öncecsonraa. Kilitlenme olabilir mi? Olabilirse hangi kural bunu önler?Cevap
Olabilir. Üçü de ilk kilidini alırsa T1
b'yi, T2c'yi, T3 dea'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ğina<b<ckonur ve her iş parçacığı kilitlerini bu sırayla alır. T3 bu durumda öncea'yı, sonrac'yi alır ve döngü kurulamaz.Engelleyen kuyruk örneğinde
notifyAll()yerinenotify()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 olanlarwhilekoşulunu bozuk bulup yeniden bekler.Conditionile üreticilere ve tüketicilere ayrı küme verilirsesignal()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/notifyyerine 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
- Oracle, The Java Tutorials: Lesson: Concurrency (Processes and Threads, Synchronization, Liveness, Guarded Blocks, High Level Concurrency Objects).
- J. Gosling ve diğerleri, The Java Language Specification, Java SE 21 Edition, Bölüm 17: Threads and Locks.
- Oracle, Java SE 21 API Documentation:
java.lang.Thread,java.util.concurrent,java.util.concurrent.locks,java.util.concurrent.atomic. - B. Goetz ve diğerleri, Java Concurrency in Practice, Addison-Wesley, 2006.
- P. Deitel ve H. Deitel, Java: How to Program, 11. baskı, Pearson (Bölüm 23: Concurrency).
- C. S. Horstmann, Core Java, Volume I: Fundamentals, 12. baskı, Pearson (Bölüm 12: Concurrency).
- 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).
- 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.