Java Threads

ümit Samimi
8 min readMar 13, 2022

Herhangi bir dili kullanarak, bir program yazdığınızda o programın bir görevi olur. Aslında hangi talimatları gerçekleştireceğini, programı yazan kişi belirler.

Yazdığınız programı işletim sistemi onu ana belleğe(main memory) yükler. Yazdığınız programa ya da uygulamaya kavramsal olarak process denilebilir.

Thread ise o process içerisinde çalışan iş parçacığıdır. Bir process içerisinde onlarca ya da yüzlerce iş parçacığı yani thread çalışabilir. Eğer uygulama içerisinde hiç thread oluşturmazsanız belirttiğiniz talimatlar main thread ile gerçekleştirilecektir

Oluşturduğunuz her yeni thread aslında CPU’yu kullanarak talimatın bağımsız yani paralel yürütülmesini sağlar tabi oluşturduğunuz thread kadar CPU’nuz da olmalı

Tam da burada concurrency vs parallelism gibi kavramlar var. Eğer detaylı öğrenmek isterseniz, aşağıdaki linkte bir miktar açıklamaya çalıştım.

Daha önce Java Memory Model başlıklı yazımda stack ve heap’in ne olduğundan kısaca bahsetmiştim. Burada tekrar stack ve heap’in çalışma mantığının üzerinden geçmeyeceğim. Dilerseniz oraya da göz atabilirsiniz.

Şimdi özet olarak concurrency ve parallelism kavramlarını açıklamaya çalışacağım

Parallel Execution: Uygulama bir çok işi eşzamanlı olarak yani paralel gerçekleştirmesine denir. Örneğin 4-core CPU bir makinada her çekirdek eş zamanlı çalışarak 4 paralel işlem yapabilir.

Concurrent Execution: Bir önceki örnekte 4-core CPU vardı ve 4 iş parçacığı oluşturulmuştu. Ya CPU sayısından fazla iş parçacığı oluşturulursa? Bu tür durumlarda her çekirdeğin bir iş devam ederken başka bir thread’in talimatını alıp işlmesi durumuna concurency denir.

12 thread ,4 CPU’luk bir makinada yukarıdaki gibi çalışabilir. Bunun için genellikle verilen örnek, benzin istasyonundaki pompacıdır. İki araç, iki pompacının olduğu benzin istasyonuna geldiğinde, istasyon eş zamanlı olarak 2 araca aynanda hizmet verebilir

Ya 4 araç benzin almaya aynı anda gelirse, 2 pompacı aynanda 4'üne hizmet verebilir mi? Bunun cevabı kısmi evettir. Bir pompacı elindeki pompayı bir araca yerleştirdiğinde, benzin depoya dolarken bir başka aracın pompasını takmaya gidebilir.

Yani aslında eğer yeteri kadar cpu olmasa bile bazı işlemler concurrent devam edebilir. Peki bu işlemleri nasıl kategorize edebiliriz?

Direk CPU ile yapılan işlemler ve CPU’dan bağımsız devam eden işlemler olarak ikiye ayırabiliriz.

Bir iş parçacığı, network’e bağlandıktan sonra CPU yapacağı bir iş kalmamış oluyor diyebiliriz. Network ile ilgili iş bitene kadar CPU başka bir iş yapmaya devam edebilir(blocked/waiting state, reading from DB, sending HTTP request). Buna CPU context switch denilir.

Fakat bir başka iş parçacığı, for döngüsü içerisinde 1000.000 adet toplama işlemi yapıyorsa, bu I/O operasyonundan bağımsız sadece CPU ile ilgilidir. Bu ve benzeri durumlarda concurrent devam edilebilmesi mümkün değildir.

Biz işlemleri CPU-bound ve IO-Bound olarak 2 kategoriye ayırmıştık fakat modern işletim sistemlerinde 2 tip thread vardır

Kernel Threads

Kernel Thread’leri için işletim sistemi threadleri denilebilir. Bu thread’ler işletim sistemi tarafından yönetilir ve yine işletim sistemi tarafından planlanarak çalıştırılır.

User Thread

Kullanıcı seviyesindeki threadlerdir. Yönetimi ve çalıştırılma zamanı kullandığınız dilin kütüphaneleri ile ilişkilidir. Kernel thread’lerine göre hem hızlıdır hem de daha lightweight ‘tir fakat işletim sisteminin çekirdeğine doğrudan erişme, karar verme gibi daha derin özellikleri kullanma yetkileri yoktur

User Thread oluşturulduğunda, Kernel Thread’ler ile bir yerde çalıştırılması için eşleştirilmesi gerekmektedir. Bu da 3 farklı yöntem ile mümkündür.

  • One to one method: Her bir kullanıcı thread’i, 1–1 kernel thread’i ile eşleşir
  • Many to one method: Tüm kullanıcı thread’leri tek bir kernel thread’i ile eşleştiği yöntemdir.
  • Pool method: Tüm kullanıcı threadleri bir pool’da toplanır ve ihtiyaca göre farklı kernel thread’leri ile eşleşir

Thread’ler kendilerine verilen işleri hafızadaki verilere erişim sağlayarak tamamlarlar. Örneğin bir nesne oluşturursanız, bu nesne heap’te tutulur. O nesneye farklı thread’ler erişim sağlayabilir fakat thread’ler hızlı çalışabilmek için kendi yerel (local) cache’lerini de kullanırlar.

Klasik verilen örnek banka hesabıdır. İki thread, ikisi de kullanıcının banka hesabındaki tutara erişmek istiyor. Birisi arttıracak, birisi azaltacak ama ikisi de bu işlemi başlangıçtaki miktara göre yapacak. Birbirlerinden habersiz, başlangıç değerine göre biri arttırırken, diğer de azaltacak. Okuma/yazma işlemi önce thread’in local cache’inden daha sonra da heap’ten yapılacak. Buna “visibility” sorunu denir.

Thread’ler çalışırken, veriyi direk heap’e yansıtmazlar çünkü register’lara erişim daha hızlıdır. Tabi register üzerindeki kaynak miktarı da daha azdır. Sonrasında cache’e yazılır. En son heap’e yansıtılır fakat bu süre içerisinde bir başka thread, ilgili değeri güncellemiş olabilir. (visibility problem)

Tabi bu arada verinin ne zaman cache’e, ne zaman heap’e yazılacağına işletim sistemindeki algoritmalar/configürasyonlar belirler

İleri ki makalelerde zaten işliyor olacağız ama şimdi kısaca bahsetmek gerekirse, eğer local cache’e yazmak yerine direkt heap’e yansıtılmasını istiyorsanız güncellenen verinin, “volatile” anahtar kelimesini kullanmanız gerekmektedir. Başka bir çözüm ise ilgili değişkene/kod bloğuna synchronized/lock işlemlerini uygulamaktır. Böylece iki farklı thread’in aynanda erişimini engellemiş olacaksınız.

Tabi bu yöntemler farklı maliyetler içermektedir. Örneğin hızlı çalışmasının önüne geçecektir ve uygulamanızın bu bölümü daha yavaş çalışacaktır.

Peki bir thread nasıl oluşturulur?

Yukarıda da bahsettiğimiz üzere, eğer bir thread oluşturmazsanız, akış main thread üzerinden gerçekleştirilecektir.

public class ThreadExample {
public static void main(String[] args) {
System.out.println("Thread Name : " + Thread.currentThread().getName());
}
}
//Output
Thread Name : main

Gördüğünüz üzere eğer yeni bir thread oluşturmadan bir işlem yürütüyorsanız, o işlem main thread üzerinden ilerliyor. Şimdi de kendimiz bir thread oluşturalım.

public class ThreadExample {
private static class MyThread extends Thread{
@Override
public void run(){
System.out.println("Thread Name : " + Thread.currentThread().getName());
}
}
public static void main(String[] args) {
new MyThread().start();
new MyThread().start();
}
}
//Output
Thread Name : Thread-0
Thread Name : Thread-1

Burada kendi thread sınıfımızı oluşturduk ve bu thread’ler main thread’den ayrı olarak çalıştı ve onlara sırayla isim verdi.

Tabi java’da thread oluşturmanın yegane yolu Thread sınıfından extend etmek değil. Eğer isterseniz, Runnable arayüzünden de implement edebilirsiniz.

public class ThreadExample {
private static class MyRunnableThread implements Runnable{
@Override
public void run() {
System.out.println("Thread Name : " + Thread.currentThread().getName());
}
}
public static void main(String[] args) {
new Thread(new MyRunnableThread()).start();
new Thread(new MyRunnableThread()).start();
}
}
//Output
Thread Name : Thread-0
Thread Name : Thread-1

Hemen bir başka örneğe geçelim. Bir nesne oluşturalım. Haliyle oluşturduğumuz nesne heap içerisinde yer alacak. Peki bu heap içerisindeki nesnenin sınıf değişkenine aynanda 2 ya da daha fazla sayıda farklı thread erişirse ne olur?

public class ThreadExample {

int counter = 0;

private static void increment(ThreadExample threadExample) {
for (int i = 0; i < 1000000; i++)
threadExample.counter++;
System.out.println(Thread.currentThread().getName() + " counter : "+ threadExample.counter);
}

public static void main(String[] args) throws InterruptedException {
ThreadExample threadExample = new ThreadExample();
Thread thread1 = new Thread(() -> {
increment(threadExample);
});

Thread thread2 = new Thread(() -> {
increment(threadExample);
});

thread1.start();
thread2.start();
thread1.join();
thread2.join();

}
}
//Output
Thread-1 counter : 1727449
Thread-0 counter : 1892895

Aslında ilk thread için 1000000, ikincisi için de 2000000 yazmasını beklerdik değil mi? Fakat iki thread de aynı kaynağa erişerek işlem yapınca sonuç bu oldu. Thread-1 arttırım yaparken, diğeri de eş zamanlı olarak arttırıyor.

Peki bu sorunu nasıl çözebiliriz?

Çözüm 1 : Re-entrant Lock

Lock sınıfının kilitleme mekanizmasıyla, bir kod bloğuna iki farklı thread’in aynanda giriş yapmasını engelleyebiliriz.

public class ThreadExample {

int counter = 0;

private static Lock lock = new ReentrantLock();

private static void increment(ThreadExample threadExample) {
lock.lock();
for (int i = 0; i < 1000000; i++)
threadExample.counter++;
System.out.println(Thread.currentThread().getName() + " counter : "+ threadExample.counter);
lock.unlock();
}

public static void main(String[] args) throws InterruptedException {
ThreadExample threadExample = new ThreadExample();
Thread thread1 = new Thread(() -> {
increment(threadExample);
});

Thread thread2 = new Thread(() -> {
increment(threadExample);
});

thread1.start();
thread2.start();
thread1.join();
thread2.join();

}
}
Thread-1 counter : 1000000
Thread-0 counter : 2000000

Not: Lock sınıfının static olmasının sebebi, static method içerisinden çağrılması sebebiyledir.

Çözüm 2: synchronized

Methodu synchronized yaparsanız eğer, aynanda iki thread’in erişimini engellemiş olursunuz ve thread’ler sırayla giriş yapar bu methoda

private synchronized static void increment(ThreadExample threadExample) {
for (int i = 0; i < 1000000; i++)
threadExample.counter++;
System.out.println(Thread.currentThread().getName() + " counter : "+ threadExample.counter);
}

Çözüm 3: AtomicInteger

Tip olarak int kullanmak AtomicInteger kullanırsanız, bu da kodunuzu thread safe yapacaktır.

static AtomicInteger counter = new AtomicInteger();

Bir başka örnek üzerinden incelemeye devam edelim. Elimizde bir map var ve bu map, kargo firmalarını tutuyor. Id’ye göre kargo firmasının adınını ekliyorsunuz.

2 farklı thread aynanda aynı ID’li kaydı eklemek istiyor ve burada bir çakışma var. İkisi de aynanda map’i boş okuyabilir değil mi?

public class ThreadExample {
static Map<Integer, String> shippingFirmIdNameMap = new HashMap<>();
static Integer SHIPPING_FIRM_ID = 101;


public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(() -> {
if(!shippingFirmIdNameMap.containsKey(SHIPPING_FIRM_ID)){
shippingFirmIdNameMap.put(SHIPPING_FIRM_ID, "UPS");
}
});

Thread thread2 = new Thread(() -> {
if(!shippingFirmIdNameMap.containsKey(SHIPPING_FIRM_ID)) {
shippingFirmIdNameMap.put(SHIPPING_FIRM_ID, "Ocean Network Express");
}
});

thread1.start();
thread2.start();

thread1.join();
thread2.join();

System.out.println(shippingFirmIdNameMap.get(SHIPPING_FIRM_ID));
}
}

Bu sorunu yukarıda olduğu gibi lock’layarak veya synchronized ile çözebiliriz ya da istersek ConcurrentHashMap kullanabiliriz.

Java Executor Service

Peki 2'den fazla sayıda thread’i kullanmamız gerekiyorsa? Her biri için new Thread( () -> {}); gibi bir ifade mi kullacağız. Örneğin 20 tane thread oluşturacağız, alt alta 20 tane thread mi create edeceğiz?

Tabi ki hayır, bunu for döngüsü ile halledebiliriz.

public class ThreadExample {
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i<100; i++){
new Thread(()-> {
System.out.println(Thread.currentThread().getName());
}).start();
}
}
}

Bu işimizi görür öyle değil mi? Fakat bir sorun var, muhtemelen dikkatinizi çekmiştir. Önceki yazıda da değinmiştik. Core sayısından fazla sayıda oluşturduğunuz thread’ler, paralel çalışmazlar. Concurrent çalışırlar ama paralel çalışmazlar.

Hatta bazen thread sayının fazla olması da dezavantaj oluşturabilir. İş yapmak için sıra bekleyen(uzun süre) ama iş yapmayan thread’lerle dolu olabilir ortalık. Aynanda çalıştırabileceğin 10 thread’in var ama sen 100 tane oluşturmuşsun. Geri kalan 90 thread ne yapacak? Öyle ise oluşturulacak thread sayısı rastgele belirlenmemeli. Uygulamanın çalışacağı cihazın core size’ına göre bir algoritma geliştirilebilmeli, değil mi?

Number of threads = Number of Available Cores * (1 + Wait time / Service time)

Tabi bu işin içeriğine ve çalışan makinaya göre değişebilir. Benim tercihim buradaki formülden yana oluyor genellikle. Peki tek sorun, thread sayısının belirlenmesi mi? Bu thread’lerin yönetimi ve yönlendirilmesi de bir sorun değil mi?

Bu işleri kolaylaştırmak için java.util.concurrent paketinin altında Thread’leri asenkron olarak çalıştırmaya yarayan ExecutorService isimli bir arayüz tanımlanmış.

public class ThreadExample {
public static void main(String[] args) throws InterruptedException {
int NUMBER_OF_CORES = Runtime.getRuntime().availableProcessors(); // 8

ExecutorService executorService = Executors.newFixedThreadPool(NUMBER_OF_CORES);
for(int i = 0; i < 100; i++){
executorService.submit(() -> {
System.out.println(Thread.currentThread().getName());
});
}
}
}

Biz newFixedThreadPool’u kullanarak, core sayısı (NUMBER_OF_CORES) kadar thread oluşturup(varsayalım ki 8 olsun), 8 thread’e 100 tane işi yapmasını söyledik.

For döngüsü içerisinde 100 tane işi, executerService içerisine submit ettik. Submit edilen her iş linkedBlockingQueue isimli bir queue’ya kaydedildi. 8 adet thread, her biri işini bitirdikçe “linkedBlockingQueue” türdeki queue içerisinden yeni bir işi alacak ve işleyecek. İşlendikçe de queue içerisindeki işler azalmış olacak.

Bir önceki yazıda geçen, kasiyer örneğini düşünürsek. 100 adet müşterimiz var ve 8 tane kasamız var. 100 müşteriyi, 8 kasiyer 8 kasada eritmiş olacak.

Peki sadece 1 tane mi Thread Pool var, tabi ki hayır. Farklı ihtiyaçlara göre farklı thread pool’lar var.

  • FixedThreadPool
  • CachedThreadPool
  • ScheduledThreadPool
  • SingleThreadedExecutor

Bir sonraki yazı, Callable/Future ‘ı işleyeceğiz.

Umarım faydalı olmuştur. Sevgi ve sağlıkla kalın

--

--