Etiket: java

  • Thread Lifecycle, Context Switch ve Thread Memory Hakkında

    Merhaba bu yazıda java threadleri hakkında yazmaya çalışacağım. Burada bahsedeceklerim Virtual Threads ile alakalı olmayacak sadece klasik platform threadlerinde bahsediyor olacağım.

    Thred için bir tanım getirmemiz gerekirse basitce CPU üzerinde çalışan iş parçacığı diyebiliriz. Java’da JVM tarafından oluşturulan Thread nesnesi , işletim sisteminin sahip olduğu native threadler ile bire bir mapping kurulması ile oluşur. Threadin cpu üzerindeki işleri yürütmesi konusu işletim sisteminin schedulerı ile gerçekleştirilir. Threadler programımızın eşzamanlı veya paralel şekilde yürütülen iş parçacıklarıdır.

    Thread t = new Thread(() -> System.out.println("Mert uykusuz.."));
    t.start();

    Thread Lifecycle

    Threadler javada temel olarak 6 ana durumda bulunurlar. Bunlar şunlar ; NEW, RUNABLE , BLOCKED, WAITING, TIMED_WAITING , TERMINATED.

    • NEW -> Threadi oluşturduk fakat henüz nesne üzerinde .start() ile çağırmadıysak new durumundadır. Java tarafında bir nesnemiz var fakat cpuya hiç gitmedik yani cpu üzerinde şu an çalışmıyor.

    • RUNABLE -> .start() çağrıldıktan sonra jvm tarafında artık thread statüsü RUNABLE duruma gelir. Yani isminde de anlayacağımız üzere thread çalışmak için hazır durumdadır. İşletim sistemi tarafında schedular threade CPU vermek için zamanı vermek için beklemeye başlar. CPU meşgulse RUNABLE durumda beklemeye devam eder ama bu durumda beklemesi bloklandığı vs anlamına gelmez.

    • BLOCKED -> Thread başka bir threadin tuttuğu bir kiliti bekliyorsa BLOCKED statüsüne geçer. Bu blocked statüsü aslında bana göre threadlerde en kritik mekanizma olan kilit mekanizmasının sonucunda açığa çıkan bir statüdür. Javadaki threadlerin aynı kaynağa aynı anda erişmesinin engellendiği ve bu sayede race conditionın olmadığını belirten bir statüdür. Eğer bu engelleme olmasaydı farklı threadler aynı kaynağı güncelleyebilir , veri tutarlılığı olmaz ve istenmeyen durumlar ortaya çıkardı. Blocked statüsü threadin kaynak kullanımda güvenlik için “cezalı” olduğu anlamına gelir.
        private final Object lock = new Object();
    
        public void criticalSection() {
            synchronized(lock) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {}
            }
        }

    • WAITING -> Bazı durumlarda bir threadin işinin tamamlanması diğer threding işinin tamamlanmasına bağlı olabilir. WAITING statüsü threadlerin bir birleri arasında senkronize olmalarını sağlar. WAITING statüsü sayesinde sonsuz döngülerle diğer bağımlı olunan threading işinin bitip bitmediğini sürekli sorgulayıp cpuyu yormak yerine biten işlemşin bittiğinden notify olmamızı sağlar böyleye gereksiz CPU harcamamızın önüne geçer. Kısacası WAITING statülü bir thread başka bir threadin kendisine haber vermesini bekler.
    public class WaitingExample {
        public static void main(String[] args) {
            Object lock = new Object();
    
            Thread t1 = new Thread(() -> {
                synchronized (lock) {
                    try {
                        System.out.println("Thread 1: Bekliyorum.");
                        lock.wait(); // WAITING durumu
                        System.out.println("Thread 1: Devam ediyorum.");
                    } catch (InterruptedException e) {}
                }
            });
    
            Thread t2 = new Thread(() -> {
                try {
                    Thread.sleep(2000); // 2 saniye bekle
                    synchronized (lock) {
                        System.out.println("Thread 2: Thread 1'i uyandırıyorum.");
                        lock.notify(); // t1 runnable duruma geçer ve cpudan zaman alabilirse runninge geçer 
                    }
                } catch (InterruptedException e) {}
            });
    
            t1.start();
            t2.start();
        }
    }

    Ana thread t1.join() metodunu kullanarak kendi akışını WAITING statüsüne alır ve join() çağrısı yapılan thread’in işini tamamlamasını bekler.
    wait() kullanımından farklı olarak burada manuel bir notify() çağrısına gerek yoktur; ilgili thread tamamlandığında JVM ana thread’i otomatik olarak uyandırır.
    Thread tamamlandıktan sonra ana thread RUNNABLE duruma geri döner ve CPU zamanı alabilirse çalışmaya devam eder.
    Örnek verecek olursak :

    Thread t1 = new Thread(() -> {
        try { Thread.sleep(3000); } catch (InterruptedException e) {}
        System.out.println("t1 bitti");
    });
    t1.start();
    t1.join(); // Ana thread burada WAITING durumuna girer , t1 terminate olursa devam eder.
    System.out.println("Ana thread devam :)");

    TIMED_WAITING: Thread’i manuel olarak belli bir süre bekletmek istediğimizde kullanılır. Thread.sleep(3000) veya object.wait(3000) çağrıları thread’i TIMED_WAITING statüsüne sokar. Süre bitince veya notify gelirse thread RUNNABLE duruma geçer ve CPU zamanı alabilirse running hale gelir.


    TERMINATED -> Threadin işini bitirdikten sonra aldığı statüdür. Yani kabaca threadin görevini tamamlayıp öldüğü haldir.


    Blogda yazarken akılda canlanabilmesi için yazılarda gerçek hayat örneklemlerine sıklıkla yer vermeye çalışacağım. O zaman threadleri de gerçek hayat ile örneklendirelim 🙂

    Gerçek Hayat Örnekleri İle Thread Statüleri

    Bu örneklememde Garsonu thread , mutfağı CPU , locku ve paylaşılan ortak kaynağı tencere olarak örneklendireceğim.

    • Garson iş yerine geldi fakat mutfağa henüz girmemişse NEW statüsündedir -> thread oluşturulda fakat .start() çağırılmadı.
    • Garson mutfak kapısında bekliyor , çalışmaya hazır ve mutfak kapısı açılırsa çalışmaya başlayacak. Bu durumda RUNABLE statüsündedir -> Thread çalışmaya hazır sadece CPU zamanı bekliyor
    • Garson mutfakta ama tek bir tane düdüklü tencere var o tencerede de başka bir garson yemek yapıyor. Tencerenin kilidi alınmış durumda. Bizim garson BLOCKED (CPU zamanı olsa bile iş yapamamaz) durumda. Mutfakta olmasına rağmen iş yapamıyor. İlk garson işini bitiriyor ve kilit otomatikmen serbest hale geliyor. Bizim garson running duruma geçiyor. Burada bahsettiğin running bir thread statüsü değil thread çalışıyor demek 🙂
    • Garsonumuz mutfağa girdi tenceredeki yemeğin üzerine sos yapacak. Ama tencerede başka garson yemek yapıyor ve işi devam ediyor. Bizim garson “işin bitince bana haber ver diyor” ve beklemeye başlıyor. Garsonumuz WAITING statüsünde. notify() olunca çalışmaya başlayacak.
    • Garsonumuz mutfağa giriyor ve tencereye sos eklemek istiyor. Başka bir garson tencereyi zaten kullanıyor. Bizim garsonda iki dakika mola veriririm süre dolarsa başka bir işe geçerim diyor. Garsonumuz TIMED_WAITING statüsünde.
    • Garsonumuz mutfağa girdi , tencereyi aldı , yemeği bitirdi ve tencereyi bıraktı. Tüm işini bitirdi ve başka iş beklemiyor artık TERMINATED statüsünde.

    Thredlerin statüleri hem açıklama ve hem de mutfak örneklendirmesi ile bu kadar anlatmak yeterli diye düşünüyorum. Şimdi threadlerle alakalı başka bir konu başlağı ile devam edelim.

    Çok Thread Her Zaman Hız Demek Değildir – Context Switching’in Görünmeyen Maliyeti

    CPU çekirdek başına aynı anda her zaman 1 thread çalıştırabilir. 4 çekirdekli bir CPU ya sahipsek aynı anda 4 thread çalıştırabilir bundan fazla thread ile çalışmak istediğimizde bu threadleri CPU sırayla çalıştıracaktır.

    İşte tam burada CPU bir threadden başka bir threade geçtiğinde OS ve CPU tarafında çeşitli işlemler yapılır. Bu da bize context switch maliyeti olarak döner.
    Burada context switchde bize en çok maliyet oluşturan şey “cache” kaybıdır.

    Thread A çalışırken kullandığı veriler CPU üzerindeki cache de tutulur. Context switch gerçekleşip Thread B çalışmaya başladığında ise CPU cache yi bu sefer Thread B nin kullandığı verilerle doldurulur.

    Thread A tekrar çalışmaya başladığında ihtiyaç duyduğu verileri cache de bulamazsa bu verileri RAM üzerinden yeniden okumak zorunda kalır. RAM, CPU cache’e kıyasla çok daha yavaş olduğu için bu durum ek bir latency oluşturur ve uygulamanın genel performansını olumsuz etkiler. Buradan da anlaşılabileceği üzere kısacası context switch arttıkça verimsizlik artar.

    IO erişimi yoğun işlemlerde zaten IO işlemi için doğal bir bekleme olduğundan aslında thread sayısını fazla vermek context switch bazlı çok da bir verimsizlik oluşturmaz fakat CPU bazlı işlerin çoğunlukta olduğu işlerde (hesaplama vs) thread sayısına dikkat edilmedir.

    Stack, Heap ve Thread-Local Memory Kavramlarına Bakış

    Threadlerde memory kavramı benimde epeyce uzak ve yabancı olduğum bir konuydu. Fakat önemli olduğunu düşündüğüm için üzerinde notlar oluşturmanın ve burada değinmenin önemli olacağını düşünüyorum.

    Stack için kısaca en güvenli bellek alanı diyebilir. Stack alanı threade özgü bir alandır ve diğer threadler tarafından görülemez ve erişilemez . Bu sebeple safedir ve race conditionu engeller. Metot çağrıları , return typelar , metot parametreleri ve local değişkenler burada tutulur. Garson bir thread ise Stack cebindeki notlardır.

    Heap ise new ile oluşturduğumuz nesnelerin barındığı alandır. Tüm threadler erişebilir ve değiştirebilir. Bu sebeple race conditiona açıktır. Heap için mutfakta ortak kullanabilir durumdaki bir tencereyi örnek verebiliriz. Eğer herkes tencereye erişip içine bir şeyler doğramaya çalışırsa kaos çıkabilir 🙂 . Bunu engellemek için synchronized , Lock vs gibi yapılar kullanabiliriz.

    ThreadLocal ise Stack ve Heap’in karması diyebiliriz. Anlaması biraz daha güç ama anladığım kadarı ile şöyle açıklayayım. Fiziksel olarak heap ama mantıksal olarak stack özelliklerini taşıyor. Threadler ThreadLocal ile kendilerine özgü kopyalar tutar ve bu kopyaları başka threadler göremez.

    ThreadLocal<Integer> threadId = ThreadLocal.withInitial(() -> 0);
    threadId.set(1905);
    System.out.println(threadId.get());

    Bu şekilde bir setleme ile farklı threadler farklı idler dönecektir.

    Peki neden ThreadLocal gibi bir şeye ihtiyaç duyuyoruz ? Bir threadin set ettiği bir şeyi başka bir thread override edip async çağırımlarda bağlamın kopmasına neden olmasın diye.

    ThreadLocal<UserContext> currentUser;

    Bu şekilde bir kullanımda her thread kendi UserContext bilgisini taşıyacaktır. Burada dikkat etmemiz gereken en önemli şey eğer iş bittikten sonra bir yerde currentUser.remove() etmezsek memory leak e neden olabiliriz.

    Bugünlü bu yazınında sonuna geldik , bir sonraki yazıda görüşmek üzere 🙂