İçeriğe geç

Java'da CompletableFuture ile Asenkron Programlama

T
Tolgahan
· · 11 dk okuma · 67 görüntülenme

Java'da CompletableFuture ile Asenkron Programlama

Diyelim ki bir e-ticaret uygulaması geliştiriyorsun. Kullanıcı bir ürün sayfasını açtığında arka planda şunlar oluyor: ürün bilgisi veritabanından çekiliyor, stok durumu envanter servisinden sorgulanıyor, kullanıcının geçmiş siparişlerine göre öneriler hesaplanıyor ve fiyat bilgisi döviz servisinden güncelleniyor. Bu dört işlemi sırayla yaparsan, her biri 200ms sürse bile toplam 800ms beklersin. Ama hepsi birbirinden bağımsız — neden paralel çalıştırmayasın?

İşte tam burada CompletableFuture sahneye çıkıyor. Java 8 ile hayatımıza giren bu yapı, asenkron programlamayı callback cehenneminden kurtarıp okunabilir, zincirleme bir hale getiriyor. Bu yazıda CompletableFuture'ı sıfırdan, derinlemesine öğreneceksin — sadece API'yi değil, neden böyle tasarlandığını ve gerçek projelerde nasıl kullanılacağını anlayacaksın.

Senkron vs Asenkron: Temel Fark

Bunu bir restoran analojisiyle düşün. Senkron çalışma, garsonun her masaya teker teker gidip sipariş alması, mutfağa iletmesi, yemeği beklemesi ve masaya götürmesi gibi. Bir masa bitene kadar diğerleri bekler. Asenkron çalışma ise garsonun tüm masaların siparişini alması, hepsini mutfağa iletmesi ve hazır olanı götürmesi. Garson boşa beklemez.

Java'da senkron kod yazarken her satır bir öncekinin bitmesini bekler:

// Senkron yaklaşım — her çağrı sırayla, hepsi birbirini bekler
String urunBilgisi = urunServisi.getUrun(urunId);         // 200ms
int stokDurumu = stokServisi.getStok(urunId);              // 200ms
List<String> oneriler = oneriServisi.getOneriler(userId);  // 200ms
double fiyat = dovizServisi.getFiyat(urunId, "TRY");       // 200ms
// Toplam: ~800ms — kullanıcı bekliyor

Asenkron yaklaşımda ise bu dört işlemi aynı anda başlatır, hepsi bittiğinde sonucu birleştirirsin. Toplam süre en yavaş olanın süresi kadar olur: ~200ms.

Future'ın Yetersizliği

Java 5'te gelen Future arayüzü asenkron programlamanın ilk adımıydı. Ama ciddi eksiklikleri vardı:

// Eski usul Future — bloklayan, zincirlenemeyen, hata yönetimi zor
ExecutorService executor = Executors.newFixedThreadPool(4);

Future<String> future = executor.submit(() -> {
    // Uzun süren bir işlem
    Thread.sleep(1000);
    return "Sonuç hazır";
});

// future.get() çağırana kadar thread bloklanır!
String sonuc = future.get(); // Bu satır 1 saniye bekler

Future.get() çağırdığın an thread bloklanır ve asenkronluğun tüm avantajı yok olur. Ayrıca Future'ları zincirleyemezsin — "önce şunu yap, sonra onun sonucuyla bunu yap" diyemezsin. Hata yönetimi de try-catch ile yapılır, fonksiyonel bir yaklaşım yoktur.

CompletableFuture: Modern Çözüm

CompletableFuture<T>, Future<T> arayüzünü implemente eder ama çok daha fazlasını sunar. İsmi "tamamlanabilir gelecek" anlamına gelir — yani hem asenkron bir sonucu temsil eder, hem de sen onu programatik olarak tamamlayabilirsin.

Temel Oluşturma Yöntemleri

import java.util.concurrent.CompletableFuture;

public class CompletableFutureBasics {
    public static void main(String[] args) throws Exception {
        
        // 1. supplyAsync — değer döndüren asenkron işlem
        // ForkJoinPool.commonPool() üzerinde çalışır
        CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> {
            System.out.println("Çalışan thread: " + Thread.currentThread().getName());
            return "Merhaba CompletableFuture!";
        });
        
        // 2. runAsync — değer döndürmeyen asenkron işlem (Void)
        CompletableFuture<Void> future2 = CompletableFuture.runAsync(() -> {
            System.out.println("Arka planda log yazılıyor...");
        });
        
        // 3. completedFuture — zaten tamamlanmış bir future (test için ideal)
        CompletableFuture<String> future3 = CompletableFuture.completedFuture("Hazır değer");
        
        // Sonuçları al
        System.out.println(future1.get());  // "Merhaba CompletableFuture!"
        System.out.println(future3.get());  // "Hazır değer"
    }
}

supplyAsync en çok kullanacağın metot. Bir Supplier<T> alır, arka planda çalıştırır ve CompletableFuture<T> döndürür. Varsayılan olarak ForkJoinPool.commonPool() kullanır ama kendi ExecutorService'ini de verebilirsin — birazdan buna geleceğiz.

Zincirleme Operasyonlar: Callback Hell'e Son

CompletableFuture'ın asıl gücü zincirleme (chaining) operasyonlarında. Bunlar üç ana kategoride:

thenApply — Sonucu Dönüştür

thenApply, Stream API'deki map gibi düşün. Bir önceki adımın sonucunu alır, dönüştürür ve yeni bir CompletableFuture döndürür:

CompletableFuture<String> sonuc = CompletableFuture
    .supplyAsync(() -> "  Merhaba Dünya  ")
    .thenApply(s -> s.trim())              // boşlukları temizle
    .thenApply(s -> s.toUpperCase())        // büyük harfe çevir
    .thenApply(s -> s + "!!!");             // sonuna ünlem ekle

System.out.println(sonuc.get()); // "MERHABA DÜNYA!!!"

Her thenApply bir öncekinin sonucunu alır ve yeni bir değer üretir. Kod düz bir boru hattı (pipeline) gibi okunur — callback hell yok, iç içe geçmiş yapılar yok.

thenCompose — Asenkron Zincirleme

Peki ya bir adımın sonucuyla başka bir asenkron işlem başlatmak istersen? thenApply kullanırsan CompletableFuture<CompletableFuture<String>> gibi iç içe geçmiş bir tip alırsın. İşte thenCompose bunu düzleştirir — Stream API'deki flatMap gibi:

// Senaryo: Kullanıcı ID'siyle kullanıcıyı bul, sonra siparişlerini getir

public CompletableFuture<String> kullaniciBul(int id) {
    return CompletableFuture.supplyAsync(() -> {
        // Veritabanı sorgusu simülasyonu
        System.out.println("Kullanıcı aranıyor: " + id);
        return "Tolgahan"; // kullanıcı adı
    });
}

public CompletableFuture<List<String>> siparisleriGetir(String kullaniciAdi) {
    return CompletableFuture.supplyAsync(() -> {
        // Sipariş servisi çağrısı simülasyonu
        System.out.println("Siparişler getiriliyor: " + kullaniciAdi);
        return List.of("Laptop", "Mouse", "Klavye");
    });
}

// thenCompose ile düz zincirleme — flatMap mantığı
CompletableFuture<List<String>> siparisler = kullaniciBul(42)
    .thenCompose(ad -> siparisleriGetir(ad));

System.out.println(siparisler.get()); // [Laptop, Mouse, Klavye]

thenApply vs thenCompose farkı: thenApply senkron bir fonksiyon alır (T → U), thenCompose asenkron bir fonksiyon alır (T → CompletableFuture<U>). Eğer zincirin bir sonraki adımı da asenkronsa thenCompose kullan.

thenCombine — İki Bağımsız Sonucu Birleştir

Bazen iki bağımsız asenkron işlemin ikisi de bittiğinde sonuçları birleştirmek istersin:

CompletableFuture<String> isimFuture = CompletableFuture.supplyAsync(() -> {
    sleep(200); // simülasyon
    return "Tolgahan";
});

CompletableFuture<Integer> yasFuture = CompletableFuture.supplyAsync(() -> {
    sleep(300); // simülasyon
    return 28;
});

// İkisi de bittiğinde birleştir
CompletableFuture<String> birlesik = isimFuture.thenCombine(yasFuture, 
    (isim, yas) -> isim + " (" + yas + " yaş)");

System.out.println(birlesik.get()); // "Tolgahan (28 yaş)"
// Toplam süre: ~300ms (paralel çalıştı, en yavaşı kadar bekledi)

Hata Yönetimi: exceptionally ve handle

Asenkron kodda hata yönetimi kritik. CompletableFuture bunu fonksiyonel bir şekilde çözüyor.

exceptionally — Sadece Hatayı Yakala

CompletableFuture<String> sonuc = CompletableFuture
    .supplyAsync(() -> {
        if (true) throw new RuntimeException("API çöktü!");
        return "Başarılı";
    })
    .exceptionally(ex -> {
        System.err.println("Hata yakalandı: " + ex.getMessage());
        return "Varsayılan değer"; // fallback
    });

System.out.println(sonuc.get()); // "Varsayılan değer"

exceptionally sadece hata olduğunda çalışır. Bir fallback değer döndürür ve zincir normal devam eder. Try-catch'ten çok daha temiz.

handle — Hem Başarıyı Hem Hatayı Yakala

CompletableFuture<String> sonuc = CompletableFuture
    .supplyAsync(() -> {
        // Bu bazen başarılı, bazen hatalı olabilir
        if (Math.random() > 0.5) throw new RuntimeException("Şanssızlık!");
        return "Başarılı sonuç";
    })
    .handle((deger, hata) -> {
        if (hata != null) {
            // Hata durumu — loglayıp fallback dön
            System.err.println("Hata: " + hata.getMessage());
            return "Fallback değer";
        }
        // Başarı durumu — değeri dönüştür
        return deger.toUpperCase();
    });

System.out.println(sonuc.get());

handle her iki durumda da çalışır. İki parametre alır: değer (başarılıysa) ve hata (varsa). İkisinden biri her zaman null olur.

Birden Fazla Future'ı Yönetme

allOf — Hepsini Bekle

CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> { sleep(100); return "A"; });
CompletableFuture<String> f2 = CompletableFuture.supplyAsync(() -> { sleep(200); return "B"; });
CompletableFuture<String> f3 = CompletableFuture.supplyAsync(() -> { sleep(150); return "C"; });

// Üçü de bitene kadar bekle
CompletableFuture<Void> hepsi = CompletableFuture.allOf(f1, f2, f3);

// allOf Void döner, sonuçları elle topla
hepsi.thenRun(() -> {
    try {
        String sonuclar = f1.get() + ", " + f2.get() + ", " + f3.get();
        System.out.println("Tüm sonuçlar: " + sonuclar); // "A, B, C"
    } catch (Exception e) {
        e.printStackTrace();
    }
});

allOf hepsinin bitmesini bekler ama direkt sonuç döndürmez (Void döner). Sonuçları her future'dan ayrı ayrı alman gerekir. Bunu daha kullanışlı hale getirmek için bir yardımcı metot yazabilirsin:

// Tüm future'ların sonuçlarını liste olarak toplayan yardımcı metot
@SafeVarargs
public static <T> CompletableFuture<List<T>> allOfResults(
        CompletableFuture<T>... futures) {
    return CompletableFuture.allOf(futures)
        .thenApply(v -> Arrays.stream(futures)
            .map(CompletableFuture::join) // join = get ama checked exception fırlatmaz
            .collect(Collectors.toList()));
}

// Kullanım
CompletableFuture<List<String>> tumSonuclar = allOfResults(f1, f2, f3);
System.out.println(tumSonuclar.get()); // [A, B, C]

anyOf — İlk Biteni Al

CompletableFuture<Object> ilkBiten = CompletableFuture.anyOf(f1, f2, f3);
System.out.println("İlk biten: " + ilkBiten.get()); // Muhtemelen "A" (100ms)

anyOf yarış (race) mantığıyla çalışır. İlk tamamlanan future'ın sonucunu döndürür. Birden fazla kaynaktan veri çekip en hızlı yanıtı almak istediğinde kullanışlıdır.

Gerçek Dünya Örneği: Paralel HTTP Çağrıları

Şimdi öğrendiklerimizi birleştirelim. Bir e-ticaret API'sinde ürün detay sayfası için birden fazla servisi paralel çağıran gerçekçi bir örnek:

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ProductPageService {

    private final HttpClient httpClient;
    private final ExecutorService executor;

    public ProductPageService() {
        this.executor = Executors.newFixedThreadPool(10);
        this.httpClient = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(5))
            .executor(executor)
            .build();
    }

    // Tek bir HTTP GET çağrısı yapan yardımcı metot
    private CompletableFuture<String> fetchAsync(String url) {
        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(url))
            .timeout(Duration.ofSeconds(3))
            .GET()
            .build();

        return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
            .thenApply(HttpResponse::body)
            .exceptionally(ex -> {
                System.err.println("HTTP hatası [" + url + "]: " + ex.getMessage());
                return "{}"; // boş JSON fallback
            });
    }

    // Ürün detay sayfası — tüm verileri paralel çek
    public CompletableFuture<ProductPageData> getProductPage(long productId, long userId) {
        
        // 4 bağımsız servis çağrısını aynı anda başlat
        CompletableFuture<String> urunFuture = fetchAsync(
            "https://api.example.com/products/" + productId);
        
        CompletableFuture<String> stokFuture = fetchAsync(
            "https://api.example.com/inventory/" + productId);
        
        CompletableFuture<String> oneriFuture = fetchAsync(
            "https://api.example.com/recommendations?user=" + userId);
        
        CompletableFuture<String> yorumFuture = fetchAsync(
            "https://api.example.com/reviews?product=" + productId);

        // Dördü de bittiğinde sonuçları birleştir
        return CompletableFuture.allOf(urunFuture, stokFuture, oneriFuture, yorumFuture)
            .thenApply(v -> new ProductPageData(
                urunFuture.join(),
                stokFuture.join(),
                oneriFuture.join(),
                yorumFuture.join()
            ))
            .orTimeout(5, java.util.concurrent.TimeUnit.SECONDS) // 5sn timeout
            .exceptionally(ex -> {
                System.err.println("Sayfa yüklenemedi: " + ex.getMessage());
                return ProductPageData.empty();
            });
    }

    record ProductPageData(String product, String stock, 
                           String recommendations, String reviews) {
        static ProductPageData empty() {
            return new ProductPageData("{}", "{}", "[]", "[]");
        }
    }
}

Bu örnekte birkaç önemli nokta var:

  1. Kendi ExecutorService'imizi verdikForkJoinPool.commonPool() uygulama genelinde paylaşılır. I/O yoğun işlemler (HTTP, veritabanı) için ayrı bir thread pool oluşturmak best practice'tir. Aksi halde common pool'u tıkarsın ve uygulamanın diğer kısımları etkilenir.

  2. `exceptionally` ile fallback — Her HTTP çağrısı bağımsız olarak hata yönetimi yapıyor. Stok servisi çökse bile ürün bilgisi gösterilebilir.

  3. `orTimeout` — Java 9 ile gelen bu metot, belirli sürede tamamlanmazsa TimeoutException fırlatır. Production'da mutlaka timeout koymalısın.

  4. `join()` vs `get()`join() checked exception fırlatmaz (CompletionException fırlatır), bu yüzden lambda içinde kullanması daha kolay.

Thread Pool Seçimi: Kritik Bir Karar

CompletableFuture'ın varsayılan olarak ForkJoinPool.commonPool() kullanması bir kolaylık ama aynı zamanda bir tuzak. Bu pool, CPU çekirdek sayısı - 1 kadar thread içerir. I/O işlemleri (HTTP çağrısı, veritabanı sorgusu, dosya okuma) thread'i bloklar ve pool'u tıkar.

// ❌ YANLIŞ — I/O işlemleri common pool'da
CompletableFuture.supplyAsync(() -> {
    return httpClient.send(request, BodyHandlers.ofString()); // thread bloklanır!
});

// ✅ DOĞRU — I/O işlemleri için özel pool
ExecutorService ioPool = Executors.newFixedThreadPool(20);

CompletableFuture.supplyAsync(() -> {
    return httpClient.send(request, BodyHandlers.ofString());
}, ioPool); // ikinci parametre olarak pool'u geç

Kural: CPU-yoğun işlemler (hesaplama, dönüşüm) için commonPool() uygundur. I/O-yoğun işlemler için her zaman kendi pool'unu oluştur.

Java 21 ile gelen Virtual Thread'ler bu sorunu büyük ölçüde çözer:

// Java 21+ — Virtual thread'ler ile sınırsız I/O paralelliği
ExecutorService virtualPool = Executors.newVirtualThreadPerTaskExecutor();

CompletableFuture.supplyAsync(() -> {
    return httpClient.send(request, BodyHandlers.ofString());
}, virtualPool);

Yaygın Hatalar ve Tuzaklar

1. Future'ın Sonucunu Hiç Tüketmemek

// ❌ Hata sessizce yutulur — kimse fark etmez
CompletableFuture.supplyAsync(() -> {
    throw new RuntimeException("Kritik hata!");
}).thenApply(s -> s.toUpperCase());
// Bu future'ı ne get() ne join() ile çağırırsan, hatayı asla görmezsin

Sonucu tüketmediğin bir CompletableFuture'daki hatalar sessizce kaybolur. Her zaman ya get()/join() çağır ya da exceptionally/handle ile hatayı yakala.

2. thenApply vs thenApplyAsync Farkını Bilmemek

CompletableFuture.supplyAsync(() -> "merhaba")
    .thenApply(s -> s.toUpperCase())      // aynı thread'de çalışır
    .thenApplyAsync(s -> s + "!!!");       // farklı thread'de çalışır

thenApply bir önceki adımın thread'inde çalışır. thenApplyAsync yeni bir thread'de çalışır. Hafif dönüşümler için thenApply, ağır işlemler veya I/O için thenApplyAsync kullan.

3. get() ile Deadlock Riski

// ❌ Deadlock tehlikesi — aynı pool'da birbirini bekleyen future'lar
ExecutorService pool = Executors.newFixedThreadPool(1); // sadece 1 thread!

CompletableFuture<String> outer = CompletableFuture.supplyAsync(() -> {
    // İç future da aynı pool'u kullanmaya çalışıyor ama thread yok!
    CompletableFuture<String> inner = CompletableFuture.supplyAsync(() -> "iç", pool);
    return inner.join(); // sonsuza kadar bekler — DEADLOCK!
}, pool);

Thread pool boyutu yetersizse ve iç içe future'lar aynı pool'u kullanıyorsa deadlock oluşur. Bu yüzden iç içe future'lar için farklı pool'lar kullanmayı düşün.

4. Zincirde İstisna Yönetimini Unutmak

// ❌ exceptionally sadece kendi üstündeki hatayı yakalar
CompletableFuture.supplyAsync(() -> "veri")
    .thenApply(s -> {
        throw new RuntimeException("Dönüşüm hatası!");
    })
    .exceptionally(ex -> "fallback") // Bu yakalar ✓
    .thenApply(s -> {
        throw new RuntimeException("İkinci hata!");
    });
    // Bu ikinci hata yakalanmaz! ✗

// ✅ Her kritik adımdan sonra hata yönetimi koy veya en sona handle ekle
CompletableFuture.supplyAsync(() -> "veri")
    .thenApply(s -> riskliBirIslem(s))
    .thenApply(s -> baskaRiskliBirIslem(s))
    .handle((sonuc, hata) -> {
        // Zincirin herhangi bir yerindeki hatayı yakalar
        if (hata != null) return "genel fallback";
        return sonuc;
    });

Best Practices

  1. Timeout her zaman koy. orTimeout() veya completeOnTimeout() kullan. Timeout olmayan asenkron çağrı, production'da bilet kesilir.

  2. I/O için özel thread pool kullan. commonPool() CPU-bound işler için tasarlandı. HTTP, veritabanı, dosya I/O için Executors.newFixedThreadPool() veya Java 21'de newVirtualThreadPerTaskExecutor() tercih et.

  3. `join()` tercih et, `get()` değil. Lambda içinde checked exception fırlatmayan join() daha kullanışlıdır. get() sadece timeout ile kullanman gerektiğinde mantıklı: get(5, TimeUnit.SECONDS).

  4. Hata yönetimini asla atla. Her zincirin sonunda exceptionally veya handle olmalı. Silent failure (sessiz hata) en tehlikeli bug türüdür.

  5. İsimlendirme anlamlı olsun. cf1, cf2 değil, stokFuture, fiyatFuture gibi isimler kullan. Asenkron kod zaten takip edilmesi zordur, isimler yardımcı olsun.

  6. Test yazarken `completedFuture` kullan. Mock servislerde gerçek asenkron işlem başlatmak yerine CompletableFuture.completedFuture(mockDeger) döndür. Testler deterministic ve hızlı olur.

  7. Zinciri çok uzatma. 5-6 adımdan uzun bir CompletableFuture zinciri okunması zor hale gelir. Ara adımları anlamlı metotlara çıkar.

Java 9+ ile Gelen Eklemeler

Java 9 ve sonrasında CompletableFuture'a güçlü eklemeler yapıldı:

// Java 9 — Timeout desteği
CompletableFuture<String> future = fetchAsync("https://api.example.com/data")
    .orTimeout(3, TimeUnit.SECONDS)           // 3sn'de tamamlanmazsa TimeoutException
    .completeOnTimeout("varsayılan", 3, TimeUnit.SECONDS); // veya varsayılan değer dön

// Java 9 — copy() ile defensive copy
CompletableFuture<String> copy = future.copy(); // dışarıya read-only referans ver

// Java 12 — exceptionallyCompose, asenkron hata kurtarma
CompletableFuture<String> resilient = fetchAsync("https://primary.api.com/data")
    .exceptionallyCompose(ex -> fetchAsync("https://backup.api.com/data")); // fallback servisi çağır

exceptionallyCompose özellikle güçlü — hata durumunda başka bir asenkron işlem başlatabilirsin. Circuit breaker, retry, fallback servis gibi pattern'leri temiz bir şekilde uygularsın.

Sonuç

CompletableFuture, Java'da asenkron programlamanın temel taşı. Bu yazıda öğrendiklerini özetleyelim:

  • `supplyAsync` ile asenkron işlem başlat, `thenApply` ile sonucu dönüştür, `thenCompose` ile asenkron zincirle

  • `thenCombine` ile bağımsız işlemleri birleştir, `allOf` ile hepsini bekle

  • `exceptionally` ve `handle` ile hata yönetimini fonksiyonel yap

  • I/O işlemleri için kendi thread pool'unu oluştur, commonPool()'a güvenme

  • Timeout koymayı asla unutma

  • Java 9+'daki orTimeout, completeOnTimeout, exceptionallyCompose gibi eklemeleri kullan

CompletableFuture öğrenme eğrisi biraz dik olabilir, ama bir kez alıştığında senkron koda geri dönmek istemeyeceksin. Özellikle mikroservis mimarilerinde, birden fazla servisi paralel çağırmak performansı dramatik şekilde artırır. Bir sonraki adım olarak Project Reactor veya Virtual Threads (Java 21) ile tanışmanı öneririm — ama önce CompletableFuture'ı sağlam öğren, çünkü temeli bu oluşturuyor.

Paylaş:
Son güncelleme: Jun 04, 2026

Yorumlar

Giriş yapın ve yorum bırakın.

Henüz yorum yok

Düşüncelerinizi paylaşan ilk siz olun!

Bu yazıyı beğendiniz mi?

Bültene abone olun ve yeni yazılardan ilk siz haberdar olun. Spam yok, söz.

İlgili Yazılar