Aplikasi Java berjalan di JVM, tetapi tahukah Anda tentang teknologi JVM? Artikel ini (bagian pertama dari seri ini) menjelaskan cara kerja mesin virtual Java klasik, seperti: pro dan kontra Java write-once, mesin lintas platform, dasar-dasar pengumpulan sampah, algoritma GC klasik, dan optimasi kompilasi. Artikel selanjutnya akan membahas tentang optimalisasi kinerja JVM, termasuk desain JVM terbaru - mendukung kinerja dan skalabilitas aplikasi Java yang sangat bersamaan saat ini.
Jika Anda seorang developer, Anda pasti pernah mengalami perasaan istimewa ini, tiba-tiba Anda mendapat kilasan inspirasi, semua ide Anda terhubung, dan Anda dapat mengingat kembali ide-ide Anda sebelumnya dari sudut pandang baru. Saya pribadi menyukai perasaan mempelajari pengetahuan baru. Saya mengalami pengalaman ini berkali-kali saat bekerja dengan teknologi JVM, terutama dengan pengumpulan sampah dan optimalisasi kinerja JVM. Di dunia baru Java ini, saya berharap dapat berbagi inspirasi ini dengan Anda. Saya harap Anda bersemangat mempelajari kinerja JVM saat saya menulis artikel ini.
Rangkaian artikel ini ditulis untuk semua pengembang Java yang tertarik untuk mempelajari lebih lanjut tentang pengetahuan dasar JVM dan apa yang sebenarnya dilakukan JVM. Pada tingkat tinggi, saya akan membahas pengumpulan sampah dan upaya tanpa henti untuk mendapatkan keamanan dan kecepatan memori bebas tanpa memengaruhi pengoperasian aplikasi. Anda akan mempelajari bagian-bagian penting dari JVM: pengumpulan sampah dan algoritma GC, optimasi kompilasi, dan beberapa optimasi yang umum digunakan. Saya juga akan membahas mengapa markup Java sangat sulit dan memberikan saran kapan Anda harus mempertimbangkan pengujian kinerja. Terakhir, saya akan membahas beberapa inovasi baru dalam JVM dan GC, termasuk Zing JVM dari Azul, IBM JVM, dan fokus pengumpulan sampah Oracle's Garbage First (G1).
Saya harap Anda menyelesaikan membaca seri ini dengan pemahaman yang lebih mendalam tentang sifat kendala skalabilitas Java dan bagaimana kendala ini memaksa kita untuk membuat penerapan Java dengan cara yang optimal. Mudah-mudahan Anda mendapatkan pencerahan dan inspirasi Java yang baik: berhentilah menerima keterbatasan itu dan ubahlah! Jika Anda belum menjadi pekerja open source, seri ini mungkin mendorong Anda untuk mengembangkan bidang ini.
Kinerja JVM dan tantangan “kompilasi sekali, jalankan di mana saja”.
Saya punya berita baru bagi mereka yang keras kepala dan percaya bahwa platform Java pada dasarnya lambat. Ketika Java pertama kali menjadi aplikasi tingkat perusahaan, masalah kinerja Java yang dikritik oleh JVM sudah terjadi lebih dari sepuluh tahun yang lalu, namun kesimpulan ini sekarang sudah ketinggalan jaman. Memang benar bahwa jika Anda menjalankan tugas statis dan deterministik sederhana pada platform pengembangan yang berbeda saat ini, kemungkinan besar Anda akan menemukan bahwa menggunakan kode yang dioptimalkan mesin akan bekerja lebih baik daripada menggunakan lingkungan virtual apa pun, di bawah JVM yang sama. Namun, kinerja Java telah meningkat pesat dalam 10 tahun terakhir. Permintaan pasar dan pertumbuhan industri Java telah menghasilkan beberapa algoritma pengumpulan sampah, inovasi kompilasi baru, dan sejumlah heuristik dan optimasi yang memiliki teknologi JVM yang canggih. Saya akan membahas beberapa di antaranya di bab mendatang.
Keindahan teknis JVM juga merupakan tantangan terbesarnya: tidak ada yang dapat dianggap sebagai aplikasi "kompilasi sekali, jalankan di mana saja". Daripada mengoptimalkan satu kasus penggunaan, satu aplikasi, atau satu beban pengguna tertentu, JVM terus melacak apa yang sedang dilakukan aplikasi Java dan mengoptimalkannya. Operasi dinamis ini menimbulkan serangkaian masalah dinamis. Pengembang yang bekerja pada JVM tidak bergantung pada kompilasi statis dan tingkat alokasi yang dapat diprediksi ketika merancang inovasi (setidaknya tidak ketika kita menuntut kinerja dalam lingkungan produksi).
Penyebab kinerja JVM
Pada awal pekerjaan saya, saya menyadari bahwa pengumpulan sampah sangat sulit untuk "diselesaikan", dan saya selalu terpesona oleh JVM dan teknologi middleware. Kecintaan saya terhadap JVM dimulai ketika saya berada di tim JRockit, membuat kode cara baru untuk belajar sendiri dan men-debug algoritma pengumpulan sampah sendiri (lihat Sumberdaya). Proyek ini (yang berubah menjadi fitur eksperimental JRockit dan menjadi dasar algoritma Pengumpulan Sampah Deterministik) memulai perjalanan saya ke dalam teknologi JVM. Saya pernah bekerja di BEA Systems, Intel, Sun, dan Oracle (karena Oracle mengakuisisi BEA Systems, saya bekerja sebentar di Oracle). Lalu saya bergabung dengan tim di Azul Systems untuk mengelola Zing JVM, dan sekarang saya bekerja untuk Cloudera.
Kode yang dioptimalkan mesin mungkin mencapai kinerja yang lebih baik (tetapi dengan mengorbankan fleksibilitas), namun ini bukan alasan untuk mempertimbangkannya untuk aplikasi perusahaan dengan pemuatan dinamis dan fungsionalitas yang berubah dengan cepat. Demi keunggulan Java, sebagian besar perusahaan lebih rela mengorbankan kinerja yang nyaris sempurna yang dihasilkan oleh kode yang dioptimalkan mesin.
1. Pengembangan kode dan fungsi yang mudah (berarti waktu yang lebih singkat untuk merespons pasar)
2. Dapatkan programmer yang berpengetahuan luas
3. Gunakan Java API dan perpustakaan standar untuk pengembangan yang lebih cepat
4. Portabilitas - tidak perlu menulis ulang aplikasi Java untuk platform baru
Dari kode Java hingga bytecode
Sebagai seorang programmer Java, Anda mungkin familiar dengan coding, kompilasi, dan mengeksekusi aplikasi Java. Contoh: Anggaplah Anda memiliki sebuah program (MyApp.java) dan sekarang Anda ingin menjalankannya. Untuk menjalankan program ini, Anda harus mengkompilasinya terlebih dahulu dengan javac (bahasa Java statis untuk kompiler bytecode yang ada di dalam JDK). Berdasarkan kode Java, javac menghasilkan bytecode yang dapat dieksekusi dan menyimpannya di file kelas dengan nama yang sama: MyApp.class. Setelah mengkompilasi kode Java menjadi bytecode, Anda dapat memulai file kelas yang dapat dieksekusi melalui perintah java (melalui baris perintah atau skrip startup, tanpa menggunakan opsi startup) untuk menjalankan aplikasi Anda. Dengan cara ini, kelas Anda dimuat ke dalam runtime (artinya berjalannya mesin virtual Java), dan program mulai dijalankan.
Ini adalah apa yang dijalankan setiap aplikasi di permukaan, tapi sekarang mari kita jelajahi apa sebenarnya yang terjadi ketika Anda menjalankan perintah java. Apa itu mesin virtual Java? Sebagian besar pengembang berinteraksi dengan JVM melalui debugging berkelanjutan - alias memilih dan menetapkan opsi startup untuk membuat program Java Anda berjalan lebih cepat sambil menghindari kesalahan "kehabisan memori" yang terkenal. Namun pernahkah Anda bertanya-tanya mengapa kita memerlukan JVM untuk menjalankan aplikasi Java?
Apa itu mesin virtual Java?
Sederhananya, JVM adalah modul perangkat lunak yang mengeksekusi bytecode aplikasi Java dan mengubah bytecode menjadi instruksi khusus perangkat keras dan sistem operasi. Dengan melakukan ini, JVM memungkinkan program Java dieksekusi di lingkungan yang berbeda setelah pertama kali ditulis, tanpa memerlukan perubahan pada kode aslinya. Portabilitas Java adalah kunci bahasa aplikasi perusahaan: pengembang tidak perlu menulis ulang kode aplikasi untuk platform yang berbeda karena JVM menangani penerjemahan dan optimalisasi platform.
JVM pada dasarnya adalah lingkungan eksekusi virtual yang bertindak sebagai mesin instruksi bytecode dan digunakan untuk mengalokasikan tugas eksekusi dan melakukan operasi memori dengan berinteraksi dengan lapisan yang mendasarinya.
JVM juga menangani manajemen sumber daya dinamis untuk menjalankan aplikasi Java. Artinya, ia menguasai pengalokasian dan pelepasan memori, memelihara model threading yang konsisten pada setiap platform, dan mengatur instruksi yang dapat dieksekusi di mana aplikasi dijalankan dengan cara yang sesuai untuk arsitektur CPU. JVM membebaskan pengembang dari melacak referensi ke objek dan berapa lama mereka harus ada dalam sistem. Demikian pula, kita tidak perlu mengatur kapan harus melepaskan memori - masalah yang menyulitkan dalam bahasa non-dinamis seperti C.
Anda dapat menganggap JVM sebagai sistem operasi yang dirancang khusus untuk menjalankan Java; tugasnya adalah mengelola lingkungan yang berjalan untuk aplikasi Java. JVM pada dasarnya adalah lingkungan eksekusi virtual yang berinteraksi dengan lingkungan yang mendasarinya sebagai mesin instruksi bytecode untuk mengalokasikan tugas eksekusi dan melakukan operasi memori.
Ikhtisar komponen JVM
Ada banyak artikel yang ditulis tentang internal JVM dan optimalisasi kinerja. Sebagai dasar dari seri ini, saya akan merangkum dan meninjau komponen JVM. Ikhtisar singkat ini sangat berguna bagi pengembang yang baru mengenal JVM dan akan membuat Anda ingin mempelajari lebih lanjut tentang diskusi lebih mendalam setelahnya.
Dari Satu Bahasa ke Bahasa Lain - Tentang Java Compiler
Kompiler mengambil satu bahasa sebagai masukan dan kemudian mengeluarkan pernyataan lain yang dapat dieksekusi. Kompiler Java memiliki dua tugas utama:
1. Menjadikan bahasa Java lebih portabel dan tidak perlu lagi terpaku pada platform tertentu saat menulis untuk pertama kali;
2. Pastikan kode eksekusi yang valid dihasilkan untuk platform tertentu.
Kompiler bisa statis atau dinamis. Contoh kompilasi statis adalah javac. Dibutuhkan kode Java sebagai masukan dan mengubahnya menjadi bytecode (bahasa yang dijalankan di mesin virtual Java). Kompiler statis menafsirkan kode masukan satu kali dan mengeluarkan formulir yang dapat dieksekusi, yang akan digunakan saat program dijalankan. Karena masukannya statis, Anda akan selalu melihat hasil yang sama. Hanya jika Anda memodifikasi kode asli dan mengkompilasi ulang Anda akan melihat keluaran yang berbeda.
Kompiler dinamis , seperti kompiler Just-In-Time (JIT), mengonversi satu bahasa ke bahasa lain secara dinamis, yang berarti mereka melakukan ini saat kode sedang dieksekusi. Kompiler JIT memungkinkan Anda mengumpulkan atau membuat analitik runtime (dengan memasukkan jumlah kinerja), menggunakan keputusan kompiler, menggunakan data lingkungan yang ada. Kompiler dinamis dapat mengimplementasikan urutan instruksi yang lebih baik selama proses kompilasi ke dalam suatu bahasa, mengganti serangkaian instruksi dengan yang lebih efisien, dan bahkan menghilangkan operasi yang berlebihan. Seiring waktu, Anda akan mengumpulkan lebih banyak data konfigurasi kode dan membuat keputusan kompilasi yang lebih banyak dan lebih baik; keseluruhan proses inilah yang biasa kami sebut pengoptimalan dan kompilasi ulang kode.
Kompilasi dinamis memberi Anda keuntungan dalam beradaptasi dengan perubahan dinamis berdasarkan perilaku, atau pengoptimalan baru seiring dengan meningkatnya jumlah beban aplikasi. Inilah sebabnya mengapa kompiler dinamis sempurna untuk operasi Java. Perlu dicatat bahwa kompiler dinamis meminta struktur data eksternal, sumber daya thread, analisis dan pengoptimalan siklus CPU. Semakin dalam pengoptimalannya, semakin banyak sumber daya yang Anda perlukan. Namun, di sebagian besar lingkungan, lapisan atas hanya menambah sedikit kinerja - kinerja 5 hingga 10 kali lebih cepat daripada interpretasi murni Anda.
Alokasi menyebabkan pengumpulan sampah
Dialokasikan di setiap thread berdasarkan setiap "proses Java mengalokasikan ruang alamat memori", atau disebut Java heap, atau langsung disebut heap. Di dunia Java, alokasi single-thread adalah hal biasa dalam aplikasi klien. Namun, alokasi single-threaded tidak bermanfaat dalam aplikasi perusahaan dan server beban kerja karena tidak memanfaatkan paralelisme lingkungan multi-core saat ini.
Desain aplikasi paralel juga memaksa JVM untuk memastikan bahwa beberapa thread tidak mengalokasikan ruang alamat yang sama pada waktu yang bersamaan. Anda dapat mengontrolnya dengan mengunci seluruh ruang yang dialokasikan. Namun teknik ini (sering disebut penguncian heap) sangat intensif kinerja, dan menahan atau mengantri thread dapat memengaruhi pemanfaatan sumber daya dan kinerja pengoptimalan aplikasi. Hal yang baik tentang sistem multi-inti adalah sistem ini menciptakan kebutuhan akan berbagai metode baru untuk mencegah kemacetan thread tunggal saat mengalokasikan sumber daya, dan serialisasi.
Pendekatan yang umum adalah dengan membagi heap menjadi beberapa bagian, di mana setiap partisi memiliki ukuran yang masuk akal untuk aplikasi - jelas mereka perlu disesuaikan, tingkat alokasi dan ukuran objek sangat bervariasi antar aplikasi, dan jumlah thread untuk aplikasi yang sama juga berbeda. Thread Local Allocation Buffer (TLAB), atau terkadang Thread Local Area (TLA), adalah partisi khusus di mana thread dapat dengan bebas mengalokasikan tanpa mendeklarasikan kunci heap penuh. Jika area sudah penuh, maka heap sudah penuh, artinya tidak ada cukup ruang kosong di heap untuk menempatkan objek, dan ruang perlu dialokasikan. Ketika tumpukan sudah penuh, pengumpulan sampah akan dimulai.
pecahan
Menggunakan TLAB untuk menangkap pengecualian memecah heap untuk mengurangi efisiensi memori. Jika suatu aplikasi tidak dapat menambah atau mengalokasikan ruang TLAB sepenuhnya saat mengalokasikan objek, terdapat risiko bahwa ruang tersebut akan terlalu kecil untuk menghasilkan objek baru. Ruang kosong seperti itu dianggap "fragmentasi". Jika aplikasi menyimpan referensi ke objek dan kemudian mengalokasikan ruang yang tersisa, pada akhirnya ruang tersebut akan kosong untuk waktu yang lama.
Fragmentasi adalah ketika fragmen tersebar di seluruh heap - membuang-buang ruang heap melalui sebagian kecil ruang memori yang tidak terpakai. Mengalokasikan ruang TLAB yang "salah" untuk aplikasi Anda (berkenaan dengan ukuran objek, ukuran objek campuran, dan rasio penyimpanan referensi) adalah penyebab peningkatan fragmentasi heap. Saat aplikasi berjalan, jumlah fragmen bertambah dan menghabiskan ruang di heap. Fragmentasi menyebabkan penurunan kinerja dan sistem tidak dapat mengalokasikan cukup thread dan objek ke aplikasi baru. Pengumpul sampah kemudian akan mengalami kesulitan mencegah pengecualian kehabisan memori.
Limbah TLAB dihasilkan pada pekerjaan. Salah satu cara untuk menghindari fragmentasi secara keseluruhan atau sementara adalah dengan mengoptimalkan ruang TLAB pada setiap operasi yang mendasarinya. Pendekatan yang umum pada pendekatan ini adalah selama aplikasi memiliki perilaku alokasi, aplikasi tersebut perlu disetel ulang. Hal ini dapat dicapai melalui algoritma JVM yang kompleks. Metode lainnya adalah dengan mengatur partisi heap untuk mencapai alokasi memori yang lebih efisien. Misalnya, JVM dapat mengimplementasikan daftar bebas, yang dihubungkan bersama sebagai daftar blok memori bebas dengan ukuran tertentu. Blok memori bebas yang berdekatan dihubungkan ke blok memori lain yang berdekatan dengan ukuran yang sama, sehingga menciptakan sejumlah kecil daftar tertaut, masing-masing dengan batasannya sendiri. Dalam beberapa kasus, daftar bebas menghasilkan alokasi memori yang lebih baik. Thread dapat mengalokasikan objek dalam blok dengan ukuran yang sama, berpotensi menciptakan lebih sedikit fragmentasi dibandingkan jika Anda hanya mengandalkan TLAB berukuran tetap.
hal-hal sepele GC
Beberapa pemulung awal mempunyai beberapa generasi lama, namun memiliki lebih dari dua generasi lama akan menyebabkan biaya overhead lebih besar daripada nilainya. Cara lain untuk mengoptimalkan alokasi dan mengurangi fragmentasi adalah dengan menciptakan apa yang disebut generasi muda, yaitu ruang heap khusus yang didedikasikan untuk mengalokasikan objek baru. Tumpukan yang tersisa disebut generasi lama. Generasi lama digunakan untuk mengalokasikan benda-benda yang berumur panjang. Benda-benda yang dianggap ada dalam jangka waktu lama antara lain benda-benda yang bukan merupakan sampah yang dikumpulkan atau benda-benda yang berukuran besar. Untuk lebih memahami metode alokasi ini, kita perlu membahas beberapa pengetahuan tentang pengumpulan sampah.
Pengumpulan sampah dan kinerja aplikasi
Pengumpulan sampah adalah pengumpul sampah JVM untuk melepaskan memori tumpukan yang tidak direferensikan. Ketika pengumpulan sampah dipicu untuk pertama kalinya, semua referensi objek masih dipertahankan, dan ruang yang ditempati oleh referensi sebelumnya dilepaskan atau dialokasikan kembali. Setelah semua memori yang dapat diambil kembali telah dikumpulkan, ruang tersebut menunggu untuk diambil dan dialokasikan kembali ke objek baru.
Pengumpul sampah tidak pernah dapat mendeklarasikan ulang objek referensi, hal ini akan melanggar spesifikasi standar JVM. Pengecualian terhadap aturan ini adalah referensi lunak atau lemah yang dapat ditangkap jika pengumpul sampah kehabisan memori. Saya sangat menyarankan agar Anda mencoba menghindari referensi yang lemah, karena ambiguitas spesifikasi Java menyebabkan salah tafsir dan kesalahan penggunaan. Terlebih lagi, Java dirancang untuk manajemen memori dinamis, karena Anda tidak perlu memikirkan kapan dan di mana harus melepaskan memori.
Salah satu tantangan pengumpul sampah adalah mengalokasikan memori dengan cara yang tidak mempengaruhi aplikasi yang sedang berjalan. Jika Anda tidak mengumpulkan sampah sebanyak mungkin, aplikasi Anda akan menghabiskan memori; jika Anda terlalu sering mengumpulkannya, Anda akan kehilangan throughput dan waktu respons, yang akan berdampak buruk pada aplikasi yang sedang berjalan.
Algoritma GC
Ada banyak algoritma pengumpulan sampah yang berbeda. Beberapa poin akan dibahas secara mendalam pada seri ini nanti. Pada tingkat tertinggi, dua metode utama pengumpulan sampah adalah penghitungan referensi dan pelacakan pemulung.
Kolektor penghitungan referensi melacak berapa banyak referensi yang ditunjuk suatu objek. Ketika referensi suatu objek mencapai 0, memori akan segera diambil kembali, yang merupakan salah satu keuntungan dari pendekatan ini. Kesulitan dengan pendekatan penghitungan referensi terletak pada struktur data melingkar dan memperbarui semua referensi secara real time.
Kolektor pelacakan menandai objek yang masih direferensikan, dan menggunakan objek yang ditandai untuk berulang kali mengikuti dan menandai semua objek yang direferensikan. Ketika semua objek yang masih direferensikan ditandai sebagai "hidup", semua ruang yang tidak ditandai akan diambil kembali. Pendekatan ini mengelola struktur data cincin, namun dalam banyak kasus kolektor harus menunggu hingga semua penandaan selesai sebelum mengambil kembali memori yang tidak direferensikan.
Ada berbagai cara untuk melakukan cara di atas. Algoritma yang paling terkenal adalah algoritma penandaan atau penyalinan, algoritma paralel atau bersamaan. Saya akan membahasnya di artikel selanjutnya.
Secara umum, arti dari pengumpulan sampah adalah mengalokasikan ruang alamat ke objek baru dan lama di heap. “Benda-benda tua” adalah benda-benda yang masih bertahan dari banyak pengumpulan sampah. Gunakan generasi baru untuk mengalokasikan objek baru dan generasi lama ke objek lama. Hal ini dapat mengurangi fragmentasi dengan cepat mendaur ulang objek berumur pendek yang menempati memori. Semua ini mengurangi fragmentasi antara objek yang berumur panjang dan menghemat memori heap dari fragmentasi. Efek positif dari generasi baru adalah menunda pengumpulan objek generasi lama yang lebih mahal, dan Anda dapat menggunakan kembali ruang yang sama untuk objek fana. (Koleksi ruang lama akan lebih mahal karena objek berumur panjang akan berisi lebih banyak referensi dan memerlukan lebih banyak traversal.)
Algoritma terakhir yang patut disebutkan adalah pemadatan, yang merupakan metode mengelola fragmentasi memori. Pemadatan pada dasarnya menggerakkan objek bersama-sama untuk melepaskan ruang memori berdekatan yang lebih besar. Jika Anda familiar dengan fragmentasi disk dan alat yang menanganinya, Anda akan menemukan bahwa pemadatan sangat mirip dengannya, hanya saja pemadatan ini berjalan di memori heap Java. Saya akan membahas pemadatan secara rinci nanti di seri ini.
Ringkasan: Ulasan dan Sorotan
JVM memungkinkan portabilitas (memprogram sekali, dijalankan di mana saja) dan manajemen memori dinamis, semua fitur utama platform Java yang berkontribusi terhadap popularitas dan peningkatan produktivitas.
Pada artikel pertama tentang sistem pengoptimalan kinerja JVM, saya menjelaskan bagaimana kompiler mengubah bytecode menjadi bahasa instruksi platform target dan membantu mengoptimalkan eksekusi program Java secara dinamis. Aplikasi yang berbeda memerlukan kompiler yang berbeda.
Saya juga membahas secara singkat alokasi memori dan pengumpulan sampah, dan bagaimana kaitannya dengan kinerja aplikasi Java. Pada dasarnya, semakin cepat Anda mengisi tumpukan dan semakin sering memicu pengumpulan sampah, semakin tinggi tingkat pemanfaatan aplikasi Java Anda. Salah satu tantangan bagi pengumpul sampah adalah mengalokasikan memori dengan cara yang tidak mempengaruhi aplikasi yang sedang berjalan, namun sebelum aplikasi kehabisan memori. Di artikel mendatang kita akan membahas pengumpulan sampah tradisional dan baru serta optimalisasi kinerja JVM secara lebih rinci.