Pertanyaan paling sering muncul setelah berbulan memakai stream: “apakah stream lebih lambat daripada perulangan for?” Jawaban yang jujur adalah tergantung — dan untuk menjawabnya dengan bertanggung jawab, kita perlu tahu apa yang sebenarnya dikerjakan JVM di balik layar. Mengukur dengan System.nanoTime() di sekitar potongan kode justru sering menyesatkan karena JIT compiler mengubah program selama berjalan. Pada Chapter ini kita memakai pendekatan yang bisa diverifikasi ulang: menghitung pekerjaan struktural — jumlah elemen yang diproses dan jumlah objek yang dialokasikan — lalu menimbang kapan stream unggul dan kapan perulangan lebih tepat.

Mengapa Pengukuran Waktu Sederhana Menyesatkan #

JVM tidak mengeksekusi bytecode apa adanya. Just-In-Time (JIT) compiler mengamati kode yang sering dijalankan lalu mengompilasinya menjadi instruksi mesin asli — kode yang baru dijalankan beberapa kali masih tereksekusi dalam mode interpreter yang jauh lebih lambat, fenomena yang disebut warmup. Sebaliknya, kode yang hasilnya tidak pernah dipakai bisa dihapus JIT sepenuhnya (dead code elimination), sehingga pengukuran waktu bisa menunjukkan angka nyaris nol untuk pekerjaan yang sebenarnya kita ingin ukur.

Akibatnya, program kecil yang mengukur waktu sekali jalan mengukur campuran interpreter, kompilasi JIT, dan pengumpulan sampah — bukan biaya pipeline itu sendiri. Pengukuran yang layak dipercaya memakai harness khusus seperti JMH (Java Microbenchmark Harness) milik OpenJDK, yang menangani warmup, fork proses, dan statistik. Untuk chapter ini kita memilih jalan lain yang deterministik: menghitung operasi dan alokasi secara eksplisit.

Bukti Struktural dari Jumlah Elemen yang Diproses #

Tugasnya sama sekali konkret: dari data 1 sampai 1000, ambil empat bilangan kelipatan tiga pertama. Tiga implementasi berbeda menyelesaikannya — perulangan naif yang memindai semua data, perulangan cerdas dengan break, dan stream dengan limit. Penghitung pada tiap implementasi mencatat berapa banyak elemen yang benar-benar dilihat:

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

public class ElemenDiproses {

    public static void main(String[] args) {
        List<Integer> data = IntStream.rangeClosed(1, 1000).boxed().toList();

        // 1. Perulangan naif: memindai seluruh data walau hasil sudah cukup
        int dilihatNaif = 0;
        List<Integer> semuaKelipatanTiga = new ArrayList<>();
        for (int n : data) {
            dilihatNaif++;
            if (n % 3 == 0) {
                semuaKelipatanTiga.add(n);
            }
        }
        List<Integer> empatNaif = semuaKelipatanTiga.subList(0, 4);
        System.out.println("perulangan naif    = " + empatNaif
            + ", elemen dilihat: " + dilihatNaif);

        // 2. Perulangan yang sama + break manual
        int dilihatBreak = 0;
        List<Integer> hasilBreak = new ArrayList<>();
        for (int n : data) {
            dilihatBreak++;
            if (n % 3 == 0) {
                hasilBreak.add(n);
                if (hasilBreak.size() == 4) {
                    break;
                }
            }
        }
        System.out.println("perulangan + break = " + hasilBreak
            + ", elemen dilihat: " + dilihatBreak);

        // 3. Stream + limit: short-circuit dikerjakan otomatis
        int[] dilihatStream = {0};
        List<Integer> hasilStream = data.stream()
            .peek(n -> dilihatStream[0]++)
            .filter(n -> n % 3 == 0)
            .limit(4)
            .collect(Collectors.toList());
        System.out.println("stream + limit     = " + hasilStream
            + ", elemen dilihat: " + dilihatStream[0]);
    }
}

Output yang Dihasilkan:

text
perulangan naif    = [3, 6, 9, 12], elemen dilihat: 1000
perulangan + break = [3, 6, 9, 12], elemen dilihat: 12
stream + limit     = [3, 6, 9, 12], elemen dilihat: 12

Mari kita telaah baris-baris kode di atas:

  1. Baris 14-19 (perulangan naif): perulangan memindai seluruh 1000 elemen dan mengumpulkan semua 333 kelipatan tiga ke semuaKelipatanTiga, padahal yang dibutuhkan hanya empat pertama — baris 20 memotongnya setelah pekerjaan selesai. Elemen yang dilihat: 1000.
  2. Baris 27-35 (perulangan + break): penulis kode harus mengelola variabel hasilBreak.size() == 4 dan break secara manual. Optimal secara elemen — hanya 12 — tetapi logika berhenti menyatu dengan logika pencarian, dan setiap perubahan kebutuhan (misalnya lima hasil) menyentuh dua tempat.
  3. Baris 41-45 (stream + limit(4)): hasil identik [3, 6, 9, 12] dengan 12 elemen dilihat — sama efisiennya dengan versi break, tetapi tanpa satu pun variabel kendali manual. limit dan findFirst memindahkan tanggung jawab berhenti lebih awal ke mesin stream; laziness yang kita buktikan pada Chapter Stream Laziness inilah sumber penghematan strukturalnya.

Menghitung Biaya Boxing #

Biaya kedua yang bisa dihitung tanpa stopwatch adalah alokasi objek. Stream<Integer> mengalirkan objek, dan objek berarti ruang heap plus pekerjaan garbage collector. Program berikut memakai kelas Angka yang menghitung pembuatannya sendiri, membandingkan stream berisi objek pembungkus melawan IntStream untuk menjumlahkan 1000 angka:

java JejakBoxing.java
import java.util.stream.IntStream;
import java.util.stream.Stream;

public class JejakBoxing {

    static int kotakDibuat = 0;

    // Kelas pembungkus sederhana, meniru objek hasil boxing Integer
    static final class Angka {
        final int nilai;

        Angka(int nilai) {
            kotakDibuat++;
            this.nilai = nilai;
        }
    }

    public static void main(String[] args) {
        int n = 1000;

        // 1. Stream berisi objek pembungkus: n objek dialokasikan di heap
        kotakDibuat = 0;
        int totalObjek = Stream.generate(() -> new Angka(1))
            .limit(n)
            .mapToInt(a -> a.nilai)
            .sum();
        System.out.println("Stream<Angka>  : total = " + totalObjek
            + ", objek dibuat = " + kotakDibuat);

        // 2. Stream primitif: nol objek pembungkus
        kotakDibuat = 0;
        int totalPrimitif = IntStream.rangeClosed(1, n)
            .map(x -> 1)
            .sum();
        System.out.println("IntStream      : total = " + totalPrimitif
            + ", objek dibuat = " + kotakDibuat);
    }
}

Output yang Dihasilkan:

text
Stream<Angka>  : total = 1000, objek dibuat = 1000
IntStream      : total = 1000, objek dibuat = 0

Mari kita telaah baris-baris kode di atas:

  1. Baris 9-16 (kelas Angka): konstruktor menaikkan kotakDibuat setiap kali objek terbentuk. Kelas ini meniru peran Integer hasil boxing — JVM memang mengalokasikan objek nyata untuk angka di luar rentang cache -128 sampai 127 (kebetulan angka 1 masuk cache, tetapi cache hanya menutupi sebagian kecil kasus nyata).
  2. Baris 23-26 (Stream.generate(() -> new Angka(1))): stream objek memanggil new Angka(1) seribu kali — penghitung membuktikan 1000 objek terbentuk hanya untuk membawa nilai yang muat dalam 4 byte int masing-masing.
  3. Baris 32-34 (IntStream.rangeClosed(1, n).map(x -> 1)): stream primitif menulis angka pada struktur internal berbasis array int, sehingga penghitung tetap 0. Kedua versi menjawab total = 1000, tetapi versi primitif tidak menyisakan satu pun objek sampah untuk dibersihkan garbage collector. Pada data jutaan elemen, selisih ini menentukan seberapa sering garbage collection berjalan.

Kasus Kecil Memihak Perulangan #

Sisi sebaliknya juga perlu diakui. Setiap intermediate operation membangun objek tahap pipeline, dan setiap lambda yang menangkap variabel menghasilkan objek tambahan pada saat runtime — pada pipeline tiga tahap atas sepuluh elemen, biaya pembentukan struktur ini belum terbayar. Perulangan for atas int[] bekerja langsung pada register dan memori array tanpa perantara apa pun, dan JIT mudah mengoptimalkannya menjadi instruksi mesin yang rapat.

Pipeline yang panjang menumpuk biaya lain: sorted() menyalin seluruh elemen ke array internal sebelum mengurutkan dan mengalirkannya kembali, dan flatMap membuat stream per elemen. Rantai Iterator manual yang ditulis tangan (“chaining iterator” gaya lawas) bahkan lebih buruk daripada stream karena setiap pemanggilan hasNext/next melewati seluruh rantai, sementara stream menyatukan tahap-tahapnya dalam satu perjalanan elemen.

Kesimpulannya bukan “stream cepat” atau “perulangan cepat”, melainkan perbandingan kebiasaan kode:

SituasiPilihan yang cenderung baikAlasan struktural
Data kecil, logika sederhanaperulangan fortanpa objek tahap dan lambda
Butuh short-circuit (limit, findFirst)streamberhenti otomatis tanpa break manual
Komputasi numerik deret besarIntStream/DoubleStreamnol boxing, operasi statistik bawaan
Transformasi bertingkat kompleksstreamsatu perjalanan elemen per tahap
Butuh angka pasti lintas arsitekturJMHwarmup dan statistik terkontrol

Panduan Memilih dengan Sadar #

Tiga kebiasaan ini menjaga kode tetap jujur terhadap biayanya. Pertama, mulai dari kejelasan: tulis versi yang paling terbaca, lalu curigai performanya hanya bila ada bukti. Kedua, ketika bukti dibutuhkan, ukur dengan JMH atau hitung struktural (jumlah elemen, jumlah alokasi) alih-alih stopwatch satu eksekusi. Ketiga, waspadai pola yang telah kita bahas — boxing pada Stream<Integer>, mutasi koleksi luar, dan pipeline panjang yang tidak pernah dihentikan terminal operation yang hemat.

Performa adalah hasil desain, bukan bumbu akhir. Chapter penutup section ini merangkum seluruh kebiasaan baik yang telah kita kumpulkan — pipeline pendek, method reference, flatMap pengganti stream bersarang — menjadi daftar periksa yang siap dipakai saat menulis kode nyata.

Referensi #

Berkas Sumber

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

ElemenDiproses.java

Unduh berkas
java
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

public class ElemenDiproses {

    public static void main(String[] args) {
        List<Integer> data = IntStream.rangeClosed(1, 1000).boxed().toList();

        // 1. Perulangan naif: memindai seluruh data walau hasil sudah cukup
        int dilihatNaif = 0;
        List<Integer> semuaKelipatanTiga = new ArrayList<>();
        for (int n : data) {
            dilihatNaif++;
            if (n % 3 == 0) {
                semuaKelipatanTiga.add(n);
            }
        }
        List<Integer> empatNaif = semuaKelipatanTiga.subList(0, 4);
        System.out.println("perulangan naif    = " + empatNaif
            + ", elemen dilihat: " + dilihatNaif);

        // 2. Perulangan yang sama + break manual
        int dilihatBreak = 0;
        List<Integer> hasilBreak = new ArrayList<>();
        for (int n : data) {
            dilihatBreak++;
            if (n % 3 == 0) {
                hasilBreak.add(n);
                if (hasilBreak.size() == 4) {
                    break;
                }
            }
        }
        System.out.println("perulangan + break = " + hasilBreak
            + ", elemen dilihat: " + dilihatBreak);

        // 3. Stream + limit: short-circuit dikerjakan otomatis
        int[] dilihatStream = {0};
        List<Integer> hasilStream = data.stream()
            .peek(n -> dilihatStream[0]++)
            .filter(n -> n % 3 == 0)
            .limit(4)
            .collect(Collectors.toList());
        System.out.println("stream + limit     = " + hasilStream
            + ", elemen dilihat: " + dilihatStream[0]);
    }
}

JejakBoxing.java

Unduh berkas
java
import java.util.stream.IntStream;
import java.util.stream.Stream;

public class JejakBoxing {

    static int kotakDibuat = 0;

    // Kelas pembungkus sederhana, meniru objek hasil boxing Integer
    static final class Angka {
        final int nilai;

        Angka(int nilai) {
            kotakDibuat++;
            this.nilai = nilai;
        }
    }

    public static void main(String[] args) {
        int n = 1000;

        // 1. Stream berisi objek pembungkus: n objek dialokasikan di heap
        kotakDibuat = 0;
        int totalObjek = Stream.generate(() -> new Angka(1))
            .limit(n)
            .mapToInt(a -> a.nilai)
            .sum();
        System.out.println("Stream<Angka>  : total = " + totalObjek
            + ", objek dibuat = " + kotakDibuat);

        // 2. Stream primitif: nol objek pembungkus
        kotakDibuat = 0;
        int totalPrimitif = IntStream.rangeClosed(1, n)
            .map(x -> 1)
            .sum();
        System.out.println("IntStream      : total = " + totalPrimitif
            + ", objek dibuat = " + kotakDibuat);
    }
}