Pada chapter-chapter sebelumnya kita berfokus pada pemahaman cara kerja GC dan deteksi kebocoran memori. Chapter penutup section ini berfokus pada optimasi aktif: bagaimana mengurangi tekanan GC dan alokasi heap dengan memilih struktur data dan pola kode yang tepat. Topik yang dibahas mencakup object pooling, autoboxing pada tipe primitif, buffer reuse, serta perbandingan Array<Int> vs IntArray. Pemahaman pola-pola ini menjadi bekal untuk menulis kode Kotlin yang hemat memori tanpa mengurangi kejelasan.
Object Pooling: Kapan Diperlukan #
Object pooling adalah pola menyimpan objek yang sudah dialokasikan ke dalam pool dan memakainya kembali alih-alih membuat instance baru. Pola ini relevan bila tiga kondisi berikut terpenuhi bersamaan:
- Biaya pembuatan objek jauh lebih mahal dari biaya sinkronisasi pool (mis. koneksi database, thread, direct buffer).
- Objek memiliki lifecycle yang dapat di-reset secara deterministik.
- Aplikasi berjalan di beban kerja yang sangat sensitif terhadap alokasi (mis. game loop, trading engine).
Untuk objek biasa seperti data class atau String, pooling biasanya tidak menguntungkan. GC modern sangat cepat pada objek muda; pool justru menambah kompleksitas dan overhead sinkronisasi. Aturan praktis: jangan pooling kecuali profiling membuktikan alokasi adalah bottleneck.
Autoboxing: Penyebab Alokasi Tersembunyi #
Autoboxing adalah konversi otomatis antara primitif (int) dan wrapper (java.lang.Integer). Di Kotlin, konversi ini terjadi saat kita memakai tipe nullable Int? atau collection seperti Array<Int>, List<Int>, dan MutableList<Int>. Setiap elemen adalah Integer objek di heap, bukan int primitif di stack.
| Konstruktor Kotlin | Tipe JVM | Alokasi per 1000 elemen |
|---|---|---|
IntArray(1000) | int[] | ~4 KB (header + 1000 primitif) |
Array<Int>(1000) | Integer[] | ~28 KB (header + 1000 ref + 1000 objek Integer) |
List<Int> (mutableListOf) | java.util.ArrayList<Integer> | ~28 KB sama seperti di atas |
Untuk array ukuran besar (10.000+ elemen), perbedaan ini menjadi signifikan. Pakai IntArray, LongArray, DoubleArray selama tipe elemen adalah primitif dan tidak nullable.
Demonstrasi Pola Optimasi #
Kode berikut memperlihatkan empat pola optimasi memori secara berurutan: pilihan IntArray vs Array<Int>, object pooling dengan ArrayBlockingQueue, perbandingan loop primitif vs boxed, dan penggunaan random seeded.
import java.util.concurrent.ArrayBlockingQueue
import java.util.concurrent.ThreadLocalRandom
fun main() {
println("=== Optimasi Memori: Array<Int> vs IntArray ===")
// Pola 1: IntArray (primitif) — lebih kompak di heap
val intArray = IntArray(5) { it * 10 }
println("IntArray: ${intArray.toList()}")
println(" Lokasi memori: heap, satu array berisi 5 primitif int (20 byte + header)")
// Pola 2: Array<Int> (boxed) — setiap elemen adalah Integer object
val boxedArray = Array<Int>(5) { it * 10 }
println()
println("Array<Int>: ${boxedArray.toList()}")
println(" Lokasi memori: heap, satu array berisi 5 referensi + 5 Integer object")
println(" Total: ~40 byte header + 5*4 byte ref + 5*16 byte Integer = ~140 byte")
// Pola 3: Reuse buffer dengan ArrayBlockingQueue (object pooling)
println()
println("=== Object Pooling ===")
val pool = ArrayBlockingQueue<StringBuilder>(3)
repeat(3) { pool.add(StringBuilder()) }
val sb1 = pool.take()
sb1.append("Halo")
println("Builder 1 setelah append: '$sb1'")
sb1.clear()
pool.add(sb1)
println("Builder dikembalikan ke pool, ukuran pool: ${pool.size}")
// Pola 4: Hindari autoboxing di loop panjang
println()
println("=== Autoboxing di Loop ===")
val sumBoxed = hitungDenganBoxed(1..5)
val sumPrimitif = hitungDenganPrimitif(intArrayOf(1, 2, 3, 4, 5))
println("Sum boxed (Array<Int>): $sumBoxed")
println("Sum primitif (IntArray): $sumPrimitif")
// Pola 5: Pemakaian Random dengan seed deterministik
println()
println("=== Deterministic Random ===")
val random = ThreadLocalRandom.current()
// Catatan: ThreadLocalRandom tidak seeded; untuk output deterministik
// gunakan java.util.Random(42) atau kotlin.random.Random(42)
val seededRandom = java.util.Random(42)
val hasil = (1..3).map { seededRandom.nextInt(100) }
println("Random dengan seed 42: $hasil (deterministik)")
}
fun hitungDenganBoxed(range: IntRange): Int {
var sum = 0
for (i in range) {
sum += i // tidak ada autoboxing karena i adalah Int primitif
}
return sum
}
fun hitungDenganPrimitif(arr: IntArray): Int {
var sum = 0
for (i in arr) {
sum += i // tidak ada autoboxing karena i adalah Int primitif
}
return sum
}Output yang Dihasilkan:
=== Optimasi Memori: Array<Int> vs IntArray ===
IntArray: [0, 10, 20, 30, 40]
Lokasi memori: heap, satu array berisi 5 primitif int (20 byte + header)
Array<Int>: [0, 10, 20, 30, 40]
Lokasi memori: heap, satu array berisi 5 referensi + 5 Integer object
Total: ~40 byte header + 5*4 byte ref + 5*16 byte Integer = ~140 byte
=== Object Pooling ===
Builder 1 setelah append: 'Halo'
Builder dikembalikan ke pool, ukuran pool: 3
=== Autoboxing di Loop ===
Sum boxed (Array<Int>): 15
Sum primitif (IntArray): 15
=== Deterministic Random ===
Random dengan seed 42: [30, 63, 48] (deterministik)Mari kita telaah kode di atas:
IntArray(5) { it * 10 }: konstruktorIntArraymenghasilkan array primitifint[]di JVM. Inisialisasi lambda{ it * 10 }dijalankan untuk setiap index tanpa autoboxing. Memori total sekitar 24 byte untuk 5 elemen.Array<Int>(5) { it * 10 }: meskipun kode terlihat mirip, output JVM adalahInteger[]— array referensi. Setiap elemen adalah objekIntegertersendiri. Memori total ~140 byte, lebih dari 5 kaliIntArray.ArrayBlockingQueue<StringBuilder>: pool sederhana berisi tigaStringBuilder. Methodtake()meminjam instance,add()mengembalikannya. Setelahclear(), builder dapat dipakai ulang tanpa alokasi baru.- Dua fungsi
hitungDengan*: keduanya menghindari autoboxing karena iteratorIntRangedanIntArraymengembalikan primitifInt. Perbedaan utama terletak pada tipe input —IntArraylebih hemat memori bila datang dari sumber yang sudah primitif. java.util.Random(42): penggunaan seed deterministik memastikan output sama di setiap eksekusi. Berbeda denganThreadLocalRandom.current()yang tidak seeded sehingga cocok untuk produksi tetapi tidak untuk pengujian unit yang deterministik.
Buffer Reuse #
Saat membaca atau menulis data berukuran besar (file, jaringan), buffer sering dialokasikan ulang di setiap iterasi. Pola ini menambah tekanan GC. Solusinya adalah memakai satu buffer tetap yang di-reuse:
fun bacaDenganReuse(input: java.io.InputStream, ukuran: Int = 8192): Long {
val buffer = ByteArray(ukuran) // dialokasikan sekali
var total = 0L
while (true) {
val baca = input.read(buffer)
if (baca == -1) break
total += baca
}
return total
}Praktik Umum #
Berikut rangkuman praktis untuk menulis kode Kotlin yang hemat memori:
- Default ke
IntArray,LongArray,DoubleArrayuntuk data numerik besar. - Pakai
List<Int>hanya bila ukuran kecil atau API memang mengharuskan tipe generic. - Hindari
Int?di field class yang sering dibuat — gunakan sentinel value (-1) bila perlu. - Untuk buffer I/O, alokasikan sekali dan reuse.
- Hindari membuat koleksi temporer di dalam loop; pindahkan ke luar dan
clear(). - Profil dengan async-profiler
event=allocsebelum melakukan optimasi kompleks.
Referensi Lanjutan #
- Kotlin/JVM — dokumentasi backend Kotlin/JVM sebagai dasar optimasi memori
- JetBrains Profiler — panduan profiler JetBrains untuk menganalisis performa dan alokasi memori
Penutup #
Section JVM dan Memory Management berakhir di sini. Kita telah menelusuri jalur kompilasi Kotlin/JVM, struktur bytecode, pembagian stack/heap, algoritma GC, kebocoran memori, opsi JVM, alat profiling, JFR, dan pola optimasi memori. Pemahaman utuh atas topik ini menjadi modal untuk membangun aplikasi Kotlin yang stabil di bawah beban produksi. Section berikutnya akan membahas topik yang setiap pengembang Kotlin hadapi setiap hari: build tools dan manajemen dependensi.