Takip et

Java Multithreading Explained: Thread States, Synchronization & Deadlocks – Through One Example

Java Multithreading Explained: Thread States, Synchronization & Deadlocks – Through One Example Java multithreading, modern yazılım geliştirmenin temel t…

Java Multithreading Explained: Thread States, Synchronization & Deadlocks – Through One Example

Java multithreading, modern yazılım geliştirmenin temel taşlarından biridir. Uygulamaların daha hızlı, daha tepkisel ve daha verimli çalışmasını sağlayan bu güçlü mekanizma, aynı zamanda dikkatli kullanılmadığında karmaşık sorunlara yol açabilir. Bu makalede, Java’da çoklu iş parçacığı (multithreading) kavramını, iş parçacığı durumlarını, eşzamanlılık kontrolünü (synchronization) ve kilitlenme (deadlock) gibi yaygın sorunları tek bir örnek üzerinden detaylı bir şekilde inceleyeceğiz. Amacımız, hem teorik bilgiyi pekiştirmek hem de pratik kod örnekleriyle konuyu somutlaştırmaktır.

1. Giriş: Java Multithreading Nedir ve Neden Önemlidir?

Multithreading, bir programın aynı anda birden fazla iş parçacığını (thread) eşzamanlı olarak yürütme yeteneğidir. Her bir thread, programın bağımsız bir yürütme yolunu temsil eder. Tek çekirdekli işlemcilerde bu, CPU’nun farklı thread’ler arasında hızla geçiş yapmasıyla (context switching) sağlanırken, çok çekirdekli işlemcilerde birden fazla thread gerçekten paralel olarak çalışabilir. Multithreading, özellikle kullanıcı arayüzü donmalarını engellemek, uzun süren işlemleri arka planda çalıştırmak ve genel uygulama performansını artırmak için kritik öneme sahiptir. Örneğin, bir web sunucusu her gelen isteği ayrı bir thread’de işleyerek binlerce kullanıcıya aynı anda hizmet verebilir.

2. Thread Kavramı ve Yaşam Döngüsü (Thread States)

Java’da bir thread oluşturmanın iki ana yolu vardır: Thread sınıfından türetmek veya Runnable arayüzünü uygulamak. Runnable arayüzünü kullanmak genellikle tercih edilen yöntemdir çünkü Java tek kalıtımı desteklediğinden, sınıfınızın başka bir sınıftan miras almasını engellemez.

2.1. Thread Oluşturma ve Başlatma

// Runnable arayüzünü uygulayarak thread oluşturma
class MyRunnable implements Runnable {
    private String threadName;

    public MyRunnable(String name) {
        this.threadName = name;
        System.out.println(threadName + " oluşturuldu.");
    }

    @Override
    public void run() {
        System.out.println(threadName + " çalışmaya başladı.");
        try {
            for (int i = 0; i < 5; i++) {
                System.out.println(threadName + ": " + i);
                Thread.sleep(100); // Thread'i kısa bir süre beklet
            }
        } catch (InterruptedException e) {
            System.out.println(threadName + " kesintiye uğradı.");
        }
        System.out.println(threadName + " sona erdi.");
    }
}

public class ThreadCreationExample {
    public static void main(String[] args) {
        Thread t1 = new Thread(new MyRunnable("Thread-1"));
        Thread t2 = new Thread(new MyRunnable("Thread-2"));

        t1.start(); // Thread'i başlat
        t2.start(); // Thread'i başlat
    }
}


Yukarıdaki örnekte start() metodu çağrıldığında, JVM yeni bir iş parçacığı oluşturur ve bu iş parçacığı üzerinde run() metodunu yürütür.

2.2. Thread Yaşam Döngüsü ve Durumları

Bir Java thread'i yaşam döngüsü boyunca farklı durumlar arasında geçiş yapar. Bu durumları bilmek, thread'lerin davranışını anlamak ve olası sorunları teşhis etmek için çok önemlidir.

Durum Açıklama
NEW Thread nesnesi oluşturuldu ancak henüz start() metodu çağrılmadı.
RUNNABLE Thread, start() metodu çağrıldıktan sonra çalışmaya hazır hale geldi veya çalışıyor. İşletim sistemi zamanlayıcısı tarafından çalıştırılmayı bekliyor veya çalışıyor.
BLOCKED Thread, bir monitör kilidini bekliyor (örneğin, synchronized bir bloğa veya metoda girmek için).
WAITING Thread, başka bir thread'in belirli bir eylem gerçekleştirmesini süresiz olarak bekliyor (örneğin, Object.wait(), Thread.join(), LockSupport.park() çağrıldığında).
TIMED_WAITING Thread, belirli bir süre boyunca başka bir thread'in eylem gerçekleştirmesini bekliyor (örneğin, Thread.sleep(long), Object.wait(long), Thread.join(long), LockSupport.parkNanos(), LockSupport.parkUntil() çağrıldığında).
TERMINATED Thread, run() metodunu tamamladı veya anormal bir şekilde sonlandı.

2.3. Bir Thread Nasıl Çalışır?

İşletim sistemi, CPU zamanını thread'ler arasında paylaştırır. Her thread'e belirli bir "zaman dilimi" (time slice) atanır. Bu süre dolduğunda veya thread bir I/O işlemi gibi beklemeye geçtiğinde, işletim sistemi başka bir thread'e geçiş yapar (context switching). Bu hızlı geçişler, kullanıcıya birden fazla işlemin aynı anda çalışıyormuş gibi görünmesini sağlar.

3. Örnek Senaryo: Güvenli Olmayan Bir Banka Hesabı İşlemi

Şimdi multithreading'in getirdiği zorlukları anlamak için basit bir banka hesabı örneği üzerinden ilerleyelim.

3.1. Problem Tanımı: Yarış Durumu (Race Condition)

Birden fazla thread'in aynı anda paylaşılan bir kaynağa (bu durumda banka hesabı bakiyesi) erişip onu değiştirmeye çalıştığı durumlarda "yarış durumu" (race condition) ortaya çıkabilir. Bu, işlemlerin beklenen sırayla veya atomik (bölünmez) bir şekilde gerçekleşmemesi nedeniyle veri tutarsızlıklarına yol açar.

3.2. Banka Hesabı Sınıfı

Aşağıda, basit bir BankAccount sınıfı bulunmaktadır. Bu sınıfın deposit (yatırma) ve withdraw (çekme) metotları vardır.

class BankAccount {
    private double balance;

    public BankAccount(double initialBalance) {
        this.balance = initialBalance;
    }

    public double getBalance() {
        return balance;
    }

    public void deposit(double amount) {
        double tempBalance = balance; // Mevcut bakiyeyi oku
        tempBalance = tempBalance + amount; // Yeni bakiyeyi hesapla
        balance = tempBalance; // Yeni bakiyeyi yaz
        System.out.println(Thread.currentThread().getName() + " yatırdı: " + amount + ", Yeni Bakiye: " + balance);
    }

    public void withdraw(double amount) {
        if (balance >= amount) {
            double tempBalance = balance; // Mevcut bakiyeyi oku
            tempBalance = tempBalance - amount; // Yeni bakiyeyi hesapla
            balance = tempBalance; // Yeni bakiyeyi yaz
            System.out.println(Thread.currentThread().getName() + " çekti: " + amount + ", Yeni Bakiye: " + balance);
        } else {
            System.out.println(Thread.currentThread().getName() + " çekemedi: " + amount + " (Yetersiz bakiye)");
        }
    }
}

3.3. Güvenli Olmayan Senaryo

Şimdi iki thread'in aynı BankAccount nesnesine eşzamanlı olarak erişmeye çalıştığı bir senaryo oluşturalım.

public class UnsafeBankTransaction {
    public static void main(String[] args) throws InterruptedException {
        BankAccount account = new BankAccount(1000);

        Runnable depositor = () -> {
            for (int i = 0; i < 5; i++) {
                account.deposit(100);
                try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); }
            }
        };

        Runnable withdrawer = () -> {
            for (int i = 0; i < 5; i++) {
                account.withdraw(150);
                try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); }
            }
        };

        Thread t1 = new Thread(depositor, "Yatırıcı-Thread");
        Thread t2 = new Thread(withdrawer, "Çekici-Thread");

        t1.start();
        t2.start();

        t1.join(); // t1 bitene kadar bekle
        t2.join(); // t2 bitene kadar bekle

        System.out.println("Son Bakiye: " + account.getBalance());
        // Beklenen son bakiye: 1000 + (5*100) - (5*150) = 1000 + 500 - 750 = 750
    }
}


Bu kodu çalıştırdığınızda, "Son Bakiye" çıktısının genellikle beklenen 750 değerinden farklı olduğunu göreceksiniz. Bunun nedeni, deposit ve withdraw metotlarının atomik olmamasıdır. Örneğin, deposit metodu içindeki balance okuma, hesaplama ve yazma işlemleri arasında başka bir thread araya girip balance değerini değiştirebilir. Bu durum, veri tutarsızlığına yol açar.

4. Eşzamanlılık Kontrolü: Synchronization

Java, paylaşılan kaynaklara güvenli erişimi sağlamak için synchronized anahtar kelimesini sunar. synchronized metotlar veya kod blokları, bir anda yalnızca tek bir thread'in erişimine izin verir.

4.1. synchronized Anahtar Kelimesi

synchronized anahtar kelimesi, bir nesneye ait monitör kilidini kullanarak çalışır. Bir thread synchronized bir metoda veya bloğa girdiğinde, ilgili nesnenin kilidini alır. Başka bir thread aynı nesnenin synchronized bir metoduna veya bloğuna girmeye çalıştığında, kilit serbest bırakılana kadar BLOCKED durumuna geçer ve bekler.

4.2. synchronized Kullanımı ile Çözüm

BankAccount sınıfındaki deposit ve withdraw metotlarını synchronized yaparak yarış durumunu engelleyebiliriz:

class SynchronizedBankAccount {
    private double balance;

    public SynchronizedBankAccount(double initialBalance) {
        this.balance = initialBalance;
    }

    public double getBalance() {
        return balance;
    }

    // synchronized metot
    public synchronized void deposit(double amount) {
        double tempBalance = balance;
        tempBalance = tempBalance + amount;
        balance = tempBalance;
        System.out.println(Thread.currentThread().getName() + " yatırdı: " + amount + ", Yeni Bakiye: " + balance);
    }

    // synchronized metot
    public synchronized void withdraw(double amount) {
        if (balance >= amount) {
            double tempBalance = balance;
            tempBalance = tempBalance - amount;
            balance = tempBalance;
            System.out.println(Thread.currentThread().getName() + " çekti: " + amount + ", Yeni Bakiye: " + balance);
        } else {
            System.out.println(Thread.currentThread().getName() + " çekemedi: " + amount + " (Yetersiz bakiye)");
        }
    }
}

public class SafeBankTransaction {
    public static void main(String[] args) throws InterruptedException {
        SynchronizedBankAccount account = new SynchronizedBankAccount(1000);

        Runnable depositor = () -> {
            for (int i = 0; i < 5; i++) {
                account.deposit(100);
                try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); }
            }
        };

        Runnable withdrawer = () -> {
            for (int i = 0; i < 5; i++) {
                account.withdraw(150);
                try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); }
            }
        };

        Thread t1 = new Thread(depositor, "Yatırıcı-Thread");
        Thread t2 = new Thread(withdrawer, "Çekici-Thread");

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println("Son Bakiye (Güvenli): " + account.getBalance());
        // Bu sefer her zaman beklenen 750 değerini almalıyız.
    }
}


Bu kodda, deposit veya withdraw metotlarından herhangi birine aynı anda sadece bir thread erişebilir. Diğer thread, kilit serbest bırakılana kadar bekler. Bu, veri tutarsızlığını önler ve "Son Bakiye" çıktısının her zaman doğru olmasını sağlar.

4.3. wait(), notify(), notifyAll()

Bu metotlar, thread'ler arasında daha karmaşık iletişim ve koordinasyon sağlamak için kullanılır. Her zaman synchronized bir blok veya metot içinde çağrılmaları gerekir.
* wait(): Thread'i mevcut nesnenin kilidini bırakmaya ve WAITING durumuna geçmeye zorlar. Başka bir thread notify() veya notifyAll() çağırana kadar bekler.
* notify(): Aynı nesne üzerinde wait() durumunda olan tek bir thread'i uyandırır.
* notifyAll(): Aynı nesne üzerinde wait() durumunda olan tüm thread'leri uyandırır.

4.4. Lock API (ReentrantLock)

synchronized anahtar kelimesi basit senaryolar için yeterli olsa da, java.util.concurrent.locks paketindeki Lock arayüzü (özellikle ReentrantLock), daha esnek ve gelişmiş kilit mekanizmaları sunar. Örneğin, kilidi belirli bir süre içinde almaya çalışma (tryLock()) veya kilitleri farklı metotlarda alıp bırakma gibi özellikler sağlar.

5. Kilitlenme (Deadlock): Eşzamanlılığın Karanlık Yüzü

Synchronization, yarış durumlarını engellerken, yanlış kullanıldığında "kilitlenme" (deadlock) gibi daha sinsi sorunlara yol açabilir. Deadlock, iki veya daha fazla thread'in birbirini sonsuza kadar beklemesi durumudur.

5.1. Deadlock Nedir?

Deadlock, aşağıdaki dört koşulun aynı anda gerçekleşmesiyle ortaya çıkar:
1. Karşılıklı Dışlama (Mutual Exclusion): En az bir kaynak, aynı anda yalnızca bir thread tarafından kullanılabilir.
2. Beklet ve Bekle (Hold and Wait): Bir thread, en az bir kaynağı elinde tutarken, başka bir thread tarafından tutulan ek kaynakları bekler.
3. Kaynakların Önceden Alınamaması (No Preemption): Bir thread tarafından tutulan kaynaklar, yalnızca o thread tarafından gönüllü olarak serbest bırakılabilir.
4. Döngüsel Bekleme (Circular Wait): Thread'lerden oluşan bir döngü vardır, öyle ki her thread, döngüdeki bir sonraki thread tarafından tutulan bir kaynağı bekler.

5.2. Deadlock Senaryosu

İki farklı banka hesabına para transferi yapmaya çalışan iki thread düşünelim. Her thread, transfer yapmadan önce her iki hesabın da kilidini almak zorundadır.

class Account {
    private double balance;
    private final int id;

    public Account(int id, double initialBalance) {
        this.id = id;
        this.balance = initialBalance;
    }

    public int getId() { return id; }
    public double getBalance() { return balance; }

    public void deposit(double amount) { balance += amount; }
    public void withdraw(double amount) { balance -= amount; }

    public void transfer(Account target, double amount) {
        // İki hesabın kilidini belirli bir sırada almaya çalışalım
        // Kilitleri sıralı almak deadlock'u önler, ancak burada kasıtlı olarak deadlock yaratacağız.
        // Genellikle hesap ID'sine göre sıralama yapılır.
        
        // Yanlış Sıralama: Thread-1 önce account1, sonra account2 kilitler.
        // Thread-2 önce account2, sonra account1 kilitler.
        
        // Bu senaryoda deadlock oluşması için kilit alma sırasını farklılaştıracağız.
        // Normalde id'ye göre sıralı kilit alınır:
        // synchronized (this.id < target.id ? this : target) {
        //     synchronized (this.id < target.id ? target : this) {
        //         ...
        //     }
        // }
        
        // Deadlock yaratmak için kasıtlı olarak yanlış sıralama yapalım:
        // Thread-1: kilitA -> kilitB
        // Thread-2: kilitB -> kilitA
        
        // Bu örnekte, transfer metodu içinde kilitleri doğrudan alacağız.
        // Gerçek bir deadlock için iki farklı thread'in farklı kilit alma sırasına sahip olması gerekir.
        // Örnekte, iki thread'in aynı anda farklı kilitleri almaya çalışmasıyla deadlock oluşur.
        
        System.out.println(Thread.currentThread().getName() + " " + this.id + " hesabından " + target.id + " hesabına " + amount + " transfer etmeye çalışıyor.");
        
        // Önce kendi hesabının kilidini al
        synchronized (this) {
            System.out.println(Thread.currentThread().getName() + ": Account " + this.id + " kilitlendi.");
            try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } // Deadlock şansını artırmak için beklet

            // Sonra hedef hesabın kilidini al
            synchronized (target) {
                System.out.println(Thread.currentThread().getName() + ": Account " + target.id + " kilitlendi.");
                if (this.balance >= amount) {
                    this.withdraw(amount);
                    target.deposit(amount);
                    System.out.println(Thread.currentThread().getName() + ": Transfer başarılı. " + this.id + " -> " + target.id);
                } else {
                    System.out.println(Thread.currentThread().getName() + ": Yetersiz bakiye. Transfer iptal edildi.");
                }
            }
        }
    }
}

public class DeadlockExample {
    public static void main(String[] args) throws InterruptedException {
        Account account1 = new Account(1, 500);
        Account account2 = new Account(2, 500);

        Runnable transfer1to2 = () -> {
            account1.transfer(account2, 100);
        };

        Runnable transfer2to1 = () -> {
            account2.transfer(account1, 200);
        };

        Thread t1 = new Thread(transfer1to2, "Thread-1");
        Thread t2 = new Thread(transfer2to1, "Thread-2");

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println("Son Bakiye Hesap 1: " + account1.getBalance());
        System.out.println("Son Bakiye Hesap 2: " + account2.getBalance());
    }
}


Yukarıdaki DeadlockExample kodunu çalıştırdığınızda, Thread-1 account1'in kilidini alıp account2'nin kilidini beklerken, Thread-2 account2'nin kilidini alıp account1'in kilidini bekleyebilir. Bu durumda her iki thread de sonsuza kadar bekler ve program kilitlenir. Konsol çıktısında iki thread'in de "kilitlendi" mesajlarını görebilir ancak transfer işleminin tamamlanmadığını fark edersiniz.

5.3. Deadlock'tan Kaçınma Yöntemleri

Deadlock'tan kaçınmanın en yaygın ve etkili yolu, kaynakları her zaman belirli bir sırada edinmektir. Örneğin, banka hesaplarını ID'lerine göre sıralayıp her zaman önce daha düşük ID'li hesabın kilidini, sonra daha yüksek ID'li hesabın kilidini almak.

// Deadlock'tan kaçınan transfer metodu
public void safeTransfer(Account target, double amount) {
    Account firstLock = this.id < target.id ? this : target;
    Account secondLock = this.id < target.id ? target : this;

    synchronized (firstLock) {
        System.out.println(Thread.currentThread().getName() + ": Account " + firstLock.getId() + " kilitlendi.");
        try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); }

        synchronized (secondLock) {
            System.out.println(Thread.currentThread().getName() + ": Account " + secondLock.getId() + " kilitlendi.");
            if (this.balance >= amount) {
                this.withdraw(amount);
                target.deposit(amount);
                System.out.println(Thread.currentThread().getName() + ": Transfer başarılı. " + this.id + " -> " + target.id);
            } else {
                System.out.println(Thread.currentThread().getName() + ": Yetersiz bakiye. Transfer iptal edildi.");
            }
        }
    }
}


Bu safeTransfer metodunda, her iki thread de kilitleri aynı sırayla (ID'ye göre küçükten büyüğe) almaya çalışacağından deadlock oluşmaz.

6. Sonuç ve En İyi Uygulamalar

Java multithreading, uygulamalarınıza büyük performans ve tepkisellik kazandırabilirken, eşzamanlılık sorunları (yarış durumları, deadlock'lar) nedeniyle geliştirme sürecini karmaşıklaştırabilir. synchronized anahtar kelimesi, wait(), notify() gibi mekanizmalar ve java.util.concurrent paketindeki gelişmiş araçlar (Executor Framework, Concurrent Collections, Locks), bu sorunları yönetmek için güçlü çözümler sunar.

En İyi Uygulamalar:
* Kilit Kapsamını Dar Tutun: synchronized bloklarını mümkün olduğunca küçük tutarak kilitli kalma süresini azaltın.
* Deadlock'tan Kaçının: Kaynakları her zaman tutarlı bir sırayla edinmeye çalışın.
* java.util.concurrent Paketini Kullanın: Bu paket, çoğu eşzamanlılık ihtiyacı için test edilmiş, yüksek performanslı ve güvenli yapılar sunar (örneğin, ExecutorService, ConcurrentHashMap, AtomicInteger).
* Immutable (Değişmez) Nesneler Kullanın: Mümkün olduğunca durumu değiştirilemez nesnelerle çalışmak, eşzamanlılık sorunlarını büyük ölçüde azaltır.
* Thread Güvenliğini Test Edin: Multithreaded uygulamaları kapsamlı bir şekilde test etmek, gizli eşzamanlılık hatalarını ortaya çıkarmak için hayati öneme sahiptir.

7. Sıkça Sorulan Sorular (SSS)

7.1. Thread ile Process farkı nedir?

Bir Process, işletim sistemi tarafından ayrılmış kendi bellek alanına, kaynaklarına ve bağımsız yürütme ortamına sahip bir program örneğidir. Thread ise bir process içindeki hafif bir yürütme birimidir. Aynı process içindeki thread'ler bellek ve kaynakları paylaşır, bu da aralarındaki iletişimi daha hızlı ve kolay hale getirir ancak eşzamanlılık sorunlarına yol açabilir.

7.2. Race Condition nedir?

Birden fazla thread'in paylaşılan bir kaynağa aynı anda erişip onu değiştirmeye çalıştığı ve işlemlerin beklenen sırayla veya atomik olarak gerçekleşmemesi nedeniyle veri tutarsızlığına yol açtığı durumdur.

7.3. volatile anahtar kelimesi ne işe yarar?

volatile bir değişkene uygulandığında, bu değişkenin her zaman ana bellekten okunmasını ve ana belleğe yazılmasını sağlar. Bu, CPU önbelleklerindeki eski değerlerin kullanılmasını engeller ve thread'ler arasında değişkenin en güncel değerinin görünürlüğünü garanti eder. Ancak atomiklik sağlamaz; yani volatile bir değişken üzerinde birden fazla işlem (örneğin i++) hala yarış durumlarına yol açabilir.

7.4. Executor Framework nedir?

java.util.concurrent paketinin bir parçası olan Executor Framework, thread yönetimini basitleştiren bir yapıdır. Doğrudan Thread nesneleri oluşturmak yerine, görevleri (task) bir ExecutorService'e gönderirsiniz. ExecutorService, thread havuzlarını yöneterek thread oluşturma, yeniden kullanma ve sonlandırma gibi maliyetli işlemleri sizin yerinize halleder, böylece kaynak kullanımını optimize eder.

Yorumlar
İçeriği beğendiniz mi? Bir tartışma başlatın veya görüşlerinizi paylaşın.
Yorum Yaz

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir

E-posta Bülteni
Yazılım Topluluğuna Katılın
En son güncellemeleri, yaratıcı ipuçlarını ve özel kaynakları doğrudan e-posta kutunuza alın. Tasarım ve inovasyonun geleceğini birlikte keşfedelim.