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:

text
--- pipeline tanpa terminal selesai dibangun ---
dilihat: Budi
dilihat: Siti
dilihat: Dewi
hasil: [BUDI, SITI, DEWI]

Mari kita telaah baris-baris kode di atas:

  1. Baris 10-12 (nama.stream().peek(...).map(...) tanpa collect): hasil map tidak diikat ke variabel dan tidak dipakai — boleh saja secara sintaks. Output membuktikan tidak ada baris dilihat: sebelum garis pemisah, artinya peek dan map sama sekali tidak dieksekusi. Pipeline itu hanya menjadi objek yang terbentuk lalu tidak terpakai lagi.
  2. Baris 14 (System.out.println("--- ... ---")): garis pemisah ini tercetak paling awal. Urutan output adalah bukti kronologis: pembangunan pipeline tidak menghasilkan efek apa pun.
  3. Baris 17-20 (pipeline yang sama + collect): begitu collect(Collectors.toList()) hadir, ketiga nama ditarik dari List, terlihat oleh peek, diubah oleh map, 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:

java JejakElemen.java
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:

text
findFirst = Optional[12], elemen dilihat: 3
anyMatch  = true, elemen dilihat: 5
limit 3   = [3, 9, 12], elemen dilihat: 3
counting  = 6, elemen dilihat: 6

Mari kita telaah baris-baris kode di atas:

  1. Baris 13-17 (findFirst setelah filter n % 4 == 0): filter menolak 3 dan 9, menerima 12, dan findFirst langsung berhenti — elemen 15, 21, dan 30 tidak pernah dilihat. Penghitung berhenti di 3 dari 6 elemen.
  2. 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, jawaban anyMatch tidak mungkin berubah lagi.
  3. Baris 28-33 (filter + limit(3)): seluruh elemen data adalah kelipatan 3, sehingga limit(3) terpenuhi setelah elemen ketiga. Hasil [3, 9, 12] dikumpulkan dan elemen 15, 21, 30 tidak diproses.
  4. Baris 37-41 (collect(Collectors.counting())): tanpa short-circuit, collect wajib melihat seluruh elemen — penghitung mencapai 6. Bandingkan keempat angka pada output: 3, 5, 3, dan 6 elemen untuk kebutuhan yang berbeda; perulangan for manual perlu variabel break tambahan 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:

java
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 #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

JejakElemen.java

Unduh berkas
java
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);
    }
}

TanpaTerminal.java

Unduh berkas
java
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);
    }
}