Pengoptimalan kinerja JVM, Bagian 1: Primer teknologi JVM

Aplikasi Java berjalan di JVM, tetapi apa yang Anda ketahui tentang teknologi JVM? Artikel ini, yang pertama dalam rangkaian, adalah ikhtisar tentang cara kerja mesin virtual Java klasik seperti pro dan kontra dari mesin tulis-sekali, run-everywhere Java, dasar-dasar pengumpulan sampah, dan contoh algoritma GC umum dan pengoptimalan compiler . Artikel selanjutnya akan beralih ke pengoptimalan kinerja JVM, termasuk desain JVM yang lebih baru untuk mendukung kinerja dan skalabilitas aplikasi Java yang sangat serentak saat ini.

Jika Anda seorang programmer, maka Anda pasti pernah mengalami perasaan khusus itu ketika cahaya menyala dalam proses berpikir Anda, ketika neuron-neuron itu akhirnya membuat sambungan, dan Anda membuka pola pikir sebelumnya ke perspektif baru. Saya pribadi menyukai perasaan mempelajari sesuatu yang baru. Saya sudah berkali-kali mengalami momen itu dalam pekerjaan saya dengan teknologi mesin virtual Java (JVM), terutama yang berkaitan dengan pengumpulan sampah dan pengoptimalan kinerja JVM. Dalam seri JavaWorld baru ini saya berharap dapat membagikan sebagian iluminasi itu kepada Anda. Mudah-mudahan Anda akan bersemangat mempelajari tentang kinerja JVM seperti yang saya tulis tentangnya!

Seri ini ditulis untuk setiap pengembang Java yang tertarik untuk mempelajari lebih lanjut tentang lapisan yang mendasari JVM dan apa yang sebenarnya dilakukan JVM. Pada tingkat tinggi, saya akan membahas pengumpulan sampah dan pencarian tanpa akhir untuk membebaskan memori dengan aman dan cepat tanpa mempengaruhi aplikasi yang sedang berjalan. Anda akan mempelajari tentang komponen utama JVM: pengumpulan sampah dan algoritme GC, ragam kompiler, dan beberapa pengoptimalan umum. Saya juga akan membahas mengapa pembandingan Java sangat sulit dan menawarkan tip yang perlu dipertimbangkan saat mengukur kinerja. Terakhir, saya akan menyentuh beberapa inovasi baru dalam teknologi JVM dan GC, termasuk sorotan dari Azul's Zing JVM, IBM JVM, dan pengumpul sampah Oracle's Garbage First (G1).

Saya harap Anda akan meninggalkan seri ini dengan pemahaman yang lebih baik tentang faktor-faktor yang membatasi skalabilitas Java saat ini, serta bagaimana batasan tersebut memaksa kami merancang penerapan Java dengan cara yang tidak optimal. Mudah-mudahan, Anda akan mengalami aha! momen dan terinspirasi untuk melakukan sesuatu yang baik untuk Java: berhenti menerima batasan dan bekerja untuk perubahan! Jika Anda belum menjadi kontributor open source, mungkin seri ini akan mendorong Anda ke arah itu.

Optimalisasi kinerja JVM: Baca seri

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

Performa JVM dan tantangan 'satu untuk semua'

Saya punya berita untuk orang-orang yang terjebak dengan gagasan bahwa platform Java pada dasarnya lambat. Keyakinan bahwa JVM yang harus disalahkan atas kinerja Java yang buruk sudah berusia puluhan tahun - ini dimulai saat Java pertama kali digunakan untuk aplikasi perusahaan, dan sudah usang! ini adalahbenar bahwa jika Anda membandingkan hasil dari menjalankan tugas statis dan deterministik sederhana pada platform pengembangan yang berbeda, kemungkinan besar Anda akan melihat eksekusi yang lebih baik menggunakan kode yang dioptimalkan mesin daripada menggunakan lingkungan virtual apa pun, termasuk JVM. Namun kinerja Java telah mengalami lompatan besar selama 10 tahun terakhir. Permintaan pasar dan pertumbuhan industri Java telah menghasilkan beberapa algoritme pengumpulan sampah dan inovasi kompilasi baru, dan banyak heuristik dan pengoptimalan telah muncul seiring dengan kemajuan teknologi JVM. Saya akan memperkenalkan beberapa di antaranya nanti di seri ini.

Keindahan teknologi JVM juga merupakan tantangan terbesarnya: tidak ada yang dapat diasumsikan dengan aplikasi "tulis sekali, jalankan di mana saja". Daripada mengoptimalkan untuk satu kasus penggunaan, satu aplikasi, dan satu pemuatan pengguna tertentu, JVM terus melacak apa yang sedang terjadi di aplikasi Java dan secara dinamis mengoptimalkannya. Runtime dinamis ini mengarah ke kumpulan masalah dinamis. Pengembang yang mengerjakan JVM tidak dapat mengandalkan kompilasi statis dan tingkat alokasi yang dapat diprediksi saat merancang inovasi, setidaknya tidak jika kita menginginkan kinerja dalam lingkungan produksi!

Karir dalam kinerja JVM

Di awal karir saya, saya menyadari bahwa pengumpulan sampah sulit untuk "dipecahkan", dan saya telah terpesona dengan JVM dan teknologi middleware sejak saat itu. Semangat saya untuk JVM dimulai saat saya bekerja di tim JRockit, membuat kode pendekatan baru untuk algoritme pengumpulan sampah belajar mandiri dan otomatis (lihat Sumberdaya). Proyek itu, yang berubah menjadi fitur eksperimental JRockit dan meletakkan dasar bagi algoritme Pengumpulan Sampah Deterministik, memulai perjalanan saya melalui teknologi JVM. Saya telah bekerja untuk BEA Systems, bermitra dengan Intel dan Sun, dan pernah dipekerjakan secara singkat oleh Oracle setelah mengakuisisi BEA Systems. Saya kemudian bergabung dengan tim di Azul Systems untuk mengelola Zing JVM, dan hari ini saya bekerja untuk Cloudera.

Kode yang dioptimalkan oleh mesin mungkin memberikan kinerja yang lebih baik, tetapi hal itu mengakibatkan ketidakfleksibelan, yang bukan merupakan pertukaran yang bisa diterapkan untuk aplikasi perusahaan dengan beban dinamis dan perubahan fitur yang cepat. Sebagian besar perusahaan bersedia mengorbankan kinerja yang nyaris sempurna dari kode yang dioptimalkan mesin untuk manfaat Java:

  • Kemudahan pengkodean dan pengembangan fitur (artinya, waktu lebih cepat ke pasar)
  • Akses ke programmer berpengetahuan
  • Perkembangan pesat menggunakan Java API dan pustaka standar
  • Portabilitas - tidak perlu menulis ulang aplikasi Java untuk setiap platform baru

Dari kode Java ke bytecode

Sebagai seorang programmer Java, Anda mungkin akrab dengan pengkodean, kompilasi, dan menjalankan aplikasi Java. Sebagai contoh, anggaplah Anda memiliki sebuah program, MyApp.javadan Anda ingin menjalankannya. Untuk menjalankan program ini, Anda harus terlebih dahulu mengkompilasinya dengan javac, compiler bahasa-ke-bytecode Java statis bawaan JDK. Berdasarkan kode Java, javacmenghasilkan bytecode dieksekusi sesuai dan menyimpannya ke dalam file kelas yang sama-bernama: MyApp.class. Setelah mengompilasi kode Java menjadi bytecode, Anda siap untuk menjalankan aplikasi Anda dengan meluncurkan file kelas yang dapat dieksekusi dengan javaperintah dari baris perintah atau skrip startup, dengan atau tanpa opsi startup. Kelas dimuat ke dalam runtime (artinya mesin virtual Java yang sedang berjalan) dan program Anda mulai dijalankan.

Itulah yang terjadi di permukaan skenario eksekusi aplikasi sehari-hari, tetapi sekarang mari kita jelajahi apa yang sebenarnya terjadi saat Anda memanggil javaperintah itu. Apa yang disebut mesin virtual Java ? Sebagian besar pengembang telah berinteraksi dengan JVM melalui proses penyetelan berkelanjutan - alias memilih dan menetapkan nilai opsi startup untuk membuat program Java Anda berjalan lebih cepat, sambil dengan cekatan menghindari kesalahan JVM "kehabisan memori" yang terkenal. Tapi pernahkah Anda bertanya-tanya mengapa kita membutuhkan JVM untuk menjalankan aplikasi Java?

Apa itu mesin virtual Java?

Sederhananya, JVM adalah modul perangkat lunak yang menjalankan bytecode aplikasi Java dan menerjemahkan bytecode ke dalam instruksi khusus perangkat keras dan sistem operasi. Dengan demikian, JVM memungkinkan program Java dijalankan di lingkungan yang berbeda dari tempat mereka pertama kali menulis, tanpa memerlukan perubahan apa pun pada kode aplikasi asli. Portabilitas Java adalah kunci popularitasnya sebagai bahasa aplikasi perusahaan: pengembang tidak perlu menulis ulang kode aplikasi untuk setiap platform karena JVM menangani terjemahan dan pengoptimalan platform.

JVM pada dasarnya adalah lingkungan eksekusi virtual yang bertindak sebagai mesin untuk instruksi bytecode, sambil menetapkan tugas eksekusi dan melakukan operasi memori melalui interaksi dengan lapisan yang mendasarinya.

JVM juga menangani manajemen sumber daya dinamis untuk menjalankan aplikasi Java. Artinya, ia menangani pengalokasian dan pengalokasian memori, mempertahankan model thread yang konsisten di setiap platform, dan mengatur instruksi yang dapat dieksekusi dengan cara yang sesuai untuk arsitektur CPU tempat aplikasi dijalankan. JVM membebaskan programmer dari melacak referensi antar objek dan mengetahui berapa lama mereka harus disimpan dalam sistem. Ini juga membebaskan kita dari keharusan untuk memutuskan dengan tepat kapan harus mengeluarkan instruksi eksplisit untuk mengosongkan memori - titik sakit yang diakui dari bahasa pemrograman non-dinamis seperti C.

Anda bisa membayangkan JVM sebagai sistem operasi khusus untuk Java; tugasnya adalah mengelola lingkungan runtime untuk aplikasi Java. JVM pada dasarnya adalah lingkungan eksekusi virtual yang bertindak sebagai mesin untuk instruksi bytecode, sambil menetapkan tugas eksekusi dan melakukan operasi memori melalui interaksi dengan lapisan yang mendasarinya.

Ringkasan komponen JVM

Masih banyak lagi yang perlu ditulis tentang internal JVM dan pengoptimalan kinerja. Sebagai dasar untuk artikel yang akan datang dalam seri ini, saya akan menyimpulkan dengan gambaran umum tentang komponen JVM. Tur singkat ini akan sangat membantu bagi pengembang yang baru mengenal JVM, dan akan menarik minat Anda untuk diskusi yang lebih mendalam nanti dalam seri ini.

Dari satu bahasa ke bahasa lain - tentang kompiler Java

Sebuah compiler mengambil satu bahasa sebagai masukan dan menghasilkan bahasa dieksekusi sebagai output. Kompiler Java memiliki dua tugas utama:

  1. Aktifkan bahasa Java agar lebih portabel, tidak terikat pada platform tertentu saat pertama kali ditulis
  2. Pastikan hasilnya adalah kode eksekusi yang efisien untuk platform eksekusi target yang diinginkan

Kompiler bersifat statis atau dinamis. Contoh kompiler statis adalah javac. Dibutuhkan kode Java sebagai input dan menerjemahkannya menjadi bytecode - bahasa yang dapat dieksekusi oleh mesin virtual Java. Kompiler statis menafsirkan kode masukan satu kali dan keluaran yang dapat dieksekusi dalam bentuk yang akan digunakan saat program dijalankan. Karena inputnya statis, Anda akan selalu melihat hasil yang sama. Hanya ketika Anda membuat perubahan pada sumber asli dan mengkompilasi ulang Anda akan melihat hasil yang berbeda.

Kompiler dinamis , seperti kompiler Just-In-Time (JIT), melakukan terjemahan dari satu bahasa ke bahasa lain secara dinamis, artinya mereka melakukannya saat kode dijalankan. Compiler JIT memungkinkan Anda mengumpulkan atau membuat data profil runtime (dengan cara memasukkan penghitung kinerja) dan membuat keputusan compiler dengan cepat, menggunakan data lingkungan yang ada. Kompilasi dinamis memungkinkan instruksi urutan yang lebih baik dalam bahasa yang dikompilasi, mengganti satu set instruksi dengan set yang lebih efisien, atau bahkan menghilangkan operasi yang berlebihan. Seiring waktu, Anda dapat mengumpulkan lebih banyak data pembuatan profil kode dan membuat keputusan kompilasi tambahan yang lebih baik; semuanya ini biasanya disebut sebagai pengoptimalan dan kompilasi ulang kode.

Kompilasi dinamis memberi Anda keuntungan karena dapat beradaptasi dengan perubahan dinamis dalam perilaku atau pemuatan aplikasi dari waktu ke waktu yang mendorong kebutuhan akan pengoptimalan baru. Inilah sebabnya mengapa kompiler dinamis sangat cocok untuk runtime Java. Masalahnya adalah compiler dinamis dapat memerlukan struktur data tambahan, resource thread, dan siklus CPU untuk pembuatan profil dan pengoptimalan. Untuk pengoptimalan yang lebih canggih, Anda akan membutuhkan lebih banyak sumber daya. Di sebagian besar lingkungan, bagaimanapun, overhead sangat kecil untuk peningkatan kinerja eksekusi yang diperoleh - kinerja lima atau 10 kali lebih baik daripada apa yang akan Anda dapatkan dari interpretasi murni (artinya, menjalankan bytecode apa adanya, tanpa modifikasi).

Alokasi mengarah ke pengumpulan sampah

Alokasi dilakukan pada basis per-thread di setiap "proses Java dedicated memory address space," juga dikenal sebagai Java heap, atau disingkat heap. Alokasi single-threaded umum di dunia aplikasi sisi klien di Java. Alokasi single-threaded dengan cepat menjadi tidak optimal di aplikasi perusahaan dan sisi penyajian beban kerja, karena tidak memanfaatkan paralelisme di lingkungan multicore modern.

Desain aplikasi Parallell juga memaksa JVM untuk memastikan bahwa beberapa utas tidak mengalokasikan ruang alamat yang sama pada waktu yang sama. Anda dapat mengontrol ini dengan mengunci seluruh ruang alokasi. Namun teknik ini (yang disebut kunci heap ) menimbulkan biaya, karena thread yang menahan atau mengantri dapat menyebabkan penurunan performa pada penggunaan resource dan performa aplikasi. Sisi positif dari sistem multicore adalah bahwa mereka telah menciptakan permintaan untuk berbagai pendekatan baru untuk alokasi sumber daya untuk mencegah bottlenecking alokasi berseri benang tunggal.

Pendekatan yang umum adalah dengan membagi heap menjadi beberapa partisi, di mana setiap partisi memiliki "ukuran yang layak" untuk aplikasi - jelas sesuatu yang perlu disetel, karena tingkat alokasi dan ukuran objek bervariasi secara signifikan untuk aplikasi yang berbeda, serta oleh jumlah utas. A Thread Local Allocation Buffer (TLAB), atau terkadang Thread Local Area(TLA), adalah partisi khusus yang dialokasikan oleh thread secara bebas di dalamnya, tanpa harus mengklaim kunci heap penuh. Setelah area tersebut penuh, utas diberi area baru sampai heap kehabisan area untuk didedikasikan. Jika tidak ada cukup ruang tersisa untuk mengalokasikan heap "penuh", artinya ruang kosong di heap tidak cukup besar untuk objek yang perlu dialokasikan. Saat heap sudah penuh, pengumpulan sampah dimulai.

Fragmentasi

Masalah dengan penggunaan TLAB adalah risiko menyebabkan inefisiensi memori dengan memecah tumpukan. Jika aplikasi kebetulan mengalokasikan ukuran objek yang tidak menambah atau mengalokasikan penuh ukuran TLAB, ada risiko bahwa ruang kosong kecil yang terlalu kecil untuk menampung objek baru akan ditinggalkan. Ruang sisa ini disebut sebagai "fragmen". Jika aplikasi juga menyimpan referensi ke objek yang dialokasikan di sebelah ruang sisa ini, maka ruang tersebut dapat tetap tidak digunakan untuk waktu yang lama.