Aplikasi Kotlin yang mengalokasikan banyak objek pendek akan menyebabkan GC pressure — garbage collector bekerja keras membersihkan objek-objek yang baru saja dibuat. Gejalanya: latensi naik secara periodik, throughput turun saat heap penuh, dan CPU digunakan untuk GC alih-alih logic bisnis. Pada bab ini kita akan mempraktikkan allocation profiling dengan profiler sederhana, membandingkan empat strategi implementasi, dan menjelaskan kapan escape analysis JIT menyingkirkan alokasi secara otomatis.
Mengapa Alokasi Penting #
Setiap objek yang dialokasikan di heap akan menjadi kandidat GC. Bila aplikasi mengalokasikan 1 juta objek per detik, GC minor akan terpicu sering, menyebabkan jeda 5-50 ms setiap kali. Akumulasi jeda ini bisa membuat aplikasi terasa “stutter” walaupun throughput rata-rata tinggi.
| Sumber alokasi | Frequency | Dampak |
|---|---|---|
| String concatenation di loop | Sangat sering | O(n) String objects |
| Boxing Int/Long ke boxed type | Setiap primitive di generic | Integer/Long wrapper |
| Lambda capture (tidak inline) | Setiap lambda invocation | Function object |
Pair/Triple ephemeral | Setiap return multi-value | Pair object |
| Iterator di for-each loop | Setiap iterasi collection | Iterator object |
Tools Profiling Alokasi #
VisualVM Memory Profiler melacak setiap alokasi objek dan menampilkan histogram per class. Cocok untuk developer karena UI interaktif. Overhead 10-30% sehingga tidak cocok untuk produksi.
JFR Allocation Profiling merekam event alokasi ke file .jfr dengan overhead rendah (1-3%). Cocok untuk produksi. Buka dengan JDK Mission Control.
async-profiler dengan --alloc menghasilkan flamegraph alokasi, menunjukkan stack trace di mana alokasi terjadi.
Empat Strategi Implementasi #
Kode berikut membandingkan empat strategi implementasi yang sering muncul dalam kode Kotlin: String concatenation di loop, StringBuilder reuse, joinToString stdlib, dan perbedaan Array<Int> vs IntArray.
// Demo allocation profiling: menghitung alokasi objek pada beberapa
// strategi implementasi, mengidentifikasi yang paling hemat.
class AllocationProfiler {
private val allocations: MutableMap<String, Long> = mutableMapOf()
fun record(type: String, count: Long = 1) {
allocations[type] = (allocations[type] ?: 0L) + count
}
fun report(): String {
val sb = StringBuilder()
sb.appendLine("=== Allocation Profiler Report ===")
val total = allocations.values.sum()
sb.appendLine("Total allocations: $total")
sb.appendLine()
sb.appendLine("By type:")
allocations.entries.sortedByDescending { it.value }.forEach { (type, count) ->
val persen = count * 100 / (total + 1)
val bar = "#".repeat((persen / 5).toInt().coerceAtMost(20))
sb.appendLine(" %-30s %6d (%2d%%) [%s]".format(type, count, persen, bar))
}
return sb.toString()
}
fun reset() = allocations.clear()
}
// Strategi 1: alokasi String baru setiap iterasi (buruk)
fun prosesBuruk(items: List<String>, profiler: AllocationProfiler) {
var result = ""
for (item in items) {
result = result + item + ", " // alokasi String baru setiap loop
profiler.record("java.lang.String")
}
}
// Strategi 2: StringBuilder reuse (baik)
fun prosesBaik(items: List<String>, profiler: AllocationProfiler) {
val sb = StringBuilder()
for (item in items) {
sb.append(item).append(", ")
profiler.record("StringBuilder.append (internal)")
}
profiler.record("java.lang.StringBuilder", 1)
profiler.record("java.lang.String", 1) // toString() sekali di akhir
}
// Strategi 3: String join stdlib (paling ringkas)
fun prosesStdlib(items: List<String>, profiler: AllocationProfiler) {
val result = items.joinToString(", ")
profiler.record("java.lang.String", 1) // hasil akhir
profiler.record("java.lang.StringBuilder", 1) // internal joinToString
}
// Strategi 4: boxing — Array<Int> vs IntArray
fun boxingDemo(profiler: AllocationProfiler) {
// Array<Int> mengalokasikan Integer untuk setiap elemen
val boxed: Array<Int> = Array(100) { it }
boxed.forEach { profiler.record("java.lang.Integer") }
profiler.record("java.lang.Integer[]", 1)
// IntArray menyimpan primitive int tanpa boxing
val primitive: IntArray = IntArray(100) { it }
profiler.record("int[]", 1) // tidak ada Integer objects
}
fun main() {
println("=== Allocation Profiling ===")
println()
val items = (1..10).map { "item-$it" }
val profiler = AllocationProfiler()
println("=== Strategi 1: String concatenation (buruk) ===")
profiler.reset()
prosesBuruk(items, profiler)
println(profiler.report())
println()
println("=== Strategi 2: StringBuilder reuse (baik) ===")
profiler.reset()
prosesBaik(items, profiler)
println(profiler.report())
println()
println("=== Strategi 3: joinToString stdlib ===")
profiler.reset()
prosesStdlib(items, profiler)
println(profiler.report())
println()
println("=== Strategi 4: Array<Int> vs IntArray (boxing) ===")
profiler.reset()
boxingDemo(profiler)
println(profiler.report())
println()
println("=== Pelajaran ===")
val lessons = listOf(
"1. String concatenation di loop menyebabkan O(n) alokasi String",
"2. StringBuilder reuse menurunkan alokasi ke O(1) di loop",
"3. joinToString stdlib sudah optimal — pakai bila tersedia",
"4. Array<Int> mengalokasikan Integer per elemen (boxing overhead)",
"5. IntArray menyimpan primitive — hemat memori dan GC pressure",
"6. Object pooling jarang dibutuhkan; escape analysis JIT menangani"
)
lessons.forEach { println(" $it") }
}Output yang Dihasilkan:
=== Allocation Profiling ===
=== Strategi 1: String concatenation (buruk) ===
=== Allocation Profiler Report ===
Total allocations: 10
By type:
java.lang.String 10 (90%) [##################]
=== Strategi 2: StringBuilder reuse (baik) ===
=== Allocation Profiler Report ===
Total allocations: 12
By type:
StringBuilder.append (internal) 10 (76%) [###############]
java.lang.StringBuilder 1 ( 7%) [#]
java.lang.String 1 ( 7%) [#]
=== Strategi 3: joinToString stdlib ===
=== Allocation Profiler Report ===
Total allocations: 2
By type:
java.lang.String 1 (33%) [######]
java.lang.StringBuilder 1 (33%) [######]
=== Strategi 4: Array<Int> vs IntArray (boxing) ===
=== Allocation Profiler Report ===
Total allocations: 102
By type:
java.lang.Integer 100 (97%) [###################]
java.lang.Integer[] 1 ( 0%) []
int[] 1 ( 0%) []
=== Pelajaran ===
1. String concatenation di loop menyebabkan O(n) alokasi String
2. StringBuilder reuse menurunkan alokasi ke O(1) di loop
3. joinToString stdlib sudah optimal — pakai bila tersedia
4. Array<Int> mengalokasikan Integer per elemen (boxing overhead)
5. IntArray menyimpan primitive — hemat memori dan GC pressure
6. Object pooling jarang dibutuhkan; escape analysis JIT menanganiMari kita bedah hasil keempat strategi di atas:
- Strategi 1 (String concat): setiap
result + item + ", "mengalokasikan String baru. Untuk 10 item, ada 10 alokasi String. Untuk 1000 item, akan ada 1000 alokasi. Pertumbuhan alokasi bersifat O(n) terhadap jumlah item. - Strategi 2 (StringBuilder reuse): satu StringBuilder dialokasikan di awal. Setiap
appendtidak mengalokasikan String baru.toString()di akhir mengalokasikan satu String. Total alokasi: O(1) String + 1 StringBuilder. - Strategi 3 (
joinToString): implementasi stdlib memakaiStringBuilderinternal, sehingga jumlah alokasi minimal. Selalu preferjoinToStringbila tersedia karena sudah optimal. - Strategi 4 (boxing):
Array<Int>memerlukan boxing setiap elemen keInteger. Untuk 100 elemen, ada 100 objekIntegerdi heap.IntArraymenyimpanintprimitive, tidak ada objek per elemen. Beda besar untuk koleksi besar.
Escape Analysis #
JIT HotSpot mempunyai optimasi escape analysis. Bila compiler mendeteksi bahwa objek tidak “lolos” dari method (tidak disimpan ke field, tidak di-pass ke method lain), compiler dapat menghindari alokasi heap dan menyimpan objek di stack. Optimasi ini bekerja otomatis.
fun proses(data: List<Int>): Int {
val sb = StringBuilder() // tidak lolos — sb tidak return/di-field
data.forEach { sb.append(it) }
return sb.length
}Pada kasus ini, StringBuilder tidak lolos dari method, sehingga escape analysis dapat menghilangkan alokasi. Untuk memanfaatkan optimasi ini, hindari menyimpan objek sementara ke field class.
Object Pooling #
Object pooling adalah teknik menyimpan objek yang sudah dibuat di pool dan meminjamnya bila dibutuhkan. Dulu populer di Java awal karena GC lambat. Saat ini, dengan G1GC dan ZGC yang sangat cepat, pooling jarang dibutuhkan — overhead manajemen pool sering lebih besar dari biaya alokasi.
| Skenario | Pooling | Tanpa pooling |
|---|---|---|
| Object kecil, jangka pendek | Lebih lambat (lock overhead) | Lebih cepat (escape analysis) |
| Object besar (mis. ByteBuffer 1MB) | Lebih cepat (hemat alokasi) | Lebih lambat (malloc mahal) |
| Resource terbatas (DB connection) | Wajib (pooling) | Tidak mungkin |
Pengecualian: resource yang mahal dibuat (koneksi database, thread, file handle) wajib di-pool. Untuk objek domain biasa, biarkan GC yang menangani.
Referensi Lanjutan #
- JetBrains Profiler — panduan profiler JetBrains untuk melacak alokasi objek dan retensi heap
- Inline value classes
— dokumentasi
value classKotlin untuk mereduksi alokasi wrapper saat runtime
Selanjutnya #
Setelah memahami alokasi, kita beralih ke koleksi. Bab berikutnya membahas performance berbagai implementasi collection: ArrayList vs LinkedList, HashMap vs TreeMap, dan pentingnya pre-size. Materi tersebut ada di chapter Collection Performance.