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:
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: 12Mari kita telaah baris-baris kode di atas:
- 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. - Baris 27-35 (perulangan +
break): penulis kode harus mengelola variabelhasilBreak.size() == 4danbreaksecara manual. Optimal secara elemen — hanya 12 — tetapi logika berhenti menyatu dengan logika pencarian, dan setiap perubahan kebutuhan (misalnya lima hasil) menyentuh dua tempat. - Baris 41-45 (stream +
limit(4)): hasil identik[3, 6, 9, 12]dengan 12 elemen dilihat — sama efisiennya dengan versibreak, tetapi tanpa satu pun variabel kendali manual.limitdanfindFirstmemindahkan 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:
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:
Stream<Angka> : total = 1000, objek dibuat = 1000
IntStream : total = 1000, objek dibuat = 0Mari kita telaah baris-baris kode di atas:
- Baris 9-16 (kelas
Angka): konstruktor menaikkankotakDibuatsetiap kali objek terbentuk. Kelas ini meniru peranIntegerhasil boxing — JVM memang mengalokasikan objek nyata untuk angka di luar rentang cache-128sampai127(kebetulan angka 1 masuk cache, tetapi cache hanya menutupi sebagian kecil kasus nyata). - Baris 23-26 (
Stream.generate(() -> new Angka(1))): stream objek memanggilnew Angka(1)seribu kali — penghitung membuktikan 1000 objek terbentuk hanya untuk membawa nilai yang muat dalam 4 byteintmasing-masing. - Baris 32-34 (
IntStream.rangeClosed(1, n).map(x -> 1)): stream primitif menulis angka pada struktur internal berbasis arrayint, sehingga penghitung tetap 0. Kedua versi menjawabtotal = 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:
| Situasi | Pilihan yang cenderung baik | Alasan struktural |
|---|---|---|
| Data kecil, logika sederhana | perulangan for | tanpa objek tahap dan lambda |
Butuh short-circuit (limit, findFirst) | stream | berhenti otomatis tanpa break manual |
| Komputasi numerik deret besar | IntStream/DoubleStream | nol boxing, operasi statistik bawaan |
| Transformasi bertingkat kompleks | stream | satu perjalanan elemen per tahap |
| Butuh angka pasti lintas arsitektur | JMH | warmup 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 berkasimport 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 berkasimport 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);
}
}