Dapatkan Tangan Anda Kotor Dengan Scala JVM Bytecode
Diterbitkan: 2022-03-11Bahasa 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.
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
.
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:
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.
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) }
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:
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 pribadiConfig$
, digunakan untuk menginisialisasiMODULE$
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
:
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.
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 pertama,
dload_1
, mendorong referensi objek dari slot 1 tabel variabel lokal (dibahas selanjutnya) ke tumpukan operan. Dalam hal ini, ini adalah argumen metodesideLength
.- Instruksi berikutnya,aload_0
, mendorong referensi objek pada slot 0 dari tabel variabel lokal ke tumpukan operan. Dalam praktiknya, ini hampir selalu merujuk kethis
, kelas saat ini. - Ini menyiapkan tumpukan untuk panggilan berikutnya, panggilan
invokevirtual #31
, yang mengeksekusi metode instancenumSides()
.invokevirtual
operan teratas (referensi kethis
) 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. Instruksii2d
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 argumensideLength
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.