Dapatkan Tangan Anda Kotor Dengan Scala JVM Bytecode

Diterbitkan: 2022-03-11

Bahasa Scala terus mendapatkan popularitas selama beberapa tahun terakhir, berkat kombinasi yang sangat baik dari prinsip-prinsip pengembangan perangkat lunak fungsional dan berorientasi objek, dan implementasinya di atas Java Virtual Machine (JVM) yang telah terbukti.

Meskipun Scala mengkompilasi ke bytecode Java, itu dirancang untuk memperbaiki banyak kekurangan yang dirasakan dari bahasa Java. Menawarkan dukungan pemrograman fungsional penuh, sintaks inti Scala berisi banyak struktur implisit yang harus dibangun secara eksplisit oleh programmer Java, beberapa melibatkan kompleksitas yang cukup besar.

Membuat bahasa yang dikompilasi ke bytecode Java membutuhkan pemahaman mendalam tentang cara kerja Java Virtual Machine. Untuk menghargai apa yang telah dicapai oleh pengembang Scala, perlu untuk mempelajarinya, dan mengeksplorasi bagaimana kode sumber Scala ditafsirkan oleh kompiler untuk menghasilkan bytecode JVM yang efisien dan efektif.

Mari kita lihat bagaimana semua hal ini diimplementasikan.

Prasyarat

Membaca artikel ini memerlukan beberapa pemahaman dasar tentang bytecode Java Virtual Machine. Spesifikasi lengkap mesin virtual dapat diperoleh dari dokumentasi resmi Oracle. Membaca keseluruhan spesifikasi tidak penting untuk memahami artikel ini, jadi, untuk pengenalan singkat tentang dasar-dasarnya, saya telah menyiapkan panduan singkat di bagian bawah artikel.

Klik di sini untuk membaca kursus kilat tentang dasar-dasar JVM.

Utilitas diperlukan untuk membongkar bytecode Java untuk mereproduksi contoh yang diberikan di bawah ini, dan untuk melanjutkan penyelidikan lebih lanjut. Java Development Kit menyediakan utilitas baris perintahnya sendiri, javap , yang akan kita gunakan di sini. Demonstrasi cepat tentang cara kerja javap disertakan dalam panduan di bagian bawah.

Dan tentu saja, penginstalan kompiler Scala yang berfungsi diperlukan bagi pembaca yang ingin mengikuti contoh. Artikel ini ditulis menggunakan Scala 2.11.7. Versi Scala yang berbeda dapat menghasilkan bytecode yang sedikit berbeda.

Getter dan Setter Default

Meskipun konvensi Java selalu menyediakan metode pengambil dan penyetel untuk atribut publik, pemrogram Java diharuskan untuk menulis ini sendiri, meskipun fakta bahwa pola untuk masing-masing tidak berubah dalam beberapa dekade. Scala, sebaliknya, menyediakan getter dan setter default.

Mari kita lihat contoh berikut:

 class Person(val name:String) { }

Mari kita lihat di dalam kelas Person . Jika kita mengkompilasi file ini dengan scalac , maka menjalankan $ javap -p Person.class memberi kita:

 Compiled from "Person.scala" public class Person { private final java.lang.String name; // field public java.lang.String name(); // getter method public Person(java.lang.String); // constructor }

Kita dapat melihat bahwa untuk setiap bidang di kelas Scala, bidang dan metode pengambilnya dihasilkan. Bidangnya bersifat pribadi dan final, sedangkan metodenya bersifat publik.

Jika kita mengganti val dengan var di sumber Person dan mengkompilasi ulang, maka pengubah final bidang akan dihapus, dan metode penyetel juga ditambahkan:

 Compiled from "Person.scala" public class Person { private java.lang.String name; // field public java.lang.String name(); // getter method public void name_$eq(java.lang.String); // setter method public Person(java.lang.String); // constructor }

Jika ada val atau var yang didefinisikan di dalam badan kelas, maka bidang pribadi yang sesuai dan metode pengakses dibuat, dan diinisialisasi dengan tepat pada pembuatan instance.

Perhatikan bahwa implementasi bidang val dan var tingkat kelas seperti itu berarti bahwa jika beberapa variabel digunakan pada tingkat kelas untuk menyimpan nilai antara, dan tidak pernah diakses secara langsung oleh pemrogram, inisialisasi setiap bidang tersebut akan menambahkan satu atau dua metode ke jejak kelas. Menambahkan pengubah private untuk bidang tersebut tidak berarti pengakses yang sesuai akan dihapus. Mereka hanya akan menjadi pribadi.

Definisi Variabel dan Fungsi

Mari kita asumsikan bahwa kita memiliki sebuah metode, m() , dan membuat tiga referensi gaya Scala yang berbeda untuk fungsi ini:

 class Person(val name:String) { def m(): Int = { // ... return 0 } val m1 = m var m2 = m def m3 = m }

Bagaimana masing-masing referensi ke m ini dibangun? Kapan m dieksekusi dalam setiap kasus? Mari kita lihat bytecode yang dihasilkan. Output berikut menunjukkan hasil javap -v Person.class (menghilangkan banyak output yang berlebihan):

 Constant pool: #22 = Fieldref #2.#21 // Person.m1:I #24 = Fieldref #2.#23 // Person.m2:I #30 = Methodref #2.#29 // Person.m:()I #35 = Methodref #4.#34 // java/lang/Object."<init>":()V // ... public int m(); Code: // other methods refer to this method // ... public int m1(); Code: // get the value of field m1 and return it 0: aload_0 1: getfield #22 // Field m1:I 4: ireturn public int m2(); Code: // get the value of field m2 and return it 0: aload_0 1: getfield #24 // Field m2:I 4: ireturn public void m2_$eq(int); Code: // get the value of this method's input argument 0: aload_0 1: iload_1 // write it to the field m2 and return 2: putfield #24 // Field m2:I 5: return public int m3(); Code: // execute the instance method m(), and return 0: aload_0 1: invokevirtual #30 // Method m:()I 4: ireturn public Person(java.lang.String); Code: // instance constructor ... // execute the instance method m(), and write the result to field m1 9: aload_0 10: aload_0 11: invokevirtual #30 // Method m:()I 14: putfield #22 // Field m1:I // execute the instance method m(), and write the result to field m2 17: aload_0 18: aload_0 19: invokevirtual #30 // Method m:()I 22: putfield #24 // Field m2:I 25: return

Di kumpulan konstan, kita melihat bahwa referensi ke metode m() disimpan di indeks #30 . Dalam kode konstruktor, kita melihat bahwa metode ini dipanggil dua kali selama inisialisasi, dengan instruksi invokevirtual #30 muncul pertama kali pada byte offset 11, kemudian pada offset 19. Doa pertama diikuti oleh instruksi putfield #22 yang memberikan hasil dari metode ini ke bidang m1 , direferensikan oleh indeks #22 di kumpulan konstan. Pemanggilan kedua diikuti oleh pola yang sama, kali ini menetapkan nilai ke bidang m2 , diindeks pada #24 di kumpulan konstan.

Dengan kata lain, menetapkan metode ke variabel yang didefinisikan dengan val atau var hanya memberikan hasil metode ke variabel itu. Kita dapat melihat bahwa metode m1() dan m2() yang dibuat hanyalah getter untuk variabel ini. Dalam kasus var m2 , kita juga melihat bahwa penyetel m2_$eq(int) dibuat, yang berperilaku seperti penyetel lainnya, menimpa nilai di bidang.

Namun, menggunakan kata kunci def memberikan hasil yang berbeda. Daripada mengambil nilai bidang untuk dikembalikan, metode m3() juga menyertakan instruksi invokevirtual #30 . Artinya, setiap kali metode ini dipanggil, ia kemudian memanggil m() , dan mengembalikan hasil dari metode ini.

Jadi, seperti yang bisa kita lihat, Scala menyediakan tiga cara untuk bekerja dengan bidang kelas, dan ini dengan mudah ditentukan melalui kata kunci val , var , dan def . Di Java, kita harus mengimplementasikan setter dan getter yang diperlukan secara eksplisit, dan kode boilerplate yang ditulis secara manual seperti itu akan jauh lebih tidak ekspresif dan lebih rawan kesalahan.

Nilai Malas

Kode yang lebih rumit dihasilkan saat mendeklarasikan nilai lazy. Asumsikan kami telah menambahkan bidang berikut ke kelas yang ditentukan sebelumnya:

 lazy val m4 = m

Menjalankan javap -p -v Person.class sekarang akan menampilkan yang berikut:

 Constant pool: #20 = Fieldref #2.#19 // Person.bitmap$0:Z #23 = Methodref #2.#22 // Person.m:()I #25 = Fieldref #2.#24 // Person.m4:I #31 = Fieldref #27.#30 // scala/runtime/BoxedUnit.UNIT:Lscala/runtime/BoxedUnit; #48 = Methodref #2.#47 // Person.m4$lzycompute:()I // ... private volatile boolean bitmap$0; private int m4$lzycompute(); Code: // lock the thread 0: aload_0 1: dup 2: astore_1 3: monitorenter // check the flag for whether this field has already been set 4: aload_0 5: getfield #20 // Field bitmap$0:Z // if it has, skip to position 24 (unlock the thread and return) 8: ifne 24 // if it hasn't, execute the method m() 11: aload_0 12: aload_0 13: invokevirtual #23 // Method m:()I // write the method to the field m4 16: putfield #25 // Field m4:I // set the flag indicating the field has been set 19: aload_0 20: iconst_1 21: putfield #20 // Field bitmap$0:Z // unlock the thread 24: getstatic #31 // Field scala/runtime/BoxedUnit.UNIT:Lscala/runtime/BoxedUnit; 27: pop 28: aload_1 29: monitorexit // get the value of field m4 and return it 30: aload_0 31: getfield #25 // Field m4:I 34: ireturn // ... public int m4(); Code: // check the flag for whether this field has already been set 0: aload_0 1: getfield #20 // Field bitmap$0:Z // if it hasn't, skip to position 14 (invoke lazy method and return) 4: ifeq 14 // if it has, get the value of field m4, then skip to position 18 (return) 7: aload_0 8: getfield #25 // Field m4:I 11: goto 18 // execute the method m4$lzycompute() to set the field 14: aload_0 15: invokespecial #48 // Method m4$lzycompute:()I // return 18: ireturn

Dalam hal ini, nilai bidang m4 tidak dihitung sampai dibutuhkan. Metode pribadi khusus m4$lzycompute() diproduksi untuk menghitung nilai lazy, dan bidang bitmap$0 untuk melacak statusnya. Metode m4() memeriksa apakah nilai bidang ini adalah 0, yang menunjukkan bahwa m4 belum diinisialisasi, dalam hal ini m4$lzycompute() dipanggil, mengisi m4 dan mengembalikan nilainya. Metode pribadi ini juga menyetel nilai bitmap$0 ke 1, sehingga saat m4() dipanggil berikutnya, ia akan melewatkan pemanggilan metode inisialisasi, dan sebaliknya hanya mengembalikan nilai m4 .

Hasil panggilan pertama ke nilai malas Scala.

Bytecode yang dihasilkan Scala di sini dirancang untuk menjadi thread yang aman dan efektif. Agar thread aman, metode lazy compute menggunakan pasangan instruksi monitorenter / monitorexit . Metode ini tetap efektif karena kinerja overhead sinkronisasi ini hanya terjadi pada pembacaan pertama dari nilai lazy.

Hanya satu bit yang diperlukan untuk menunjukkan status nilai lazy. Jadi, jika tidak ada lebih dari 32 nilai malas, satu bidang int dapat melacak semuanya. Jika lebih dari satu nilai lazy didefinisikan dalam kode sumber, bytecode di atas akan dimodifikasi oleh kompiler untuk mengimplementasikan bitmask untuk tujuan ini.

Sekali lagi, Scala memungkinkan kita untuk dengan mudah memanfaatkan jenis perilaku tertentu yang harus diterapkan secara eksplisit di Java, menghemat upaya dan mengurangi risiko kesalahan ketik.

Fungsi sebagai Nilai

Sekarang mari kita lihat source code Scala berikut ini:

 class Printer(val output: String => Unit) { } object Hello { def main(arg: Array[String]) { val printer = new Printer( s => println(s) ); printer.output("Hello"); } }

Kelas Printer memiliki satu bidang, output , dengan tipe String => Unit : fungsi yang mengambil String dan mengembalikan objek tipe Unit (mirip dengan void di Java). Dalam metode utama, kami membuat salah satu objek ini, dan menetapkan bidang ini menjadi fungsi anonim yang mencetak string tertentu.

Kompilasi kode ini menghasilkan empat file kelas:

Kode sumber dikompilasi menjadi empat file kelas.

Hello.class adalah kelas pembungkus yang metode utamanya hanya memanggil Hello$.main() :

 public final class Hello // ... public static void main(java.lang.String[]); Code: 0: getstatic #16 // Field Hello$.MODULE$:LHello$; 3: aload_0 4: invokevirtual #18 // Method Hello$.main:([Ljava/lang/String;)V 7: return

Hello$.class yang tersembunyi berisi implementasi nyata dari metode utama. Untuk melihat bytecode-nya, pastikan Anda mengeluarkan $ dengan benar sesuai dengan aturan shell perintah Anda, untuk menghindari interpretasinya sebagai karakter khusus:

 public final class Hello$ // ... public void main(java.lang.String[]); Code: // initialize Printer and anonymous function 0: new #16 // class Printer 3: dup 4: new #18 // class Hello$$anonfun$1 7: dup 8: invokespecial #19 // Method Hello$$anonfun$1."<init>":()V 11: invokespecial #22 // Method Printer."<init>":(Lscala/Function1;)V 14: astore_2 // load the anonymous function onto the stack 15: aload_2 16: invokevirtual #26 // Method Printer.output:()Lscala/Function1; // execute the anonymous function, passing the string "Hello" 19: ldc #28 // String Hello 21: invokeinterface #34, 2 // InterfaceMethod scala/Function1.apply:(Ljava/lang/Object;)Ljava/lang/Object; // return 26: pop 27: return

Metode ini membuat Printer . Kemudian menciptakan Hello$$anonfun$1 , yang berisi fungsi anonim kami s => println(s) . Printer diinisialisasi dengan objek ini sebagai bidang output . Bidang ini kemudian dimuat ke tumpukan, dan dieksekusi dengan operan "Hello" .

Mari kita lihat kelas fungsi anonim, Hello$$anonfun$1.class , di bawah ini. Kita dapat melihat bahwa ia memperluas Function1 Scala (sebagai AbstractFunction1 ) dengan mengimplementasikan metode apply() . Sebenarnya, ini menciptakan dua metode apply() , satu membungkus yang lain, yang bersama-sama melakukan pemeriksaan tipe (dalam hal ini, bahwa inputnya adalah String ), dan menjalankan fungsi anonim (mencetak input dengan println() ).

 public final class Hello$$anonfun$1 extends scala.runtime.AbstractFunction1<java.lang.String, scala.runtime.BoxedUnit> implements scala.Serializable // ... // Takes an argument of type String. Invoked second. public final void apply(java.lang.String); Code: // execute Scala's built-in method println(), passing the input argument 0: getstatic #25 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: aload_1 4: invokevirtual #29 // Method scala/Predef$.println:(Ljava/lang/Object;)V 7: return // Takes an argument of type Object. Invoked first. public final java.lang.Object apply(java.lang.Object); Code: 0: aload_0 // check that the input argument is a String (throws exception if not) 1: aload_1 2: checkcast #36 // class java/lang/String // invoke the method apply( String ), passing the input argument 5: invokevirtual #38 // Method apply:(Ljava/lang/String;)V // return the void type 8: getstatic #44 // Field scala/runtime/BoxedUnit.UNIT:Lscala/runtime/BoxedUnit; 11: areturn

Melihat kembali metode Hello$.main() di atas, kita dapat melihat bahwa, pada offset 21, eksekusi fungsi anonim dipicu oleh panggilan ke metode apply( Object ) .

Terakhir, untuk kelengkapannya, mari kita lihat bytecode untuk Printer.class :

 public class Printer // ... // field private final scala.Function1<java.lang.String, scala.runtime.BoxedUnit> output; // field getter public scala.Function1<java.lang.String, scala.runtime.BoxedUnit> output(); Code: 0: aload_0 1: getfield #14 // Field output:Lscala/Function1; 4: areturn // constructor public Printer(scala.Function1<java.lang.String, scala.runtime.BoxedUnit>); Code: 0: aload_0 1: aload_1 2: putfield #14 // Field output:Lscala/Function1; 5: aload_0 6: invokespecial #21 // Method java/lang/Object."<init>":()V 9: return

Kita dapat melihat bahwa fungsi anonim di sini diperlakukan sama seperti variabel val lainnya. Itu disimpan dalam output bidang kelas, dan output() dibuat. Satu-satunya perbedaan adalah bahwa variabel ini sekarang harus mengimplementasikan antarmuka Scala scala.Function1 (yang AbstractFunction1 tidak).

Jadi, biaya fitur Scala yang elegan ini adalah kelas utilitas yang mendasarinya, dibuat untuk mewakili dan menjalankan satu fungsi anonim yang dapat digunakan sebagai nilai. Anda harus mempertimbangkan jumlah fungsi tersebut, serta detail implementasi VM Anda, untuk mengetahui apa artinya bagi aplikasi khusus Anda.

Pergi di bawah tenda dengan Scala: Jelajahi bagaimana bahasa yang kuat ini diimplementasikan dalam bytecode JVM.
Menciak

Sifat Scala

Ciri-ciri Scala mirip dengan antarmuka di Jawa. Sifat berikut mendefinisikan dua tanda tangan metode, dan menyediakan implementasi default dari yang kedua. Mari kita lihat bagaimana penerapannya:

 trait Similarity { def isSimilar(x: Any): Boolean def isNotSimilar(x: Any): Boolean = !isSimilar(x) } 

Kode sumber dikompilasi menjadi dua file kelas.

Dua entitas diproduksi: Similarity.class , antarmuka yang mendeklarasikan kedua metode, dan kelas sintetis, Similarity$class.class , menyediakan implementasi default:

 public interface Similarity { public abstract boolean isSimilar(java.lang.Object); public abstract boolean isNotSimilar(java.lang.Object); }
 public abstract class Similarity$class public static boolean isNotSimilar(Similarity, java.lang.Object); Code: 0: aload_0 // execute the instance method isSimilar() 1: aload_1 2: invokeinterface #13, 2 // InterfaceMethod Similarity.isSimilar:(Ljava/lang/Object;)Z // if the returned value is 0, skip to position 14 (return with value 1) 7: ifeq 14 // otherwise, return with value 0 10: iconst_0 11: goto 15 // return the value 1 14: iconst_1 15: ireturn public static void $init$(Similarity); Code: 0: return

Ketika sebuah kelas mengimplementasikan sifat ini dan memanggil metode isNotSimilar , kompiler Scala menghasilkan instruksi bytecode yang invokestatic statis untuk memanggil metode statis yang disediakan oleh kelas yang menyertainya.

Polimorfisme kompleks dan struktur pewarisan dapat dibuat dari sifat. Misalnya, beberapa sifat, serta kelas pelaksana, semuanya dapat menimpa metode dengan tanda tangan yang sama, memanggil super.methodName() untuk meneruskan kontrol ke sifat berikutnya. Ketika kompiler Scala menemukan panggilan seperti itu, ia:

  • Menentukan sifat pasti apa yang diasumsikan oleh panggilan ini.
  • Menentukan nama kelas yang menyertainya yang menyediakan bytecode metode statis yang ditentukan untuk sifat tersebut.
  • Menghasilkan instruksi invokestatic yang diperlukan.

Dengan demikian kita dapat melihat bahwa konsep sifat yang kuat diimplementasikan pada tingkat JVM dengan cara yang tidak menyebabkan overhead yang signifikan, dan pemrogram Scala dapat menikmati fitur ini tanpa khawatir akan terlalu mahal pada saat runtime.

lajang

Scala memberikan definisi eksplisit kelas tunggal menggunakan object kata kunci. Mari kita perhatikan kelas singleton berikut:

 object Config { val home_dir = "/home/user" }

Kompiler menghasilkan dua file kelas:

Kode sumber dikompilasi menjadi dua file kelas.

Config.class cukup sederhana:

 public final class Config public static java.lang.String home_dir(); Code: // execute the method Config$.home_dir() 0: getstatic #16 // Field Config$.MODULE$:LConfig$; 3: invokevirtual #18 // Method Config$.home_dir:()Ljava/lang/String; 6: areturn

Ini hanyalah penghias untuk kelas Config$ sintetis yang menyematkan fungsionalitas singleton. Memeriksa kelas itu dengan javap -p -c menghasilkan bytecode berikut:

 public final class Config$ public static final Config$ MODULE$; // a public reference to the singleton object private final java.lang.String home_dir; // static initializer public static {}; Code: 0: new #2 // class Config$ 3: invokespecial #12 // Method "<init>":()V 6: return public java.lang.String home_dir(); Code: // get the value of field home_dir and return it 0: aload_0 1: getfield #17 // Field home_dir:Ljava/lang/String; 4: areturn private Config$(); Code: // initialize the object 0: aload_0 1: invokespecial #19 // Method java/lang/Object."<init>":()V // expose a public reference to this object in the synthetic variable MODULE$ 4: aload_0 5: putstatic #21 // Field MODULE$:LConfig$; // load the value "/home/user" and write it to the field home_dir 8: aload_0 9: ldc #23 // String /home/user 11: putfield #17 // Field home_dir:Ljava/lang/String; 14: return

Ini terdiri dari berikut:

  • Variabel sintetis MODULE$ , yang melaluinya objek lain mengakses objek tunggal ini.
  • Penginisialisasi statis {} (juga dikenal sebagai <clinit> , penginisialisasi kelas) dan metode pribadi Config$ , digunakan untuk menginisialisasi MODULE$ dan menyetel bidangnya ke nilai default
  • Metode pengambil untuk bidang statis home_dir . Dalam hal ini, itu hanya salah satu metode. Jika singleton memiliki lebih banyak bidang, itu akan memiliki lebih banyak getter, serta setter untuk bidang yang bisa berubah.

Singleton adalah pola desain yang populer dan berguna. Bahasa Java tidak menyediakan cara langsung untuk menentukannya di tingkat bahasa; melainkan tanggung jawab pengembang untuk mengimplementasikannya di sumber Java. Scala, di sisi lain, menyediakan cara yang jelas dan nyaman untuk mendeklarasikan singleton secara eksplisit menggunakan kata kunci object . Seperti yang bisa kita lihat mencari di bawah tenda, itu diimplementasikan dengan cara yang terjangkau dan alami.

Kesimpulan

Kita sekarang telah melihat bagaimana Scala mengkompilasi beberapa fitur pemrograman implisit dan fungsional ke dalam struktur bytecode Java yang canggih. Dengan melihat sekilas cara kerja Scala ini, kita bisa mendapatkan apresiasi yang lebih dalam tentang kekuatan Scala, membantu kita untuk mendapatkan hasil maksimal dari bahasa yang kuat ini.

Kami juga sekarang memiliki alat untuk menjelajahi bahasa itu sendiri. Ada banyak fitur berguna dari sintaks Scala yang tidak tercakup dalam artikel ini, seperti kelas kasus, kari, dan pemahaman daftar. Saya mendorong Anda untuk menyelidiki implementasi Scala dari struktur ini sendiri, sehingga Anda dapat belajar bagaimana menjadi ninja Scala tingkat berikutnya!


Mesin Virtual Java: Kursus Singkat

Sama seperti kompiler Java, kompiler Scala mengubah kode sumber menjadi file .class , yang berisi bytecode Java untuk dieksekusi oleh Java Virtual Machine. Untuk memahami bagaimana kedua bahasa berbeda di bawah tenda, perlu untuk memahami sistem yang mereka targetkan. Di sini, kami menyajikan gambaran singkat tentang beberapa elemen utama arsitektur Java Virtual Machine, struktur file kelas, dan dasar-dasar assembler.

Perhatikan bahwa panduan ini hanya akan mencakup minimum untuk mengaktifkan mengikuti bersama dengan artikel di atas. Meskipun banyak komponen utama JVM tidak dibahas di sini, detail lengkapnya dapat ditemukan di dokumen resmi, di sini.

Mendekompilasi File Kelas dengan javap
Kolam Konstan
Tabel Bidang dan Metode
Kode Byte JVM
Metode Panggilan dan Tumpukan Panggilan
Eksekusi di Operand Stack
Variabel Lokal
kembali ke atas

Mendekompilasi File Kelas dengan javap

Java dikirimkan dengan utilitas baris perintah javap , yang mendekompilasi file .class menjadi bentuk yang dapat dibaca manusia. Karena file kelas Scala dan Java keduanya menargetkan JVM yang sama, javap dapat digunakan untuk memeriksa file kelas yang dikompilasi oleh Scala.

Mari kita kompilasi kode sumber berikut:

 // RegularPolygon.scala class RegularPolygon( val numSides: Int ) { def getPerimeter( sideLength: Double ): Double = { println( "Calculating perimeter..." ) return sideLength * this.numSides } }

Kompilasi ini dengan scalac RegularPolygon.scala akan menghasilkan RegularPolygon.class . Jika kemudian kita jalankan javap RegularPolygon.class kita akan melihat seperti berikut:

 $ javap RegularPolygon.class Compiled from "RegularPolygon.scala" public class RegularPolygon { public int numSides(); public double getPerimeter(double); public RegularPolygon(int); }

Ini adalah perincian yang sangat sederhana dari file kelas yang hanya menunjukkan nama dan tipe anggota publik kelas. Menambahkan opsi -p akan menyertakan anggota pribadi:

 $ javap -p RegularPolygon.class Compiled from "RegularPolygon.scala" public class RegularPolygon { private final int numSides; public int numSides(); public double getPerimeter(double); public RegularPolygon(int); }

Ini masih belum banyak informasi. Untuk melihat bagaimana metode diimplementasikan dalam bytecode Java, tambahkan opsi -c :

 $ javap -p -c RegularPolygon.class Compiled from "RegularPolygon.scala" public class RegularPolygon { private final int numSides; public int numSides(); Code: 0: aload_0 1: getfield #13 // Field numSides:I 4: ireturn public double getPerimeter(double); Code: 0: getstatic #23 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: ldc #25 // String Calculating perimeter... 5: invokevirtual #29 // Method scala/Predef$.println:(Ljava/lang/Object;)V 8: dload_1 9: aload_0 10: invokevirtual #31 // Method numSides:()I 13: i2d 14: dmul 15: dreturn public RegularPolygon(int); Code: 0: aload_0 1: iload_1 2: putfield #13 // Field numSides:I 5: aload_0 6: invokespecial #38 // Method java/lang/Object."<init>":()V 9: return }

Itu sedikit lebih menarik. Namun, untuk benar-benar mendapatkan keseluruhan cerita, kita harus menggunakan opsi -v atau -verbose , seperti pada javap -p -v RegularPolygon.class :

Isi lengkap dari file kelas Java.

Di sini kita akhirnya melihat apa yang sebenarnya ada di file kelas. Apa artinya semua ini? Mari kita lihat beberapa bagian yang paling penting.

Kolam Konstan

Siklus pengembangan untuk aplikasi C++ mencakup tahap kompilasi dan linkage. Siklus pengembangan untuk Java melewati tahap linkage eksplisit karena linkage terjadi saat runtime. File kelas harus mendukung penautan runtime ini. Ini berarti bahwa ketika kode sumber merujuk ke bidang atau metode apa pun, bytecode yang dihasilkan harus menyimpan referensi yang relevan dalam bentuk simbolis, siap untuk direferensikan setelah aplikasi dimuat ke dalam memori dan alamat sebenarnya dapat diselesaikan oleh runtime linker. Bentuk simbolis ini harus mengandung:

  • nama kelas
  • nama bidang atau metode
  • ketik informasi

Spesifikasi format file kelas mencakup bagian file yang disebut kumpulan konstanta , tabel semua referensi yang dibutuhkan oleh linker. Ini berisi entri dari berbagai jenis.

 // ... Constant pool: #1 = Utf8 RegularPolygon #2 = Class #1 // RegularPolygon #3 = Utf8 java/lang/Object #4 = Class #3 // java/lang/Object // ...

Byte pertama dari setiap entri adalah tag numerik yang menunjukkan jenis entri. Byte yang tersisa memberikan informasi tentang nilai entri. Jumlah byte dan aturan untuk interpretasinya tergantung pada jenis yang ditunjukkan oleh byte pertama.

Misalnya, kelas Java yang menggunakan bilangan bulat konstan 365 mungkin memiliki entri kumpulan konstan dengan bytecode berikut:

 x03 00 00 01 6D

Byte pertama, x03 , mengidentifikasi tipe entri, CONSTANT_Integer . Ini menginformasikan linker bahwa empat byte berikutnya berisi nilai integer. (Perhatikan bahwa 365 dalam heksadesimal adalah x16D ). Jika ini adalah entri ke-14 di kumpulan konstan, javap -v akan membuatnya seperti ini:

 #14 = Integer 365

Banyak tipe konstanta terdiri dari referensi ke tipe konstanta yang lebih "primitif" di tempat lain dalam kumpulan konstanta. Misalnya, kode contoh kami berisi pernyataan:

 println( "Calculating perimeter..." )

Penggunaan konstanta string akan menghasilkan dua entri di kumpulan konstan: satu entri dengan tipe CONSTANT_String , dan entri lain dengan tipe CONSTANT_Utf8 . Entri tipe Constant_UTF8 berisi representasi UTF8 aktual dari nilai string. Entri tipe CONSTANT_String berisi referensi ke entri CONSTANT_Utf8 :

 #24 = Utf8 Calculating perimeter... #25 = String #24 // Calculating perimeter...

Kerumitan seperti itu diperlukan karena ada jenis entri kumpulan konstanta lain yang merujuk ke entri tipe Utf8 dan yang bukan entri tipe String . Misalnya, referensi apa pun ke atribut kelas akan menghasilkan tipe CONSTANT_Fieldref , yang berisi serangkaian referensi ke nama kelas, nama atribut, dan tipe atribut:

 #1 = Utf8 RegularPolygon #2 = Class #1 // RegularPolygon #9 = Utf8 numSides #10 = Utf8 I #12 = NameAndType #9:#10 // numSides:I #13 = Fieldref #2.#12 // RegularPolygon.numSides:I

Untuk detail selengkapnya tentang kumpulan konstan, lihat dokumentasi JVM.

Tabel Bidang dan Metode

File kelas berisi tabel bidang yang berisi informasi tentang setiap bidang (yaitu, atribut) yang ditentukan di kelas. Ini adalah referensi ke entri kumpulan konstan yang menjelaskan nama dan jenis bidang serta tanda kontrol akses dan data relevan lainnya.

Tabel metode serupa hadir di file kelas. Namun, selain informasi nama dan tipe, untuk setiap metode non-abstrak, ini berisi instruksi bytecode aktual yang akan dieksekusi oleh JVM, serta struktur data yang digunakan oleh kerangka tumpukan metode, yang dijelaskan di bawah.

Kode Byte JVM

JVM menggunakan set instruksi internalnya sendiri untuk mengeksekusi kode yang dikompilasi. Menjalankan javap dengan opsi -c menyertakan implementasi metode yang dikompilasi dalam output. Jika kita memeriksa file RegularPolygon.class kita dengan cara ini, kita akan melihat output berikut untuk metode getPerimeter() kita:

 public double getPerimeter(double); Code: 0: getstatic #23 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: ldc #25 // String Calculating perimeter... 5: invokevirtual #29 // Method scala/Predef$.println:(Ljava/lang/Object;)V 8: dload_1 9: aload_0 10: invokevirtual #31 // Method numSides:()I 13: i2d 14: dmul 15: dreturn

Bytecode yang sebenarnya mungkin terlihat seperti ini:

 xB2 00 17 x12 19 xB6 00 1D x27 ...

Setiap instruksi dimulai dengan opcode satu byte yang mengidentifikasi instruksi JVM, diikuti oleh nol atau lebih operan instruksi yang akan dioperasikan, tergantung pada format instruksi tertentu. Ini biasanya berupa nilai konstan, atau referensi ke dalam kumpulan konstan. javap membantu menerjemahkan bytecode ke dalam bentuk yang dapat dibaca manusia yang menampilkan:

  • Offset , atau posisi byte pertama dari instruksi di dalam kode.
  • Nama yang dapat dibaca manusia, atau mnemonic , dari instruksi.
  • Nilai operan, jika ada.

Operan yang ditampilkan dengan tanda pound, seperti #23 , adalah referensi ke entri di kumpulan konstan. Seperti yang bisa kita lihat, javap juga menghasilkan komentar yang bermanfaat dalam output, mengidentifikasi apa yang sebenarnya dirujuk dari kumpulan.

Kami akan membahas beberapa petunjuk umum di bawah ini. Untuk informasi rinci tentang set instruksi JVM lengkap, lihat dokumentasi.

Metode Panggilan dan Tumpukan Panggilan

Setiap pemanggilan metode harus dapat dijalankan dengan konteksnya sendiri, yang mencakup hal-hal seperti variabel yang dideklarasikan secara lokal, atau argumen yang diteruskan ke metode. Bersama-sama, ini membentuk bingkai tumpukan . Setelah pemanggilan suatu metode, bingkai baru dibuat dan ditempatkan di atas tumpukan panggilan . Ketika metode kembali, bingkai saat ini dihapus dari tumpukan panggilan dan dibuang, dan bingkai yang berlaku sebelum metode dipanggil dipulihkan.

Sebuah bingkai tumpukan mencakup beberapa struktur yang berbeda. Dua yang penting adalah tumpukan operan dan tabel variabel lokal , yang akan dibahas selanjutnya.

Tumpukan panggilan JVM.

Eksekusi di Operand Stack

Banyak instruksi JVM beroperasi pada tumpukan operan frame mereka. Daripada menentukan operan konstan secara eksplisit dalam bytecode, instruksi ini malah mengambil nilai di atas tumpukan operan sebagai input. Biasanya, nilai-nilai ini dihapus dari tumpukan dalam proses. Beberapa instruksi juga menempatkan nilai baru di atas tumpukan. Dengan cara ini, instruksi JVM dapat digabungkan untuk melakukan operasi yang kompleks. Misalnya, ekspresi:

 sideLength * this.numSides

dikompilasi sebagai berikut dalam metode getPerimeter() kami:

 8: dload_1 9: aload_0 10: invokevirtual #31 // Method numSides:()I 13: i2d 14: dmul 

Instruksi JVM dapat beroperasi pada tumpukan operan untuk melakukan fungsi yang kompleks.

  • Instruksi pertama, dload_1 , mendorong referensi objek dari slot 1 tabel variabel lokal (dibahas selanjutnya) ke tumpukan operan. Dalam hal ini, ini adalah argumen metode sideLength .- Instruksi berikutnya, aload_0 , mendorong referensi objek pada slot 0 dari tabel variabel lokal ke tumpukan operan. Dalam praktiknya, ini hampir selalu merujuk ke this , kelas saat ini.
  • Ini menyiapkan tumpukan untuk panggilan berikutnya, panggilan invokevirtual #31 , yang mengeksekusi metode instance numSides() . invokevirtual operan teratas (referensi ke this ) dari tumpukan untuk mengidentifikasi dari kelas apa yang harus dipanggil metode tersebut. Setelah metode kembali, hasilnya didorong ke tumpukan.
  • Dalam hal ini, nilai yang dikembalikan ( numSides ) dalam format integer. Itu harus dikonversi ke format titik mengambang ganda untuk mengalikannya dengan nilai ganda lainnya. Instruksi i2d nilai integer dari tumpukan, mengubahnya menjadi format floating point, dan mendorongnya kembali ke tumpukan.
  • Pada titik ini, tumpukan berisi hasil titik mengambang this.numSides di atas, diikuti dengan nilai argumen sideLength yang diteruskan ke metode. dmul dua nilai teratas ini dari tumpukan, melakukan perkalian floating point pada mereka, dan mendorong hasilnya ke tumpukan.

Ketika sebuah metode dipanggil, tumpukan operan baru dibuat sebagai bagian dari bingkai tumpukannya, tempat operasi akan dilakukan. We must be careful with terminology here: the word “stack” may refer to the call stack , the stack of frames providing context for method execution, or to a particular frame's operand stack , upon which JVM instructions operate.

Local Variables

Each stack frame keeps a table of local variables . This typically includes a reference to this object, any arguments that were passed when the method was called, and any local variables declared within the method body. Running javap with the -v option will include information about how each method's stack frame should be set up, including its local variable table:

 public double getPerimeter(double); // ... Code: 0: getstatic #23 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: ldc #25 // String Calculating perimeter... // ... LocalVariableTable: Start Length Slot Name Signature 0 16 0 this LRegularPolygon; 0 16 1 sideLength D

In this example, there are two local variables. The variable in slot 0 is named this , with the type RegularPolygon . This is the reference to the method's own class. The variable in slot 1 is named sideLength , with the type D (indicating a double). This is the argument that is passed to our getPerimeter() method.

Instructions such as iload_1 , fstore_2 , or aload [n] , transfer different types of local variables between the operand stack and the local variable table. Since the first item in the table is usually the reference to this , the instruction aload_0 is commonly seen in any method that operates on its own class.

This concludes our walkthrough of JVM basics.

Terkait: Kurangi Kode Boilerplate Dengan Scala Macros dan Quasiquotes