Java Thread Kullanımı ve Özellikler

Posted by dogukanhan on July 15, 2018 · 13 mins read

Çoğu yeni başlıyan ve orta seviye yazılımcılar için anlaşılması güç olan Thread’ler hakkında ortada konuyu yeterince özetleyen kaynakların olmadığını farkettim. Bu problemi ortadan kaldırmak adına bir çok yazılımcı için bu yazının hazırlanmasını uygun gördüm. Yazı boyunca Thread yapısının ne işe yaradığını ve nasıl kullanılması gerektiğini çeşitli örnek senaryolar üzerinden açıklıyor olacağım.

İşlemci bilgisayarın en önemli parçası olarak bir yazılım tarafından en iyi şekilde kullanılmalıdır. Daha iyi bir dil ile açıklanacak olursa yazılan bir yazılım işlemciyi bekletmemeli ve sürekli işlem yaptırıyor olmalıdır. İşletim sistemlerinde bulunan Görev Yöntecisi veya top gibi bir yazılım üzerinden işlemcinin kullanım yüzdesi görüntülenebilir. Bu yüzde bir averaj değeridir ve işlemcinin 1, 2, 5 veya 10 saniyelik kullanım averaj oranını gösterir. İyi bir yazılım işlemciyi minimum sürede maksimum kullanmalıdır. İşlemciyi kullanmadığı zamanlardada işlemciyi bekletmemelidir.

Bir yazılım tarafından işlemcinin beklemesinin temel sebebi I/O(giriş/çıkış) işlemleri olarak adlandırdığımız işlemlerdir. İşlemci çok yüksek hızlarda veri transferi yapabilmesine karşın I/O bunun çok ve çok altındadır. Yazımlarda I/O işlemleri için en iyi örnek dosya ve network işlemleridir. Yazılımımız bu işlemlerden birisini gerçekleştiriyorsa işlemci beklemek zorundadır. Bekleme sırasında işlemciye yaptırabileceğimiz hesaplamalar ve çeşitli işlemler var ise bunları gerçekleştirerek işlemciyi verimli kullanmış oluruz. Bunun gibi bir işlemin gerçekleştirilebilmesi için Thread yapısını kullanırız.

Şimdi bir örnek ile bu anlatılanları daha iyi açılıyalım.

Yazdığımız yazılım Job1 ve Job2 işlemlerini gerçekleştirecektir. Job1 işleminin gerçekleşme süresi 1000 Ms ve Job2 İşleminin gerçekleşme süresi 1500 Ms olarak bilinmektedir. Şimdi bu işlemleri sırası ile gerçekleştiren kodu yazalım.

public class Main {

    public static void job1() throws InterruptedException {
        System.out.println(" Job1 Start");
        Thread.sleep(1000);
        System.out.println(" Job1 Finished");
    }

    public static void job2() throws InterruptedException {
        System.out.println(" Job2 Start");
        Thread.sleep(1500);
        System.out.println(" Job2 Finished");
    }

    public static void main(String[] args) throws Exception {
        long startTime = System.currentTimeMillis();
        job1();
        job2();
        long endTime = System.currentTimeMillis();
        System.out.println("Total time passed "+(endTime-startTime));
    }
}

Kodu çalıştırdığımız zaman sonuç olarak aşağıdaki çıktıyı alacağız. Süre küçük farklılıklar gösterebilir ama 2500’ün altına düşmez.

Job1 Start
Job1 Finished
Job2 Start
Job2 Finished
Total time passed 2501

System.currentTimeMilis() Şuanki zamanın milisaniye cinsinden değerini getiren bir fonksiyondur ve geçen süreyi hesaplamak için kullanılmıştır. Job1 ve Job2 nin I/O işlemleri olduğunu ve bu sırada bekleme gerçekleştiğini düşünelim. Yazılımımız yüksek bir zaman bekleme yapıyor, işlemciyi kullanamıyor. Tabii ki burada belirtmek isterim her an işlemciyi kullanan bir yazılım geliştiremeyiz,bazı şeyleri beklemek zorundayız ama gereksiz bekleme yapmamalıyız. Örnekte Job2 işlemi başlayabilmek için Job1 İşlemini 1000Ms bekliyor.Buradaki temel problemin çözümü için Thread kullanabiliriz artık. Yaptığımız iki işlemi eş zamanlı olarak başlatırsak daha verimli bir yazılım geliştirmiş oluruz.

public class Main {

    public static void job1() throws InterruptedException {
        System.out.println(" Job1 Start");
        Thread.sleep(1000);
        System.out.println(" Job1 Finished");
    }

    public static void job2() throws InterruptedException {
        System.out.println(" Job2 Start");
        Thread.sleep(1500);
        System.out.println(" Job2 Finished");
    }

    public static void main(String[] args){
        long startTime = System.currentTimeMillis();
        Thread job1= new Thread(()-> {
            try {
                job1();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        Thread job2 = new Thread(()-> {
            try {
                job2();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        job1.start(); // Job1 Thread Start
        job2.start(); // Job2 Thread Start
        while(job1.isAlive() || job2.isAlive()){
                // Wait job1 and job2
        }
        long endTime = System.currentTimeMillis();
        System.out.println("Total time passed "+(endTime-startTime));
    }
}

Eğer kodu çalıştırırsak aşağıdakine yakın bir sonuç elde edeceğiz:

Job1 Start
Job2 Start
Job1 Finished
Job2 Finished
Total time passed 1557

Örnekte de görüldüğü üzere 2500Ms’nin altına düşmeyen bir süre gerektiren önceki programımızla aynı işlemi yaklaşık 1500Ms içerisinde gerçekleştiren bir kod yazmış olduk. 1000Ms bir yazılımın optimizasyonu için büyük bir süre. İşlemciyi ve kaynakları daha verimli kullanabilmiş olduk. Thread nesnemizi yarattıktan sonra start metotlarını kullanarak yaratılan Thread’in işleme başlamasını sağlıyoruz. Bu programda 3 tane Thread var bunlardan ikisini görebiliyoruz.Üçüncüsü ise Main Thread.While döngüsünü koymamın asıl sebebi bu Thread’i diğer iki Thread’in işlemi bitene kadar bekletmektir.Eğer bekletmezsek işlemine devam eder ve geçen süreyi yazar ki bu da hatalı bir sonuç olur bizim için.

Bu örnekteki işlemi multi-thread bir yapıya çevirebilmemizin sebebi Job1 ve Job2 işlemlerinin birbirlerini bekleme zorunluluğunun olmamasıdır. Eğer Job2, Job1’den gelecek bir String değişkene ihtiyacı olsaydı Job1in bitmesini ve o değişkeni verene kadar beklemesi gerekecekti. Bu da Thread’liyemeceğimiz anlamına geliyor, fakat şu anki örnekte bunun gibi bir durum yok.

Thread’lerle ilgili diğer önemli bir konu da ortak değişkenlere erişimi ve bunun kullanımıdır.

Thread’lerin start metodu bir kez çağrılabilir ve çağrıldıktan sonra ne zaman çalışacakları asla garanti değildir. Bu yüzden Thread’ler ile yapılan işlemler standart sıralı çalışan tek Thread programlar gibi düşünülmemelidir.

public class Main {
    private static int a = 0;
    public static void job1(){
        a=1;
    }

    public static void job2()  {
        a=2;
    }
    public static void main(String[] args){
        Thread job1= new Thread(()-> {
            job1();
        });
        Thread job2 = new Thread(()-> {
            job2();
        });
        job1.start(); // Job1 Thread Start
        job2.start(); // Job2 Thread Start
        System.out.println("Final a= "+a);
    }
}

Kodu inceleyelim sizce A’nın ekrana yazılan değeri kaçtır? Cevap:Only God knows.

Aslında A’nın değeri hakkında bir tahminde bulunabiliriz. 0, 1 veya 2’den olacağı kesindir. 0 yazma durumunu ele alalım: Main Thread diğer iki Thread’i yaratmış ve start metodunu çağırmıştır fakat iki Thread’den herhangi birisi daha başlamamıştır ve ekrana 0 yazar. 1 alma durumunda ise job1 adını verdiğimiz Thread çalışmıştır ve değişkenin değerini değiştirmiştir ve job2 adlı thread daha çalışmaya başlamamıştır, Main Thread ekrana yazacağı sırada değer 1 olmuştur. 2 olma durumunda ise benzer şekilde job2 çalışmaya başlamıştır ve değeri 2 olarak değiştirmiştir. Burada dikkat edilmesi gereken en önemli nokta Main Thread Job1 Thread’inin start’ını Job2 Thread’inden önce başlatmıştır ama bu bir anlam ifade etmemektedir. Yüksek ihtimalle job1 daha erken başlar ama hiçbir zaman garanti değildir.

Tabii ki ortak değişkene erişim konusunda bu çok iyimser bir örnekti çünkü bütün Threadler değişkenin değerine bakmadan değiştiriyordu. Şimdi daha farklı bir örneğe değinelim:

public class Main {
    private static int a = 0;

    public static void job1() {
        a = a + 1;
    }

    public static void job2() {
        a = a + 1;
    }

    public static void main(String[] args) {
        Thread job1 = new Thread(() -> {
            job1();
        });
        Thread job2 = new Thread(() -> {
            job2();
        });
        job1.start(); // Job1 Thread Start
        job2.start(); // Job2 Thread Start
        a = a + 1;
        System.out.println("Final a= " + a);
    }
}

Sizce a değişkeninin son değeri nedir? Cevap:Only God knows.

Bu örnek biraz daha karmaşık çünkü çalışan her Thread a’nın değerini değiştirmeden önce a’nın son değerini alıyor. Burada ortaya çıkacak problemi bir durum üzerinden özetleyeyim. Job2 ve Job1 Threadleri başlamış olsunlar.job1 Threadi a’nın değerini çeker ve 0 olarak hafızaya alır, aynı şekilde Job1 Threadide a’nın değerini 0 olarak hafızasına alır, ikisi de a’nın değerini 1 değer artıracaktır. Yani Job1 ve Job2 artık hafızasında a’yı 1 olarak tutmaktadır ve Job1 a yı 1 olarak değiştirir, aynı şekilde Job2’de hafızasında bulunan a değerini 1 olarak değiştirir ve yazar a nın değeri 1 olmuştur. Eğer sıralı olarak çalıştırsaydık a’nın değeri sabit olarak 3 olabilirdi.Fakat bunun gibi bir durumda hangi Thread’in önce erişip değiştireceği veya Threadlerin a’nın en sonki hangi değerine eriştiğini bilemeyiz ve ortaya beklediğimizden çok farklı sonuçlar çıkabilir. Bu problemin çözümüne yönelik Atomic sınıfı değişkenler bulunmaktadır, bu değişkenler bir Thread değerine eriştiği zaman erişmek isteyen diğer Threadleri bir sırada bekletir böylelikle bütün Threadler güncel olan değeri kullanır tabii ki bu değişkenin kullanımı Threadleri bekleteceği için yazılımı yavaşlatacaktır. Bu yüzden sadece ortak erişilecek değişkenler atomic olmalıdır.

Aşağıdaki örnekte a’nın final değeri her zaman 2 olacaktır.

import java.util.concurrent.atomic.AtomicInteger;

public class Main {
    private static AtomicInteger a = new AtomicInteger();
    public static void job1() {
        a.incrementAndGet();
    }

    public static void job2() {
        a.incrementAndGet();
    }
    public static void main(String[] args) {
        Thread job1 = new Thread(() -> {
            job1();
        });
        Thread job2 = new Thread(() -> {
            job2();
        });
        job1.start(); // Job1 Thread Start
        job2.start(); // Job2 Thread Start
        while(job1.isAlive()|| job2.isAlive()){
        }
        System.out.println("Final a= " + a);
    }
}

Bazen değişkenlerdense metotları kilitlemek isteyebiliriz. Bu,bizi duruma göre Atomic değişken kullanmaktan kurtarabilir. Kilitlenen bir metoda aynı anda sadece bir Thread girebilir ve diğer Threadler önceki Thread’in işlemi bitene kadar kuyrukta tutulur, bekletilir.

public class Main {

    public static synchronized void job(){
        System.out.println(Thread.currentThread().getName()+" in here");
    }
    public static void main(String[] args) {
        Thread job1 = new Thread(() -> {
            job();
        },"Thread 1");
        Thread job2 = new Thread(() -> {
            job();
        },"Thread 2");
        job1.start(); // Job1 Thread Start
        job2.start(); // Job2 Thread Start
    
     }
}

Yukarıdaki kodu çalıştırdığımızda aşağıdakine yakın bir sonuç alırız:

Thread 1 in here
Thread 2 in here

Kodda Threadlere öncelikle isim verdim ve currentThread metodunu kullanarak o metodda aktif bulunan Thread’in ismini yazdırdım. Bir metot synchronized ile tanımlandıysa o metoda aynı anda yalnızca bir Thread girebilir. Fakat belirtmek isterim ki yukarıdaki kodda hangi Thread’in önce gireceği garanti değildir. Çünkü start metodu çağrıldıktan sonra çalışan ilk threadi bilemeyiz sadece garanti olan o metotta yalnızca bir thread olabilir. Synchronized tanımı da yalnızca gerektiğinde kullanılmalıdır. Metodun çalışma süresini 1000Ms olarak düşünelim eğer 2 tane metot synchronized ise threadler sıra ile gireceklerinden 1000Ms biri 1000Ms ise diğeri için toplam 2000Ms tutacaktır fakat böyle bir tanım kullanılmazsa eş zamanlı olarak ikisi de aynı anda girebilir ve yaklaşık toplam 1000Ms’de işlem bitebilir.

Synchronized kullanımından ortaya çıkan bir problem ise Deadlock‘tur. Çok ciddi bir bug olan deadlock Multi-Thread yazılımınızı bitirebilir. Deadlock iki tane Threadin sürekli birbirini beklemesi sonucu ortaya çıkan bir problemdir.Girdikleri metodları kitleyen Threadler sonsuza kadar birbirlerini beklerler.

public class Main {

    public static synchronized  void b(){
        System.out.println(Thread.currentThread().getName()+" in b ");
    }
    public static synchronized void a(){
        System.out.println(Thread.currentThread().getName()+" in a ");
    }

    public static void main(String[] args) {
        Thread job1 = new Thread(() -> {
            a();
            b();
        },"Thread 1");
        Thread job2 = new Thread(() -> {
            b();
            a();
        },"Thread 2");
        job1.start(); // Job1 Thread Start
        job2.start(); // Job2 Thread Start

     }
}

Thread1’imiz sırasıyla a ve b metotlarını çalıştıracaktır. Thread2 ise b ve a metotlarını sırası ile çalıştıracaktır. Metotların ikisi de synchronized ile tanımlıdır, yani aynı anda sadece bir Thread erişim sağlayabilir. Bu kod deadlock oluşturabilir. Thread1’in a metoduna girdiğini, Thread2’nin ise b metoduna girdiğini düşünelim. İkisi de bu metotları kilitleyecekti. Bundan sonra işleri bitince Thread1 b metoduna girmek isteyecektir fakat b metodu Thread2 tarafından kilitlidir ve kilit yalnızca a metodunun kilidi açıldığında açılacaktır ki bu da Thread1 oradan çıkmadan olamaz. İki Thread sonsuza kadar birbirlerini beklerler ve deadlock durumu oluşur.

 

Böylelikle bu yazı boyunca Java ve Thread mekanizmasını çeşitli örnekler ile incelemiş olduk. Bu konu hakkında anlatılacakları genel bir başlık altında derlemek istedim, konunun genişliği itibariyle özetlemesi zor olduğundan sistemi ve bilindik önemli durumları ön plana çıkarmaya çalıştım. Umarım sizler için faydalı bir yazı olmuştur. Bir sonraki yazıda görüşmek üzere.