Tampilan
Kenapa penting
Di bab sebelumnya, collect(Collectors.toList()) dan toList() hanya mengumpulkan elemen apa adanya. Padahal pertanyaan bisnis yang paling sering muncul berbentuk ringkasan: "berapa omzet per kategori bulan ini?", "pesanan mana yang dapat gratis ongkir?", "index produk berdasarkan SKU", "berapa quantity terjual per SKU dari semua pesanan?". Di M03 semua itu ditulis dengan loop dan Map manual (getOrDefault(k, 0) + 1). Collector adalah cara deklaratif untuk menulis ringkasan semacam itu: kamu cukup menyebut dikelompokkan berdasarkan apa dan diringkas bagaimana. Laporan, dashboard, dan endpoint statistik di service backend hampir selalu memakai pola ini.
Konsep inti
groupingBy: mengelompokkan dan meringkas
Collectors.groupingBy(classifier) menghasilkan Map<K, List<T>>: kuncinya hasil fungsi classifier, nilainya list elemen dalam kelompok itu. Kekuatan sebenarnya ada di argumen downstream — collector kedua yang dijalankan pada setiap kelompok.
java
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.stream.Collectors;
record Order(String id, String category, String customer, long amount) {}
public class GroupingDemo {
public static void main(String[] args) {
List<Order> orders = List.of(
new Order("O-1", "minuman", "budi", 85_000),
new Order("O-2", "makanan", "sari", 40_000),
new Order("O-3", "minuman", "sari", 30_000),
new Order("O-4", "alat", "budi", 250_000),
new Order("O-5", "minuman", "andi", 85_000));
Map<String, List<Order>> byCategory = orders.stream()
.collect(Collectors.groupingBy(Order::category));
System.out.println(byCategory.get("minuman").size());
Map<String, Long> countPerCategory = orders.stream()
.collect(Collectors.groupingBy(Order::category, TreeMap::new, Collectors.counting()));
System.out.println(countPerCategory);
Map<String, Long> revenuePerCategory = orders.stream()
.collect(Collectors.groupingBy(Order::category, TreeMap::new,
Collectors.summingLong(Order::amount)));
System.out.println(revenuePerCategory);
Map<String, Set<String>> customersPerCategory = orders.stream()
.collect(Collectors.groupingBy(Order::category, TreeMap::new,
Collectors.mapping(Order::customer, Collectors.toCollection(TreeSet::new))));
System.out.println(customersPerCategory);
}
}Output:
text
3
{alat=1, makanan=1, minuman=3}
{alat=250000, makanan=40000, minuman=200000}
{alat=[budi], makanan=[sari], minuman=[andi, budi, sari]}| Bentuk | Hasil per kelompok |
|---|---|
groupingBy(f) | List<T> berisi elemen kelompok |
groupingBy(f, counting()) | jumlah elemen (Long) |
groupingBy(f, summingLong(g)) | total g (ada juga summingInt, averagingLong) |
groupingBy(f, mapping(g, toSet())) | elemen diubah dulu dengan g, lalu dikumpulkan |
groupingBy(f, TreeMap::new, downstream) | sama, tetapi map hasilnya TreeMap (urut kunci) |
Bentuk tiga argumen dengan TreeMap::new berguna karena groupingBy biasa memakai HashMap — urutannya tidak dijamin, seperti yang kamu pelajari di M03. Untuk laporan dan test yang membandingkan teks, pilih map yang urutannya pasti. mapping sendiri berperan seperti map di dalam kelompok: ia mengubah setiap elemen (di sini Order menjadi nama customer) sebelum diteruskan ke collector berikutnya.
partitioningBy: dua kelompok, true dan false
Bila pengelompokannya hanya ya/tidak, partitioningBy(predicate) lebih jelas daripada groupingBy dengan kunci Boolean. Hasilnya selalu memuat kedua kunci, walaupun salah satu kelompok kosong.
java
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class PartitionDemo {
public static void main(String[] args) {
List<Long> amounts = List.of(85_000L, 40_000L, 30_000L, 250_000L, 120_000L);
Map<Boolean, List<Long>> freeShipping = amounts.stream()
.collect(Collectors.partitioningBy(a -> a >= 100_000));
System.out.println(freeShipping);
Map<Boolean, Long> counts = amounts.stream()
.collect(Collectors.partitioningBy(a -> a >= 500_000, Collectors.counting()));
System.out.println(counts);
}
}Output:
text
{false=[85000, 40000, 30000], true=[250000, 120000]}
{false=5, true=0}Baris kedua menunjukkan jaminan tadi: tidak ada pesanan di atas 500.000, tetapi true=0 tetap ada, sehingga counts.get(true) tidak pernah null.
toMap: membangun index, dan masalah kunci ganda
Collectors.toMap(keyMapper, valueMapper) mengubah stream menjadi Map — cocok untuk membuat index berdasarkan ID. Jebakannya: bila dua elemen menghasilkan kunci yang sama, toMap dua argumen melempar exception, bukan menimpa.
java
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
record Product(String sku, String name, long price) {}
public class ToMapDemo {
public static void main(String[] args) {
List<Product> products = List.of(
new Product("KOPI-250", "Kopi Gayo", 85_000),
new Product("TEH-100", "Teh Melati", 40_000),
new Product("KOPI-250", "Kopi Gayo (harga baru)", 90_000));
Map<String, Long> latestPrice = products.stream()
.collect(Collectors.toMap(Product::sku, Product::price,
(oldValue, newValue) -> newValue, TreeMap::new));
System.out.println(latestPrice);
Map<String, Product> bySku = products.stream()
.collect(Collectors.toMap(Product::sku, p -> p));
System.out.println(bySku.size());
}
}Output (stack trace dipotong):
text
{KOPI-250=90000, TEH-100=40000}
Exception in thread "main" java.lang.IllegalStateException: Duplicate key KOPI-250 (attempted merging values Product[sku=KOPI-250, name=Kopi Gayo, price=85000] and Product[sku=KOPI-250, name=Kopi Gayo (harga baru), price=90000])
at java.base/java.util.stream.Collectors.duplicateKeyException(Collectors.java:135)Argumen ketiga adalah merge function: ia menerima nilai lama dan nilai baru untuk kunci yang sama, lalu mengembalikan nilai yang disimpan. (oldValue, newValue) -> newValue berarti "yang terakhir menang"; Integer::sum berarti "jumlahkan" — pola menghitung frekuensi tanpa getOrDefault. Argumen keempat (opsional) memilih jenis map. Satu lagi: toMap juga melempar NullPointerException bila valueMapper menghasilkan null.
joining dan reduce
Collectors.joining(delimiter, prefix, suffix) menggabungkan stream String menjadi satu teks. reduce menggabungkan semua elemen menjadi satu nilai lewat fungsi biner, dimulai dari nilai identity.
java
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
public class JoinReduceDemo {
public static void main(String[] args) {
List<String> skus = List.of("KOPI-250", "TEH-100", "GULA-500");
System.out.println(skus.stream().collect(Collectors.joining(", ", "[", "]")));
List<Long> amounts = List.of(85_000L, 40_000L, 30_000L);
long total = amounts.stream().reduce(0L, Long::sum);
System.out.println(total);
long max = amounts.stream().reduce(0L, Math::max);
System.out.println(max);
Optional<Long> emptyTotal = List.<Long>of().stream().reduce(Long::sum);
System.out.println(emptyTotal.isPresent());
System.out.println(amounts.stream().mapToLong(Long::longValue).sum());
}
}Output:
text
[KOPI-250, TEH-100, GULA-500]
155000
85000
false
155000Identity harus netral terhadap operasinya: 0 untuk penjumlahan, 1 untuk perkalian. Versi reduce tanpa identity mengembalikan Optional karena stream bisa kosong — Optional dibahas tuntas di bab berikutnya. Untuk penjumlahan angka, mapToLong(...).sum() biasanya lebih ringkas dan menghindari boxing; reduce berguna untuk kombinasi yang tidak punya method siap pakai.
flatMap: meratakan struktur bersarang
map mengubah satu elemen menjadi satu elemen. Bila setiap elemen berisi collection — pesanan berisi baris item — map(Order::lines) menghasilkan Stream<List<Line>>. flatMap mengubah setiap elemen menjadi stream lalu menyambung semuanya menjadi satu stream datar.
java
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
record Line(String sku, int qty) {}
record Order(String id, List<Line> lines) {}
public class FlatMapDemo {
public static void main(String[] args) {
List<Order> orders = List.of(
new Order("O-1", List.of(new Line("KOPI-250", 2), new Line("GULA-500", 1))),
new Order("O-2", List.of(new Line("KOPI-250", 1))),
new Order("O-3", List.of(new Line("TEH-100", 4), new Line("GULA-500", 2))));
List<List<Line>> nested = orders.stream().map(Order::lines).toList();
System.out.println(nested.size());
List<Line> allLines = orders.stream()
.flatMap(order -> order.lines().stream())
.toList();
System.out.println(allLines.size());
Map<String, Integer> qtyPerSku = orders.stream()
.flatMap(order -> order.lines().stream())
.collect(Collectors.groupingBy(Line::sku, TreeMap::new, Collectors.summingInt(Line::qty)));
System.out.println(qtyPerSku);
}
}Output:
text
3
5
{GULA-500=3, KOPI-250=3, TEH-100=4}Sekilas: teeing dan stream gatherers
Collectors.teeing(c1, c2, merger) (Java 12) menjalankan dua collector sekaligus dalam satu lintasan, lalu menggabungkan hasilnya. Stream gatherers (JEP 485, final di Java 24) menambah operasi intermediate kustom lewat stream.gather(...); kelas Gatherers menyediakan beberapa yang siap pakai, misalnya windowFixed(n) untuk memecah stream menjadi kelompok berukuran n — berguna untuk memproses data per batch.
java
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Gatherers;
record Stats(long count, long total) {
long average() {
return count == 0 ? 0 : total / count;
}
}
public class TeeingGathererDemo {
public static void main(String[] args) {
List<Long> amounts = List.of(85_000L, 40_000L, 30_000L, 250_000L, 120_000L);
Stats stats = amounts.stream()
.collect(Collectors.teeing(
Collectors.counting(),
Collectors.summingLong(Long::longValue),
Stats::new));
System.out.println(stats + " rata-rata=" + stats.average());
List<List<Long>> batches = amounts.stream()
.gather(Gatherers.windowFixed(2))
.toList();
System.out.println(batches);
}
}Output:
text
Stats[count=5, total=525000] rata-rata=105000
[[85000, 40000], [30000, 250000], [120000]]Keduanya tidak akan kamu pakai setiap hari, tetapi bagus untuk dikenali saat membaca kode orang lain.
Contoh di dunia backend
Tim finance meminta laporan penjualan per kategori: quantity dan omzet dari pesanan berstatus PAID saja, ditambah quantity per SKU untuk tim gudang. Semua dihitung dari baris penjualan dalam satu kali baca.
java
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
enum Status { PAID, CANCELLED }
record Sale(String orderId, String category, String sku, int qty, long unitPrice, Status status) {
long subtotal() {
return qty * unitPrice;
}
}
record CategorySummary(int qty, long revenue) {}
public class SalesReport {
static Map<String, CategorySummary> byCategory(List<Sale> sales) {
return sales.stream()
.filter(sale -> sale.status() == Status.PAID)
.collect(Collectors.groupingBy(Sale::category, TreeMap::new,
Collectors.teeing(
Collectors.summingInt(Sale::qty),
Collectors.summingLong(Sale::subtotal),
CategorySummary::new)));
}
static Map<String, Integer> qtyPerSku(List<Sale> sales) {
return sales.stream()
.filter(sale -> sale.status() == Status.PAID)
.collect(Collectors.toMap(Sale::sku, Sale::qty, Integer::sum, TreeMap::new));
}
static String render(Map<String, CategorySummary> report) {
return report.entrySet().stream()
.map(e -> "%-8s qty=%3d omzet=%d".formatted(e.getKey(), e.getValue().qty(), e.getValue().revenue()))
.collect(Collectors.joining("\n"));
}
public static void main(String[] args) {
List<Sale> sales = List.of(
new Sale("O-1", "minuman", "KOPI-250", 2, 85_000, Status.PAID),
new Sale("O-1", "makanan", "GULA-500", 1, 30_000, Status.PAID),
new Sale("O-2", "minuman", "TEH-100", 3, 40_000, Status.PAID),
new Sale("O-3", "minuman", "KOPI-250", 1, 85_000, Status.CANCELLED),
new Sale("O-4", "alat", "V60-02", 1, 250_000, Status.PAID),
new Sale("O-5", "minuman", "KOPI-250", 4, 85_000, Status.PAID));
Map<String, CategorySummary> report = byCategory(sales);
System.out.println(render(report));
long grandTotal = report.values().stream()
.map(CategorySummary::revenue)
.reduce(0L, Long::sum);
System.out.println("TOTAL omzet=" + grandTotal);
System.out.println(qtyPerSku(sales));
}
}Output:
text
alat qty= 1 omzet=250000
makanan qty= 1 omzet=30000
minuman qty= 9 omzet=630000
TOTAL omzet=910000
{GULA-500=1, KOPI-250=6, TEH-100=3, V60-02=1}Perhatikan pembagian tugasnya: filter membuang pesanan batal, groupingBy + teeing menghitung dua angka per kategori dalam satu lintasan, toMap dengan Integer::sum menjumlahkan quantity SKU yang muncul berkali-kali, dan joining menyusun teks laporan. Setiap method kecil mudah dites terpisah. Di aplikasi nyata, data seperti ini sering berasal dari database — dan agregasi besar lebih baik dilakukan dengan GROUP BY di SQL (dibahas di M06); collector cocok untuk data yang sudah ada di memori.
Pertanyaan interview
1. Apa beda groupingBy dan partitioningBy?
groupingBy mengelompokkan berdasarkan kunci apa pun yang dihasilkan classifier, dan hanya membuat kunci untuk kelompok yang punya elemen. partitioningBy menerima predicate dan selalu menghasilkan tepat dua kunci, true dan false, walaupun salah satu kelompok kosong. Keduanya menerima downstream collector.
2. Kenapa Collectors.toMap bisa melempar IllegalStateException, dan bagaimana mengatasinya?
Versi dua argumen tidak punya aturan untuk kunci ganda, jadi ia melempar IllegalStateException: Duplicate key. Solusinya memberi merge function sebagai argumen ketiga — misalnya (a, b) -> b untuk "yang terakhir menang" atau Integer::sum untuk menjumlahkan — atau memvalidasi data lebih dulu bila duplikat memang kesalahan.
3. Kapan memakai flatMap dibanding map?
map untuk transformasi satu-ke-satu. flatMap bila setiap elemen menghasilkan nol, satu, atau banyak elemen — misalnya pesanan ke baris item, atau user ke daftar role — dan kamu ingin hasilnya satu stream datar, bukan stream berisi collection.
Jebakan umum
toMaptanpa merge function pada data yang bisa duplikat →IllegalStateExceptiondi production. Tentukan aturan bentrok secara eksplisit.- Mengandalkan urutan hasil
groupingBy/toMap. Default-nyaHashMap; pakaiTreeMap::newatauLinkedHashMap::newbila urutan penting. - Identity
reduceyang tidak netral, misalnyareduce(10, Integer::sum)— hasilnya bergeser, dan di stream paralel identity bisa ditambahkan berkali-kali. mappadahal butuhflatMap→ tipe jadiStream<List<T>>dan compiler menolak method reference berikutnya.- Mengambil semua baris dari database lalu
groupingBydi Java untuk data besar — biarkan database yang melakukan agregasi. - Value
nullditoMap→NullPointerException; petakan ke nilai default atau saring dulu.
Ringkasan
groupingBy(f, downstream)mengelompokkan lalu meringkas per kelompok:counting,summingLong,mapping, dll.partitioningBy(predicate)membagi dua dan selalu punya kuncitruesertafalse.toMapmembangun index; kunci ganda butuh merge function, jika tidak →IllegalStateException.joiningmenggabungkan teks;reduce(identity, op)menggabungkan elemen menjadi satu nilai dengan identity netral.flatMapmeratakan stream bersarang;teeingmenjalankan dua collector sekaligus;Gatherers.windowFixedmemecah stream per batch.