Tampilan
Kenapa penting
Hampir setiap service backend mengolah collection: memfilter pesanan yang sudah dibayar, mengubah entity menjadi DTO, mengurutkan produk berdasarkan harga, membuat halaman hasil pencarian, atau menjumlahkan total transaksi. Dengan loop, setiap tugas itu butuh variabel sementara, if, dan list penampung — logika bisnisnya tenggelam di antara kode teknis. Stream API membuat kode itu terbaca seperti kalimat: "ambil pesanan, saring yang PAID, ambil ID-nya, kumpulkan". Pola ini muncul di service Spring, mapper DTO, dan test, jadi kamu akan membaca dan menulisnya setiap hari.
Konsep inti
Pipeline: sumber → intermediate → terminal
Stream adalah urutan elemen yang diproses, bukan tempat menyimpan data. Setiap stream pipeline punya tiga bagian:
- Sumber — biasanya
collection.stream(), bisa jugaStream.of(...)atauIntStream.range(...). - Operasi intermediate (nol atau lebih) —
filter,map,sorted, dll. Masing-masing mengembalikan stream baru. - Operasi terminal (tepat satu) —
toList,count,forEach, dll. Menghasilkan nilai akhir dan menutup stream.
java
import java.util.Comparator;
import java.util.List;
record Product(String sku, String category, long price, int stock) {}
public class FirstPipeline {
public static void main(String[] args) {
List<Product> products = List.of(
new Product("KOPI-250", "minuman", 85_000, 12),
new Product("TEH-100", "minuman", 40_000, 0),
new Product("GULA-500", "bahan", 30_000, 7),
new Product("SUSU-1L", "minuman", 22_000, 30));
List<String> available = products.stream() // 1. sumber
.filter(p -> p.stock() > 0) // 2. intermediate
.filter(p -> p.category().equals("minuman"))
.sorted(Comparator.comparingLong(Product::price))
.map(Product::sku)
.toList(); // 3. terminal
System.out.println(available);
System.out.println(products.size()); // sumber tidak berubah
}
}Output:
text
[SUSU-1L, KOPI-250]
4filter(predicate)mempertahankan elemen yangtest-nyatrue— parameternyaPredicatedari bab lambda.map(function)mengubah setiap elemen menjadi nilai lain; di siniProduct→String. Tipe stream ikut berubah dariStream<Product>menjadiStream<String>.sorted()tanpa argumen memakai urutan alami;sorted(comparator)memakaiComparatoryang sama seperti di M03.- Stream tidak mengubah sumbernya.
productstetap berisi empat produk.
Memotong dan membuang duplikat: distinct, limit, skip
java
import java.util.List;
public class SliceOps {
public static void main(String[] args) {
List<String> tags = List.of("promo", "kopi", "promo", "arabika", "kopi", "gayo", "diskon");
System.out.println(tags.stream().distinct().toList());
System.out.println(tags.stream().distinct().sorted().toList());
int page = 2;
int size = 2;
List<String> secondPage = tags.stream()
.distinct()
.skip((long) (page - 1) * size)
.limit(size)
.toList();
System.out.println(secondPage);
}
}Output:
text
[promo, kopi, arabika, gayo, diskon]
[arabika, diskon, gayo, kopi, promo]
[arabika, gayo]distinct membuang duplikat berdasarkan equals/hashCode (ingat bab equals & hashCode) sambil mempertahankan urutan kemunculan pertama. Pasangan skip(n) + limit(size) adalah pola paginasi di memori. Untuk data dari database, paginasi dilakukan di query SQL — jangan memuat jutaan baris lalu skip.
Operasi terminal yang sering dipakai
java
import java.util.List;
record Order(String id, String status, long total) {}
public class Terminals {
public static void main(String[] args) {
List<Order> orders = List.of(
new Order("O-1", "PAID", 120_000),
new Order("O-2", "NEW", 45_000),
new Order("O-3", "PAID", 300_000),
new Order("O-4", "CANCELLED", 80_000));
long paid = orders.stream().filter(o -> o.status().equals("PAID")).count();
boolean anyBig = orders.stream().anyMatch(o -> o.total() > 250_000);
boolean allPositive = orders.stream().allMatch(o -> o.total() > 0);
boolean noneRefund = orders.stream().noneMatch(o -> o.status().equals("REFUNDED"));
System.out.println(paid + " " + anyBig + " " + allPositive + " " + noneRefund);
String firstNew = orders.stream()
.filter(o -> o.status().equals("NEW"))
.map(Order::id)
.findFirst()
.orElse("tidak ada");
String firstRefund = orders.stream()
.filter(o -> o.status().equals("REFUNDED"))
.map(Order::id)
.findFirst()
.orElse("tidak ada");
System.out.println(firstNew + " " + firstRefund);
orders.stream()
.filter(o -> !o.status().equals("CANCELLED"))
.forEach(o -> System.out.println(o.id() + " " + o.total()));
}
}Output:
text
2 true true true
O-2 tidak ada
O-1 120000
O-2 45000
O-3 300000| Terminal | Hasil |
|---|---|
toList() | List baru yang unmodifiable (Java 16+) |
count() | jumlah elemen (long) |
anyMatch / allMatch / noneMatch | boolean; berhenti begitu jawabannya pasti |
findFirst() | Optional berisi elemen pertama, atau kosong |
forEach(consumer) | menjalankan aksi untuk tiap elemen; tidak ada nilai balik |
findFirst mengembalikan Optional<T> karena hasilnya bisa tidak ada. Untuk sekarang cukup orElse(nilaiDefault); Optional dibahas tuntas di bab tersendiri. Kamu juga akan menemui collect(Collectors.toList()) di kode lama — hasilnya ArrayList yang bisa diubah. collect dan collector lainnya adalah topik bab berikutnya.
Lazy evaluation dan stream sekali pakai
Operasi intermediate tidak langsung dijalankan. Ia hanya menyusun resep; pekerjaan baru terjadi saat operasi terminal dipanggil, dan elemen diproses satu per satu melewati seluruh pipeline.
java
import java.util.List;
import java.util.stream.Stream;
public class Laziness {
public static void main(String[] args) {
List<String> skus = List.of("teh-100", "kopi-250", "gula-500", "susu-1l");
Stream<String> pipeline = skus.stream()
.filter(s -> {
System.out.println("filter " + s);
return s.length() > 7;
})
.map(s -> {
System.out.println("map " + s);
return s.toUpperCase();
});
System.out.println("pipeline dibuat, belum ada yang jalan");
String first = pipeline.findFirst().orElse("-");
System.out.println("hasil: " + first);
Stream<String> once = skus.stream();
System.out.println(once.count());
System.out.println(once.count());
}
}Output:
text
pipeline dibuat, belum ada yang jalan
filter teh-100
filter kopi-250
map kopi-250
hasil: KOPI-250
4
Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed
at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:260)
at java.base/java.util.stream.ReferencePipeline.count(ReferencePipeline.java:750)
at Laziness.main(Laziness.java:24)Tiga hal penting dari output ini:
- Tidak ada yang tercetak sebelum
findFirstdipanggil — pipeline tanpa terminal tidak melakukan apa pun. - Elemen diproses vertikal:
kopi-250melewatifilterlalu langsungmap, bukan semua elemen di-filterdulu. - Begitu
findFirstmenemukan hasil, sisa elemen (gula-500,susu-1l) tidak disentuh sama sekali. Ini short-circuit, sama sepertianyMatchdanlimit.
Stream juga sekali pakai: setelah operasi terminal, ia tertutup. Simpan collection-nya, bukan stream-nya, lalu panggil .stream() lagi untuk pipeline berikutnya.
Stream primitif: IntStream, mapToLong, sum, average
Stream<Integer> menyimpan object, sehingga setiap angka di-boxing. Untuk perhitungan angka, Java menyediakan IntStream, LongStream, dan DoubleStream dengan method agregasi bawaan.
java
import java.util.List;
import java.util.stream.IntStream;
record Line(String sku, int qty, long unitPrice) {}
public class Primitives {
public static void main(String[] args) {
System.out.println(IntStream.range(1, 5).boxed().toList());
System.out.println(IntStream.rangeClosed(1, 5).sum());
List<Line> lines = List.of(
new Line("KOPI-250", 2, 85_000),
new Line("GULA-500", 1, 30_000),
new Line("SUSU-1L", 3, 22_000));
long total = lines.stream().mapToLong(l -> l.qty() * l.unitPrice()).sum();
int items = lines.stream().mapToInt(Line::qty).sum();
double avgPrice = lines.stream().mapToLong(Line::unitPrice).average().orElse(0);
long maxLine = lines.stream().mapToLong(l -> l.qty() * l.unitPrice()).max().orElse(0);
System.out.println(total + " " + items + " " + avgPrice + " " + maxLine);
double emptyAvg = List.<Line>of().stream().mapToLong(Line::unitPrice).average().orElse(0);
System.out.println(emptyAvg);
System.out.println(lines.stream().mapToLong(Line::unitPrice).summaryStatistics());
}
}Output:
text
[1, 2, 3, 4]
15
266000 6 45666.666666666664 170000
0.0
LongSummaryStatistics{count=3, sum=137000, min=22000, average=45666.666667, max=85000}IntStream.range(a, b)menghasilkanasampaib - 1;rangeClosedmenyertakanb.boxed()mengubahnya kembali menjadiStream<Integer>.mapToInt/mapToLongmengubahStream<T>menjadi stream primitif;sum()dari stream kosong adalah0.average(),max(), danmin()mengembalikanOptionalDouble/OptionalLongkarena stream bisa kosong — tutup denganorElse.summaryStatistics()menghitung count, sum, min, max, dan rata-rata dalam satu kali jalan.
Stream atau loop?
Bandingkan dua versi "ambil dua barang aktif dengan stok di bawah titik pemesanan ulang":
java
// Potongan — versi loop
List<Item> matches = new ArrayList<>();
for (Item item : items) {
if (item.active() && item.warehouse().equals(warehouse) && item.stock() <= item.reorderPoint()) {
matches.add(item);
}
}
matches.sort(Comparator.comparingInt(Item::stock).thenComparing(Item::sku));
List<String> result = new ArrayList<>();
for (int i = 0; i < matches.size() && i < max; i++) {
result.add(matches.get(i).sku() + " (" + matches.get(i).stock() + ")");
}Versi stream-nya ada di contoh berikut. Panduan praktis:
| Pilih stream bila… | Pilih loop bila… |
|---|---|
| transformasi data: saring → ubah → urutkan → kumpulkan | perlu break/continue rumit atau beberapa keluaran sekaligus |
| hasilnya nilai baru (list, angka, boolean) | perlu mengubah variabel lokal atau melempar checked exception |
| langkah-langkahnya ingin terbaca sebagai satu kalimat | algoritma berbasis indeks (dua pointer, elemen sebelumnya) |
Performa untuk collection biasa kurang lebih setara; pilih yang paling mudah dibaca timmu. Hindari stream yang "menyamar" sebagai loop, yaitu forEach yang menambah ke list luar — itu tanda kamu seharusnya memakai map + toList().
Contoh di dunia backend
Service inventaris membuat laporan barang yang perlu dipesan ulang di satu gudang, nilai persediaan, dan peringatan bila ada barang aktif yang stoknya habis.
java
import java.util.Comparator;
import java.util.List;
record Item(String sku, String warehouse, int stock, int reorderPoint, long unitCost, boolean active) {}
public class LowStockReport {
static List<String> lowStock(List<Item> items, String warehouse, int max) {
return items.stream()
.filter(Item::active)
.filter(item -> item.warehouse().equals(warehouse))
.filter(item -> item.stock() <= item.reorderPoint())
.sorted(Comparator.comparingInt(Item::stock).thenComparing(Item::sku))
.limit(max)
.map(item -> item.sku() + " (" + item.stock() + ")")
.toList();
}
static long inventoryValue(List<Item> items, String warehouse) {
return items.stream()
.filter(item -> item.warehouse().equals(warehouse))
.mapToLong(item -> item.stock() * item.unitCost())
.sum();
}
public static void main(String[] args) {
List<Item> items = List.of(
new Item("KOPI-250", "JKT", 3, 10, 60_000, true),
new Item("TEH-100", "JKT", 0, 5, 25_000, true),
new Item("GULA-500", "JKT", 40, 15, 18_000, true),
new Item("SUSU-1L", "BDG", 2, 20, 15_000, true),
new Item("SIRUP-1L", "JKT", 1, 5, 30_000, false),
new Item("COKLAT-200", "JKT", 3, 8, 45_000, true));
System.out.println(lowStock(items, "JKT", 2));
System.out.println(lowStock(items, "JKT", 10));
System.out.println(inventoryValue(items, "JKT"));
System.out.println(items.stream().anyMatch(i -> i.active() && i.stock() == 0));
}
}Output:
text
[TEH-100 (0), COKLAT-200 (3)]
[TEH-100 (0), COKLAT-200 (3), KOPI-250 (3)]
1065000
trueSetiap filter mewakili satu aturan bisnis, sehingga aturan baru cukup ditambah satu baris. thenComparing(Item::sku) membuat urutan stabil saat stok sama — penting agar laporan dan test tidak berubah-ubah. Hasil lowStock sama persis dengan versi loop di atas, tetapi tanpa list penampung dan indeks manual.
Pertanyaan interview
1. Apa beda operasi intermediate dan terminal?
Operasi intermediate (filter, map, sorted) mengembalikan stream baru dan bersifat lazy — hanya menyusun pipeline. Operasi terminal (toList, count, forEach, findFirst) memicu eksekusi, menghasilkan nilai akhir atau efek samping, lalu menutup stream. Tanpa terminal, tidak ada yang diproses.
2. Apa maksud stream bersifat lazy, dan apa untungnya?
Elemen baru diproses saat terminal dipanggil, satu per satu melewati seluruh pipeline. Untungnya: operasi short-circuit seperti findFirst, anyMatch, dan limit bisa berhenti lebih awal tanpa memproses sisa elemen, dan tidak ada collection perantara yang dibuat di setiap langkah.
3. Apa beda Stream.toList() dan collect(Collectors.toList())?
toList() (Java 16+) mengembalikan list unmodifiable — add atau remove melempar UnsupportedOperationException. Collectors.toList() dalam praktiknya mengembalikan ArrayList yang bisa diubah, walau spesifikasinya tidak menjamin jenis list-nya. Untuk kode baru, toList() lebih ringkas dan lebih aman.
Jebakan umum
- Lupa operasi terminal. Pipeline tanpa
toList()/count()/forEachtidak menjalankan apa pun. - Memakai ulang stream →
IllegalStateException: stream has already been operated upon or closed. Simpan collection-nya, bukan stream-nya. - Mengubah hasil
toList()→UnsupportedOperationException. Salin keArrayListbila perlu diubah. forEachyang menambah ke list luar menggantikanmap+toList(). Lebih panjang dan rawan bug saat stream dijadikan paralel.- Efek samping di
filter/map(log, ubah state) bisa tidak jalan atau jalan lebih sedikit dari dugaan karena lazy dan short-circuit. average()pada data kosong menghasilkanOptionalDoublekosong — selalu tutup denganorElse.
Ringkasan
- Stream pipeline = sumber → nol atau lebih operasi intermediate → tepat satu operasi terminal; sumbernya tidak berubah.
filter,map,sorted,distinct,skip, danlimitadalah operasi intermediate yang paling sering dipakai.- Terminal umum:
toList()(unmodifiable),count,anyMatch/allMatch/noneMatch,findFirst+orElse,forEach. - Stream itu lazy (diproses saat terminal, bisa short-circuit) dan sekali pakai.
- Untuk angka, pakai
mapToInt/mapToLonglalusum,average,max, atausummaryStatistics. - Pakai stream untuk transformasi data; pakai loop untuk alur kontrol rumit atau algoritma berbasis indeks.