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.
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.
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:
defaultvestaticmetotlar (gövdeleri var).Object'in public metotlarının yeniden bildirimi.Comparatorinterface'iboolean equals(Object)bildirir ama yine functional interface'tir, çünkü her sınıfequals'ı zatenObject'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
| Interface | Metot | İmza | Örnek kullanım |
|---|---|---|---|
Function<T, R> | apply | T → R | Order → BigDecimal (tutar) |
BiFunction<T, U, R> | apply | (T, U) → R | (sepet, kupon) → indirim |
UnaryOperator<T> | apply | T → T | fiyata KDV ekle |
BinaryOperator<T> | apply | (T, T) → T | BigDecimal::add |
Supplier<T> | get | () → T | varsayılan değer, lazy hesap |
Consumer<T> | accept | T → void | event yayınla, logla |
BiConsumer<T, U> | accept | (T, U) → void | map.forEach((k, v) -> ...) |
Predicate<T> | test | T → 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:
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.
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:
- 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ı.
- 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ı.
- 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.
// 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.
| Lambda | Anonim sınıf | |
|---|---|---|
this | Çevreleyen sınıfın instance'ı (lexical scoping) | Anonim sınıfın kendisi |
| Yeni scope | Açmaz: çevredeki bir local ile aynı isimde parametre/değişken tanımlanamaz | Açar: shadowing serbest |
| State | Kendi alanı olamaz | Alan tanımlayabilir |
| Hedef | Sadece functional interface | Herhangi bir interface ya da abstract/concrete sınıf, birden çok metot |
| Derleme | invokedynamic + LambdaMetafactory; sınıf çalışma zamanında üretilir | Derleme zamanında ayrı bir Outer$1.class dosyası |
| Nesne kimliği | Spesifikasyon yeni nesne üretileceğini garanti etmez | Her 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 üzerindesynchronizedkullanma. - 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 sadecethis'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 | Örnek | Eşdeğer lambda |
|---|---|---|
| Static metot | Integer::parseInt | s -> Integer.parseInt(s) |
| Bağlı (bound) instance | cart::addItem | item -> cart.addItem(item) |
| Bağsız (unbound) instance | String::length | s -> s.length() |
| Constructor | ArrayList::new | () -> new ArrayList<>() |
| Dizi constructor | String[]::new | n -> 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:
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:
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.
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:
@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:
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:
- Kaynak:
Collection.stream(),Stream.of,Arrays.stream,IntStream.range,Files.lines,Stream.iterate. - Ara işlemler (intermediate):
filter,map,flatMap,mapMulti,distinct,sorted,peek,limit,skip,takeWhile,dropWhile,gather(Java 24+). Her biri yeni birStreamdöner ve hiçbir şey çalıştırmaz. - 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.
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" .-> SBu 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.
// 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:
// 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:
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:
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.sortedsonsuz bir stream'de asla bitmez.sorted'dan sonra gelen pahalımap,limitsayesinde 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
distinctdoğal çalışır. new BigDecimal("1.0")ilenew BigDecimal("1.00")farklı scale'e sahip olduğu içinequalsile eşit değildir;distinctikisini de tutar. Para değerlerinde tekilleştirme yapacaksan öncestripTrailingZeros()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ğilseunordered()bu maliyeti düşürür.
Collectors: mutable reduction
reduce ile collect farklı iki indirgeme (reduction) türüdür:
reduceimmutable reduction yapar: her adımda iki değerden yeni bir değer üretir.BigDecimal::addgibi immutable değerler için doğaldır.collectmutable reduction yapar: bir container (liste, map,StringBuilder) oluşturur ve elemanları ona ekler. Parallel'de her thread kendi container'ını doldurur, sonundacombinerile 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:
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:
| Downstream | Grup sonucu |
|---|---|
counting() | Long |
summingInt/Long/Double(mapper), averagingX | sayı |
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:
// 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.
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:
- İki parametreli
toMap(keyMapper, valueMapper)aynı key ikinci kez gelirseIllegalStateExceptionfı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. - Değer
nulliseNullPointerExceptionfırlatır. İki parametreli versiyon Java 9+'ta değeriObjects.requireNonNullile kontrol edipputIfAbsentile ekler; üç ve dört parametreli versiyonlarMap.mergekullanır vemergede null değeri reddeder. Yani merge function vermek null sorununu çözmez. - Üç parametreli
toMap(k, v, mergeFunction)çakışma kuralını açıkça yazdırır:(first, second) -> first,BigDecimal::add,BinaryOperator.maxBy(...). - Dört parametreli
toMap(k, v, merge, mapFactory)map tipini seçtirir:TreeMap::new,LinkedHashMap::new.
// 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:
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çerideStringBuilder.collectingAndThen(toList(), Collections::unmodifiableList): sonucu son bir adımda sarmak.groupingByConcurrent,toConcurrentMap: parallel stream'de tek birConcurrentMap'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.
OptionalSerializabledeğildir; JPA entity alanı olarak eşlenemez; her nesneye ekstra bir sarmalayıcı ekler. Alan nullable olabilir; getterOptionaldönebilir. - Metot parametresi olarak. Çağıranı
Optional.of(x)yazmaya zorlar ve yine denullgeç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:
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:
// 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:
| Metot | Sürüm | Ne yapar |
|---|---|---|
map(f) | 8 | Doluysa dönüştürür; f null dönerse boş Optional |
flatMap(f) | 8 | f'in kendisi Optional dönüyorsa iç içe Optional'ı önler |
filter(p) | 8 | Koşulu sağlamıyorsa boşaltır |
orElse, orElseGet, orElseThrow(supplier) | 8 | Varsayılan / hata |
ifPresentOrElse(consumer, runnable) | 9 | İki dal birden |
or(supplier) | 9 | Boşsa başka bir Optional'a geç (ör. önce cache, sonra DB) |
stream() | 9 | 0 ya da 1 elemanlı stream; flatMap(Optional::stream) ile boşları atar |
orElseThrow() | 10 | get()'in daha açık isimli hali (NoSuchElementException) |
isEmpty() | 11 | !isPresent() |
// 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))ConcurrentModificationExceptionfı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:
// 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 eleman | Sürüm | |
|---|---|---|---|
stream.toList() | Hayır (unmodifiable) | Kabul eder | Java 16+ |
collect(Collectors.toList()) | Spesifikasyon garanti etmez; pratikte ArrayList | Kabul eder | Java 8 |
collect(Collectors.toUnmodifiableList()) | Hayır | NullPointerException | Java 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 + 1gibi ö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.rangeortadan 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,forEachOrderedparallel'de sırayı korumak için ek iş yapar; sıra önemli değilsefindAnyveunordered()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+'taGatherers.mapConcurrentkullan (bkz. Bölüm 5). -Djava.util.concurrent.ForkJoinPool.common.parallelism=Nile common pool'u büyütmek tüm uygulamayı etkiler; genelde yanlış araçtır.- Parallel stream'i özel bir
ForkJoinPool'unsubmitmetodu 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:
| Gatherer | Ne 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 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)
@FunctionalInterfacetek 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)
Supplierparametre almaz,Consumerdeğer döndürmez,Predicateboolean döner,FunctionT'yi R'ye çevirir. Doğru. BunlaraBi-versiyonları,UnaryOperator/BinaryOperatorve boxing'i önleyen primitive özelleşmeler eşlik eder. - d) Lambda içindeki
thisçevreleyen sınıfı, anonim sınıftakithisanonim 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::lengthbirFunction<String, Integer>olarak kullanılamaz. Yanlış: bu bir unbound instance method reference'tır ve ilk parametre receiver olur, yanis -> s.length(). - f) Checked exception fırlatan metot doğrudan
Function'a verilebilir, derleyici sarar. Yanlış:Function.applythrowsbildirmez; 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:
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:limitshort-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:
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:
- Kategori siparişin değil satırın özelliği olduğu için önce
flatMapileStream<Order>'danStream<OrderLine>'a inilir.mapkullanılsaydıStream<Stream<OrderLine>>elde edilirdi. ToplamaBigDecimal::addile yapılır;double'a çevirmek kuruş hatası üretir. partitioningByher zaman iki anahtar üretir; çağıran tarafget(true)veget(false)için null kontrolü yapmak zorunda kalmaz. KarşılaştırmacompareToile yapılır,equalsile değil, çünküBigDecimal.equalsscale'e de bakar (100.0ile100.00eşit değildir).mapToLongile primitive stream'e geçmekdistinctvesortedsırasında boxing'i ortadan kaldırır. Alternatif:collect(Collectors.toCollection(TreeSet::new))hem tekilleştirir hem sıralar.- Liste boş olabileceği için
maxzatenOptionaldöner;get()ile açmak çağıranın işidir veorElseThrowya damapile 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)
flatMapher elemanı bir stream'e çevirip düzleştirir. Doğru. Siparişlerden satırlara inmek tipik örnektir.Optional.flatMapda aynı mantıkla iç içeOptional'ı önler. - b)
isPresent()+get()yerinemap,orElse,orElseThrow,ifPresentOrElse. Doğru.isPresent/getnull 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 bilecreateDefault()'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 etkiliyseorElseGet. - f)
peekdebug amaçlıdır;count()gibi durumlarda hiç çalışmayabilir. Doğru. Java 9+count()boyutu kaynaktan biliyorsa pipeline'ı çalıştırmaz. - g)
parallelStream()ortakForkJoinPool.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ı;
Serializabledeğil, JPA eşlemez, parametrede üçüncü bir durum (nullOptional) 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; gerekiyorsaSupplier<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:
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.putgibi 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:
// 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:
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:
Clockenjeksiyonu:Instant.now()doğrudan çağrılırsa test deterministik olmaz; testteClock.fixed(...)verilir. Spring'deClockbir 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)ilegroupingBy(..., reducing(BigDecimal.ZERO, Order::amount, BigDecimal::add))eşdeğerdir.toMapbiraz daha kısadır ve gereksizZEROtoplamı 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 diyethenComparing(comparingByKey())eklendi; bu, ekran ve test sonuçlarının çalıştırmadan çalıştırmaya değişmemesini sağlar. limit(3)vetoList(): 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:
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?"
seviye: Orta etiketler: [stream, lazy-evaluation] kaynak: Tahmini
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?"
seviye: Başlangıç etiketler: [optional] kaynak: Tahmini
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?"
seviye: Orta etiketler: [stream, parallel, forkjoin] kaynak: Tahmini
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?"
seviye: Orta etiketler: [lambda, closure] kaynak: Tahmini
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.
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:
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.
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:
// 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:
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 →doubleikili kayan noktadır,0.1 + 0.2 != 0.3; milyonlarca satırda kuruş farkı birikir →BigDecimalilereduce(BigDecimal.ZERO, BigDecimal::add)ya datoMap(..., BigDecimal::add), bölmede scale veRoundingModever. - İki parametreli
toMap'i veri şeklini bilmeden kullanmak → duplicate key'deIllegalStateException, null değerde NPE → çoklu değer içingroupingBy+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ştaNoSuchElementException→map/flatMap/orElseThrow.peekya damapiç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.forEachile 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 daGatherers.mapConcurrent. - Eski kodu toplu olarak
stream.toList()'e çevirmek → sonucu değiştiren çağıranlar çalışma anındaUnsupportedOperationExceptionalır → değiştirilebilir liste gerekiyorsatoCollection(ArrayList::new). - Stream'i değişkende tutup iki kez tüketmek →
IllegalStateException→ kaynaktan yeniden stream aç ya daSupplier<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
- java.util.stream paket özeti (Java 21): Laziness, short-circuit, non-interference, stateless davranış ve side-effect kurallarının resmi tanımı; mutlaka okunmalı.
- Collectors (Java 21 Javadoc):
toMapoverload'ları,groupingBy,teeingve downstream collector'ların örnekli açıklaması. - Optional (Java 21 Javadoc): Optional'ın neden dönüş tipi için tasarlandığını söyleyen API notu.
- java.util.function paket özeti (Java 21): Tüm hazır functional interface'ler ve kompozisyon metotları.
- The Stream API (dev.java): Oracle'ın 12 bölümlük resmi öğretici serisi; kendi Collector'ını yazmaktan Gatherer API'ye kadar.
- JEP 485: Stream Gatherers: Gatherer API'nin motivasyonu, tasarımı ve hazır gatherer'lar; Java 24'te final.
- Gatherers (Java 25 Javadoc):
mapConcurrent'in virtual thread kullanımı, sıra koruması ve hata davranışı. - When to use parallel streams (Doug Lea): NQ modeli ve parallel stream'in ne zaman fayda sağladığına dair JDK yazarlarının rehberi.
- JLS 15.27: Lambda Expressions (Java SE 21): Effectively final kuralı, lexical scoping ve lambda değerlendirmesinin resmi tanımı.