JVM di balik Scala

Heap, garbage collector, dan kompilasi JIT sebagai panggung tempat kode Scala berjalan.

Daftar isi

Setiap program Scala tidak berjalan sendirian, melainkan di atas Java Virtual Machine (JVM), mesin eksekusi yang memuat dan menjalankan kode terkompilasi. Topik ini membahas apa yang terjadi di balik layar tersebut, mulai dari proses kompilasi kode Scala menjadi bytecode, area memori bernama heap tempat objek ditampung, kerja garbage collector yang membersihkan objek mati, hingga kompilasi JIT yang mempercepat jalur eksekusi yang sering dilalui. Pada akhir topik, program pengamatan pola alokasi menunjukkan bagaimana dua cara menulis kode yang keduanya benar dapat menghasilkan beban sampah yang berbeda jauh.

Dari kode Scala menjadi bytecode#

Kompiler Scala menerjemahkan setiap berkas .scala menjadi berkas .class yang berisi bytecode, yaitu instruksi biner berformat standar milik JVM. Format ini sama dengan yang dihasilkan kompiler Java, sehingga kelas hasil kompilasi Scala dan kelas hasil kompilasi Java dapat saling memanggil dalam satu program. Perintah scala-cli run melakukan kedua langkah itu sekaligus, yaitu mengompilasi kode menjadi .class lalu menyerahkannya kepada JVM untuk dieksekusi.

JVM bertindak sebagai mesin eksekusi yang terdiri atas pemuat kelas (class loader), penerjemah bytecode, kompiler JIT, dan pengelola memori. Karena instruksi bytecode tidak terikat pada satu arsitektur prosesor, satu berkas .class yang sama dapat dijalankan di mana pun JVM tersedia. Isi berkas .class dapat diperiksa dengan alat javap yang disertakan dalam JDK, misalnya untuk melihat daftar metode yang dihasilkan kompiler Scala dari sebuah kelas.

Heap dan generasi objek#

Heap adalah area memori tempat semua objek dialokasikan pada saat program berjalan, mulai dari instansi kelas, koleksi, hingga String. Alokasi di heap berlangsung sangat cepat karena JVM hanya menggeser sebuah penunjuk ke ruang kosong berikutnya. Biaya sesungguhnya bukan pada saat membuat objek, melainkan pada saat jumlah objek yang hidup dan mati bertambah banyak.

JVM mengelompokkan objek menurut umurnya. Objek yang baru dibuat ditempatkan di generasi muda (young generation), sedangkan objek yang bertahan melalui beberapa kali pembersihan dipindahkan ke generasi tua (old generation). Pengelompokan ini didasarkan pada pengamatan bahwa mayoritas objek berumur pendek. Gaya pemrograman Scala yang membentuk koleksi baru daripada mengubah koleksi lama menghasilkan banyak objek perantara, tetapi objek-objek itu biasanya mati segera setelah digunakan, sehingga cocok dengan pola generasi di atas.

Garbage collector dan tekanan alokasi#

Garbage collector (GC) bertugas mengklaim kembali memori objek yang tidak lagi dapat dijangkau dari program. Proses ini berjalan otomatis, sehingga kode Scala tidak membebaskan memori secara manual. Implikasi praktisnya ada dua sisi. Di satu sisi, alokasi yang murah membuat gaya immutable tetap praktis. Di sisi lain, setiap objek sampah harus ditelusuri dan dibersihkan, sehingga program yang mengalokasikan sangat banyak objek di jalur yang sering dieksekusi menimbulkan tekanan GC, yaitu beban kerja tambahan yang memakan waktu pemroses dan sesekali menghentikan eksekusi program sejenak.

Beban tersebut dapat dikurangi dari sisi kode dengan beberapa kebiasaan. Gunakan StringBuilder untuk menyusun teks secara bertahap, gunakan kembali struktur data yang masih dapat digunakan di dalam perulangan, dan hindari membungkus nilai primitif ke dalam objek baru apabila tidak diperlukan. Objek berumur panjang sebaiknya dibuat hanya apabila benar-benar dibutuhkan, karena memori yang menumpuk di generasi tua hanya dibereskan oleh pembersihan yang lebih jarang dan lebih mahal.

Kompilasi JIT dan jalur panas#

Bytecode pada awalnya ditafsirkan instruksi demi instruksi, dan cara ini lambat. Untuk mengatasinya, JVM memantau metode yang paling sering dipanggil atau berulang paling dalam, lalu mengompilasi bagian tersebut menjadi kode mesin prosesor melalui kompilasi JIT (just-in-time). Kode mesin hasil kompilasi ini dapat disederhanakan lebih jauh, misalnya dengan menyalin isi metode kecil langsung ke titik panggilnya (inlining), sehingga jalur yang panas berjalan jauh lebih cepat daripada versi tafsirannya.

Salah satu akibatnya adalah angka hasil pengukuran kinerja tidak dapat dipercaya sebelum program memanas. Eksekusi seribu iterasi pertama mencerminkan kode tafsiran, sedangkan eksekusi setelahnya mencerminkan kode mesin. Karena itu, program pembanding kinerja perlu masa pemanasan (warm-up) sebelum data diambil, dan nilainya tetap berbeda antarmesin. Teknik pengukuran yang benar dibahas pada topik pengukuran kinerja.

Program: mengamati pola alokasi#

Program berikut menyusun teks seribu huruf dengan dua cara, yaitu menggabungkan String pada setiap langkah dan menumbuhkan satu StringBuilder. Hasil akhir keduanya identik, tetapi jumlah kerja di belakangnya berbeda. Program menghitung panjang teksnya serta jumlah karakter yang disalin, dan angka-angka tersebut dihitung dari masukan tetap sehingga keluarannya selalu sama.

scala pola-alokasi.scala
// Pola alokasi: penggabungan String versus StringBuilder
@main def polaAlokasi(): Unit =
  val n = 1000

  // Penggabungan: setiap langkah membuat String baru
  var hasil = ""
  var i = 0
  while i < n do
    hasil = hasil + "a"
    i += 1

  // StringBuilder: satu penyangga yang ditumbuhkan
  val penyangga = new StringBuilder
  var j = 0
  while j < n do
    penyangga.append("a")
    j += 1

  println(s"Panjang teks versi penggabungan: ${hasil.length}")
  println(s"Panjang teks versi StringBuilder: ${penyangga.length}")
  println(s"Total karakter disalin (penggabungan): ${n * (n + 1) / 2}")
  println(s"Total karakter ditulis (StringBuilder): $n")
bash
# menjalankan program pola-alokasi.scala
scala-cli run pola-alokasi.scala
text Keluaran
Panjang teks versi penggabungan: 1000
Panjang teks versi StringBuilder: 1000
Total karakter disalin (penggabungan): 500500
Total karakter ditulis (StringBuilder): 1000

Angka 500500 berasal dari penjumlahan satu sampai seribu, yaitu total karakter yang disalin ulang karena setiap penggabungan menyalin seluruh teks lama ke String baru. Artinya, program menampung sekitar seribu objek String perantara yang segera menjadi sampah bagi garbage collector. Versi StringBuilder hanya menulis setiap karakter sekali ke dalam satu penyangga, sehingga objek perantaranya nyaris tidak ada. Apabila nilai n diperbesar, jumlah sampah versi penggabungan bertumbuh sebanding dengan kuadrat n, sedangkan beban GC yang ditimpanya dapat diamati melalui opsi -Xlog:gc, meskipun baris catatannya berbeda antarmesin.

Ringkasan#

  • Kompiler Scala menghasilkan berkas .class berisi bytecode JVM, dan JVM adalah mesin eksekusi yang memuat serta menjalankan bytecode tersebut di mana pun tersedia.
  • Objek dialokasikan di heap, dikelompokkan ke generasi muda dan tua, dan mayoritas objek perantara gaya immutable berumur pendek sehingga segera menjadi sampah.
  • Garbage collector secara otomatis mengklaim kembali objek yang tidak dapat dijangkau, tetapi alokasi yang berlebihan di jalur panas menimbulkan tekanan GC yang memakan waktu pemroses.
  • Kompilasi JIT mengubah metode pada jalur panas menjadi kode mesin setelah program memanas, sehingga pengukuran kinerja menuntut masa pemanasan dan nilainya berbeda antarmesin.

Referensi#