گشتی درون بایتکد جاوا
اگر فکر میکنید عنوان مطلب به تصویر ربطی ندارد و اشتباهی صورت گرفته، با ما همراه باشید!
از ابتدای یادگیری جاوا آموختهایم که سورسکد جاوا را در ادیتور به صورت فایل متنی مینویسیم و به وسیله کامپایلر جاوا، تبدیل به بایتکد میکنیم که یک نوع کد میانی مخصوص زبانهای جاوایی است و در فایل class. ذخیره میشود. همچنین میدانیم که این زبان میانی مستقل از سکو است و روی هر ماشینی که ماشین مجازی جاوا (JVM) نصب باشد، قابل اجراست.
همچنین پیشتر در مورد معماری و ساختار داخلی ماشین مجازی جاوا این مطلب در جاواکاپ را با هم خواندیم.
جالب است بدانید که امکان ساخت یک ماشین که به صورت بومی، بایتکد جاوا را اجرا کند هم وجود دارد. حتی مواردی هم به مرحله تولید و فروش رسیدهاند (به ویکیپدیا مراجعه کنید). یک مثال جالب از این دسته، Jazelle است، قسمتی از پردازندههای ARM که (بخشی از) مجموعه دستورات بایتکد جاوا را اجرا میکرد.
اکنون میخواهیم ساختار بایتکدها را بررسی کنیم، ببینیم به چه شکل ذخیره شدهاند، مجموعه دستوراتشان به چه شکل است و چگونه اجرا میشوند و در آخر هم با ابزاری آشنا میشویم که میتواند این بایتکدها را بسازد (مشابه کاری که کامپایلر انجام میدهد) یا تغییر دهد.
معرفی بایتکد جاوا
بایتکد جاوا، یک زبان میانی بین زبان ماشین و جاواست. این زبان میانی از پارادایم اجرای پشتهگونه استفاده میکند که باعث میشود پیادهسازی ماشین مجازی برای اجرای آن راحتتر شود. اگر از ماشینحسابهای HP استفاده کرده باشید که از نشانهگذاری معکوس لهستانی (Reverse Polish notation) استفاده میکنند، ایده کلی را دارید. همچنین زبان برنامهنویسی Forth نیز ایده مشابهی را دنبال میکند.
مثلا در این زبان به جای اینکه بنویسیم
println(3+4);
مینویسیم
3 4 + println
فهم این مدل نوشتن، خیلی پیچیده نیست، اول 3 و 4 را تعریف میکنیم. عملگر + روی دو عدد قبل از خودش کار میکند و مقدار 7 را تولید میکند که به آخرین مورد (println) داده میشود. اکنون دستور print، مقدار 7 از سر پشته را بر میدارد و نتیجه را روی صفحه چاپ میکند.
هر عبارت که نوشته شد، برای خود یک دستور است، پس برای درک بهتر آنها در خطوط مجزا قرارشان میدهیم:
3 // push 3 on the stack 4 // push 4 on the stack + // consume 3 and 4 and push the result on the stack println // consume the result and print it
حالا که به این مرحله رسیدیم، می توانیم نمایش بایتکد را هم (با سادهسازی) ببینیم:
iconst_3 iconst_4 iadd println // push the method pointer on the stack invokestatic // consume the method on the stack and invoke it
معرفی ساختمان داده پشته (stack)
ساختمانداده پشته یکی از معروفترین و پرکاربردترین ساختماندادههاست. پشته از سیستم last-in-first-out یا LIFO استفاده میکند، یعنی اولین دادهای که با pop کردن، خارج میشود همان دادهای است که آخر از همه وارد شده است (push شده است).
تمام پردازندهها برای اجرای عملیات فرخوانی توابع، از پشته استفاده میکنند. به این صورت که با فراخوانی یک تابع، اطلاعات آن تابع و متغیرهای محلیِ داخلِ آن، در پشته قرار میگیرند و با پایانِ تابع از پشته خارج (pop) میشوند و پردازنده اجرای تابع قبلی که اکنون سر پشته است را از سر میگیرد.
همانطور که گفته شد، بیشتر زبانهای برنامهنویسی متغیرهای محلی را در پشته قرار میدهند اما Forth و جاوا، پا را جلوتر گذاشته و همهچیز را در پشته قرار میدهند.
برای نمونه پشته را در حین اجرای دستور
3 4 + println
ببینیم:
- ابتدا با پشته خالی شروع میکنیم.
- دستور iconst_3 مقدار عدد صحیح 3 را در اولین خانه خالی پشته میگذارد. (push میکند.)
- دستور بعدی iconst_4 است که مقدار عدد صحیح 4 را در اولین خانه خالی که بالای 3 است قرار میدهد.
- دستور جمع، ۲ مقدار بالایی پشته را گرفته و آنها را با هم جمع میکند. سپس نتیجه را مجددا در اولین خانهٔ خالی پشته قرار میدهد.
- دستور ()println بالاترین مقدار پشته را برمیدارد و آن را در خروجی استاندارد چاپ میکند. پس از اجرای این دستور، پشته مجددا خالی میشود.
ایده اصلی سیستم پشتهگونه همین است که هر دستوری که اجرا میشود، یا چیزی به پشته اضافه میکند یا چیزی از سر آن برمیدارد. پس مجموعه دستورات بسیار ساده است. هر دستور، صفر یا یک آرگومان ورودی میگیرد ولی همه چیزهای دیگر روی پشته هستند، حتی بهتر، هرچیزی که دستور ما لازم دارد درست در بالای پشته قرار دارد.
یک خاصیت خوب دیگر ماشینهای پشتهای، این است که دیگر لازم نیست نگران اولویت عملگرها باشند. مثلا عبارت زیر را در نظر بگیرید:
println(3*(4+5));
برای محاسبه این عبارت ابتدا باید 3*4 را محاسبه کنیم یا 4+5 را؟ اگرچه پیادهسازی اولویت عملگرها کار دشواری نیست اما پشته این مشکل را کاملا از میان بر میدارد.
(فکر میکنم با دانستن سیستم پشتهگونهی اجرای بایتکد، ارتباط تصویر مطلب با عنوان، به خوبی روشن شده باشد)
حالا به مرحله عملی میرسیم که واقعا داخل بایتکد جاوا را بررسی کنیم.
ساخت یک بایتکد ساده
ابتدا باید یک بایتکد داشته باشیم. اجازه دهید یک برنامه ساده بنویسیم و آن را کامپایل کنیم تا به ما فایل class. بدهد.
public final class DivisorPrinter { private final int number; public DivisorPrinter(int number) { this.number = number; } public void print() { for (int i = 1; i <= number; ++i) { /* If i is a divisor of number */ if (number % i == 0) { System.out.print(i); if (i != number) { System.out.print(", "); // Append a comma if there are more divisors to come } } } } }
public final class SimpleAlgorithm { public static void main(String[] args) { String input; /* If arguments were provided, assign them, else exit the program */ if (args.length > 0) { input = args[0]; } else { System.exit(1); return; } int number = Integer.parseInt(input); DivisorPrinter printer = new DivisorPrinter(number); printer.print(); } }
برنامه اول، یک عدد میگیرد و تمام مقسومعلیههای آن را در خروجی استاندارد چاپ میکند.
برنامه دوم آرگومانهای پاسشده به برنامه را میگیرد و الگوریتم اول را برای عضو اول آرایه صدا میزند، در صورتی که هیچ عضوی وجود نداشته باشد، برنامه را خاتمه میدهد.
البته در اینجا امکان رخداد استثنای NumberFormatException وجود دارد که در اینجا هندل نشده چون باعث پیچیدگی بایتکد میشد.
این برنامه به عمد به این صورت نوشته شده که طیف وسیعی از عملیاتها را پوشش دهد: مقداردهی متغیر، ساخت شی، آرایهها، متدها و فیلدهای استاتیک و غیراستاتیک و البته کنترل روند اجرای برنامه.
تلاش برای باز کردن بایتکد
قدم بعدی برای ما کامپایل کردن کدمان و باز کردن فایل class. است، با استفاده از دستور javac فایل SimpleAlgorithm.java را کامپایل میکنیم. اینکار باعث ایجاد دو فایل class. برای دو کلاس متفاوت میشود.
زمانی که این فایلها را با یک ویرایشگر متنی معمولی (مثل notepad++) باز کنیم متوجه میشویم که نتیجه مطلوب و معنیداری نیست چرا که بایتکد ها نه به شکل متنی، بلکه به صورت باینری ذخیره میشوند. تنها چیزی که ممکن است در این حالت دستگیرمان شود، اسم برخی کلاسهای جاوا است که در متن برنامه استفاده شدهاند.
راه مناسب برای مشاهده محتوای بایتکد
برای این کار، از ابزار javap استفاده میکنیم. این ابزار به صورت پیشفرض همراه جاوا نصب میشود و کافیست برایش فایل class.مان را مشخص کنیم تا محتویات آن را به صورت خوانا نمایش دهد.
برای مثال، خروجی دستور javap SimpleAlgorithm
چیزی شبیه زیر است:
Compiled from "SimpleAlgorithm.java" public final class SimpleAlgorithm { public SimpleAlgorithm(); public static void main(java.lang.String[]); }
توجه: شکل و نحوه نمایش خروجی این دستور در هر JDK بهبود مییابد و خروجی شفافتر و زیباتری تولید میشود، پس خروجی نسخه شما ممکن است با این نمونه ظاهر متفاوتی داشته باشد.
این خروجی برای ما چندان سودمند نیست. چراکه تنها به تصریح امضای متدها بسنده شده و جزئیات پیادهسازی را به ما نشان نمیدهد. (توجه کنید که سازنده پیشفرض به صورت ضمنی اضافه شده است.)
برای مشاهده اطلاعات بیشتر، از سوییچ c- استفاده میکنیم. با این کار، javap برای ما دستورات هر متد را نیز نمایش میدهد. سوییچ مفید دیگر، v- است که به حالت وراج اشاره دارد و تمام اطلاعات موجود از جمله محتویات constant pool را نمایش میدهد.
اکنون روی خروجی دستورات با آرگومان c- تمرکز میکنیم:
javap -c SimpleAlgorithm
Compiled from "SimpleAlgorithm.java" public final class SimpleAlgorithm { public SimpleAlgorithm(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return public static void main(java.lang.String[]); Code: 0: aload_0 1: arraylength 2: ifle 12 5: aload_0 6: iconst_0 7: aaload 8: astore_1 9: goto 17 12: iconst_1 13: invokestatic #2 // Method java/lang/System.exit:(I)V 16: return 17: aload_1 18: invokestatic #3 // Method java/lang/Integer.parseInt:(Ljava/lang/String;)I 21: istore_2 22: new #4 // class DivisorPrinter 25: dup 26: iload_2 27: invokespecial #5 // Method DivisorPrinter."<init>":(I)V 30: astore_3 31: aload_3 32: invokevirtual #6 // Method DivisorPrinter.print:()V 35: return
این خروجی دیساسمبل شدهٔ فایل SimpleAlgorithm.class است. ممکن است در ابتدا گیجکننده به نظر برسد اما فهم مفهوم کلی پشتِ آن چندان سخت نیست.
این نیز خروجی دیساسمبل فایل DivisorPrinter.class
Compiled from "DivisorPrinter.java" public final class DivisorPrinter { public DivisorPrinter(int); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>": ()V 4: aload_0 5: iload_1 6: putfield #2 // Field number:I 9: return public void print(); Code: 0: iconst_1 1: istore_1 2: iload_1 3: aload_0 4: getfield #2 // Field number:I 7: if_icmpgt 48 10: aload_0 11: getfield #2 // Field number:I 14: iload_1 15: irem 16: ifne 42 19: getstatic #3 // Field java/lang/System.out:Ljava/ io/PrintStream; 22: iload_1 23: invokevirtual #4 // Method java/io/PrintStream.print: (I)V 26: iload_1 27: aload_0 28: getfield #2 // Field number:I 31: if_icmpeq 42 34: getstatic #3 // Field java/lang/System.out:Ljava/ io/PrintStream; 37: ldc #5 // String , 39: invokevirtual #6 // Method java/io/PrintStream.print: (Ljava/lang/String;)V 42: iinc 1, 1 45: goto 2 48: return }
چرا goto وجود دارد؟
زبان برنامهنویسی جاوا اگرچه کلیدواژه goto را به صورت رزروشده دارد، اما اجازه استفاده از آن را به برنامهنویسان نمی دهد. پس اما چگونه در بایتکد جاوا پیدا شدند؟
اگر با زبان ماشین آشنا باشید میدانید که به جای انواع عملیات کنترل اجرای برنامه، goto وجود دارد. چرا که if و while و for و غیره عملیاتهایی پیچیده و با سطح انتزاعیسازی بالا برای ماشین هستند و ماشین به جای همه آنها، فقط قابلیت اجرای goto را فراهم میکند و کامپایلر عملیات تبدیل ساختارهای پیچیده به goto را انجام میدهد. کامپایلر جاوا هم برای راحت کردن کار JVM، عملیاتهای شرطی و حلقهها را به goto تبدیل میکند.
نام متغیرها کجا هستند؟
چیز دیگری که به نظر میرسد، این است که هیچ اسم متغیری وجود ندارد، البته گاهی اوقات آرگومان برای برخی دستورات وجود دارند مثل 2# یا 17 اما به طور کلی هیچ انتصاب مستقیم، تعریف متغیر یا استفاده از متغیری وجود ندارد. دلیل تمام اینها، سیستم پشتهای است که در ابتدای مطلب توضیح دادهشد.
همچنین میتوانید از داکیومنتهای رسمی اوراکل یا صفحه ویکیپدیا، در مورد تکتک دستورات داخل بایتکد بخوانید. ضمنا بررسی این ویدیو از سحنرانی مدیر پروژهٔ آپدیت JDK8 میتواند بسیار کمککننده باشد.
ساخت و ویرایش بایتکد
تا اینجا درک عمیقی از جزئیات داخل بایتکد و چیزی که کامپایلر انجام میدهد و نحوه اجرای دستورات یاد گرفتیم. اما برای اینکه از دانش خود استفاده کنیم میتوانیم یک بایتکد را از اول بسازیم یا بایتکد مربوط به یک کلاس کامپایلشده را ویرایش کنیم.
برای این کار، کتابخانههای مختلفی وجود دارند که لیست کامل آنها را می توانید از این لینک بررسی کنید. ما در اینجا کتابخانه ASM را بررسی میکنیم.
کتابخانه ASM از کتابخانههای سریع و با کارایی بالاست که در کامپایلرهای دینامیک برای تولید کد در زمان اجرا نیز استفاده میشود. اما همچنان برای تولید کد در کامپایلرهای استاتیک نیز کاراست. از این کتابخانه در کامپایلر Groovy و Kotlin استفاده شده است. از مزایای دیگر این کتابخانه میتوان به محاسبه خودکار اندازه فریم پشته و مدیریت خودکار constant pool اشاره کرد.
لینک رسمی پروژه: asm.ow2.io
لینک داکیومنت و آموزش کامل کتابخانه: asm4-guide
این کتابخانه دو مدل استفاده متفاوت را فراهم کرده: مدل اول با استفاده از الگوی طراحی visitor و مدل دوم با الگوی طراحی مرسوم شیگرا.
مدل اول به گفته سازنده سریعتر است و مدل شیگرا نیز در باطن از همان visitor استفاده میکند. برای ساخت هر المان (مثلا یک کلاس، یک تابع یا عملیاتهای مختلف) باید متدهای visit متفاوتی را روی ClassWriter یا MethodVisitor صدا بزنید تا اندکاندک کلاس (و متد) ساخته و تکمیل شود. در نهایت پس از تکمیل کلاس، با استفاده از toByteArray آن را به آرایه بایتی تبدیل کرده و در فایل مینویسیم و یا میتوانیم مستقیم با استفاده از ClassLoader آن را در برنامه فعلی لود کنیم.
همچنین یک ابزار dumper برای ما وجود دارد که میتوانیم فایل class. مورد نظر خود را که با جاوا ۸ کامپایل کردهایم به ابزار asm بدهیم و یک فایل جاوا خروجی بگیریم که دستورات لازم برای تولید همان بایتکد است و برای آموزش و فهم ارتباط بین دستورات داخل بایتکد و دستور متناظر در asm میتواند کمککننده باشد. برای این کار، با داشتن فایلهای jar که از سایت ASM دانلود میکنیم، میتوانیم کلاس زیر را فراخوانی کنیم:
org.objectweb.asm.util.ASMifier
مثلا دستوری شبیه به این:
java -classpath ".:asm-7.1.jar:asm-util-7.1.jar" org.objectweb.asm.util.ASMifier A.class > ADump.java
علاوه برا دانلود فایل jar ابزارهای ASM از سایت رسمی، میتوانید پیشنیاز مربوطه را به maven یا gradle اضافه کنید.
کد زیر یک نمونه از فراخوانیهای لازم برای ساخت یک کلاس hello world است که با همین ابزار dumper تولید شدهاست:
import org.objectweb.asm.*; import static org.objectweb.asm.Opcodes.*; import java.io.*; public class ADump { public static void main(String[] args) throws Exception { ClassWriter classWriter = new ClassWriter(0); FieldVisitor fieldVisitor; MethodVisitor methodVisitor; AnnotationVisitor annotationVisitor0; classWriter.visit(V1_8, ACC_PUBLIC | ACC_SUPER, "A", null, "java/lang/Object", null); classWriter.visitSource("A.java", null); { methodVisitor = classWriter.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null); methodVisitor.visitCode(); methodVisitor.visitVarInsn(ALOAD, 0); methodVisitor.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false); methodVisitor.visitInsn(RETURN); methodVisitor.visitMaxs(1, 1); methodVisitor.visitEnd(); } { methodVisitor = classWriter.visitMethod(ACC_PUBLIC | ACC_STATIC, "main", "([Ljava/lang/String;)V", null, null); methodVisitor.visitCode(); methodVisitor.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"); methodVisitor.visitLdcInsn("hello javacup"); methodVisitor.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false); methodVisitor.visitInsn(RETURN); methodVisitor.visitMaxs(2, 1); methodVisitor.visitEnd(); } classWriter.visitEnd(); byte[] output = classWriter.toByteArray(); try(FileOutputStream f = new FileOutputStream(new File("./A.class")) ){ f.write(output); } } }
برای ساخت این کد، ابتدا یک helloworld با جاوا نوشته و کامپایل کردیم تا فایل A.class را به دست بیاوریم، در مرحله بعد با ابزار dump داخل ASM، آن را dump کرده و یک فایل ADump.java به دست میآوریم. (که در بالا پیوست شده).
اکنون با اجرای این برنامه (و اضافه کردن jarهای لازم به classpath) فایل class.ای با همان مشخصات تولید میشود و قابل اجراست. در صورتی که در این فایل جاوا تغییراتی اعمال کنیم، میتوانیم فایلهای class. مشابه بسازیم.
منابع استفادهشده در نگارش این مطلب:
An Introduction to JVM Bytecode
A Java Programmer’s Guide to Byte Code
تجربیات نویسنده در استفاده از ASM
.
.
.
با ما همراه باشید
آدرس کانال تلگرام: JavaCupIR@
آدرس اکانت توییتر: JavaCupIR@
آدرس صفحه اینستاگرام: javacup.ir
آدرس گروه لینکدین: Iranian Java Developers