İçeriğe geç
Educora
İleri24 dk15 / 18

Java'da eşzamanlılık

İş parçacıkları, `ExecutorService`, `CompletableFuture`, `synchronized` ve atomik türlerle birden çok işi aynı anda yap; Java 21'in sanal iş parçacıklarıyla tanış.

Kendini test et
Bu derste öğreneceklerin
  • Bir iş parçacığı oluşturmak, start() ile başlatmak ve join() ile beklemek
  • Bir yarış durumunu tanımak ve onu synchronized ya da AtomicInteger ile gidermek
  • Görevleri ExecutorService, CompletableFuture ve sanal iş parçacıklarıyla çalıştırmak

Tek kasası olan bir markette kuyruk uzar, beş kasa açılınca ise müşterilere hızla hizmet verilir. Bilgisayarın işlemcisinde de birkaç çekirdek vardır ve bir sunucu aynı anda binlerce isteğe yanıt vermek zorundadır. Birden çok işi aynı anda yapabilme yeteneğine eşzamanlılık (concurrency) denir. Java bu alanda en güçlü dillerden biridir: basit iş parçacıklarından Java 21'in sanal iş parçacıklarına kadar.

Tanım
İş parçacığı (thread)

Bir programın içindeki bağımsız bir yürütme hattı. Her Java programı main adlı tek bir iş parçacığıyla başlar; yeni iş parçacıkları oluşturup işi aralarında bölüştürebilirsin. İş parçacıkları aynı belleği, yani aynı nesneleri paylaşır; bu hem kullanışlı hem de tehlikelidir.

İş parçacığı oluşturmak: start ve join

Yapılacak işi new Thread(...) yapıcısına bir lambda olarak veriyoruz. start() yeni iş parçacığını başlatır ve hemen geri döner; main kendi işine devam eder. join() ise o iş parçacığı bitene kadar bekler. Aşağıda 1'den 1.000.000'a kadar sayıların toplamı iki yarıya bölündü ve her yarıyı ayrı bir iş parçacığı hesaplıyor; sonuçları ancak join()'den sonra okuyoruz.

Java
public class Main {
    public static void main(String[] args) throws InterruptedException {
        long[] sums = new long[2];
        Thread first = new Thread(() -> sums[0] = sum(1, 500_000));
        Thread second = new Thread(() -> sums[1] = sum(500_001, 1_000_000));
        first.start();
        second.start();
        first.join();
        second.join();
        System.out.println("Part 1: " + sums[0]);
        System.out.println("Part 2: " + sums[1]);
        System.out.println("Total:  " + (sums[0] + sums[1]));
        System.out.println("Printed by: " + Thread.currentThread().getName());
    }

    static long sum(long from, long to) {
        long total = 0;
        for (long i = from; i <= to; i++) total += i;
        return total;
    }
}
Beklenen çıktı
Part 1: 125000250000
Part 2: 375000250000
Total:  500000500000
Printed by: main
İki iş parçacığı işi paylaşır, main ise onları join() ile bekler
Yanlış: `run()` sıradan bir metot çağrısıdır
Thread t = new Thread(() -> System.out.println(Thread.currentThread().getName()));
t.run();     // prints main: no new thread was created!
Doğru: `start()` yeni bir iş parçacığı açar
Thread t = new Thread(() -> System.out.println(Thread.currentThread().getName()));
t.start();   // prints Thread-0: the code runs in a new thread
t.join();

Paylaşılan veri: yarış durumu

count++ tek bir işlem gibi görünür ama aslında üç adımdır: değeri oku, 1 ekle, geri yaz. İki iş parçacığı aynı anda okursa ikisi de aynı değeri yazar ve bir artırma kaybolur. Bu hataya yarış durumu (race condition) denir: sonuç, iş parçacıklarının rastgele sırasına bağlıdır. Bir denemede 100.000'lik döngüler çalıştıran iki iş parçacığı 200.000 yerine örneğin 121.962 verdi; her çalıştırmada da farklı bir sayı çıkar.

Yarış durumu
static int count = 0;

// each of two threads runs:
for (int i = 0; i < 100_000; i++) {
    count++;                    // read + add + write: not atomic
}
// result: often less than 200000
Atomik sayaç
static final AtomicInteger count = new AtomicInteger();

// each of two threads runs:
for (int i = 0; i < 100_000; i++) {
    count.incrementAndGet();    // one indivisible operation
}
// result: always 200000

Sorunu çözmenin iki ana yolu var. synchronized anahtar sözcüğü metodu kilitler: içinde aynı anda yalnızca bir iş parçacığı bulunabilir, diğerleri sırasını bekler. java.util.concurrent.atomic paketindeki AtomicInteger, AtomicLong gibi sınıflar ise artırmayı kilitsiz, bölünmez tek bir işlemci işlemiyle yapar ve genellikle daha hızlıdır. Koleksiyonlar için hazır güvenli sürümler vardır: ConcurrentHashMap, CopyOnWriteArrayList.

Java
import java.util.concurrent.atomic.AtomicInteger;

public class Main {
    static int syncCount = 0;
    static final AtomicInteger atomicCount = new AtomicInteger();

    static synchronized void incrementSync() {
        syncCount++;
    }

    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            for (int i = 0; i < 100_000; i++) {
                incrementSync();
                atomicCount.incrementAndGet();
            }
        };
        Thread a = new Thread(task);
        Thread b = new Thread(task);
        a.start();
        b.start();
        a.join();
        b.join();
        System.out.println("synchronized: " + syncCount);
        System.out.println("AtomicInteger: " + atomicCount.get());
    }
}
Beklenen çıktı
synchronized: 200000
AtomicInteger: 200000
İki yöntem de her zaman 200.000 verir

ExecutorService: iş parçacığı havuzu

Her görev için yeni bir iş parçacığı oluşturmak pahalıdır. ExecutorService, hazır iş parçacıklarından oluşan bir havuz (pool) tutar ve görevleri onlara dağıtır. submit tek bir görev verir ve bir Future döndürür; yani gelecekteki sonucun “makbuzunu”. invokeAll ise bir görev listesi verir ve hepsini bekler. Future.get() sonuç hazır olana kadar bekler. Java 19'dan beri bir ExecutorService, try-with-resources ile kapatılabilir: blok bittiğinde tüm görevler beklenir ve havuz kapanır.

Java
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class Main {
    public static void main(String[] args) throws Exception {
        List<String> classes = List.of("10A", "10B", "11A");
        long start = System.currentTimeMillis();
        try (ExecutorService pool = Executors.newFixedThreadPool(3)) {
            List<Callable<String>> jobs = classes.stream()
                    .map(c -> (Callable<String>) () -> buildReport(c))
                    .toList();
            for (Future<String> f : pool.invokeAll(jobs)) {
                System.out.println(f.get());
            }
        }
        long ms = System.currentTimeMillis() - start;
        System.out.println("Parallel was faster: " + (ms < 900));
    }

    static String buildReport(String name) throws InterruptedException {
        Thread.sleep(300);   // pretend to read the database
        return "Report for " + name + " is ready";
    }
}
Beklenen çıktı
Report for 10A is ready
Report for 10B is ready
Report for 11A is ready
Parallel was faster: true
Her rapor 300 ms sürüyor ama üçü birlikte yaklaşık 300 ms'de hazır oluyor

CompletableFuture: asenkron zincirler

Future.get() iş parçacığını engeller. CompletableFuture ise sonuç geldiğinde ne yapılacağını önceden tarif etmeni sağlar: supplyAsync işi arka planda başlatır, thenApply sonucu dönüştürür, thenCombine iki bağımsız sonucu birleştirir, exceptionally ise bir hata olursa yedek bir değer verir. Sonunda join() nihai sonucu alır. Bu, JavaScript'teki Promise'e çok benzer.

Java
import java.util.concurrent.CompletableFuture;

public class Main {
    public static void main(String[] args) {
        CompletableFuture<Integer> exam = CompletableFuture.supplyAsync(() -> slow(92, 300));
        CompletableFuture<Integer> bonus = CompletableFuture.supplyAsync(() -> slow(5, 200));

        CompletableFuture<String> report = exam
                .thenCombine(bonus, Integer::sum)
                .thenApply(total -> Math.min(total, 100))
                .thenApply(total -> "Final score: " + total);
        System.out.println(report.join());

        CompletableFuture<Integer> broken = CompletableFuture
                .supplyAsync(() -> Integer.parseInt("ninety"))
                .exceptionally(ex -> -1);
        System.out.println("With fallback: " + broken.join());
    }

    static int slow(int value, long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return value;
    }
}
Beklenen çıktı
Final score: 97
With fallback: -1
Sınav puanı ve bonus paralel hesaplanır; ninety sayıya çevrilemediği için yedek değer -1 devreye girer

Sanal iş parçacıkları (Java 21)

Sıradan (platform) bir iş parçacığı işletim sisteminin bir iş parçacığına bağlıdır ve yığını için genellikle yaklaşık 1 MB bellek ayırır; bu yüzden binlercesini oluşturmak zordur. Java 21 ile gelen sanal iş parçacıkları ise JVM'nin yönettiği hafif iş parçacıklarıdır: beklerken (ağ, dosya, veritabanı) alttaki iş parçacığını serbest bırakırlar ve milyonlarcası oluşturulabilir. Aşağıda 10.000 görevin her biri 1 saniye “bekliyor”. 100 iş parçacıklı sıradan bir havuzda bu yaklaşık 100 saniye sürerdi; sanal iş parçacıklarıyla ise bir iki saniye sürer.

Java
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;

public class Main {
    public static void main(String[] args) {
        AtomicInteger done = new AtomicInteger();
        Instant start = Instant.now();
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 10_000; i++) {
                executor.submit(() -> {
                    Thread.sleep(Duration.ofSeconds(1));   // a slow network call
                    done.incrementAndGet();
                    return null;
                });
            }
        }   // close() waits until every task has finished
        Duration took = Duration.between(start, Instant.now());
        System.out.println("Finished tasks: " + done.get());
        System.out.println("Took less than 5 s: " + (took.toSeconds() < 5));

        Thread vt = Thread.ofVirtual().start(() -> {});
        System.out.println("Is virtual: " + vt.isVirtual());
    }
}
Beklenen çıktı
Finished tasks: 10000
Took less than 5 s: true
Is virtual: true
Her görev için ayrı bir sanal iş parçacığı: newVirtualThreadPerTaskExecutor()

Sanal iş parçacıklarıyla yazılan kod, sıradan ardışık koda benzer: Thread.sleep, dosya okuma ve veritabanı sorgusu her zamanki gibi yazılır; geri çağırmalara ve zincirlere gerek kalmaz. Tek bir iş parçacığını Thread.ofVirtual().start(...) ya da Thread.startVirtualThread(...) ile de başlatabilirsin. Web sunucuları da bundan yararlanır: örneğin Spring Boot 3.2'den beri spring.threads.virtual.enabled=true ayarı her HTTP isteğini sanal bir iş parçacığında işler.

DurumAraç
Çok bekleme: HTTP istekleri, veritabanları, dosyalarnewVirtualThreadPerTaskExecutor()
Ağır hesaplamalar (CPU)newFixedThreadPool(cores)
Sonuçları zincirleme ve birleştirmeCompletableFuture
Paylaşılan sayaç ya da eşlemAtomicInteger, ConcurrentHashMap

Önemli noktalar

  • start() yeni bir iş parçacığı açar, join() onu bekler; run() ise iş parçacığı oluşturmaz.
  • Paylaşılan değişken veri yarış durumuna yol açar; çözümler synchronized, atomik türler ve ConcurrentHashMap'tir.
  • ExecutorService iş parçacıklarını yeniden kullanır; submit ve invokeAll Future döndürür.
  • Bir CompletableFuture zinciri: supplyAsync işi başlatır, thenApply / thenCombine sonuçları dönüştürür ve birleştirir, exceptionally hataları yakalar, join ise nihai değeri alır.
  • Java 21'in sanal iş parçacıkları beklemeli işler içindir; hesaplama için çekirdek sayısı kadar iş parçacıklı bir havuz seç.

Kendini test et

10 soru. Her doğru cevap XP kazandırır.

1 / 10
t.start() yerine t.run() çağırırsan ne olur?