Jenis Kompilasi di JVM: Mengekspos Sesi Sihir Hitam

Halo semuanya!

Hari ini, perhatian Anda diundang ke terjemahan artikel, yang menunjukkan contoh opsi kompilasi di JVM. Perhatian khusus diberikan pada kompilasi AOT yang didukung di Java 9 dan di atasnya.

Selamat membaca!

Saya percaya siapa pun yang pernah diprogram di Jawa telah mendengar kompilasi instan (JIT), dan mungkin kompilasi sebelum eksekusi (AOT). Selain itu, tidak perlu menjelaskan apa itu bahasa "ditafsirkan". Artikel ini akan menjelaskan bagaimana semua fitur ini diimplementasikan dalam mesin virtual Java, JVM.

Anda mungkin tahu bahwa ketika memprogram di Jawa, Anda perlu menjalankan kompiler (menggunakan program “javac”) yang mengumpulkan kode sumber Java (file .java) ke dalam bytecode Java (file .class). Bytecode Java adalah bahasa perantara. Ini disebut "perantara" karena tidak dipahami oleh perangkat komputasi nyata (CPU) dan tidak dapat dieksekusi oleh komputer dan, dengan demikian, merupakan bentuk transisi antara kode sumber dan kode mesin "asli" yang dieksekusi dalam prosesor.

Agar bytecode Java melakukan pekerjaan tertentu, ada 3 cara untuk melakukannya:

  1. Langsung jalankan kode perantara. Lebih baik dan lebih benar untuk mengatakan bahwa itu perlu "ditafsirkan". JVM memiliki juru bahasa Java. Seperti yang Anda ketahui, agar JVM berfungsi, Anda harus menjalankan program "java".
  2. Tepat sebelum menjalankan kode perantara, kompilasi ke dalam kode asli dan paksakan CPU untuk mengeksekusi kode asli yang baru dipanggang ini. Dengan demikian, kompilasi terjadi sesaat sebelum eksekusi (Just in Time) dan disebut "dinamis".
  3. 3Hal pertama, bahkan sebelum program diluncurkan, kode perantara diterjemahkan ke dalam bahasa asli dan menjalankannya melalui CPU dari awal hingga akhir. Kompilasi ini dilakukan sebelum eksekusi dan disebut AoT (Ahead of Time).

Jadi, (1) adalah karya penerjemah, (2) adalah hasil dari kompilasi JIT, dan (3) adalah hasil dari kompilasi AOT.

Demi kelengkapan, saya akan menyebutkan bahwa ada pendekatan keempat - untuk langsung menafsirkan kode sumber, tetapi di Jawa ini tidak diterima. Ini dilakukan, misalnya, dengan Python.
Sekarang mari kita lihat bagaimana "java" bekerja sebagai (1) juru bahasa dari (2) kompiler JIT dan / atau (3) kompiler AOT - dan kapan.

Singkatnya - sebagai aturan, "java" melakukan keduanya (1) dan (2). Dimulai dengan Java 9, opsi ketiga juga dimungkinkan.

Inilah kelas Test kami, yang akan digunakan dalam contoh di masa mendatang.

 public class Test { public int f() throws Exception { int a = 5; return a; } public static void main(String[] args) throws Exception { for (int i = 1; i <= 10; i++) { System.out.println("call " + Integer.valueOf(i)); long a = System.nanoTime(); new Test().f(); long b = System.nanoTime(); System.out.println("elapsed= " + (ba)); } } } 

Seperti yang Anda lihat, ada metode main yang instantiate objek Test dan secara otomatis memanggil fungsi f 10 kali berturut-turut. Fungsi f hampir tidak menghasilkan apa-apa.

Jadi, jika Anda mengkompilasi dan menjalankan kode di atas, output akan sangat diharapkan (tentu saja, nilai waktu yang telah berlalu akan berbeda untuk Anda):

 call 1 elapsed= 5373 call 2 elapsed= 913 call 3 elapsed= 654 call 4 elapsed= 623 call 5 elapsed= 680 call 6 elapsed= 710 call 7 elapsed= 728 call 8 elapsed= 699 call 9 elapsed= 853 call 10 elapsed= 645 

Dan sekarang pertanyaannya adalah: apakah kesimpulan ini hasil dari pekerjaan "java" sebagai penerjemah, yaitu, opsi (1), "java" sebagai kompiler JIT, yaitu, opsi (2) atau entah bagaimana itu terkait dengan kompilasi AOT , yaitu opsi (3)? Dalam artikel ini saya akan menemukan jawaban yang tepat untuk semua pertanyaan ini.

Jawaban pertama yang ingin saya berikan adalah kemungkinan besar bahwa hanya (1) yang terjadi di sini. Saya katakan "kemungkinan besar", karena saya tidak tahu apakah ada variabel lingkungan yang ditetapkan di sini yang akan mengubah opsi JVM default. Jika tidak ada yang berlebihan diinstal, dan ini adalah bagaimana "java" bekerja secara default, maka di sini kita 100% mengamati opsi yang adil (1), yaitu, kode sepenuhnya ditafsirkan. Saya yakin akan hal ini, karena:

  • Menurut dokumentasi java, opsi -XX:CompileThreshold=invocations dimulai dengan invocations=1500 default invocations=1500 pada klien JVM (lebih lanjut tentang klien JVM dijelaskan di bawah). Karena saya menjalankannya hanya 10 kali, dan 10 <1500, kita tidak berbicara tentang kompilasi dinamis di sini. Biasanya, opsi baris perintah ini menentukan berapa kali (maksimum) fungsi harus ditafsirkan sebelum langkah kompilasi dinamis dimulai. Saya akan membahas ini di bawah.
  • Sebenarnya, saya menjalankan kode ini dengan tanda diagnostik, jadi saya tahu jika kode itu dikompilasi secara dinamis. Saya juga akan menjelaskan poin ini di bawah ini.

Harap dicatat: JVM dapat bekerja dalam mode klien atau server, dan opsi yang ditetapkan secara default dalam kasus pertama dan kedua akan berbeda. Sebagai aturan, keputusan tentang mode startup dibuat secara otomatis, tergantung pada lingkungan atau komputer tempat JVM diluncurkan. Selanjutnya, saya akan menentukan opsi –client selama semua dimulai, agar tidak meragukan bahwa program sedang berjalan dalam mode klien. Opsi ini tidak akan memengaruhi aspek yang ingin saya tunjukkan di pos ini.

Jika Anda menjalankan "java" dengan opsi -XX:PrintCompilation , program akan mencetak baris ketika fungsi dikompilasi secara dinamis. Jangan lupa bahwa kompilasi JIT dilakukan untuk setiap fungsi secara terpisah, beberapa fungsi di kelas mungkin tetap dalam bytecode (yaitu, tidak dikompilasi), sementara yang lain mungkin sudah melewati kompilasi JIT, yaitu, siap untuk dieksekusi langsung di prosesor .

Di bawah ini saya juga menambahkan opsi -Xbatch . Opsi -Xbatch diperlukan hanya untuk membuat output terlihat lebih rapi; jika tidak, kompilasi JIT berlangsung secara kompetitif (bersama dengan interpretasi), dan output setelah kompilasi terkadang terlihat aneh saat runtime (karena -XX:PrintCompilation ). Namun, opsi –Xbatch menonaktifkan kompilasi latar belakang, oleh karena itu, sebelum menjalankan kompilasi JIT, eksekusi program kami akan dihentikan.

(Demi keterbacaan, saya akan menulis setiap opsi dari baris baru)

 $ java -client -Xbatch -XX:+PrintCompilation Test 

Saya tidak akan menyisipkan output dari perintah ini di sini, karena secara default JVM mengkompilasi banyak fungsi internal (terkait, misalnya, untuk paket java, sun, jdk), sehingga output akan sangat panjang - jadi, di layar saya, ada 274 baris pada fungsi internal , dan beberapa lagi - hingga akhir program). Untuk mempermudah penelitian ini, saya akan membatalkan kompilasi JIT untuk kelas dalam atau secara selektif mengaktifkannya hanya untuk metode saya ( Test.f ). Untuk melakukan ini, tentukan satu opsi lagi, -XX:CompileCommand . Anda dapat menentukan banyak perintah (kompilasi), sehingga akan lebih mudah untuk menempatkannya dalam file terpisah. Untungnya, kami memiliki opsi -XX:CompileCommandFile . Jadi, beralihlah ke membuat file. Saya akan menyebutnya hotspot_compiler karena alasan yang akan saya jelaskan segera dan tulis yang berikut ini:

 quiet exclude java/* * exclude jdk/* * exclude sun/* * 

Dalam hal ini, harus sepenuhnya jelas bahwa kita mengecualikan semua fungsi (* terakhir) di semua kelas dari semua paket yang dimulai dengan java, jdk dan sun (nama paket dipisahkan oleh /, dan Anda dapat menggunakan *). Perintah quiet memberitahu JVM untuk tidak menulis apa pun tentang kelas yang dikecualikan, jadi hanya mereka yang sekarang dikompilasi akan output ke konsol. Jadi, saya jalankan:

 java -client -Xbatch -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler Test 

Sebelum memberitahu Anda tentang output dari perintah ini, saya mengingatkan Anda bahwa saya menamai file ini hotspot_compiler , karena tampaknya (saya tidak memeriksa) bahwa dalam Oracle JDK nama .hotspot_compiler diatur secara default untuk file dengan perintah kompiler.

Jadi kesimpulannya adalah:

 many lines like this 111 1 n 0 java.lang.invoke.MethodHandle::linkToStatic(LLLLLL)L (native) (static) call 1 some more lines like this 161 48 n 0 java.lang.invoke.MethodHandle::linkToStatic(ILIJL)I (native) (static) elapsed= 7558 call 2 elapsed= 1532 call 3 elapsed= 920 call 4 elapsed= 732 call 5 elapsed= 774 call 6 elapsed= 815 call 7 elapsed= 767 call 8 elapsed= 765 call 9 elapsed= 757 call 10 elapsed= 868 

Pertama, saya tidak tahu mengapa beberapa metode java.lang.invoke.MethodHandler. masih dikompilasi java.lang.invoke.MethodHandler. Mungkin, beberapa hal tidak bisa dimatikan. Karena saya mengerti apa masalahnya, saya akan memperbarui pos ini. Namun, seperti yang Anda lihat, semua langkah kompilasi lainnya (sebelumnya ada 274 baris) kini telah menghilang. Dalam contoh lebih lanjut, saya juga akan menghapus java.lang.invoke.MethodHandler dari output log kompilasi.

Mari kita lihat apa yang telah kita lakukan. Sekarang kita memiliki kode sederhana di mana kita menjalankan fungsi kita 10 kali. Saya sebutkan sebelumnya bahwa fungsi ini ditafsirkan, tidak dikompilasi, seperti yang ditunjukkan dalam dokumentasi, dan sekarang kita melihatnya di log (pada saat yang sama, kita tidak melihatnya dalam kompilasi log, dan ini berarti bahwa itu tidak dikompilasi dengan JIT). Nah, Anda baru saja melihat alat "java" dalam aksi, menafsirkan dan hanya menafsirkan fungsi kami dalam 100% kasus. Jadi, kita dapat mencentang kotak yang menemukan dengan opsi (1). Kami lolos ke (2), kompilasi dinamis.

Menurut dokumentasi, Anda dapat menjalankan fungsi 1.500 kali dan memastikan bahwa kompilasi JIT benar-benar terjadi. Namun, Anda juga dapat menggunakan -XX:CompileThreshold=invocations panggilan -XX:CompileThreshold=invocations , mengatur nilai yang diinginkan alih-alih 1500. Mari kita tunjukkan di sini 5. Ini berarti bahwa kita mengharapkan yang berikut: setelah 5 "interpretasi" fungsi kita f, JVM harus mengkompilasi metode, dan kemudian menjalankan versi yang dikompilasi.
java -client -Xbatch

 -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler -XX:CompileThreshold=5 Test 

Jika Anda menjalankan perintah ini, Anda mungkin telah memperhatikan bahwa tidak ada yang berubah dibandingkan dengan contoh di atas. Artinya, kompilasi masih belum terjadi. Ternyata, menurut dokumentasi, -XX:CompileThreshold hanya berfungsi ketika TieredCompilation dinonaktifkan, yang merupakan default. -XX:-TieredCompilation seperti ini: -XX:-TieredCompilation . Tiered Compilation adalah fitur yang diperkenalkan di Java 7 untuk meningkatkan kecepatan peluncuran dan daya jelajah JVM. Dalam konteks posting ini, itu tidak penting, jadi silakan menonaktifkannya. Sekarang mari kita jalankan perintah ini lagi:

 java -client -Xbatch -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler -XX:CompileThreshold=5 -XX:-TieredCompilation Test 

Ini adalah output (saya ingat, saya telah melewatkan baris tentang java.lang.invoke.MethodHandle ):

 call 1 elapsed= 9411 call 2 elapsed= 1291 call 3 elapsed= 862 call 4 elapsed= 1023 call 5 227 56 b Test::<init> (5 bytes) 228 57 b Test::f (4 bytes) elapsed= 1051739 call 6 elapsed= 18516 call 7 elapsed= 940 call 8 elapsed= 769 call 9 elapsed= 855 call 10 elapsed= 838 

Kami menyambut (halo!) Fungsi yang dikompilasi secara dinamis Test.f atau Test::<init> segera setelah memanggil nomor 5, karena saya mengatur CompileThreshold ke 5. JVM menginterpretasikan fungsi 5 kali, kemudian mengkompilasinya dan akhirnya menjalankan versi yang dikompilasi. Karena fungsi ini dikompilasi, ia harus berjalan lebih cepat, tetapi kami tidak dapat memverifikasi ini di sini, karena fungsi ini tidak melakukan apa pun. Saya pikir ini adalah topik yang bagus untuk pos terpisah.

Seperti yang mungkin sudah Anda tebak, fungsi lain dikompilasi di sini, yaitu Test::<init> , yang merupakan konstruktor dari kelas Test . Karena kode memanggil constructor ( Test() baru Test() ), setiap kali f dipanggil, ia mengkompilasi secara bersamaan dengan fungsi f , tepat setelah 5 panggilan.

Pada prinsipnya, ini dapat mengakhiri diskusi opsi (2), kompilasi JIT. Seperti yang Anda lihat, dalam kasus ini, fungsi pertama kali ditafsirkan oleh JVM, kemudian secara dinamis dikompilasi setelah interpretasi lima kali lipat. Saya ingin menambahkan detail terakhir tentang kompilasi JIT, yaitu, untuk menyebutkan opsi -XX:+PrintAssembly . Seperti namanya, itu output ke konsol versi terkompilasi dari fungsi (versi dikompilasi = kode mesin asli = kode assembler). Namun, ini hanya akan berfungsi jika ada disassembler di jalur perpustakaan. Saya kira disassembler mungkin berbeda di JVM yang berbeda, tetapi dalam kasus ini kita berurusan dengan hsdis - disassembler untuk openjdk. Kode sumber perpustakaan hsdis atau file binernya dapat diambil di tempat yang berbeda. Dalam hal ini, saya mengkompilasi file ini dan meletakkan hsdis-amd64.so di JAVA_HOME/lib/server .

Jadi sekarang kita bisa menjalankan perintah ini. Tetapi pertama-tama saya harus menambahkan itu untuk menjalankan -XX:+PrintAssembly juga perlu menambahkan opsi -XX:+UnlockDiagnosticVMOptions , dan harus mengikuti sebelum opsi PrintAssembly . Jika ini tidak dilakukan, maka JVM akan memberi Anda peringatan tentang penggunaan opsi PrintAssembly . Mari kita jalankan kode ini:

 java -client -Xbatch -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler -XX:CompileThreshold=5 -XX:-TieredCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly Test 

Outputnya akan panjang, dan akan ada baris seperti:

 0x00007f4b7cab1120: mov 0x8(%rsi),%r10d 0x00007f4b7cab1124: shl $0x3,%r10 0x00007f4b7cab1128: cmp %r10,%rax 

Seperti yang Anda lihat, fungsi yang sesuai dikompilasi ke dalam kode mesin asli.

Akhirnya, diskusikan opsi 3, AOT. Kompilasi sebelum eksekusi, AOT, tidak tersedia di Java sebelum versi 9.

Alat baru telah muncul di JDK 9, jaotc - seperti namanya, itu adalah kompiler AOT untuk Java. Idenya adalah ini: jalankan Java "javac" compiler, kemudian compiler AOT untuk Java "jaotc", dan kemudian jalankan JVM "java" seperti biasa. JVM biasanya melakukan interpretasi dan kompilasi JIT. Namun, jika fungsi memiliki kode yang dikompilasi AOT, ia langsung menggunakannya, dan tidak menggunakan interpretasi atau kompilasi JIT. Biarkan saya jelaskan: Anda tidak harus menjalankan kompiler AOT, itu adalah opsional, dan jika Anda menggunakannya, Anda hanya dapat mengkompilasi kelas yang Anda inginkan sebelum dijalankan.

Mari kita membangun perpustakaan yang terdiri dari versi Test::f dikompilasi AOT. Jangan lupa: untuk melakukannya sendiri, Anda membutuhkan JDK 9 dalam versi 150+.

 jaotc --output=libTest.so Test.class 

Akibatnya, libTest.so dihasilkan, pustaka yang berisi kode fungsi asli yang dikompilasi AOT yang termasuk dalam kelas Test . Anda dapat melihat karakter yang ditentukan di perpustakaan ini:

 nm libTest.so 

Dalam kesimpulan kami, antara lain, akan ada:

 0000000000002120 t Test.f()I 00000000000021a0 t Test.<init>()V 00000000000020a0 t Test.main([Ljava/lang/String;)V 

Jadi, semua fungsi kita, konstruktor, f , metode statis main ada di libTest.so perpustakaan.

Seperti dalam kasus opsi "java" yang sesuai, dalam hal ini opsi dapat disertai dengan file, untuk ini ada opsi -compile-commands ke jaotc. JEP 295 memberikan contoh yang relevan yang tidak akan saya perlihatkan di sini.

Sekarang mari kita jalankan "java" dan lihat apakah metode yang dikompilasi AOT digunakan. Jika Anda menjalankan "java" seperti sebelumnya, maka pustaka AOT tidak akan digunakan, dan ini tidak mengejutkan. Untuk menggunakan fitur baru ini, opsi -XX:AOTLibrary disediakan, yang harus Anda tentukan:

 java -XX:AOTLibrary=./libTest.so Test 

Anda dapat menentukan beberapa pustaka AOT, dipisahkan dengan koma.

Output dari perintah ini persis sama dengan ketika memulai "java" tanpa AOTLibrary , karena perilaku program Test tidak berubah sama sekali. Untuk memeriksa apakah fungsi yang dikompilasi AOT digunakan, Anda dapat menambahkan opsi baru, -XX:+PrintAOT .

 java -XX:AOTLibrary=./libTest.so -XX:+PrintAOT Test 

Sebelum hasil Test Program, perintah ini menunjukkan yang berikut:

  9 1 loaded ./libTest.so aot library 99 1 aot[ 1] Test.main([Ljava/lang/String;)V 99 2 aot[ 1] Test.f()I 99 3 aot[ 1] Test.<init>()V 

Seperti yang direncanakan, pustaka AOT dimuat, dan fungsi yang dikompilasi AOT digunakan.

Jika Anda tertarik, Anda dapat menjalankan perintah berikut dan memeriksa apakah kompilasi JIT terjadi.

 java -client -Xbatch -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler -XX:CompileThreshold=5 -XX:-TieredCompilation -XX:AOTLibrary=./libTest.so -XX:+PrintAOT Test 

Seperti yang diharapkan, kompilasi JIT tidak terjadi, karena metode di kelas Test dikompilasi sebelum eksekusi dan disediakan sebagai perpustakaan.

Sebuah pertanyaan yang mungkin adalah: jika kita memberikan kode fungsi asli, lalu bagaimana JVM menentukan apakah kode asli sudah usang / basi? Sebagai contoh terakhir, mari kita modifikasi fungsi f dan atur menjadi 6.

 public int f() throws Exception { int a = 6; return a; } 

Saya melakukan ini hanya untuk memodifikasi file kelas. Sekarang kita membuat kompilasi javac dan menjalankan perintah yang sama seperti di atas.

 javac Test.java java -client -Xbatch -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler -XX:CompileThreshold=5 -XX:-TieredCompilation -XX:AOTLibrary=./libTest.so -XX:+PrintAOT Test 

Seperti yang Anda lihat, saya tidak menjalankan "jaotc" setelah "javac", jadi kode dari pustaka AOT sekarang sudah tua dan salah, dan fungsi f memiliki a = 5.

Output dari perintah "java" di atas menunjukkan:

 228 56 b Test::<init> (5 bytes) 229 57 b Test::f (5 bytes) 

Ini berarti bahwa fungsi dalam hal ini dikompilasi secara dinamis, sehingga kode yang dihasilkan dari kompilasi AOT tidak digunakan. Jadi, perubahan telah terdeteksi di file kelas. Ketika kompilasi dilakukan menggunakan javac, sidik jarinya dimasukkan ke dalam kelas, dan sidik jari kelas juga disimpan di perpustakaan AOT. Karena sidik jari baru dari kelas berbeda dari yang disimpan di perpustakaan AOT, kode asli yang dikompilasi sebelumnya (AOT) tidak digunakan. Itu saja yang ingin saya ceritakan tentang opsi kompilasi terakhir, sebelum eksekusi.

Dalam artikel ini, saya mencoba menjelaskan dan mengilustrasikan dengan contoh-contoh realistis sederhana bagaimana JVM mengeksekusi kode Java: menafsirkannya, mengkompilasi secara dinamis (JIT) atau di muka (AOT) - apalagi, kesempatan terakhir hanya muncul di JDK 9. Saya harap Anda mempelajari sesuatu baru.

Source: https://habr.com/ru/post/id466529/


All Articles