Java · Bölüm 4

Lambda ve Stream: fonksiyonel Java'yı doğru kullanmak

Functional interface'lerden Gatherers'a kadar lambda, method reference, Stream işlem modeli, Collectors, Optional ve parallel stream'i mekanizmasıyla ve tuzaklarıyla öğretir.

~40 dk okumaQuiz: j12, j13, j14, j15, j16, q3
Cevaplar:

Bu bölümde

Lambda ve Stream API, Java mülakatlarında üç biçimde karşına çıkar. Live coding'de "bu listeyi kategoriye göre grupla, toplamı al, ilk 3'ü döndür" gibi bir rapor yazdırılır. Kod okuma sorularında bir pipeline'ın ekrana ne yazdığı ya da production'da neden IllegalStateException fırlattığı sorulur. Soru-cevapta ise orElse ile orElseGet farkı, parallel stream'in ne zaman zararlı olduğu ve lambda'nın neden effectively final değişken istediği sorulur.

Bu bölümde önce lambda'nın dil seviyesindeki karşılığını (functional interface, yakalama, method reference), sonra Stream'in içeride nasıl çalıştığını (tembel pipeline, dikey işleme, short-circuit, stateful işlemler) ve son olarak Collectors, Optional, parallel stream ve Java 24 ile final olan Gatherers'ı göreceksin.

Konu anlatımı

Functional interface: lambda'nın hedef tipi

Java'da lambda'nın kendine ait bir tipi yoktur. Bir lambda ifadesi her zaman bir functional interface'in implementasyonu olarak derlenir ve hangi interface olduğunu bağlamdan (atandığı değişkenden, geçirildiği parametreden) çıkarır. Buna target typing (hedef tiplendirme) denir.

java
Runnable r = () -> System.out.println("çalıştı");
Callable<String> c = () -> "çalıştı";
// Aynı gövde, farklı hedef tipler: hangisi olduğu atandığı yere göre belirlenir.

Functional interface, tam olarak bir abstract metodu olan interface'tir (SAM: single abstract method). Sayıma girmeyenler:

  • default ve static metotlar (gövdeleri var).
  • Object'in public metotlarının yeniden bildirimi. Comparator interface'i boolean equals(Object) bildirir ama yine functional interface'tir, çünkü her sınıf equals'ı zaten Object'ten alır.

@FunctionalInterface anotasyonu zorunlu değildir. Yararı, derleyicinin "bu interface'e ikinci abstract metot eklenirse derleme hatası ver" kontrolünü yapmasıdır. Ortak bir kütüphanede kural motoru için interface yazıyorsan anotasyonu koy: biri yanlışlıkla ikinci metot eklediğinde bütün lambda kullanımları kırılmadan önce hatayı görürsün.

java.util.function paketindeki temel tipler

InterfaceMetotİmzaÖrnek kullanım
Function<T, R>applyT → ROrder → BigDecimal (tutar)
BiFunction<T, U, R>apply(T, U) → R(sepet, kupon) → indirim
UnaryOperator<T>applyT → Tfiyata KDV ekle
BinaryOperator<T>apply(T, T) → TBigDecimal::add
Supplier<T>get() → Tvarsayılan değer, lazy hesap
Consumer<T>acceptT → voidevent yayınla, logla
BiConsumer<T, U>accept(T, U) → voidmap.forEach((k, v) -> ...)
Predicate<T>testT → boolean"sepet kampanyaya uygun mu?"
BiPredicate<T, U>test(T, U) → boolean(kullanıcı, kupon) uygun mu

UnaryOperator<T>, Function<T, T>'yi; BinaryOperator<T> ise BiFunction<T, T, T>'yi genişletir. Yani UnaryOperator beklenen yere aynı imzalı bir Function değişkeni veremezsin ama tersi olur.

Primitive özelleşmeler (IntPredicate, ToLongFunction<T>, LongSupplier, IntUnaryOperator, ObjIntConsumer<T> ...) boxing maliyetini ortadan kaldırmak için vardır. Function<Integer, Integer> her çağrıda Integer nesnesi üretebilir; IntUnaryOperator doğrudan int ile çalışır. Milyonlarca elemanlık bir döngüde bu fark ölçülebilir hale gelir.

Kompozisyon: davranışı birleştirmek

Functional interface'lerin default metotları, küçük kuralları birleştirip büyük kural kurmanı sağlar. Kampanya motoru bunun tipik örneğidir:

java
record Customer(long id, int orderCount) {}
record Cart(Customer customer, BigDecimal total) {}

Predicate<Cart> hasMinTotal   = c -> c.total().compareTo(new BigDecimal("500")) >= 0;
Predicate<Cart> isNewCustomer = c -> c.customer().orderCount() == 0;
Predicate<Cart> eligible      = hasMinTotal.and(Predicate.not(isNewCustomer)); // Predicate.not: Java 11+

Function<Cart, BigDecimal> tenPercent   = c -> c.total().multiply(new BigDecimal("0.10"));
UnaryOperator<BigDecimal>  capAt100     = d -> d.min(new BigDecimal("100"));
Function<Cart, BigDecimal> discount     = tenPercent.andThen(capAt100);
  • f.andThen(g) = g(f(x)): önce f, sonra g.
  • f.compose(g) = f(g(x)): önce g, sonra f.
  • Predicate: and, or, negate, Predicate.not(p) (Java 11+), Predicate.isEqual(x).
  • Consumer.andThen, Function.identity(), UnaryOperator.identity(), BinaryOperator.minBy/maxBy(comparator).

and ve or kısa devre yapar: hasMinTotal.and(isNewCustomer) ilk koşul false ise ikinciyi çalıştırmaz. Pahalı kontrolü (DB'ye giden bir kural) sağa koy.

Effectively final ve yakalama (capture)

Lambda, çevreleyen metodun local değişkenlerini kullanabilir; buna capture (yakalama) denir. JLS'nin kuralı: yakalanan local değişken final ya da effectively final olmalıdır. Effectively final, final yazılmamış ama ilk atamadan sonra hiç değiştirilmeyen değişkendir.

java
BigDecimal threshold = new BigDecimal("500");
Predicate<Cart> p = c -> c.total().compareTo(threshold) >= 0;   // derlenir

int count = 0;
orders.forEach(o -> count++);   // derleme hatası: count effectively final değil

Neden bu kural var? Üç sebep, hepsi mekanizmadan gelir:

  1. Yakalanan değer kopyalanır. Local değişken metodun stack frame'inde yaşar ve metot dönünce yok olur. Lambda ise metottan daha uzun yaşayabilir (bir executor'a verilir, bir alanda saklanır). Java bu yüzden değişkenin kendisini değil, o anki değerini lambda nesnesinin bir alanına kopyalar. Değişken sonradan değişebilseydi, lambda'nın içindeki kopya ile dışarıdaki değişken birbirinden kopar ve kod okuyanı yanıltırdı.
  2. Eşzamanlılık. Lambda başka bir thread'de çalışabilir. Değiştirilebilir bir local, senkronizasyon olmadan iki thread arasında paylaşılırsa data race doğar. Dil tasarımcıları bu kapıyı baştan kapattı.
  3. Okunabilirlik. "Bu lambda hangi değeri görüyor?" sorusunun cevabı tek olmalı.

Kural sadece local değişkenler için geçerlidir. Instance alanlarına lambda içinden yazabilirsin, çünkü lambda aslında this'i yakalar ve alanı this üzerinden okur. Bu da kendi tuzağını getirir: lambda'yı uzun yaşayan bir yerde (static cache, scheduler) saklarsan çevreleyen nesneyi de canlı tutarsın; parallel stream içinden alana yazarsan race condition yaratırsın.

Effectively final kuralını int[] counter = {0} ya da AtomicInteger ile "atlatmak" teknik olarak derlenir ama genelde kod kokusudur: niyetin bir reduction (sayma, toplama) yapmaksa bunu stream'in kendisine yaptır.

java
// Kötü: dışarıdaki state'i lambda içinden değiştirmek
AtomicInteger delivered = new AtomicInteger();
orders.forEach(o -> { if (o.status() == OrderStatus.DELIVERED) delivered.incrementAndGet(); });

// İyi: niyeti bildirimsel yaz
long deliveredCount = orders.stream().filter(o -> o.status() == OrderStatus.DELIVERED).count();

Kutuyu gerçekten gerektiren meşru durumlar da vardır. Örneğin ConcurrentHashMap.computeIfPresent içinde "stok düşüldü mü?" bilgisini dışarı taşımak için AtomicBoolean kullanmak (burada amaç thread-safety değil, lambda'dan dışarıya değer taşıyan bir kutu) makul bir çözümdür. Bölüm 5'teki stok örneği bunu gösterir (bkz. Bölüm 5).

Lambda ve anonim sınıf farkı

Lambda, anonim sınıfın kısa yazımı değildir. Derleme ve çalışma zamanında farklı şeylerdir.

LambdaAnonim sınıf
thisÇevreleyen sınıfın instance'ı (lexical scoping)Anonim sınıfın kendisi
Yeni scopeAçmaz: çevredeki bir local ile aynı isimde parametre/değişken tanımlanamazAçar: shadowing serbest
StateKendi alanı olamazAlan tanımlayabilir
HedefSadece functional interfaceHerhangi bir interface ya da abstract/concrete sınıf, birden çok metot
Derlemeinvokedynamic + LambdaMetafactory; sınıf çalışma zamanında üretilirDerleme zamanında ayrı bir Outer$1.class dosyası
Nesne kimliğiSpesifikasyon yeni nesne üretileceğini garanti etmezHer new yeni nesne

Lambda'nın gövdesi derleyici tarafından çevreleyen sınıfta private bir sentetik metoda (lambda$main$0 gibi) dönüştürülür. Çağrı noktasında bir invokedynamic komutu durur; ilk çalıştığında LambdaMetafactory interface'i implemente eden küçük bir sınıfı çalışma zamanında üretir ve call site'a bağlar. Sonraki çağrılar doğrudan bu bağlantıyı kullanır.

Pratik sonuçları:

  • Hiçbir şey yakalamayan (non-capturing) lambda'lar mevcut HotSpot implementasyonunda tek bir instance olarak yeniden kullanılır; yakalayanlar her değerlendirmede küçük bir nesne üretir. Bu bir implementasyon detayıdır, JLS garanti etmez. Lambda'ları == ile karşılaştırma ya da üzerinde synchronized kullanma.
  • Anonim sınıf (static olmayan bağlamda) Java 17 ve öncesinde çevreleyen instance'a her zaman örtük bir referans (this$0) tutar; JDK 18+ javac bu alanı, sınıf onu kullanmıyorsa genelde artık üretmez (yine de kodu buna güvenerek yazma). Lambda ise baştan beri sadece this'i gerçekten kullanıyorsa yakalar. Bu, bellek sızıntısı açısından lambda'yı daha güvenli yapar.
  • Anonim sınıfı hâlâ şu durumlarda kullanırsın: abstract bir sınıfı genişletmen gerekiyorsa, birden fazla metodu override edeceksen ya da nesnenin kendi state'i olacaksa.

Method reference türleri

Method reference, gövdesi tek bir metot çağrısı olan lambda'nın kısa yazımıdır. Dört tür vardır (artı dizi constructor'ı):

TürÖrnekEşdeğer lambda
Static metotInteger::parseInts -> Integer.parseInt(s)
Bağlı (bound) instancecart::addItemitem -> cart.addItem(item)
Bağsız (unbound) instanceString::lengths -> s.length()
ConstructorArrayList::new() -> new ArrayList<>()
Dizi constructorString[]::newn -> new String[n]

Unbound instance en çok karıştırılanıdır: ilk parametre metodun çağrılacağı nesne (receiver) olur. String::length bir Function<String, Integer>'dır. String::compareToIgnoreCase iki parametre alır (receiver + argüman), bu yüzden bir Comparator<String> olarak kullanılabilir.

Bound instance'ta receiver, method reference oluşturulduğu anda değerlendirilir:

java
Supplier<Integer> len = name::length;   // name null ise NPE BURADA fırlar, get() çağrılınca değil

Consumer<String> printer = System.out::println;
System.setOut(otherStream);
printer.accept("x");   // eski PrintStream'e yazar: receiver oluşturma anında yakalandı

Lambda () -> name.length() olsaydı NPE get() çağrıldığında fırlardı ve name'in effectively final olması gerekirdi. Method reference'ta receiver bir ifade olarak bir kez değerlendirilip saklanır; bu yüzden effectively final şartı yoktur ama davranış farkı vardır.

Constructor reference hedef tipin imzasına göre doğru constructor'ı seçer: Function<Long, Product> için Product::new tek long alan constructor'a, BiFunction<Long, String, Product> için iki parametreli olana bağlanır.

Checked exception'lar lambda içinde

Function.apply, Consumer.accept gibi metotların imzasında throws yoktur. Bu yüzden checked exception fırlatan bir metodu doğrudan map'e veremezsin:

java
List<String> contents = paths.stream()
        .map(Files::readString)   // derleme hatası: incompatible thrown types IOException in functional expression
        .toList();

Derleyici exception'ı otomatik sarmaz. Seçenekler:

1. Lambda içinde yakalayıp anlamlı bir unchecked exception'a çevirmek. En yaygın ve en okunur çözüm. I/O için JDK'nın kendi UncheckedIOException'ı vardır.

java
List<String> contents = paths.stream()
        .map(path -> {
            try {
                return Files.readString(path);
            } catch (IOException e) {
                throw new UncheckedIOException("Dosya okunamadı: " + path, e);
            }
        })
        .toList();

2. Kendi functional interface'ini ve bir adaptörü tanımlamak. Aynı kalıp kod tabanında çok tekrarlanıyorsa:

java
@FunctionalInterface
interface ThrowingFunction<T, R, E extends Exception> {
    R apply(T t) throws E;

    static <T, R> Function<T, R> unchecked(ThrowingFunction<T, R, ?> f) {
        return t -> {
            try {
                return f.apply(t);
            } catch (RuntimeException e) {
                throw e;
            } catch (Exception e) {
                // catch (IOException e) burada derlenmez: try bloğu sadece E (wildcard capture) fırlatıyor görünür
                if (e instanceof IOException io) throw new UncheckedIOException(io);
                throw new IllegalStateException(e);
            }
        };
    }
}

List<String> contents = paths.stream().map(ThrowingFunction.unchecked(Files::readString)).toList();

3. Hataları veri olarak toplamak. Toplu işlemde (ör. satıcının yüklediği 10.000 satırlık ürün dosyası) ilk hatada her şeyi durdurmak istemezsin. Her eleman için bir sonuç tipi üret, sonra başarılı ve başarısızları ayır:

java
sealed interface ImportResult permits ImportResult.Ok, ImportResult.Failed {
    record Ok(Product product) implements ImportResult {}
    record Failed(int line, String reason) implements ImportResult {}
}

Map<Boolean, List<ImportResult>> split = rows.stream()
        .map(this::parseRow)   // exception fırlatmaz, ImportResult döner
        .collect(Collectors.partitioningBy(r -> r instanceof ImportResult.Ok));

"Sneaky throw" (generic tip silme hilesiyle checked exception'ı derleyiciden gizlemek) çalışır ama exception'ı imzada görünmez yapar ve çağıran tarafın catch (IOException e) yazmasını derleyici engeller. Uygulama kodunda kullanma.

Stream'in anatomisi: kaynak, ara işlemler, terminal işlem

Bir stream pipeline'ı üç parçadan oluşur:

  1. Kaynak: Collection.stream(), Stream.of, Arrays.stream, IntStream.range, Files.lines, Stream.iterate.
  2. Ara işlemler (intermediate): filter, map, flatMap, mapMulti, distinct, sorted, peek, limit, skip, takeWhile, dropWhile, gather (Java 24+). Her biri yeni bir Stream döner ve hiçbir şey çalıştırmaz.
  3. Terminal işlem: collect, toList, forEach, reduce, count, min, max, anyMatch, allMatch, noneMatch, findFirst, findAny, toArray. Pipeline'ı çalıştıran ve stream'i tüketen işlemdir.

Laziness (tembellik) şu demektir: ara işlemler çağrıldığında sadece pipeline'ın tarifi oluşturulur. JDK içinde her ara işlem bir "stage" nesnesidir ve bir öncekine bağlanır. Terminal işlem çağrıldığında bu stage'ler sondan başa doğru birbirine sarılarak bir Sink zinciri kurulur: filter'ın sink'i elemanı test eder ve geçerse map'in sink'ine iter, o da dönüştürüp limit'in sink'ine iter, ve böyle devam eder. Ardından kaynağın Spliterator'ı elemanları teker teker bu zincirin başına gönderir.

diyagram yükleniyor…
flowchart LR
    S[Spliterator: kaynak] -->|eleman 1| F[filter sink]
    F -->|geçerse| M[map sink]
    M --> L[limit sink]
    L --> T[toList sink]
    L -. "doldu: cancellationRequested" .-> S

Bu yüzden stream'ler dikey (eleman eleman) işlenir, yatay (önce tüm elemanlar filter'dan, sonra tümü map'ten) değil. Tembellik ve dikey işleme birlikte iki şeyi mümkün kılar: gereksiz işi hiç yapmamak (short-circuit) ve sonsuz kaynaklarla çalışabilmek.

Stream tek kullanımlıktır. Terminal işlem çalıştıktan sonra aynı stream nesnesine ikinci bir işlem çağırırsan IllegalStateException: stream has already been operated upon or closed alırsın. Aynı veriyi iki kez işlemek istiyorsan kaynaktan yeniden stream aç ya da bir Supplier<Stream<T>> tut.

İşlem sırası ve short-circuit

Dikey işlemenin ilk sonucu: ara işlemlerin sırası performansı doğrudan etkiler.

java
// 1.000.000 ürün, %1'i aktif kampanyada
products.stream()
        .map(this::calculateFinalPrice)          // pahalı: 1.000.000 kez çalışır
        .filter(p -> p.campaignActive())
        .toList();

products.stream()
        .filter(Product::campaignActive)          // ucuz: önce eleme
        .map(this::calculateFinalPrice)           // sadece 10.000 kez çalışır
        .toList();

İkinci sırada pahalı hesap 100 kat daha az çalışır. Kural: ucuz ve seçici filtreleri başa, pahalı dönüşümleri sona koy.

Short-circuiting işlemler, tüm elemanları görmeden bitebilen işlemlerdir:

  • Ara işlemler: limit(n), takeWhile(predicate) (Java 9+).
  • Terminal işlemler: findFirst, findAny, anyMatch, allMatch, noneMatch.

Mekanizma: short-circuit bir sink, istediğini aldığında cancellationRequested() sorusuna true cevap verir. Kaynak her elemandan önce bu soruyu sorar ve true gelince durur. Sonsuz stream'ler bu sayede biter:

java
// Sipariş numarası üreteci: ilk 5 geçerli numara
List<Long> next5 = Stream.iterate(1_000_000L, n -> n + 1)
        .filter(this::isAvailable)
        .limit(5)
        .toList();

limit olmasaydı bu pipeline hiç bitmezdi. Java 9+ Stream.iterate(seed, hasNext, next) overload'ı, for döngüsü gibi bir bitiş koşulu da alır.

Tarihsel bir not: Java 8'de flatMap, iç stream'i tamamen tüketiyordu; yani flatMap(...).findFirst() iç stream'in tüm elemanlarını işliyordu ve sonsuz bir iç stream'de takılıyordu. Bu hata Java 10'da düzeltildi (sonradan 8u'ya da taşındı). Eski bir JDK 8 sisteminde performans sorunu inceliyorsan bunu hatırla.

count() ve atlanan pipeline. Java 9+'ta count(), eleman sayısını kaynaktan doğrudan bilebiliyorsa (kaynak SIZED ve araya sayıyı değiştiren filter gibi bir işlem girmemişse) pipeline'ı hiç çalıştırmayabilir:

java
long n = List.of("a", "b", "c").stream()
        .peek(s -> System.out.println("görüldü: " + s))
        .count();   // Java 9+: 3 döner, hiçbir şey yazmayabilir

Bu, peek ve map içine iş mantığı koymamanın en somut gerekçesidir: Stream API, sonucu değiştirmediği sürece lambda'larını çalıştırmama hakkını saklı tutar. Javadoc bunu açıkça yazar.

Stateful ara işlemler: sorted ve distinct

filter ve map stateless'tır: bir elemanı işlemek için önceki elemanları bilmeleri gerekmez. sorted ve distinct ise stateful'dur.

sorted bir bariyerdir. Tüm elemanları görmeden ilk elemanı çıkaramaz. İçeride gelen her elemanı bir listeye toplar; kaynak bittiğinde (sink'in end() sinyali) listeyi sıralar ve elemanları sırayla aşağı akıta akıta iter. Bu, dikey akışı ikiye böler:

java
Stream.of("kalem", "defter", "kitap")
        .peek(s -> System.out.print("A:" + s + " "))
        .sorted()
        .peek(s -> System.out.print("B:" + s + " "))
        .findFirst();
// Çıktı: A:kalem A:defter A:kitap B:defter

sorted'dan önceki aşama tüm elemanlar için çalıştı; sonraki aşama ise findFirst short-circuit olduğu için sadece bir kez. Buradan şu pratik sonuçlar çıkar:

  • sorted().limit(n) tüm veriyi sıralar: O(n log n) zaman ve O(n) bellek. JDK burada "top-k" optimizasyonu yapmaz. Sadece ilk birkaç elemanı istiyorsan ve veri büyükse boyutu k olan bir min-heap (PriorityQueue) O(n log k) ile aynı işi yapar.
  • sorted sonsuz bir stream'de asla bitmez.
  • sorted'dan sonra gelen pahalı map, limit sayesinde sadece n eleman için çalışır. Pahalı dönüşümü sıralamadan sonraya koymak bu yüzden kârlıdır, tabii sıralama anahtarı o dönüşüme bağlı değilse.

distinct görülen elemanları bir HashSet benzeri yapıda tutar; bellek, farklı eleman sayısı kadar büyür. Eşitlik equals/hashCode ile belirlenir. Bu yüzden:

  • Record'larla distinct doğal çalışır.
  • new BigDecimal("1.0") ile new BigDecimal("1.00") farklı scale'e sahip olduğu için equals ile eşit değildir; distinct ikisini de tutar. Para değerlerinde tekilleştirme yapacaksan önce stripTrailingZeros() ya da sabit bir scale ile normalize et.
  • Parallel ve sıralı (ordered) bir stream'de distinct, "ilk görülen" elemanı korumak zorunda olduğu için pahalıdır. Sıra önemli değilse unordered() bu maliyeti düşürür.

Collectors: mutable reduction

reduce ile collect farklı iki indirgeme (reduction) türüdür:

  • reduce immutable reduction yapar: her adımda iki değerden yeni bir değer üretir. BigDecimal::add gibi immutable değerler için doğaldır.
  • collect mutable reduction yapar: bir container (liste, map, StringBuilder) oluşturur ve elemanları ona ekler. Parallel'de her thread kendi container'ını doldurur, sonunda combiner ile birleştirilir.

reduce ile liste biriktirmeye çalışmak (her adımda yeni liste kopyalamak) O(n²) maliyet üretir; liste, set, map gibi sonuçlar için her zaman collect.

groupingBy

Üç overload'u vardır:

java
groupingBy(classifier)                          // Map<K, List<T>>, HashMap + toList
groupingBy(classifier, downstream)              // Map<K, D>
groupingBy(classifier, mapFactory, downstream)  // ör. TreeMap::new, () -> new EnumMap<>(Status.class)

Downstream collector, her grubun elemanlarına ne yapılacağını söyler. En çok kullanılanlar:

DownstreamGrup sonucu
counting()Long
summingInt/Long/Double(mapper), averagingXsayı
reducing(identity, mapper, op)tek değer (BigDecimal toplamı için)
mapping(mapper, downstream)dönüştürüp toplama (List<String> gibi)
filtering(predicate, downstream) (Java 9+)grup içinde filtre
flatMapping(mapper, downstream) (Java 9+)grup içinde düzleştirme
toSet(), toList()koleksiyon
maxBy(comparator), minBy(comparator)Optional<T>
collectingAndThen(downstream, finisher)sonucu son bir kez dönüştürme

BigDecimal için hazır bir summingBigDecimal yoktur. İki doğru yol:

java
// 1) reducing
Map<String, BigDecimal> revenueByCategory = lines.stream()
        .collect(Collectors.groupingBy(
                OrderLine::category,
                Collectors.reducing(BigDecimal.ZERO, OrderLine::total, BigDecimal::add)));

// 2) toMap + merge function
Map<String, BigDecimal> revenueByCategory2 = lines.stream()
        .collect(Collectors.toMap(OrderLine::category, OrderLine::total, BigDecimal::add));

mapToDouble(...).sum() yazmak para hesabında kırmızı bayraktır: 0.1 + 0.2 double'da 0.30000000000000004 eder ve milyonlarca satırda kuruş farkları birikir.

filtering ile "önce filter, sonra groupingBy" arasında ince bir fark vardır: önce filtrelersen hiç elemanı kalmayan grup map'te görünmez; filtering downstream olarak kullanılırsa grup anahtarı sıfır değerle görünür. "Her kategori için 1000 TL üstü satır sayısı, sıfır olanlar dahil" gibi bir rapor ikinciyi gerektirir.

java
Map<String, Long> bigLinesPerCategory = lines.stream()
        .collect(Collectors.groupingBy(
                OrderLine::category,
                Collectors.filtering(l -> l.total().compareTo(new BigDecimal("1000")) > 0,
                                     Collectors.counting())));

groupingBy'da classifier null dönerse NullPointerException: element cannot be mapped to a null key alırsın. Kategori alanı boş olabilecek verilerde önce normalize et.

partitioningBy

partitioningBy(predicate) sonucu Map<Boolean, List<T>>'dir ve her zaman iki anahtar içerir; bir taraf boş olsa bile true ve false anahtarları vardır. groupingBy(o -> o.total().compareTo(t) >= 0) ise sadece gerçekten oluşan anahtarları içerir. Kod result.get(false).size() çağırıyorsa partitioningBy NPE riskini ortadan kaldırır. İkinci parametre olarak downstream da alır: partitioningBy(pred, counting()).

toMap: duplicate key ve null tuzakları

toMap'in dört davranışı ezberlenmeli:

  1. İki parametreli toMap(keyMapper, valueMapper) aynı key ikinci kez gelirse IllegalStateException fırlatır. Java 9+ mesajı: Duplicate key 7 (attempted merging values Kalem and Defter). Java 8'deki mesaj hatalıydı ve key yerine değeri yazıyordu; eski log'larda bu kafa karıştırır.
  2. Değer null ise NullPointerException fırlatır. İki parametreli versiyon Java 9+'ta değeri Objects.requireNonNull ile kontrol edip putIfAbsent ile ekler; üç ve dört parametreli versiyonlar Map.merge kullanır ve merge de null değeri reddeder. Yani merge function vermek null sorununu çözmez.
  3. Üç parametreli toMap(k, v, mergeFunction) çakışma kuralını açıkça yazdırır: (first, second) -> first, BigDecimal::add, BinaryOperator.maxBy(...).
  4. Dört parametreli toMap(k, v, merge, mapFactory) map tipini seçtirir: TreeMap::new, LinkedHashMap::new.
java
// Her kategoride en pahalı satır
Map<String, OrderLine> mostExpensive = lines.stream()
        .collect(Collectors.toMap(
                OrderLine::category,
                Function.identity(),
                BinaryOperator.maxBy(Comparator.comparing(OrderLine::total))));

Değerler gerçekten null olabiliyorsa ve map'te null tutmak istiyorsan (genelde istememelisin), üç argümanlı collect ile HashMap.put'u doğrudan kullan: collect(HashMap::new, (m, x) -> m.put(k(x), v(x)), Map::putAll). Bu versiyon çakışmada sessizce üzerine yazar; bunu bilinçli seçtiğini belirt.

teeing (Java 12+)

teeing(c1, c2, merger) aynı stream'i tek geçişte iki collector'a verir ve sonuçlarını birleştirir. Stream bir kez tüketilebildiği için "hem adet hem toplam" gibi iki sonucu tek geçişte almanın temiz yoludur:

java
record Stats(long count, BigDecimal total) {
    BigDecimal average() {
        return count == 0 ? BigDecimal.ZERO
                          : total.divide(BigDecimal.valueOf(count), 2, RoundingMode.HALF_EVEN);
    }
}

Stats stats = orders.stream().collect(Collectors.teeing(
        Collectors.counting(),
        Collectors.reducing(BigDecimal.ZERO, Order::total, BigDecimal::add),
        Stats::new));

BigDecimal bölmesinde scale ve RoundingMode vermezsen sonsuz ondalık durumunda (10 / 3) ArithmeticException alırsın. Para hesabında yuvarlama kuralını her zaman açıkça yaz.

Diğerleri

  • joining(", ", "[", "]"): metin birleştirme, içeride StringBuilder.
  • collectingAndThen(toList(), Collections::unmodifiableList): sonucu son bir adımda sarmak.
  • groupingByConcurrent, toConcurrentMap: parallel stream'de tek bir ConcurrentMap'e yazar, birleştirme maliyetini kaldırır ama sıra garantisi vermez.

Optional: doğru kullanım

Optional<T>, "bu metot sonuç döndürmeyebilir" bilgisini dönüş tipine yazmak için tasarlandı. Javadoc'taki API notu bunu açıkça söyler: amaç, "sonuç yok" durumunu temsil etmenin açık bir yolunun gerektiği metot dönüş tipleridir ve Optional dönen bir değişken asla null olmamalıdır.

Nerede kullanılmaz:

  • Alan (field) olarak. Optional Serializable değildir; JPA entity alanı olarak eşlenemez; her nesneye ekstra bir sarmalayıcı ekler. Alan nullable olabilir; getter Optional dönebilir.
  • Metot parametresi olarak. Çağıranı Optional.of(x) yazmaya zorlar ve yine de null geçilmesini engellemez: artık üç durum vardır (dolu, boş, null). Overload ya da nullable parametre daha temizdir.
  • Koleksiyon yerine. Optional<List<Order>> yerine boş liste dön. "Hiç sipariş yok" ile "boş sipariş listesi" arasında anlamlı bir fark yoktur.

orElse ve orElseGet farkı. Java metot argümanlarını çağrıdan önce değerlendirir (eager evaluation). orElse(x)'in argümanı, Optional dolu olsa bile hesaplanır:

java
Customer c = cache.find(id)
        .orElse(repository.loadFromDb(id));          // cache'te olsa bile DB'ye gider!

Customer c2 = cache.find(id)
        .orElseGet(() -> repository.loadFromDb(id)); // sadece boşsa DB'ye gider

Kural: varsayılan değer bir sabit ya da zaten hesaplanmış bir değişkense orElse; hesaplanması pahalıysa veya yan etkisi varsa (DB, HTTP, log, metrik) orElseGet. Hata fırlatmak için orElseThrow(() -> new CustomerNotFoundException(id)).

isPresent() + get() kalıbı null kontrolünün aynısıdır ve Optional'ın sağladığı ifade gücünü boşa harcar. Bunun yerine:

java
// Kötü
Optional<Customer> c = repo.findById(id);
if (c.isPresent()) {
    return c.get().email();
}
throw new CustomerNotFoundException(id);

// İyi
return repo.findById(id)
        .map(Customer::email)
        .orElseThrow(() -> new CustomerNotFoundException(id));

Önemli metotlar ve sürümleri:

MetotSürümNe yapar
map(f)8Doluysa dönüştürür; f null dönerse boş Optional
flatMap(f)8f'in kendisi Optional dönüyorsa iç içe Optional'ı önler
filter(p)8Koşulu sağlamıyorsa boşaltır
orElse, orElseGet, orElseThrow(supplier)8Varsayılan / hata
ifPresentOrElse(consumer, runnable)9İki dal birden
or(supplier)9Boşsa başka bir Optional'a geç (ör. önce cache, sonra DB)
stream()90 ya da 1 elemanlı stream; flatMap(Optional::stream) ile boşları atar
orElseThrow()10get()'in daha açık isimli hali (NoSuchElementException)
isEmpty()11!isPresent()
java
// Zincirleme: müşterinin varsayılan adresinin şehri, yoksa "Bilinmiyor"
String city = repo.findById(id)                 // Optional<Customer>
        .flatMap(Customer::defaultAddress)      // Customer::defaultAddress Optional<Address> döner
        .map(Address::city)
        .orElse("Bilinmiyor");

// Optional dönen arama sonuçlarından sadece bulunanlar
List<Product> found = skus.stream()
        .map(catalog::findBySku)                // Stream<Optional<Product>>
        .flatMap(Optional::stream)              // Java 9+
        .toList();

Diğer kurallar: Optional.of(null) hemen NPE fırlatır, null olabilecek değer için Optional.ofNullable. Primitive'ler için OptionalInt, OptionalLong, OptionalDouble boxing'i önler. Optional bir value-based sınıftır: == ile karşılaştırma, üzerinde synchronized kullanma, kimliğine güvenme. Spring Data repository'lerinde findById zaten Optional döner.

Side-effect ve non-interference

Stream javadoc'u, ara işlemlere verilen lambda'lardan iki şey bekler:

  • Non-interfering: Pipeline çalışırken kaynağı değiştirmemeli. orders.stream().forEach(o -> orders.remove(o)) ConcurrentModificationException fırlatır (ya da daha kötüsü, kaynağa bağlı olarak tanımsız davranış gösterir).
  • Stateless (tercihen): Sonucu, pipeline çalışırken değişebilecek bir dış state'e bağlı olmamalı.

Yan etkinin en sık görülen hali, dışarıdaki bir koleksiyonu forEach ile doldurmaktır:

java
// Yanlış: sıralı stream'de çalışır gibi görünür, parallel'e geçince bozulur
List<Long> ids = new ArrayList<>();
orders.parallelStream().map(Order::id).forEach(ids::add);   // ArrayList thread-safe değil:
                                                            // eleman kaybı ya da ArrayIndexOutOfBoundsException
// Doğru
List<Long> ids2 = orders.parallelStream().map(Order::id).toList();

collect ve toList parallel'de her thread'e ayrı container verip sonra birleştirdiği için güvenlidir; forEach ile dış state'e yazmak değildir.

peek sadece debug içindir. peek içine iş mantığı (event yayınlama, DB'ye yazma, sayaç artırma) koyma: yukarıda görüldüğü gibi count() pipeline'ı atlayabilir, short-circuit işlemler bazı elemanlar için peek'i hiç çağırmayabilir, parallel'de çağrı sırası belirsizdir.

forEach ile forEachOrdered farkı: parallel stream'de forEach sıra garantisi vermez; forEachOrdered kaynağın sırasını (encounter order) korur ama paralellik kazancının bir kısmını geri verir.

toList(), Collectors.toList() ve toUnmodifiableList()

Değiştirilebilir mi?null elemanSürüm
stream.toList()Hayır (unmodifiable)Kabul ederJava 16+
collect(Collectors.toList())Spesifikasyon garanti etmez; pratikte ArrayListKabul ederJava 8
collect(Collectors.toUnmodifiableList())HayırNullPointerExceptionJava 10+

Stream.toList() kısa ve niyeti açık olduğu için yeni kodda varsayılan seçimdir. Tuzak, eski kodu toplu olarak toList()'e çevirmektir: sonucu daha sonra add, remove ya da sort ile değiştiren bir çağıran varsa UnsupportedOperationException alırsın. Bu hata derleme zamanında değil, o kod yolu çalıştığında çıkar. Refactor yaparken sonucu kimin değiştirdiğini kontrol et; gerçekten değiştirilebilir liste gerekiyorsa collect(Collectors.toCollection(ArrayList::new)) yaz ki niyet açık olsun.

Parallel stream: ne zaman, ne zaman asla

parallelStream() ya da .parallel() çağrıldığında kaynak Spliterator.trySplit() ile parçalara bölünür ve parçalar ForkJoinPool.commonPool() üzerinde işlenir. Common pool'un varsayılan paralelliği Runtime.availableProcessors() - 1'dir; çağıran thread de işe katılır.

Fayda sağlaması için koşulların hepsi gerekir:

  • Veri büyük ve işlem CPU-yoğun. Doug Lea'nın kaba kuralı: eleman sayısı (N) ile eleman başına maliyet (Q) çarpımı en az 10.000 olmalı. x -> x + 1 gibi önemsiz bir işlemde bu, en az 10.000 eleman demektir; daha temkinli olmak için bir iki sıfır daha ekle.
  • Kaynak iyi bölünüyor. ArrayList, dizi, IntStream.range ortadan ikiye ucuzca bölünür. LinkedList, Stream.iterate, BufferedReader.lines() kötü bölünür.
  • Yan etki ve paylaşılan değiştirilebilir state yok.
  • Birleştirme ucuz. İki HashMap'i birleştirmek, iki sayıyı toplamaktan pahalıdır.
  • Sıra önemli değil. findFirst, limit, sorted, forEachOrdered parallel'de sırayı korumak için ek iş yapar; sıra önemli değilse findAny ve unordered() kullan.

Common pool tuzağı. Bir web uygulamasında 200 Tomcat thread'i aynı anda parallelStream() çağırırsa hepsi aynı, çekirdek sayısından bir eksik thread'li havuzu paylaşır. Dahası, executor verilmemiş CompletableFuture.supplyAsync çağrıları da aynı havuzu kullanır. Parallel stream'in içinde bloklayan I/O (HTTP, JDBC) varsa havuzun thread'leri ağda bekler ve uygulamanın tamamen alakasız bir yerindeki paralel işler de durur. 8 çekirdekli bir pod'da common pool'un 7 worker thread'i vardır; hepsi ağda beklerken havuza gelen diğer işler (çağıran thread'in kendi payına düşen kısım hariç) sıraya girer.

Çıkış yolları:

  • I/O-yoğun paralelleştirme için parallel stream kullanma. Açık bir executor (CompletableFuture.supplyAsync(task, ioExecutor)), virtual thread'ler ya da Java 24+'ta Gatherers.mapConcurrent kullan (bkz. Bölüm 5).
  • -Djava.util.concurrent.ForkJoinPool.common.parallelism=N ile common pool'u büyütmek tüm uygulamayı etkiler; genelde yanlış araçtır.
  • Parallel stream'i özel bir ForkJoinPool'un submit metodu içinde çalıştırma hilesi, stream'in görevlerinin o havuzda çalışmasına yol açar; ama bu belgelenmiş bir garanti değil, implementasyon davranışıdır. Production'da güvenme.

reduce ve parallel. reduce(identity, accumulator)'da identity gerçekten etkisiz eleman olmalı (toplama için 0, çarpma için 1) ve işlem birleşmeli (associative) olmalı. reduce(BigDecimal.TEN, BigDecimal::add) sıralı stream'de "10 fazla" sonuç verir; parallel'de her parça 10 eklediği için parça sayısına göre değişen bir sonuç verir. Bu tür hatalar sadece parallel'e geçince görünür.

Son olarak: ölçmeden karar verme. JMH ile ya da en azından gerçekçi veriyle sıralı ve parallel versiyonu karşılaştır. Çoğu backend kodunda koleksiyonlar küçüktür ve istek seviyesinde zaten paralellik vardır (her istek kendi thread'inde); parallel stream eklemek throughput'u düşürür.

Java 22+: Stream Gatherers

Stream API'nin en büyük eksiği, kendi ara işlemini yazamamaktı. Kendi terminal işlemini Collector ile yazabiliyordun ama "elemanları 500'lük gruplara böl", "kayan pencere ortalaması", "en fazla 16 eşzamanlı çağrıyla map'le" gibi işlemler için ya stream'den çıkmak ya da hantal hilelere başvurmak gerekiyordu.

Gatherer API'si bu boşluğu doldurur. Sürüm durumu:

  • Java 22: preview (JEP 461).
  • Java 23: ikinci preview (JEP 473).
  • Java 24: final (JEP 485), değişiklik olmadan. Java 25 LTS'de standart API'dir; Java 21 LTS'de yoktur.

Stream.gather(gatherer) ile kullanılır. Bir Gatherer, Collector'a benzer biçimde dört parçadan oluşur: initializer (state oluşturur), integrator (her elemanı işler, downstream'e sıfır, bir ya da çok eleman iter ve devam edip etmeyeceğini döner), combiner (parallel için) ve finisher (sonda kalan state'i boşaltır).

java.util.stream.Gatherers içindeki hazır gatherer'lar:

GathererNe yapar
windowFixed(n)Elemanları n'lik listelere böler; son pencere küçük olabilir
windowSliding(n)n boyutlu, birer kayan, örtüşen pencereler
fold(initial, folder)Sıralı bir indirgeme; sonda tek eleman üretir
scan(initial, scanner)Önek toplamı: her eleman için o ana kadarki birikimi iter
mapConcurrent(maxConcurrency, mapper)Fonksiyonu virtual thread'lerde, en fazla maxConcurrency eşzamanlılıkla çalıştırır; sırayı korur
java
// Java 24+
// 1) Outbox event'lerini 500'lük batch'lerle Kafka'ya/DB'ye yazmak
List<List<OutboxEvent>> batches = pending.stream()
        .gather(Gatherers.windowFixed(500))
        .toList();

// 2) Fiyat servisini en fazla 16 eşzamanlı çağrıyla çağırmak (bulkhead dahil)
List<Price> prices = productIds.stream()
        .gather(Gatherers.mapConcurrent(16, pricingClient::fetch))
        .toList();

// 3) Kendi gatherer'ın: anahtara göre tekilleştirme (her satıcının ilk ürünü)
static <T, K> Gatherer<T, ?, T> distinctBy(Function<? super T, ? extends K> key) {
    return Gatherer.ofSequential(
            HashSet<K>::new,
            (seen, element, downstream) ->
                    !seen.add(key.apply(element)) || downstream.push(element));
}

List<Product> firstPerSeller = products.stream().gather(distinctBy(Product::sellerId)).toList();

mapConcurrent parallel stream'in I/O için yanlış araç olma sorununu stream'in içinde çözer: common pool'u kullanmaz, her görev için bir virtual thread açar, eşzamanlılığı sınırlar ve sonuçları girdi sırasıyla verir. Fonksiyon bir hata fırlatırsa kalan görevler iptal edilir ve hata RuntimeException olarak yeniden fırlatılır.

Integrator'ın boolean dönüşü short-circuit'in anahtarıdır: downstream.push false dönerse aşağısı daha fazla eleman istemiyor demektir (ör. arkada limit var) ve gatherer da false dönerek kaynağı durdurur. Yukarıdaki distinctBy, görülmüş elemanda true (devam et), yeni elemanda ise push'un sonucunu döner.

Quiz'deki sorular: çözüm ve nedenleri

j12: Lambda'lar ve functional interface'ler

Senaryo: Kampanya motorunda kurallar Predicate<Cart> ve Function<Cart, BigDecimal> ile tanımlanıyor. Lambda ve functional interface hakkındaki ifadelerden doğru olanlar soruluyor.

Doğru cevaplar: a, b, c, d.

  • a) Lambda sadece effectively final local değişkenleri yakalayabilir. Doğru. Yakalanan değer lambda nesnesine kopyalanır; değişken değişebilseydi kopya ile orijinal birbirinden kopardı ve başka thread'den okunduğunda data race olurdu. Kural sadece local'ler için geçerli; instance alanları this üzerinden okunur ve değiştirilebilir.
  • b) @FunctionalInterface tek bir abstract metot içerir; default ve static metotlar sayılmaz. Doğru. Object'in public metotlarının yeniden bildirimi de sayılmaz (Comparator.equals). Anotasyon zorunlu değil, derleyiciye kontrol ettirir.
  • c) Supplier parametre almaz, Consumer değer döndürmez, Predicate boolean döner, Function T'yi R'ye çevirir. Doğru. Bunlara Bi- versiyonları, UnaryOperator/BinaryOperator ve boxing'i önleyen primitive özelleşmeler eşlik eder.
  • d) Lambda içindeki this çevreleyen sınıfı, anonim sınıftaki this anonim sınıfın kendisini gösterir. Doğru. Lambda yeni bir scope açmaz (lexical scoping); gövdesi çevreleyen sınıfta sentetik bir metoda derlenir.

Yanlışlar:

  • e) String::length bir Function<String, Integer> olarak kullanılamaz. Yanlış: bu bir unbound instance method reference'tır ve ilk parametre receiver olur, yani s -> s.length().
  • f) Checked exception fırlatan metot doğrudan Function'a verilebilir, derleyici sarar. Yanlış: Function.apply throws bildirmez; lambda içinde yakalayıp unchecked'e çevirmek ya da kendi functional interface'ini tanımlamak gerekir.

Ana kavram ve production karşılığı: Davranışı değer gibi taşımak, kural motorları, strateji seçimi ve test edilebilirlik için güçlü bir araçtır. Production'da sık görülen hata, lambda içinden dış state'i değiştirmek (effectively final kuralını AtomicInteger ile atlatmak) ve checked exception'ları catch (Exception e) {} ile yutmaktır.

Quiz'in açıklamasındaki "yakalama yapmayan lambda'lar tek instance olarak yeniden kullanılır" cümlesi bugünkü HotSpot davranışı için doğrudur, ama JLS bunu garanti etmez; lambda kimliğine dayanan kod yazılmamalıdır.

j13: Stream tembeldir: ekrana tam olarak ne yazar?

Senaryo:

java
List<String> result = Stream.of("kalem", "defter", "silgi", "kitap")
        .filter(s -> { System.out.print("F:" + s + " "); return s.startsWith("k"); })
        .map(s -> { System.out.print("M:" + s + " "); return s.toUpperCase(); })
        .limit(1)
        .toList();

Doğru cevap: b) F:kalem M:kalem.

Neden: toList() terminal işlemi pipeline'ı çalıştırır. Kaynak ilk elemanı (kalem) sink zincirine iter: filter yazdırır ve geçirir, map yazdırır ve dönüştürür, limit(1) elemanı alır ve sayacı doldurur. Kaynak bir sonraki elemanı göndermeden önce cancellationRequested() diye sorar; limit'in sink'i true döner ve kaynak durur. defter, silgi, kitap hiç çekilmez.

Yanlışlar:

  • a) F:kalem F:defter F:silgi F:kitap M:kalem M:kitap: Stream'ler aşama aşama (yatay) değil, eleman eleman (dikey) işlenir.
  • c) Hiçbir şey yazmaz: Tembellik "terminal işlem gelene kadar bekler" demektir; toList() terminal işlemdir.
  • d) F:kalem M:kalem F:defter F:silgi F:kitap M:kitap: limit short-circuit'tir; dolduktan sonra kaynaktan eleman çekilmez.

Ana kavram: Laziness + dikey işleme + short-circuit. Production karşılığı: pahalı map'i seçici filter'dan sonraya koymak ve sonsuz ya da çok büyük kaynaklarda (Files.lines ile büyük log dosyası) limit/anyMatch ile erken çıkmak.

Bu soruya bir varyasyon eklemek kavramayı test eder: filter ile map arasına .sorted() koysaydık çıktı F:kalem F:defter F:silgi F:kitap M:kalem olurdu. sorted bariyer olduğu için önce dört elemanın hepsi filter'dan geçer; geçen kalem ve kitap tamponlanıp sıralanır, sonra sadece ilki (kalem) map'e iner, çünkü limit dolunca sorted da akıtmayı keser. sorted'ı map ile limit arasına koysaydık bu sefer map iki eleman için de çalışırdı: F:kalem M:kalem F:defter F:silgi F:kitap M:kitap.

Quiz açıklamasındaki "sorted'ı mümkünse limit'ten sonra değil, gerekiyorsa en sonda kullan" cümlesi kafa karıştırıcıdır. Doğrusu: sorted().limit(n) doğru sonucu verir ama tüm veriyi sıralar; limit(n).sorted() ise farklı bir soruya cevap verir (ilk n elemanı sıralar, en küçük n elemanı değil). Sıralamanın maliyetini azaltmak için yapılacak şey sorted'ın yerini değiştirmek değil, ondan önce veri hacmini filter ile küçültmek ya da top-k için heap kullanmaktır.

j14: Sipariş analitiği: dört stream sorgusu

Senaryo: Order(id, customerId, List<OrderLine>) ve OrderLine(productId, category, unitPrice, quantity) record'ları üzerinde dört rapor yazılacak.

Model çözüm:

java
Map<String, BigDecimal> revenueByCategory(List<Order> orders) {
    return orders.stream()
            .flatMap(o -> o.lines().stream())
            .collect(Collectors.groupingBy(
                    OrderLine::category,
                    Collectors.reducing(BigDecimal.ZERO, OrderLine::total, BigDecimal::add)));
}

Map<Boolean, List<Order>> splitByThreshold(List<Order> orders, BigDecimal threshold) {
    return orders.stream()
            .collect(Collectors.partitioningBy(o -> o.total().compareTo(threshold) >= 0));
}

List<Long> distinctProductIds(List<Order> orders) {
    return orders.stream()
            .flatMap(o -> o.lines().stream())
            .mapToLong(OrderLine::productId)
            .distinct()
            .sorted()
            .boxed()
            .toList();
}

Optional<Order> biggestOrder(List<Order> orders) {
    return orders.stream().max(Comparator.comparing(Order::total));
}

Neden böyle:

  1. Kategori siparişin değil satırın özelliği olduğu için önce flatMap ile Stream<Order>'dan Stream<OrderLine>'a inilir. map kullanılsaydı Stream<Stream<OrderLine>> elde edilirdi. Toplama BigDecimal::add ile yapılır; double'a çevirmek kuruş hatası üretir.
  2. partitioningBy her zaman iki anahtar üretir; çağıran taraf get(true) ve get(false) için null kontrolü yapmak zorunda kalmaz. Karşılaştırma compareTo ile yapılır, equals ile değil, çünkü BigDecimal.equals scale'e de bakar (100.0 ile 100.00 eşit değildir).
  3. mapToLong ile primitive stream'e geçmek distinct ve sorted sırasında boxing'i ortadan kaldırır. Alternatif: collect(Collectors.toCollection(TreeSet::new)) hem tekilleştirir hem sıralar.
  4. Liste boş olabileceği için max zaten Optional döner; get() ile açmak çağıranın işidir ve orElseThrow ya da map ile yapılmalıdır.

Ana kavram: flatMap (1→N düzleştirme), downstream collector, partitioningBy ile groupingBy farkı. Production'da 4. sorgunun verimlilik notu önemlidir: Comparator.comparing(Order::total) her karşılaştırmada toplamı yeniden hesaplar (sipariş başına O(satır sayısı)). Büyük listelerde tutarı bir kez hesaplayıp record OrderTotal(Order order, BigDecimal total) gibi bir ara tipe taşımak daha verimlidir. Eşit toplamlı siparişler varsa hangisinin döneceği belirsizdir; deterministik sonuç istiyorsan thenComparing(Order::id) ekle.

j15: Optional ve stream tuzakları

Doğru cevaplar: a, b, d, f, g.

  • a) flatMap her elemanı bir stream'e çevirip düzleştirir. Doğru. Siparişlerden satırlara inmek tipik örnektir. Optional.flatMap da aynı mantıkla iç içe Optional'ı önler.
  • b) isPresent() + get() yerine map, orElse, orElseThrow, ifPresentOrElse. Doğru. isPresent/get null kontrolünün aynısıdır; Optional'ın sağladığı zincirleme ve "boş durumu unutamama" garantisini boşa çıkarır.
  • d) orElse(createDefault()) Optional dolu olsa bile createDefault()'u çalıştırır; orElseGet çalıştırmaz. Doğru. Java argümanları çağrıdan önce değerlendirir. Varsayılan pahalı veya yan etkiliyse orElseGet.
  • f) peek debug amaçlıdır; count() gibi durumlarda hiç çalışmayabilir. Doğru. Java 9+ count() boyutu kaynaktan biliyorsa pipeline'ı çalıştırmaz.
  • g) parallelStream() ortak ForkJoinPool.commonPool()'u kullanır; bloklayan I/O tüm uygulamayı yavaşlatabilir. Doğru. Common pool küçük ve paylaşımlıdır.

Yanlışlar:

  • c) Optional entity alanı ve parametre olarak önerilir. Yanlış: dönüş tipi için tasarlandı; Serializable değil, JPA eşlemez, parametrede üçüncü bir durum (null Optional) yaratır.
  • e) Tüketilmiş bir stream'de tekrar terminal işlem çağrılabilir. Yanlış: IllegalStateException: stream has already been operated upon or closed; gerekiyorsa Supplier<Stream<T>>.

Production karşılığı: orElse ile yanlışlıkla her istekte DB'ye ya da uzak servise gitmek, gerçek bir latency ve maliyet hatasıdır ve kod okurken fark edilmesi zordur. Code review'da orElse( içinde metot çağrısı gördüğünde dur ve sor.

j16: Satıcı → ürün haritası production'da patladı

Senaryo:

java
Map<Long, String> bySeller = products.stream()
        .collect(Collectors.toMap(Product::sellerId, Product::name));

Testte her satıcının tek ürünü vardı.

Doğru cevap: c) IllegalStateException: Duplicate key ... fırlar.

İki parametreli toMap çakışmaya izin vermez. Java 9+ mesajı key'i ve iki değeri birlikte yazar: Duplicate key 7 (attempted merging values Kalem and Defter).

Yanlışlar:

  • a) Son gelen sessizce yazılır: toMap, HashMap.put gibi davranmaz; çakışmayı hata sayar.
  • b) İlk gelen korunur: Bu davranış için merge function vermek gerekir: (first, second) -> first.
  • d) Derleme hatası: Derleyici verinin içeriğini bilemez; hata çalışma zamanında çıkar.

Quiz açıklamasında düzeltme: Açıklama, null değerdeki NPE için "içeride HashMap.merge kullanılır" diyor. Bu Java 8 için doğruydu; Java 9+'ta iki parametreli toMap değeri Objects.requireNonNull ile kontrol edip putIfAbsent ile ekler. Sonuç aynıdır (null değerde NPE), mekanizma farklıdır. Üç ve dört parametreli versiyonlar hâlâ Map.merge kullanır, bu yüzden merge function vermek null sorununu çözmez.

Doğru tasarım:

java
// Satıcı başına TÜM ürün adları
Map<Long, List<String>> namesBySeller = products.stream()
        .collect(Collectors.groupingBy(Product::sellerId,
                 Collectors.mapping(Product::name, Collectors.toList())));

// Gerçekten tek değer isteniyorsa kuralı AÇIKÇA yaz (ör. en düşük id'li ürün)
Map<Long, Product> firstBySeller = products.stream()
        .collect(Collectors.toMap(Product::sellerId, Function.identity(),
                 BinaryOperator.minBy(Comparator.comparingLong(Product::id))));

Quiz'in modelindeki (first, second) -> first kuralı, sıralı bir kaynakta "listede ilk gelen" demektir; kaynak sırası bir anlam taşımıyorsa (ör. DB'den ORDER BY olmadan gelen liste) sonuç da deterministik değildir. İş kuralını (en eski ürün, en ucuz ürün) comparator ile açıkça yazmak daha sağlamdır.

Ana kavram: Mutlu yol verisiyle yazılmış test, veri şekline dair varsayımı (her satıcının tek ürünü var) doğrulamaz. Test verisine bilinçli olarak duplicate key, null alan, boş liste ve sınır değerleri koy. Benzer "sessiz varsayım" tuzakları: Map.of duplicate key'de IllegalArgumentException fırlatır; List.of/Map.of null kabul etmez; Arrays.asList sabit boyutludur (add → UnsupportedOperationException).

q3: Son 30 günün en çok ciro yapan 3 satıcısı (Java stream kısmı)

Senaryo: Bellekteki List<Order>'dan son 30 günde DELIVERED olmuş siparişlerin satıcı bazında cirosu hesaplanıp en yüksek 3 satıcı azalan sırada dönülecek. Clock parametre olarak veriliyor. (Sorunun "50 milyon siparişte nerede yapardın" kısmı, yani SQL GROUP BY ve önceden toplanmış tablolar, SQL bölümünde anlatılıyor; bkz. Bölüm 9.)

Model çözüm:

java
record Order(long sellerId, OrderStatus status, BigDecimal amount, Instant createdAt) {}
record SellerRevenue(long sellerId, BigDecimal revenue) {}

List<SellerRevenue> topSellers(List<Order> orders, Clock clock) {
    Instant since = clock.instant().minus(Duration.ofDays(30));

    Map<Long, BigDecimal> revenueBySeller = orders.stream()
            .filter(o -> o.status() == OrderStatus.DELIVERED)
            .filter(o -> !o.createdAt().isBefore(since))
            .collect(Collectors.toMap(Order::sellerId, Order::amount, BigDecimal::add));

    return revenueBySeller.entrySet().stream()
            .sorted(Map.Entry.<Long, BigDecimal>comparingByValue().reversed()
                    .thenComparing(Map.Entry.comparingByKey()))
            .limit(3)
            .map(e -> new SellerRevenue(e.getKey(), e.getValue()))
            .toList();
}

Neden böyle:

  • Clock enjeksiyonu: Instant.now() doğrudan çağrılırsa test deterministik olmaz; testte Clock.fixed(...) verilir. Spring'de Clock bir bean olarak tanımlanıp constructor injection ile alınır.
  • Filtre sırası: Ucuz enum karşılaştırması önce. Tarih filtresinde !isBefore(since) sınırı dahil eder; isAfter(since) tam sınırdaki siparişi dışarıda bırakır. Hangisinin istendiğini söylemek, sınır değerleri düşündüğünü gösterir.
  • Toplama: toMap(..., BigDecimal::add) ile groupingBy(..., reducing(BigDecimal.ZERO, Order::amount, BigDecimal::add)) eşdeğerdir. toMap biraz daha kısadır ve gereksiz ZERO toplamı yapmaz.
  • Sıralama: Map.Entry.<Long, BigDecimal>comparingByValue() çağrısında tip tanığı (type witness) gerekir, çünkü .reversed() zincirinde Java hedef tipi çıkaramaz. Eşit ciroda hangi satıcının döneceği tanımsız kalmasın diye thenComparing(comparingByKey()) eklendi; bu, ekran ve test sonuçlarının çalıştırmadan çalıştırmaya değişmemesini sağlar.
  • limit(3) ve toList(): Unmodifiable sonuç döner; rapor katmanının listeyi değiştirmesine gerek yoktur.

Quiz'de eksik olan nokta: Soru "son 30 günde teslim edilmiş" diyor ama Order record'unda sadece createdAt var. Model çözüm siparişin oluşturulma tarihine göre filtreliyor; 35 gün önce verilip 25 gün önce teslim edilen bir sipariş dışarıda kalır. Gerçek bir mülakatta senior cevap bunu netleştirme sorusu olarak sorar ("ciroyu teslim tarihine göre mi, sipariş tarihine göre mi raporluyoruz?") ve gerekiyorsa record'a deliveredAt eklenmesini önerir. Finansal raporlarda bu tür tanım farkları muhasebe ile mutabakat sorunlarına yol açar.

Top-k notu: Satıcı sayısı çok büyükse (yüz binler) tüm girişleri sıralamak O(n log n) sürer. Boyutu 3 olan bir min-heap ile O(n log 3) ≈ O(n) yeterlidir:

java
PriorityQueue<Map.Entry<Long, BigDecimal>> heap =
        new PriorityQueue<>(Map.Entry.comparingByValue());
for (var e : revenueBySeller.entrySet()) {
    heap.offer(e);
    if (heap.size() > 3) heap.poll();     // en küçüğü at
}
// heap'te en büyük 3 var, küçükten büyüğe; ters çevirip dön

Bu, mülakatta "sıralama maliyetini fark ediyor" sinyalidir; ama 50 milyon kayıtta asıl cevap hesabı uygulama belleğine hiç getirmemektir.

Üç seviyede cevap

Soru: "Stream'ler lazy'dir ne demek? Bir pipeline nasıl çalışır?"

Stream'ler lazy'dir, yani terminal işlem çağrılmadan hiçbir şey çalışmaz. filter, map gibi metotlar intermediate, collect, forEach gibi metotlar terminal işlemlerdir. Terminal işlem çağrılınca önce filter tüm elemanlara uygulanır, sonra map uygulanır ve sonuç toplanır.

Intermediate işlemler sadece pipeline'ı tarif eder; terminal işlem gelince çalışır. İşleme eleman eleman olur: bir eleman filter'dan geçerse hemen map'e, sonra bir sonraki aşamaya iner. Bu yüzden limit, findFirst, anyMatch gibi short-circuit işlemler kaynaktan gereğinden fazla eleman çekmez ve sonsuz stream'lerle çalışabilir. Pratikte ucuz filtreleri başa, pahalı map'leri sona koyarım. sorted ve distinct stateful olduğu için bu akışı keser; sorted tüm elemanları toplamadan ilkini veremez. Stream tek kullanımlıktır, ikinci terminal işlem IllegalStateException verir.

Terminal işlem çağrıldığında stage'ler bir Sink zincirine sarılır ve Spliterator elemanları tek tek bu zincire iter; laziness'ın değeri buradan gelir: yapılmayan iş. Short-circuit, sink'in cancellationRequested sinyaliyle kaynağı durdurmasıdır. sorted bir bariyerdir: tamponlar, end()'de sıralar ve akıtır; sorted().limit(k) bu yüzden O(n log n) zaman ve O(n) bellek harcar, top-k için heap daha iyidir.

Laziness'ın bir yan sonucu da Stream API'nin lambda'ları çalıştırmama hakkıdır: Java 9+ count(), boyut kaynaktan biliniyorsa pipeline'ı atlar, bu yüzden peek ve map içine yan etki koymam. Çok büyük ya da sonsuz kaynaklarda (Files.lines) erken çıkışı bilinçli kullanırım ve I/O kaynaklı stream'leri try-with-resources ile kapatırım. Performans kritikse JMH ile ölçerim; boxing (Stream<Long> yerine LongStream) ve gereksiz ara koleksiyonlar genelde stream'in kendisinden daha pahalıdır.

Junior cevap tanımı biliyor ama yaygın bir yanlış inanç içeriyor: "önce filter tüm elemanlara, sonra map" yatay işlemedir ve yanlıştır. Bu inançla j13'teki gibi bir kod okuma sorusunda hatalı cevap verir. Mid cevap dikey işlemeyi, short-circuit'i ve stateful işlemlerin akışı nasıl kestiğini biliyor ve bundan pratik bir kural (filtre önce) çıkarıyor. Senior cevap mekanizmayı (Sink zinciri, cancellation sinyali, sorted'ın tamponlaması) ve bunun maliyet sonuçlarını (O(n) bellek, top-k) söylüyor, ayrıca laziness'ın "lambda'n hiç çalışmayabilir" anlamına geldiğini ve bunun yan etkiler için ne demek olduğunu biliyor.

Takip sorusu: "Stream.of(...).sorted().peek(...).findFirst() ne yazdırır, sorted'ı kaldırırsak ne değişir?"

Soru: "orElse ile orElseGet arasındaki fark nedir? Optional'ı nerede kullanırsın?"

orElse değer yoksa verilen değeri döner, orElseGet de bir Supplier alıp ondan değer döner. İkisi de aynı işi yapar, orElseGet lambda ile yazılır. Optional'ı null pointer hatalarından kaçınmak için her yerde kullanırım, entity alanlarında da kullanılabilir.

orElse'nin argümanı, Optional dolu olsa bile, metot çağrılmadan önce hesaplanır; orElseGet ise sadece boşsa Supplier'ı çağırır. Varsayılan pahalıysa ya da yan etkisi varsa, mesela DB'ye gidiyorsa, orElseGet kullanırım. Optional'ı sadece dönüş tipinde kullanırım; alan ya da parametre olarak kullanmam çünkü Serializable değil, JPA desteklemez ve parametrede çağıranı zorlar. isPresent + get yerine map, flatMap, orElseThrow ile zincir kurarım.

Fark Java'nın eager argüman değerlendirmesinden gelir: orElse(loadFromDb(id)) cache hit olsa bile DB'ye gider; bu tür hatalar fonksiyonel olarak doğru çalıştığı için testte görünmez, sadece DB metriklerinde ve latency'de görünür. Code review'da orElse( içinde metot çağrısı gördüğümde soru sorarım. Sabit ya da hazır değerde orElse kullanmak ise tamamen doğru ve daha okunur.

Optional'ı "sonuç olmayabilir" bilgisini API sözleşmesine yazmak için, metot dönüş tiplerinde kullanırım. Koleksiyon dönen metotta boş koleksiyon dönerim; alanlarda nullable alan + Optional dönen getter; parametrede overload. Java 9+ or() ile önce cache sonra DB gibi geri düşüş zincirleri, Optional::stream ile boşları ayıklama, Java 10+ orElseThrow() ile get()'in yerine daha açık isim kullanırım. Optional value-based olduğu için == ve synchronized ile kullanmam. Performans kritik sıcak yollarda (saniyede milyonlarca çağrı) Optional tahsisinin maliyeti JIT escape analysis ile genelde silinir ama garanti değildir; orada profil sonucuna göre karar veririm.

Junior cevap iki metodu eşdeğer sanıyor; bu yaygın ve production'da gerçek maliyet üreten bir yanlış inanç. "Her yerde, entity alanında da" demesi Optional'ın tasarım amacını bilmediğini gösteriyor. Mid cevap mekanizmayı (eager evaluation) ve doğru kullanım yerlerini biliyor. Senior cevap aynı bilgiyi production etkisiyle (testte görünmeyen, metriklerde görünen hata), code review alışkanlığıyla ve "nerede kullanmam, yerine ne kullanırım" alternatifleriyle veriyor; ayrıca orElse'nin doğru olduğu durumu da söyleyerek dogmatik olmadığını gösteriyor.

Takip sorusu: "Spring Data'da findById Optional dönüyor. Servis katmanında bulunamazsa 404 dönmek için bunu nasıl ele alırsın?"

Soru: "Parallel stream ne zaman kullanırsın?"

Büyük listelerde işlemi hızlandırmak için parallelStream() kullanırım. Çok çekirdekli makinede işi bölerek daha hızlı çalışır. Mesela 10.000 siparişi işlerken parallel stream ile daha hızlı biter.

Parallel stream ForkJoinPool.commonPool()'u kullanır. Veri büyükse, işlem CPU-yoğunsa, yan etki yoksa ve kaynak iyi bölünüyorsa (ArrayList, dizi) fayda sağlar. I/O içeren işlerde kullanmam, çünkü common pool küçük ve paylaşımlı; bloklayan çağrılar tüm uygulamadaki paralel işleri ve executor verilmemiş CompletableFuture'ları yavaşlatır. forEach ile dışarıdaki listeye yazmak parallel'de bozulur, collect kullanırım.

Bir web servisinde neredeyse hiç kullanmam. İstek seviyesinde zaten paralellik var: 200 istek thread'i aynı anda çalışıyorsa, her birinin parallel stream açması CPU'yu daha fazla bölmekten başka bir şey yapmaz ve hepsi çekirdek sayısından bir eksik thread'li common pool'da sıraya girer. Parallel stream'in yeri batch işler, rapor üretimi, büyük in-memory hesaplardır; o durumda bile NQ kuralına bakarım (eleman sayısı × eleman başı maliyet en az ~10.000), kaynağın bölünebilirliğine, birleştirme maliyetine ve sıranın gerekip gerekmediğine bakarım, sonra JMH ya da gerçekçi veriyle ölçerim.

I/O paralelleştirmesi ayrı bir problemdir: açık bir I/O executor'lu CompletableFuture, virtual thread'ler + Semaphore ya da Java 24+'ta Gatherers.mapConcurrent(16, client::fetch) kullanırım; bunlar hem common pool'u kirletmez hem eşzamanlılığı sınırlayarak downstream servisi korur. Parallel'e geçerken reduce identity'sinin gerçekten etkisiz eleman olduğunu ve işlemin birleşmeli olduğunu kontrol ederim; bu hatalar sıralı stream'de görünmez.

Junior cevap "parallel = hızlı" varsayımını yapıyor ve örneği (10.000 sipariş) maliyetten bağımsız veriyor; paylaşımlı havuzdan ve I/O tuzağından haberi yok. Mid cevap common pool'u, uygun koşulları ve yan etki tuzağını biliyor. Senior cevap bağlamı soruyor (web servisi mi, batch mi), istek seviyesindeki paralellikle çakışmayı açıklıyor, ölçüm yöntemini ve I/O için doğru alternatifleri (mapConcurrent, virtual thread, bulkhead) veriyor. "Ne zaman kullanmam" cevabın merkezinde.

Takip sorusu: "Bir parallel stream içinde reduce(BigDecimal.ONE, BigDecimal::add) yazılmış. Ne olur?"

Soru: "Lambda neden sadece effectively final değişkenleri yakalayabilir? Lambda ile anonim sınıf arasındaki fark nedir?"

Java öyle tasarlandığı için lambda içinde dışarıdaki değişkeni değiştiremeyiz, derleyici hata verir. Lambda, anonim sınıfın kısa yazılmış halidir; ikisi aynı şeydir ama lambda daha okunur.

Lambda yakaladığı local değişkenin değerini kopyalar. Local değişken stack'te yaşar ve metot bitince yok olur, lambda ise daha uzun yaşayabilir ya da başka thread'de çalışabilir. Değişebilseydi kopyayla orijinal tutarsız olur ve race condition çıkardı. Anonim sınıftan farkı: lambda'da this çevreleyen sınıfı gösterir, anonim sınıfta anonim sınıfın kendisini. Lambda sadece functional interface'e atanabilir, kendi state'i olamaz.

Yakalama değer kopyalamadır; kuralın amacı kopya ile değişken arasında tutarsızlığı ve thread'ler arası paylaşılan değiştirilebilir local'i dil seviyesinde imkansız kılmak. Instance alanları kurala tabi değil, çünkü lambda aslında this'i yakalıyor; bu da lambda'yı uzun yaşayan bir yerde saklarsan çevreleyen nesneyi canlı tutman anlamına gelir. Kuralı AtomicInteger ile atlatma ihtiyacı duyuyorsam genelde niyetim bir reduction'dır ve onu count, reduce veya collect ile yazarım.

Lambda anonim sınıfın sözdizimi şekeri değil: gövde sentetik private bir metoda derlenir, çağrı noktası invokedynamic ile ilk çalışmada LambdaMetafactory'ye bağlanır, .class dosyası üretilmez. Lexical scoping'dir: this çevreleyen instance'tır ve çevredeki bir local'i aynı isimle gölgeleyemezsin. Kimliği garanti edilmez; lambda'ları == ile karşılaştırmam. Anonim sınıfı hâlâ abstract sınıf genişletirken, birden çok metodu override ederken ya da nesneye state gerektiğinde kullanırım. Method reference'ta ise bağlı receiver oluşturma anında değerlendirilir: name::length null name için NPE'yi çağrıda değil oluşturmada fırlatır.

Junior cevap "öyle tasarlanmış" diyerek nedeni bilmediğini ve "aynı şey" diyerek yaygın bir yanlış inancı gösteriyor. Mid cevap kopyalama mekanizmasını ve this farkını doğru anlatıyor. Senior cevap kuralın tasarım gerekçesini, instance alanı istisnasının bellek sonucunu, kuralı atlatma ihtiyacının bir kod kokusu olduğunu ve derleme mekanizmasını (invokedynamic, sentetik metot) söylüyor; method reference'taki değerlendirme zamanı farkı gibi ince bir davranışı da biliyor.

Takip sorusu: "Supplier<Integer> s = name::length; ile Supplier<Integer> s = () -> name.length(); arasında davranış farkı var mı?"

Sorulmadı ama sorulabilir

Comparator kompozisyonu ve reversed() tuzağı

Sıralama, stream sorularının yarısında karşına çıkar. Comparator.comparing(keyExtractor), thenComparing, reversed, nullsFirst/nullsLast, primitive için comparingInt/Long/Double.

java
Comparator<Product> byPriceDescThenName = Comparator
        .comparing(Product::price, Comparator.reverseOrder())   // sadece fiyatı ters çevir
        .thenComparing(Product::name, Comparator.nullsLast(Comparator.naturalOrder()));

Tuzak: .reversed() kendisinden önceki tüm zinciri ters çevirir. comparing(Product::price).thenComparing(Product::name).reversed() hem fiyatı hem ismi ters sıralar. Sadece bir anahtarı ters çevirmek için o anahtara Comparator.reverseOrder() ver. Lambda ile comparing(p -> p.price()).reversed() yazarsan tip çıkarımı bozulup p'yi Object görebilir; method reference ya da açık tip ((Product p) -> p.price()) kullan.

Mülakat özeti: "Çok anahtarlı sıralamayı comparing/thenComparing ile kurarım; reversed tüm zinciri çevirdiği için tek anahtarı reverseOrder ile ters çeviririm ve eşitlikte deterministik sonuç için bir tie-breaker eklerim."

reduce mi collect mi? Üç argümanlı reduce

reduce immutable değerler için (sayı, BigDecimal), collect container'lar için. String birleştirmeyi reduce ile yapmak her adımda yeni bir String kopyalar; bunun yerine Collectors.joining kullanılır. Üç argümanlı reduce(identity, accumulator, combiner), akümülatör tipi eleman tipinden farklıysa kullanılır:

java
BigDecimal total = orders.stream()
        .reduce(BigDecimal.ZERO,
                (acc, order) -> acc.add(order.total()),   // (BigDecimal, Order) -> BigDecimal
                BigDecimal::add);                         // parallel'de parçaları birleştirir

Bunun daha okunur hali map(Order::total).reduce(BigDecimal.ZERO, BigDecimal::add)'dir. Üç argümanlı versiyon, combiner'ın akümülatörle uyumlu olması şartıyla (aynı sonucu vermesi) doğrudur; uyumsuz combiner sadece parallel'de yanlış sonuç verir.

Mülakat özeti: "Değer üretiyorsam reduce, container dolduruyorsam collect; reduce ile liste biriktirmek her adımda kopya üretir."

I/O kaynaklı stream'leri kapatmak

Stream, AutoCloseable'dır ama çoğu stream'in (koleksiyon kaynaklı) kapatılması gerekmez. I/O kaynaklı olanlar (Files.lines, Files.list, Files.walk, JDBC/JPA'dan dönen stream'ler) arka planda dosya tanıtıcısı ya da DB cursor'ı tutar ve kapatılmazsa sızar.

java
try (Stream<String> lines = Files.lines(path)) {
    return lines.filter(l -> l.contains("ERROR")).limit(100).toList();
}

Spring Data JPA'da Stream<Order> streamAllBy...() dönen repository metotları bir transaction içinde çağrılmalı ve try-with-resources ile kapatılmalıdır; aksi halde connection havuza dönmeyebilir. Büyük tabloları belleğe almadan işlemek için kullanışlıdır ama fetch size ayarı DB sürücüsüne göre değişir.

Mülakat özeti: "Koleksiyon stream'i kapatmam gerekmez, ama dosya ve DB cursor'ı taşıyan stream'leri try-with-resources ile kapatırım."

Stream mi, for döngüsü mü?

Stream'ler okunabilirlik içindir, hız için değil. Basit bir for döngüsü, küçük koleksiyonlarda genelde stream'den biraz daha hızlıdır (pipeline kurulumu, lambda çağrıları), ama JIT sonrası fark çoğu backend kodunda önemsizdir. Gerçek farkı yaratanlar: boxing (Stream<Integer> yerine IntStream), gereksiz ara koleksiyonlar ve algoritmik karmaşıklık. for döngüsünü şu durumlarda tercih ederim: kontrol akışı karmaşıksa (birden fazla break, checked exception, birden çok değişkeni birlikte güncellemek), indeksle çalışmak gerekiyorsa ya da profil sıcak bir yol gösteriyorsa. Debug açısından stream stack trace'leri daha derindir; IntelliJ'nin "Trace Current Stream Chain" aracı bunu kolaylaştırır.

Mülakat özeti: "Stream'i bildirimsel okunabilirlik için seçerim; performansı varsayım değil profil belirler, asıl maliyet genelde boxing ve gereksiz kopyalardır."

mapMulti (Java 16+) ve flatMap

flatMap her eleman için yeni bir Stream nesnesi oluşturur. Eleman başına sıfır ya da birkaç çıktı üreten durumlarda bu fazla tahsis demektir. mapMulti, bir Consumer'a doğrudan eleman itmeni sağlar:

java
// Sadece fiziksel ürün satırlarını, adet kadar kargo kalemine aç
Stream<ShipmentItem> items = lines.stream()
        .<ShipmentItem>mapMulti((line, sink) -> {
            if (line.physical()) {
                for (int i = 0; i < line.quantity(); i++) sink.accept(new ShipmentItem(line.productId()));
            }
        });

Tip parametresini (<ShipmentItem>) çoğu zaman açıkça yazmak gerekir, çünkü derleyici çıktı tipini lambda'dan çıkaramaz.

Mülakat özeti: "Eleman başına az sayıda ve koşullu çıktı varsa mapMulti, iç içe koleksiyonu düzleştiriyorsam flatMap kullanırım."

groupingBy sonucunu sıralı ya da enum anahtarlı almak

Varsayılan groupingBy bir HashMap döndürür; anahtar sırası tanımsızdır. Rapor çıktısında sıra önemliyse map factory ver:

java
Map<OrderStatus, Long> countByStatus = orders.stream()
        .collect(Collectors.groupingBy(
                Order::status,
                () -> new EnumMap<>(OrderStatus.class),
                Collectors.counting()));

EnumMap hem enum sırasını korur hem dizi tabanlı olduğu için HashMap'ten hızlı ve küçüktür. Alfabetik sıra için TreeMap::new, ekleme sırası için LinkedHashMap::new.

Mülakat özeti: "Sıra bir gereksinimse map tipini groupingBy'ın ikinci parametresiyle açıkça seçerim; enum anahtarda EnumMap."

Sık yapılan hatalar

  • Para toplamını mapToDouble(...).sum() ile yapmak → double ikili kayan noktadır, 0.1 + 0.2 != 0.3; milyonlarca satırda kuruş farkı birikir → BigDecimal ile reduce(BigDecimal.ZERO, BigDecimal::add) ya da toMap(..., BigDecimal::add), bölmede scale ve RoundingMode ver.
  • İki parametreli toMap'i veri şeklini bilmeden kullanmak → duplicate key'de IllegalStateException, null değerde NPE → çoklu değer için groupingBy + mapping; tek değer için merge function ile iş kuralını açıkça yaz.
  • orElse(pahalıÇağrı()) → argüman Optional dolu olsa bile çalışır, her istekte DB/HTTP maliyeti → orElseGet(() -> pahalıÇağrı()).
  • Optional'ı alan, parametre ya da Optional<List<T>> olarak kullanmak → Serializable değil, JPA eşlemez, üçüncü bir null durumu yaratır → dönüş tipinde Optional, koleksiyonda boş koleksiyon.
  • isPresent() + get() → null kontrolünün aynısı, get() boşta NoSuchElementException → map/flatMap/orElseThrow.
  • peek ya da map içine iş mantığı koymak → Stream API lambda'yı çalıştırmayabilir (count() optimizasyonu, short-circuit) → yan etkiyi terminal işlemde ya da stream dışında yap.
  • forEach ile dış koleksiyona yazmak → parallel'de veri kaybı ya da exception, sıralıda bile niyeti gizler → collect/toList.
  • Web isteğinde parallelStream() içinde HTTP/JDBC çağrısı → common pool'u bloklar, tüm uygulamadaki paralel işler ve executor'suz CompletableFuture'lar yavaşlar → I/O executor, virtual thread + Semaphore ya da Gatherers.mapConcurrent.
  • Eski kodu toplu olarak stream.toList()'e çevirmek → sonucu değiştiren çağıranlar çalışma anında UnsupportedOperationException alır → değiştirilebilir liste gerekiyorsa toCollection(ArrayList::new).
  • Stream'i değişkende tutup iki kez tüketmek → IllegalStateException → kaynaktan yeniden stream aç ya da Supplier<Stream<T>>.
  • sorted().limit(k)'yı ucuz sanmak → tüm veri sıralanır, O(n) bellek → büyük veride heap, daha büyük veride hesabı DB'ye taşı.
  • reduce'a etkisiz olmayan identity vermek (reduce(BigDecimal.TEN, ...)) → sıralıda sabit sapma, parallel'de parça sayısına bağlı yanlış sonuç → identity her zaman etkisiz eleman.
  • Checked exception'ı lambda içinde catch (Exception e) {} ile yutmak → hata kaybolur, eksik veri sessizce devam eder → anlamlı unchecked exception'a sar ya da hatayı sonuç tipi olarak topla.
  • Files.lines'ı kapatmamak → dosya tanıtıcısı sızıntısı → try-with-resources.

Hızlı tekrar

S: Functional interface'te hangi metotlar "tek abstract metot" sayımına girmez?

C: default ve static metotlar ile Object'in public metotlarının yeniden bildirimleri (Comparator.equals gibi).

S: Lambda neden effectively final local değişken ister?

C: Yakalanan değer lambda nesnesine kopyalanır; değişken değişebilseydi kopya ile orijinal tutarsız olur ve başka thread'den okunduğunda data race doğardı.

S: String::length hangi functional interface'e uyar ve neden?

C: Function<String, Integer>'a (ya da ToIntFunction<String>'e). Unbound instance method reference'ta ilk parametre receiver olur: s -> s.length().

S: Stream.of("kalem","defter","kitap").filter(k ile başlayan).map(...).limit(1).toList() kaç eleman işler?

C: Sadece kalem'i. Stream dikey işlenir ve limit dolunca cancellationRequested ile kaynak durur.

S: sorted neden stateful ve bariyerdir?

C: Tüm elemanları görmeden ilk sıradakini bilemez; elemanları tamponlar, kaynak bitince sıralar ve sonra akıtır. Maliyeti O(n log n) zaman ve O(n) bellektir.

S: İki parametreli Collectors.toMap duplicate key ve null değerde ne yapar?

C: Duplicate key'de IllegalStateException, null değerde NullPointerException fırlatır. Çakışma için 3. parametre merge function, map tipi için 4. parametre.

S: partitioningBy ile groupingBy(predicate) farkı nedir?

C: partitioningBy her zaman hem true hem false anahtarını içerir; groupingBy sadece gerçekten oluşan anahtarları içerir.

S: orElse ile orElseGet farkı nedir?

C: orElse'nin argümanı Optional dolu olsa bile önceden hesaplanır; orElseGet Supplier'ı sadece boşsa çağırır. Pahalı ya da yan etkili varsayılanda orElseGet.

S: stream.toList() ile Collectors.toList() farkı nedir?

C: toList() (Java 16+) unmodifiable liste döner ve null'a izin verir; Collectors.toList() değiştirilebilirlik garantisi vermez, pratikte ArrayList döner. toUnmodifiableList() (Java 10+) null'da NPE fırlatır.

S: Parallel stream hangi havuzda çalışır ve web uygulamasında neden tehlikelidir?

C: ForkJoinPool.commonPool() (paralellik: çekirdek sayısı - 1). Tüm parallel stream'ler ve executor verilmemiş CompletableFuture'lar bu küçük havuzu paylaşır; içinde bloklayan I/O tüm uygulamayı yavaşlatır.

S: Stream Gatherers hangi sürümde final oldu ve mapConcurrent ne yapar?

C: Java 24'te (JEP 485; Java 22 ve 23'te preview). mapConcurrent(n, f) fonksiyonu virtual thread'lerde en fazla n eşzamanlılıkla çalıştırır ve sonuçları girdi sırasıyla verir.

S: BigDecimal'ları stream'de nasıl toplarsın?

C: map(x::amount).reduce(BigDecimal.ZERO, BigDecimal::add); grup bazında groupingBy(k, reducing(BigDecimal.ZERO, mapper, BigDecimal::add)) ya da toMap(k, v, BigDecimal::add). Asla double'a çevirmeden.

S: Checked exception fırlatan bir metodu map içinde nasıl kullanırsın?

C: Lambda içinde yakalayıp anlamlı bir unchecked exception'a (UncheckedIOException gibi) sararım, tekrar eden durumda kendi ThrowingFunction adaptörümü yazarım ya da hatayı sonuç tipi olarak toplarım.

Kaynaklar