Sampai titik ini kita menulis pipeline panjang tanpa pernah bertanya: kapan sebenarnya pekerjaan itu dilakukan? Jawabannya mengejutkan pemula: tidak pernah — sampai terminal operation dipanggil. filter, map, dan sorted hanyalah intermediate operations yang menyusun rencana berlapis tanpa memproses satu elemen pun; collect, findFirst, dan forEach-lah yang menarik data melewati seluruh rencana tersebut. Sifat lazy evaluation inilah yang membuat stream efisien dan memungkinkan stream tak berujung. Pada Chapter ini kita buktikan kedua klaim itu dengan eksperimen yang bisa Anda ulang sendiri.
Intermediate Menyusun Rencana, Terminal Menjalankannya #
Setiap pemanggilan stream() menghasilkan objek pipeline, dan setiap intermediate operation mengembalikan objek pipeline baru yang membungkus tahap sebelumnya — seperti rantai decorator yang mencatat “nanti setiap elemen akan difilter begini” dan “kemudian dipetakan begitu”. Sampai di sini, JVM belum menyentuh satu elemen dari sumber data; lambda yang kita tulis bahkan belum dieksekusi sekali pun.
Terminal operation membalik arah kendali. Saat collect atau findFirst dipanggil, mesin stream mulai menarik elemen dari sumber — satu per satu atau per potongan — dan menggiring setiap elemen melewati seluruh tahap yang tersusun. Elemen pertama bisa selesai melewati semua tahap sebelum elemen kedua mulai diproses. Pendekatannya kebalikan perulangan for biasa: bukan “proses semua di tahap satu, lalu semua di tahap dua”, melainkan “satu elemen menempuh seluruh perjalanan, lalu elemen berikutnya”.
Bukti Pipeline Tanpa Terminal #
Eksperimen pertama membangun dua pipeline yang isinya identik: peek untuk mencetak jejak setiap elemen yang dilihat, lalu map untuk mengubah ke huruf besar. Pipeline pertama sengaja tidak diberi terminal operation; pipeline kedua diakhiri collect:
import java.util.List;
import java.util.stream.Collectors;
public class TanpaTerminal {
public static void main(String[] args) {
List<String> nama = List.of("Budi", "Siti", "Dewi");
// Pipeline dibangun tetapi TIDAK dijalankan: tanpa terminal operation
nama.stream()
.peek(n -> System.out.println("dilihat: " + n))
.map(String::toUpperCase);
System.out.println("--- pipeline tanpa terminal selesai dibangun ---");
// Pipeline yang sama + terminal operation = benar-benar bekerja
List<String> hasil = nama.stream()
.peek(n -> System.out.println("dilihat: " + n))
.map(String::toUpperCase)
.collect(Collectors.toList());
System.out.println("hasil: " + hasil);
}
}Output yang Dihasilkan:
--- pipeline tanpa terminal selesai dibangun ---
dilihat: Budi
dilihat: Siti
dilihat: Dewi
hasil: [BUDI, SITI, DEWI]Mari kita telaah baris-baris kode di atas:
- Baris 10-12 (
nama.stream().peek(...).map(...)tanpacollect): hasilmaptidak diikat ke variabel dan tidak dipakai — boleh saja secara sintaks. Output membuktikan tidak ada barisdilihat:sebelum garis pemisah, artinyapeekdanmapsama sekali tidak dieksekusi. Pipeline itu hanya menjadi objek yang terbentuk lalu tidak terpakai lagi. - Baris 14 (
System.out.println("--- ... ---")): garis pemisah ini tercetak paling awal. Urutan output adalah bukti kronologis: pembangunan pipeline tidak menghasilkan efek apa pun. - Baris 17-20 (pipeline yang sama +
collect): begitucollect(Collectors.toList())hadir, ketiga nama ditarik dariList, terlihat olehpeek, diubah olehmap, dan dikumpulkan menjadi[BUDI, SITI, DEWI]. Perbedaan satu baris kode mengubah program diam total menjadi program yang bekerja.
Short-Circuit dan Jumlah Elemen Minimal #
Laziness punya konsekuensi kedua yang lebih berdampak: terminal operation tertentu boleh berhenti lebih awal begitu jawabannya sudah pasti. findFirst, findAny, anyMatch, allMatch, dan noneMatch bersifat short-circuiting, sama seperti operator && yang berhenti di false pertama. limit juga memotong aliran dari sisi intermediate. Eksperimen kedua menghitung berapa banyak elemen yang benar-benar dilihat stream pada data enam angka:
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
public class JejakElemen {
static int elemenDilihat = 0;
public static void main(String[] args) {
List<Integer> data = List.of(3, 9, 12, 15, 21, 30);
// findFirst: berhenti begitu elemen pertama yang lolos ditemukan
elemenDilihat = 0;
Optional<Integer> pertama = data.stream()
.peek(n -> elemenDilihat++)
.filter(n -> n % 4 == 0)
.findFirst();
System.out.println("findFirst = " + pertama + ", elemen dilihat: " + elemenDilihat);
// anyMatch: berhenti di kecocokan pertama yang memenuhi predikat
elemenDilihat = 0;
boolean ada = data.stream()
.peek(n -> elemenDilihat++)
.anyMatch(n -> n > 20);
System.out.println("anyMatch = " + ada + ", elemen dilihat: " + elemenDilihat);
// limit: memotong aliran setelah sejumlah elemen lolos
elemenDilihat = 0;
List<Integer> tiga = data.stream()
.peek(n -> elemenDilihat++)
.filter(n -> n % 3 == 0)
.limit(3)
.collect(Collectors.toList());
System.out.println("limit 3 = " + tiga + ", elemen dilihat: " + elemenDilihat);
// collect memproses seluruh elemen sampai habis
elemenDilihat = 0;
Long total = data.stream()
.peek(n -> elemenDilihat++)
.filter(n -> n % 3 == 0)
.collect(Collectors.counting());
System.out.println("counting = " + total + ", elemen dilihat: " + elemenDilihat);
}
}Output yang Dihasilkan:
findFirst = Optional[12], elemen dilihat: 3
anyMatch = true, elemen dilihat: 5
limit 3 = [3, 9, 12], elemen dilihat: 3
counting = 6, elemen dilihat: 6Mari kita telaah baris-baris kode di atas:
- Baris 13-17 (
findFirstsetelah filtern % 4 == 0): filter menolak 3 dan 9, menerima 12, danfindFirstlangsung berhenti — elemen 15, 21, dan 30 tidak pernah dilihat. Penghitung berhenti di 3 dari 6 elemen. - Baris 21-24 (
anyMatch(n -> n > 20)): 21 adalah angka pertama yang melewati 20, sehingga pemrosesan berhenti di elemen kelima; angka 30 tidak diperiksa. Setelah satu kecocokan, jawabananyMatchtidak mungkin berubah lagi. - Baris 28-33 (
filter+limit(3)): seluruh elemen data adalah kelipatan 3, sehinggalimit(3)terpenuhi setelah elemen ketiga. Hasil[3, 9, 12]dikumpulkan dan elemen 15, 21, 30 tidak diproses. - Baris 37-41 (
collect(Collectors.counting())): tanpa short-circuit,collectwajib melihat seluruh elemen — penghitung mencapai 6. Bandingkan keempat angka pada output: 3, 5, 3, dan 6 elemen untuk kebutuhan yang berbeda; perulanganformanual perlu variabelbreaktambahan untuk meniru perilaku tersebut.
Manfaat Laziness pada Stream Tak Berujung #
Karena elemen hanya ditarik saat dibutuhkan, sumber stream boleh tak terbatas. Stream.iterate dan Stream.generate membangkitkan nilai selamanya; kombinasi dengan limit menjadikannya aman. Potongan berikut mengambil lima bilangan genap pertama dari deret tak berujung:
List<Integer> genap = Stream.iterate(2, n -> n + 2)
.limit(5)
.collect(Collectors.toList());Hasilnya [2, 4, 6, 8, 10] — tanpa limit, pemanggilan terminal mana pun akan berjalan selamanya karena sumbernya memang tidak pernah habis. Laziness-lah yang menjadikan pola ini sah.
Laziness menjelaskan berapa banyak elemen diproses, tetapi ada pertanyaan lain yang sama pentingnya: dalam urutan apa elemen keluar? Chapter berikutnya membahas encounter order — perbedaan perilaku stream yang bersumber dari List, Set, efek flag unordered(), serta arti forEachOrdered pada stream paralel.
Referensi #
- Oracle: package java.util.stream — Lazy computation
- Oracle: java.util.stream.Stream
- dev.java — Learn Java
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
JejakElemen.java
Unduh berkasimport java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
public class JejakElemen {
static int elemenDilihat = 0;
public static void main(String[] args) {
List<Integer> data = List.of(3, 9, 12, 15, 21, 30);
// findFirst: berhenti begitu elemen pertama yang lolos ditemukan
elemenDilihat = 0;
Optional<Integer> pertama = data.stream()
.peek(n -> elemenDilihat++)
.filter(n -> n % 4 == 0)
.findFirst();
System.out.println("findFirst = " + pertama + ", elemen dilihat: " + elemenDilihat);
// anyMatch: berhenti di kecocokan pertama yang memenuhi predikat
elemenDilihat = 0;
boolean ada = data.stream()
.peek(n -> elemenDilihat++)
.anyMatch(n -> n > 20);
System.out.println("anyMatch = " + ada + ", elemen dilihat: " + elemenDilihat);
// limit: memotong aliran setelah sejumlah elemen lolos
elemenDilihat = 0;
List<Integer> tiga = data.stream()
.peek(n -> elemenDilihat++)
.filter(n -> n % 3 == 0)
.limit(3)
.collect(Collectors.toList());
System.out.println("limit 3 = " + tiga + ", elemen dilihat: " + elemenDilihat);
// collect memproses seluruh elemen sampai habis
elemenDilihat = 0;
Long total = data.stream()
.peek(n -> elemenDilihat++)
.filter(n -> n % 3 == 0)
.collect(Collectors.counting());
System.out.println("counting = " + total + ", elemen dilihat: " + elemenDilihat);
}
}
TanpaTerminal.java
Unduh berkasimport java.util.List;
import java.util.stream.Collectors;
public class TanpaTerminal {
public static void main(String[] args) {
List<String> nama = List.of("Budi", "Siti", "Dewi");
// Pipeline dibangun tetapi TIDAK dijalankan: tanpa terminal operation
nama.stream()
.peek(n -> System.out.println("dilihat: " + n))
.map(String::toUpperCase);
System.out.println("--- pipeline tanpa terminal selesai dibangun ---");
// Pipeline yang sama + terminal operation = benar-benar bekerja
List<String> hasil = nama.stream()
.peek(n -> System.out.println("dilihat: " + n))
.map(String::toUpperCase)
.collect(Collectors.toList());
System.out.println("hasil: " + hasil);
}
}