Pengoptimalan kinerja JVM, Bagian 2: Penyusun

Kompiler Java menjadi pusat perhatian dalam artikel kedua ini di seri pengoptimalan kinerja JVM. Eva Andreasson memperkenalkan berbagai jenis kompilator dan membandingkan hasil kinerja dari klien, server, dan kompilasi berjenjang. Dia menyimpulkan dengan ikhtisar pengoptimalan JVM umum seperti penghapusan kode mati, penyebarisan, dan pengoptimalan loop.

Kompiler Java adalah sumber independensi platform Java yang terkenal. Pengembang perangkat lunak menulis aplikasi Java terbaik yang dia bisa, dan kemudian kompilator bekerja di belakang layar untuk menghasilkan kode eksekusi yang efisien dan berkinerja baik untuk platform target yang dimaksudkan. Jenis penyusun yang berbeda memenuhi berbagai kebutuhan aplikasi, sehingga menghasilkan hasil kinerja khusus yang diinginkan. Semakin Anda memahami tentang kompiler, dalam hal cara kerjanya dan jenis apa yang tersedia, semakin Anda dapat mengoptimalkan kinerja aplikasi Java.

Artikel kedua dalam seri pengoptimalan kinerja JVM ini menyoroti dan menjelaskan perbedaan antara berbagai kompiler mesin virtual Java. Saya juga akan membahas beberapa pengoptimalan umum yang digunakan oleh kompiler Just-In-Time (JIT) untuk Java. (Lihat "Pengoptimalan kinerja JVM, Bagian 1" untuk ikhtisar JVM dan pengantar seri.)

Apa itu kompiler?

Secara sederhana, kompiler mengambil bahasa pemrograman sebagai input dan menghasilkan bahasa yang dapat dieksekusi sebagai output. Salah satu kompiler yang umum dikenal adalah javac, yang disertakan dalam semua kit pengembangan Java standar (JDK). javacmengambil kode Java sebagai input dan menerjemahkannya menjadi bytecode - bahasa yang dapat dieksekusi untuk JVM. Bytecode disimpan ke dalam file .class yang dimuat ke dalam runtime Java saat proses Java dimulai.

Bytecode tidak dapat dibaca oleh CPU standar dan perlu diterjemahkan ke dalam bahasa instruksi yang dapat dipahami oleh platform eksekusi yang mendasarinya. Komponen di JVM yang bertanggung jawab untuk menerjemahkan bytecode ke instruksi platform yang dapat dieksekusi adalah kompiler lain. Beberapa kompiler JVM menangani beberapa level terjemahan; misalnya, kompilator dapat membuat berbagai tingkat representasi perantara dari bytecode sebelum berubah menjadi instruksi mesin yang sebenarnya, langkah terakhir terjemahan.

Bytecode dan JVM

Jika Anda ingin mempelajari lebih lanjut tentang bytecode dan JVM, lihat "Dasar-dasar Bytecode" (Bill Venners, JavaWorld).

Dari perspektif platform-agnostik kami ingin menjaga kode platform-independen sejauh mungkin, sehingga tingkat terjemahan terakhir - dari representasi terendah ke kode mesin sebenarnya - adalah langkah yang mengunci eksekusi ke arsitektur prosesor platform tertentu . Tingkat pemisahan tertinggi adalah antara kompiler statis dan dinamis. Dari sana, kami memiliki opsi bergantung pada lingkungan eksekusi apa yang kami targetkan, hasil kinerja apa yang kami inginkan, dan batasan sumber daya apa yang perlu kami penuhi. Saya secara singkat membahas kompiler statis dan dinamis di Bagian 1 dari seri ini. Di bagian berikut, saya akan menjelaskan lebih banyak.

Kompilasi statis vs dinamis

Contoh kompilator statis adalah yang disebutkan sebelumnya javac. Dengan kompiler statis, kode input diinterpretasikan satu kali dan output yang dapat dieksekusi dalam bentuk yang akan digunakan ketika program dijalankan. Kecuali Anda membuat perubahan pada sumber asli dan mengkompilasi ulang kode (menggunakan kompiler), keluarannya akan selalu menghasilkan hasil yang sama; ini karena inputnya adalah input statis dan kompilernya adalah kompiler statis.

Dalam kompilasi statis, kode Java berikut

static int add7( int x ) { return x+7; }

akan menghasilkan sesuatu yang mirip dengan bytecode ini:

iload0 bipush 7 iadd ireturn

Kompiler dinamis menerjemahkan dari satu bahasa ke bahasa lain secara dinamis, artinya itu terjadi saat kode dijalankan - selama runtime! Kompilasi dan pengoptimalan dinamis memberikan keuntungan pada runtime karena mampu beradaptasi dengan perubahan dalam beban aplikasi. Kompiler dinamis sangat cocok untuk runtime Java, yang biasanya dijalankan di lingkungan yang tidak dapat diprediksi dan selalu berubah. Kebanyakan JVM menggunakan kompilator dinamis seperti kompilator Just-In-Time (JIT). Hasil tangkapannya adalah kompiler dinamis dan pengoptimalan kode terkadang memerlukan struktur data tambahan, utas, dan sumber daya CPU. Semakin canggih pengoptimalan atau analisis konteks bytecode, semakin banyak resource yang digunakan oleh kompilasi. Di sebagian besar lingkungan, overhead masih sangat kecil dibandingkan dengan perolehan kinerja yang signifikan dari kode keluaran.

Varietas JVM dan independensi platform Java

Semua implementasi JVM memiliki satu kesamaan, yaitu upaya mereka untuk menerjemahkan bytecode aplikasi ke dalam instruksi mesin. Beberapa JVM menafsirkan kode aplikasi pada pemuatan dan menggunakan penghitung kinerja untuk fokus pada kode "panas". Beberapa JVM melewatkan interpretasi dan hanya mengandalkan kompilasi. Kompilasi yang intensif sumber daya bisa menjadi hit yang lebih besar (terutama untuk aplikasi sisi klien) tetapi juga memungkinkan pengoptimalan yang lebih canggih. Lihat Sumberdaya untuk informasi lebih lanjut.

Jika Anda seorang pemula di Java, kerumitan JVM akan banyak membungkus kepala Anda. Kabar baiknya adalah Anda tidak perlu melakukannya! JVM mengelola kompilasi dan pengoptimalan kode, jadi Anda tidak perlu khawatir tentang instruksi mesin dan cara optimal menulis kode aplikasi untuk arsitektur platform yang mendasarinya.

Dari bytecode Java hingga eksekusi

Setelah kode Java Anda dikompilasi menjadi bytecode, langkah selanjutnya adalah menerjemahkan instruksi bytecode ke kode mesin. Ini bisa dilakukan oleh juru bahasa atau kompiler.

Penafsiran

Bentuk paling sederhana dari kompilasi bytecode disebut interpretasi. Seorang interpreter hanya mencari instruksi perangkat keras untuk setiap instruksi bytecode dan mengirimkannya untuk dieksekusi oleh CPU.

Anda bisa membayangkan interpretasi yang mirip dengan menggunakan kamus: untuk kata tertentu (instruksi bytecode) ada terjemahan yang tepat (instruksi kode mesin). Karena interpreter membaca dan segera mengeksekusi instruksi bytecode satu per satu, tidak ada kesempatan untuk mengoptimalkan set instruksi. Seorang interpreter juga harus melakukan interpretasi setiap kali bytecode dipanggil, yang membuatnya cukup lambat. Interpretasi adalah cara yang akurat untuk mengeksekusi kode, tetapi set instruksi keluaran yang tidak dioptimalkan kemungkinan besar tidak akan menjadi urutan berkinerja tertinggi untuk prosesor platform target.

Kompilasi

Sebuah compiler pada beban sisi lain seluruh kode yang akan dieksekusi dalam runtime. Saat menerjemahkan bytecode, ia memiliki kemampuan untuk melihat seluruh atau sebagian konteks waktu proses dan membuat keputusan tentang cara benar-benar menerjemahkan kode. Keputusannya didasarkan pada analisis grafik kode seperti cabang pelaksanaan instruksi yang berbeda dan data konteks waktu proses.

Ketika urutan bytecode diterjemahkan ke dalam set instruksi kode mesin dan pengoptimalan dapat dilakukan ke set instruksi ini, set instruksi pengganti (misalnya, urutan yang dioptimalkan) disimpan ke dalam struktur yang disebut cache kode . Kali berikutnya bytecode dijalankan, kode yang sebelumnya dioptimalkan dapat langsung ditempatkan di cache kode dan digunakan untuk eksekusi. Dalam beberapa kasus, penghitung kinerja mungkin memulai dan menimpa pengoptimalan sebelumnya, dalam hal ini kompilator akan menjalankan urutan pengoptimalan baru. Keuntungan dari cache kode adalah bahwa set instruksi yang dihasilkan dapat dieksekusi sekaligus - tidak perlu pencarian interpretatif atau kompilasi! Ini mempercepat waktu eksekusi, terutama untuk aplikasi Java di mana metode yang sama dipanggil beberapa kali.

Optimasi

Seiring dengan kompilasi dinamis datang kesempatan untuk memasukkan penghitung kinerja. Kompilator mungkin, misalnya, memasukkan penghitung kinerjauntuk menghitung setiap kali blok bytecode (misalnya, sesuai dengan metode tertentu) dipanggil. Penyusun menggunakan data tentang seberapa "panas" bytecode yang diberikan untuk menentukan di bagian mana dalam pengoptimalan kode akan berdampak terbaik pada aplikasi yang sedang berjalan. Data pembuatan profil waktu proses memungkinkan compiler untuk membuat serangkaian keputusan pengoptimalan kode dengan cepat, yang selanjutnya meningkatkan performa eksekusi kode. Dengan semakin tersedianya data pembuatan profil kode yang tersaring, data tersebut dapat digunakan untuk membuat keputusan pengoptimalan tambahan dan lebih baik, seperti: cara menyusun instruksi urutan yang lebih baik dalam bahasa yang dikompilasi, apakah akan mengganti sekumpulan instruksi dengan set yang lebih efisien, atau bahkan apakah akan menghilangkan operasi yang berlebihan.

Contoh

Pertimbangkan kode Java:

static int add7( int x ) { return x+7; }

Ini dapat dikompilasi secara statis oleh javacke bytecode:

iload0 bipush 7 iadd ireturn

Ketika metode ini dipanggil, blok bytecode akan dikompilasi secara dinamis ke instruksi mesin. Ketika penghitung kinerja (jika ada untuk blok kode) mencapai ambang batas itu mungkin juga dioptimalkan. Hasil akhirnya bisa terlihat seperti set instruksi mesin berikut untuk platform eksekusi tertentu:

lea rax,[rdx+7] ret

Kompiler berbeda untuk aplikasi berbeda

Aplikasi Java yang berbeda memiliki kebutuhan yang berbeda pula. Aplikasi sisi server perusahaan yang berjalan lama dapat memungkinkan lebih banyak pengoptimalan, sementara aplikasi sisi klien yang lebih kecil mungkin memerlukan eksekusi cepat dengan konsumsi sumber daya minimal. Mari kita pertimbangkan tiga pengaturan kompiler yang berbeda serta pro dan kontra masing-masing.

Kompiler sisi klien

Kompiler pengoptimalan yang terkenal adalah C1, kompiler yang diaktifkan melalui -clientopsi startup JVM. Seperti yang disarankan oleh nama startupnya, C1 adalah kompiler sisi klien. Ini dirancang untuk aplikasi sisi klien yang memiliki lebih sedikit sumber daya dan, dalam banyak kasus, sensitif terhadap waktu pengaktifan aplikasi. C1 menggunakan penghitung kinerja untuk pembuatan profil kode guna mengaktifkan pengoptimalan yang sederhana dan relatif tidak mengganggu.

Kompiler sisi server

Untuk aplikasi yang berjalan lama seperti aplikasi Java perusahaan sisi server, kompiler sisi klien mungkin tidak cukup. Kompiler sisi server seperti C2 dapat digunakan sebagai gantinya. C2 biasanya diaktifkan dengan menambahkan opsi startup JVM -serverke baris perintah startup Anda. Karena sebagian besar program sisi server diharapkan berjalan untuk waktu yang lama, mengaktifkan C2 berarti Anda akan dapat mengumpulkan lebih banyak data profil daripada yang Anda lakukan dengan aplikasi klien ringan yang berjalan singkat. Jadi, Anda akan dapat menerapkan teknik dan algoritme pengoptimalan yang lebih canggih.

Tip: Lakukan pemanasan kompilator sisi server Anda

Untuk penerapan sisi server, mungkin perlu beberapa saat sebelum kompilator mengoptimalkan bagian awal "panas" dari kode, sehingga penerapan sisi server sering kali memerlukan fase "pemanasan". Sebelum melakukan segala jenis pengukuran kinerja pada penerapan sisi server, pastikan bahwa aplikasi Anda telah mencapai kondisi mapan! Memberi waktu yang cukup bagi kompiler untuk mengkompilasi dengan benar akan menguntungkan Anda! (Lihat artikel JavaWorld "Perhatikan kompiler HotSpot Anda" untuk mengetahui lebih lanjut tentang pemanasan kompiler Anda dan mekanisme pembuatan profil.)

Kompilator server memperhitungkan lebih banyak data profil daripada kompilator sisi klien, dan memungkinkan analisis cabang yang lebih kompleks, yang berarti bahwa ia akan mempertimbangkan jalur pengoptimalan mana yang lebih menguntungkan. Memiliki lebih banyak data pembuatan profil yang tersedia menghasilkan hasil aplikasi yang lebih baik. Tentu saja, melakukan pembuatan profil dan analisis yang lebih ekstensif membutuhkan lebih banyak sumber daya pada compiler. JVM dengan C2 diaktifkan akan menggunakan lebih banyak utas dan lebih banyak siklus CPU, memerlukan cache kode yang lebih besar, dan seterusnya.

Kompilasi berjenjang

Kompilasi berjenjangmenggabungkan kompilasi sisi klien dan sisi server. Azul pertama kali membuat kompilasi berjenjang tersedia di Zing JVM-nya. Baru-baru ini (pada Java SE 7) telah diadopsi oleh Oracle Java Hotspot JVM. Kompilasi berjenjang memanfaatkan keuntungan kompilator klien dan server di JVM Anda. Kompiler klien paling aktif selama startup aplikasi dan menangani pengoptimalan yang dipicu oleh ambang penghitung kinerja yang lebih rendah. Kompilator sisi klien juga menyisipkan penghitung kinerja dan menyiapkan set instruksi untuk pengoptimalan yang lebih maju, yang akan ditangani pada tahap selanjutnya oleh kompilator sisi server. Kompilasi berjenjang adalah cara pembuatan profil yang sangat hemat sumber daya karena kompilator dapat mengumpulkan data selama aktivitas kompilator berdampak rendah, yang dapat digunakan untuk pengoptimalan lebih lanjut nanti.Pendekatan ini juga menghasilkan lebih banyak informasi daripada yang akan Anda dapatkan dari menggunakan penghitung profil kode yang ditafsirkan saja.

Skema diagram pada Gambar 1 menggambarkan perbedaan performa antara interpretasi murni, sisi klien, sisi server, dan kompilasi bertingkat. Sumbu X menunjukkan waktu eksekusi (unit waktu) dan kinerja sumbu Y (unit operasi / waktu).

Gambar 1. Perbedaan kinerja antara kompiler (klik untuk memperbesar)

Dibandingkan dengan kode yang murni ditafsirkan, menggunakan kompiler sisi klien menghasilkan kinerja eksekusi sekitar 5 hingga 10 kali lebih baik (dalam operasi / dtk), sehingga meningkatkan kinerja aplikasi. Variasi dalam perolehan tentu saja bergantung pada seberapa efisien kompilator, pengoptimalan apa yang diaktifkan atau diterapkan, dan (pada tingkat yang lebih rendah) seberapa baik desain aplikasi berkaitan dengan platform target eksekusi. Yang terakhir ini benar-benar sesuatu yang pengembang Java tidak perlu khawatirkan.

Dibandingkan dengan kompilator sisi klien, kompilator sisi server biasanya meningkatkan kinerja kode sebesar 30 persen hingga 50 persen. Dalam kebanyakan kasus, peningkatan kinerja akan menyeimbangkan biaya sumber daya tambahan.