Pengoptimalan kinerja JVM, Bagian 3: Pengumpulan sampah

Mekanisme pengumpulan sampah platform Java sangat meningkatkan produktivitas pengembang, tetapi pengumpul sampah yang diimplementasikan dengan buruk dapat menghabiskan sumber daya aplikasi secara berlebihan. Dalam artikel ketiga dalam seri pengoptimalan kinerja JVM ini, Eva Andreasson menawarkan kepada pemula Java gambaran umum tentang model memori platform Java dan mekanisme GC. Dia kemudian menjelaskan mengapa fragmentasi (dan bukan GC) adalah "gotcha!" Utama. kinerja aplikasi Java, dan mengapa pengumpulan dan pemadatan sampah generasi saat ini merupakan pendekatan utama (meskipun bukan yang paling inovatif) untuk mengelola fragmentasi heap dalam aplikasi Java.

Pengumpulan sampah (GC) adalah proses yang bertujuan untuk membebaskan memori yang ditempati yang tidak lagi dirujuk oleh objek Java yang dapat dijangkau, dan merupakan bagian penting dari sistem manajemen memori dinamis mesin virtual Java (JVM). Dalam siklus pengumpulan sampah yang khas, semua objek yang masih direferensikan, dan dengan demikian dapat dijangkau, disimpan. Ruang yang ditempati oleh objek yang direferensikan sebelumnya dibebaskan dan diambil kembali untuk mengaktifkan alokasi objek baru.

Untuk memahami pengumpulan sampah serta berbagai pendekatan dan algoritme GC, Anda harus terlebih dahulu mengetahui beberapa hal tentang model memori platform Java.

Optimalisasi kinerja JVM: Baca seri

  • Bagian 1: Ringkasan
  • Bagian 2: Penyusun
  • Bagian 3: Pengumpulan sampah
  • Bagian 4: GC pemadatan bersamaan
  • Bagian 5: Skalabilitas

Pengumpulan sampah dan model memori platform Java

Ketika Anda menentukan opsi startup -Xmxpada baris perintah aplikasi Java Anda (misalnya:) java -Xmx:2g MyAppmemori ditetapkan ke proses Java. Memori ini disebut sebagai heap Java (atau hanya heap ). Ini adalah ruang alamat memori khusus tempat semua objek yang dibuat oleh program Java Anda (atau terkadang JVM) akan dialokasikan. Saat program Java Anda terus berjalan dan mengalokasikan objek baru, heap Java (artinya ruang alamat) akan terisi.

Akhirnya, heap Java akan penuh, yang berarti thread pengalokasi tidak dapat menemukan bagian memori bebas yang berurutan dan cukup besar untuk objek yang ingin dialokasikan. Pada titik itu, JVM menentukan bahwa pengumpulan sampah perlu dilakukan dan memberi tahu pengumpul sampah. Pengumpulan sampah juga bisa dipicu saat program Java memanggil System.gc(). MenggunakanSystem.gc()tidak menjamin pengumpulan sampah. Sebelum pengumpulan sampah dapat dimulai, mekanisme GC akan terlebih dahulu menentukan apakah aman untuk memulainya. Aman untuk memulai pengumpulan sampah ketika semua utas aktif aplikasi berada pada titik aman untuk memungkinkannya, misalnya dengan hanya dijelaskan bahwa akan buruk untuk memulai pengumpulan sampah di tengah alokasi objek yang sedang berlangsung, atau di tengah menjalankan urutan instruksi CPU yang dioptimalkan (lihat artikel saya sebelumnya tentang kompiler), karena Anda mungkin kehilangan konteks dan dengan demikian mengacaukan hasil akhir.

Pengumpul sampah tidak boleh mengklaim kembali objek yang direferensikan secara aktif; untuk melakukannya akan merusak spesifikasi mesin virtual Java. Seorang pengumpul sampah juga tidak diharuskan untuk segera mengumpulkan benda mati. Objek mati akhirnya dikumpulkan selama siklus pengumpulan sampah berikutnya. Meskipun ada banyak cara untuk menerapkan pengumpulan sampah, kedua asumsi ini berlaku untuk semua jenis. Tantangan sebenarnya dari pengumpulan sampah adalah untuk mengidentifikasi semua yang hidup (masih direferensikan) dan mendapatkan kembali memori yang tidak direferensikan, tetapi melakukannya tanpa memengaruhi aplikasi yang sedang berjalan lebih dari yang diperlukan. Seorang pengumpul sampah memiliki dua mandat:

  1. Untuk dengan cepat membebaskan memori yang tidak direferensikan untuk memenuhi tingkat alokasi aplikasi sehingga tidak kehabisan memori.
  2. Untuk mendapatkan kembali memori sekaligus berdampak minimal pada kinerja (misalnya, latensi dan throughput) dari aplikasi yang sedang berjalan.

Dua jenis pengumpulan sampah

Pada artikel pertama dalam seri ini saya menyinggung dua pendekatan utama untuk pengumpulan sampah, yaitu penghitungan referensi dan penelusuran kolektor. Kali ini saya akan menelusuri lebih jauh ke setiap pendekatan kemudian memperkenalkan beberapa algoritme yang digunakan untuk mengimplementasikan kolektor penelusuran di lingkungan produksi.

Baca seri pengoptimalan kinerja JVM

  • Pengoptimalan kinerja JVM, Bagian 1: Ikhtisar
  • Pengoptimalan kinerja JVM, Bagian 2: Penyusun

Referensi penghitungan kolektor

Kolektor penghitung referensi melacak berapa banyak referensi yang menunjuk ke setiap objek Java. Setelah hitungan suatu objek menjadi nol, memori dapat segera diambil kembali. Akses langsung ke memori yang diperoleh kembali ini adalah keuntungan utama dari pendekatan penghitungan referensi untuk pengumpulan sampah. Ada sedikit biaya tambahan dalam hal mempertahankan memori yang tidak direferensikan. Namun, menjaga agar semua jumlah referensi tetap mutakhir bisa sangat mahal.

Kesulitan utama dengan kolektor penghitungan referensi adalah menjaga agar jumlah referensi tetap akurat. Tantangan terkenal lainnya adalah kompleksitas yang terkait dengan penanganan struktur melingkar. Jika dua objek saling mereferensikan dan tidak ada objek langsung yang merujuk padanya, memori mereka tidak akan pernah dilepaskan. Kedua objek akan selamanya tetap dengan hitungan bukan nol. Reclaiming memory yang terkait dengan struktur melingkar membutuhkan analisis besar, yang membawa biaya overhead yang mahal ke algoritme, dan karenanya ke aplikasi.

Menelusuri kolektor

Kolektor pelacakan didasarkan pada asumsi bahwa semua objek aktif dapat ditemukan dengan menelusuri semua referensi dan referensi berikutnya secara berulang dari kumpulan awal yang diketahui sebagai objek hidup. Set awal objek langsung (disebut objek root atau singkatnya hanya root ) ditempatkan dengan menganalisis register, bidang global, dan bingkai tumpukan pada saat pengumpulan sampah dipicu. Setelah set live awal telah diidentifikasi, tracing collector mengikuti referensi dari objek ini dan mengantrekannya untuk ditandai sebagai live dan kemudian referensinya dilacak. Menandai semua objek referensi yang ditemukan secara langsungartinya set langsung yang diketahui meningkat seiring waktu. Proses ini berlanjut hingga semua objek yang direferensikan (dan karenanya semua objek aktif) ditemukan dan ditandai. Setelah kolektor penelusuran menemukan semua objek aktif, ia akan mengambil kembali memori yang tersisa.

Kolektor penelusuran berbeda dengan kolektor penghitung referensi karena mereka dapat menangani struktur melingkar. Hasil tangkapan dengan sebagian besar kolektor penelusuran adalah fase menandai, yang memerlukan waktu tunggu sebelum dapat memperoleh kembali memori yang tidak direferensikan.

Tracing collector paling sering digunakan untuk manajemen memori dalam bahasa dinamis; sejauh ini merupakan bahasa yang paling umum untuk bahasa Java dan telah dibuktikan secara komersial di lingkungan produksi selama bertahun-tahun. Saya akan fokus pada penelusuran kolektor untuk sisa artikel ini, dimulai dengan beberapa algoritme yang menerapkan pendekatan ini pada pengumpulan sampah.

Menelusuri algoritme kolektor

Menyalin dan menandai dan menyapu pengumpulan sampah bukanlah hal baru, tetapi mereka masih menjadi dua algoritme paling umum yang menerapkan pelacakan pengumpulan sampah saat ini.

Menyalin kolektor

Kolektor penyalinan tradisional menggunakan from-space dan to-space - yaitu, dua ruang alamat heap yang ditentukan secara terpisah. Pada titik pengumpulan sampah, objek hidup di dalam area yang didefinisikan sebagai dari luar angkasa disalin ke ruang yang tersedia berikutnya di dalam area yang didefinisikan sebagai ruang-ke. Ketika semua benda hidup di dalam ruang angkasa dipindahkan, seluruh benda dari ruang angkasa dapat diklaim kembali. Saat alokasi dimulai lagi, alokasi dimulai dari lokasi kosong pertama di ruang ke.

Dalam implementasi lama dari algoritme ini, tempat sakelar from-space dan to-space, artinya ketika to-space penuh, pengumpulan sampah dipicu lagi dan to-space menjadi from-space, seperti yang ditunjukkan pada Gambar 1.

Implementasi yang lebih modern dari algoritme penyalinan memungkinkan ruang alamat arbitrer di dalam heap untuk ditetapkan sebagai to-space dan from-space. Dalam kasus ini, mereka tidak harus saling bertukar lokasi; sebaliknya, masing-masing menjadi ruang alamat lain di dalam heap.

Satu keuntungan dari menyalin kolektor adalah bahwa objek-objek dialokasikan bersama-sama secara erat di ruang-ke, menghilangkan fragmentasi sepenuhnya. Fragmentasi adalah masalah umum yang dihadapi algoritma pengumpulan sampah lainnya; sesuatu yang akan saya bahas nanti di artikel ini.

Kerugian dari menyalin kolektor

Pengumpul salinan biasanya adalah pengumpul stop-the-world , yang berarti bahwa tidak ada pekerjaan aplikasi yang dapat dijalankan selama pengumpulan sampah dalam siklus. Dalam penerapan stop-the-world, semakin besar area yang perlu Anda salin, semakin tinggi dampaknya pada kinerja aplikasi Anda. Ini adalah kerugian untuk aplikasi yang sensitif terhadap waktu respons. Dengan kolektor penyalinan, Anda juga perlu mempertimbangkan skenario terburuk, ketika semuanya hidup di ruang angkasa. Anda selalu harus menyisakan ruang kepala yang cukup untuk memindahkan objek hidup, yang berarti ruang ke harus cukup besar untuk menampung semua yang ada di luar angkasa. Algoritme penyalinan sedikit tidak efisien dalam memori karena kendala ini.

Kolektor mark-and-sweep

Sebagian besar JVM komersial yang digunakan di lingkungan produksi perusahaan menjalankan kolektor mark-and-sweep (atau penandaan), yang tidak memiliki dampak kinerja seperti yang dilakukan oleh kolektor penyalin. Beberapa kolektor penanda yang paling terkenal adalah CMS, G1, GenPar, dan DeterministicGC (lihat Sumberdaya).

Sebuah mark-dan-menyapu kolektor jejak referensi dan tanda setiap objek berhasil ditemukan dengan "hidup" bit. Biasanya bit set sesuai dengan alamat atau dalam beberapa kasus satu set alamat di heap. Bit langsung dapat, misalnya, disimpan sebagai bit di header objek, vektor bit, atau peta bit.

Setelah semuanya ditandai langsung, fase sapuan akan dimulai. Jika kolektor memiliki fase sapuan, ini pada dasarnya mencakup beberapa mekanisme untuk melintasi heap lagi (bukan hanya set langsung tetapi seluruh panjang heap) untuk menemukan semua yang tidak ditandai potongan ruang alamat memori yang berurutan. Memori tanpa tanda bebas dan dapat diklaim kembali. Kolektor kemudian menautkan potongan yang tidak ditandai ini ke dalam daftar gratis yang terorganisir. Ada berbagai daftar gratis di pengumpul sampah - biasanya diatur menurut ukuran potongan. Beberapa JVM (seperti JRockit Real Time) mengimplementasikan kolektor dengan heuristik yang secara dinamis membuat daftar rentang ukuran berdasarkan data profil aplikasi dan statistik ukuran objek.

Saat fase sapuan selesai, alokasi akan dimulai lagi. Area alokasi baru dialokasikan dari daftar gratis dan potongan memori dapat disesuaikan dengan ukuran objek, rata-rata ukuran objek per ID utas, atau ukuran TLAB yang disesuaikan dengan aplikasi. Menyesuaikan ruang kosong lebih dekat dengan ukuran yang coba dialokasikan oleh aplikasi Anda akan mengoptimalkan memori dan dapat membantu mengurangi fragmentasi.

Lebih lanjut tentang ukuran TLAB

Partisi TLAB dan TLA (Thread Local Allocation Buffer atau Thread Local Area) dibahas dalam pengoptimalan kinerja JVM, Bagian 1.

Kerugian dari kolektor mark-and-sweep

Fase tanda bergantung pada jumlah data langsung di heap Anda, sedangkan fase sapuan bergantung pada ukuran heap. Karena Anda harus menunggu hingga fase mark dan sapuan selesai untuk mendapatkan kembali memori, algoritme ini menyebabkan tantangan waktu jeda untuk tumpukan yang lebih besar dan kumpulan data langsung yang lebih besar.

Salah satu cara Anda dapat membantu aplikasi yang menghabiskan banyak memori adalah dengan menggunakan opsi GC-tuning yang mengakomodasi berbagai skenario dan kebutuhan aplikasi. Penyelarasan dapat, dalam banyak kasus, membantu setidaknya menunda salah satu fase ini agar tidak menjadi risiko bagi aplikasi atau perjanjian tingkat layanan (SLA) Anda. (SLA menentukan bahwa aplikasi akan memenuhi waktu respons aplikasi tertentu - yaitu latensi.) Penyetelan untuk setiap perubahan beban dan modifikasi aplikasi adalah tugas yang berulang, namun, karena penyetelan hanya berlaku untuk beban kerja dan tingkat alokasi tertentu.

Penerapan mark-and-sweep

Setidaknya ada dua pendekatan yang tersedia secara komersial dan terbukti untuk menerapkan pengumpulan tandai dan sapu. Salah satunya adalah pendekatan paralel dan yang lainnya adalah pendekatan bersamaan (atau sebagian besar bersamaan).

Kolektor paralel

Pengumpulan paralel berarti bahwa sumber daya yang ditugaskan untuk proses digunakan secara paralel untuk tujuan pengumpulan sampah. Sebagian besar pengumpul paralel yang diterapkan secara komersial adalah pengumpul stop-the-world monolitik - semua utas aplikasi dihentikan hingga seluruh siklus pengumpulan sampah selesai. Menghentikan semua utas memungkinkan semua sumber daya digunakan secara efisien secara paralel untuk menyelesaikan pengumpulan sampah melalui fase menandai dan menyapu. Ini mengarah pada tingkat efisiensi yang sangat tinggi, biasanya menghasilkan skor tinggi pada tolok ukur throughput seperti SPECjbb. Jika throughput penting untuk aplikasi Anda, pendekatan paralel adalah pilihan yang sangat baik.