نگاهی نزدیک به کامپایلر JIT جاوا: وقت خود را با بهینهسازیهای محلی تلف نکنید
همواره به توسعهدهندگان جاوا توصیه میشود که JVM و بخشهای مختلف آن را بشناسند. یکی از این بخشها که در عملکرد برنامه جاوایی شما تاثیر زیادی دارد، just in time compiler یا به اختصار JIT است. به طور خلاصه JIT بخشهای پرتکرار کد شما (hotspot) را در زمان اجرا کامپایل میکند تا لازم نباشد برای هر اجرا آن را تفسیر کند. در این مقاله کمی با JIT کلنجار میرویم و بررسی میکنیم JIT در مقابل بهینهسازیهای دستی ما چگونه عمل میکند. (مقدمه مترجم)
قبل از توضیح اینکه JIT چه کار میکند، جالب است که یک آزمایش کوچک انجام دهیم. کلاس زیر را در نظر بگیرید:
public class PerformanceTest2 { public static void main(String[] args) { for (int outer=1;outer<=100;outer++) { long start = System.nanoTime(); testPerformance(); long duration = System.nanoTime()-start; System.out.println("Loop # " + outer + " took " + ((duration)/1000.0d) + " µs"); } } private static void testPerformance() { long sum = 0; for (int i = 1; i <= 5000; i++) { sum = sum + random(i); } } private static int random(int i) { int x = (int)(i*2.3d/2.7d); // This is a simulation int y = (int)(i*2.36d); // of time-consuming return x%y; // business logic. } }
این کلاس از دو حلقهی تودرتو تشکیل شده و زمانی که اجرای هرحلقه طول میکشد را اندازه میگیرد.
مثال ما عملکرد JIT را خیلی شفاف نشان میدهد. در اکثر موارد، دو پیمایش اول، به طرز قابل توجهی کند هستند. از پیمایش سوم، یک افزایش سرعت محسوس شروع میشود. تا تکرار دهم (یا در برخی مثالها پیمایش دههزارم) هر تکرار از قبلی سریعتر است. بعد از آن، سرعت دیگر تغییر خاصی نمیکند. در این حالت هر پیمایش تا ۵۰ برابر از پیمایش اول سریعتر است. با این حال، JIT تلاش برای بهینه کردن کد را پایان نمیدهد.
بعد از مقداری کلنجار رفتن با کدِ مورد ارزیابی و پارامترهای JVM، توانستم یک نمودار ملایمتر رسم کنم که بهینهسازی مداوم را نشان میدهد.
اکنون بیایید کدجاوا را به صورت دستی بهینه کنیم، اولین بهینهسازیای که به ذهن میرسد inline کردن متد است. زمانی که یک متد را فراخوانی میکنید، پارامترها روی استک قرار میگیرند (صرفا برای اینکه یک عملیات را نام ببریم) پس به نظر میرسد که حذف کردن متد، کارایی را بهبود میدهد. کد بهینهشده، چیزی شبیه کد زیر میشود:
public class PerformanceTest3 { public static void main(String[] args) { for (int outer=1;outer<=100;outer++) { long start = System.nanoTime(); long sum = 0; for (int i = 1; i <= 5000; i++) { int x = (int)(i*2.3d/2.7d); // This is a simulation int y = (int)(i*2.36d); // of time-consuming sum = sum + x%y; // business logic. } long duration = System.nanoTime()-start; System.out.println("Loop # " + outer + " took " + ((duration)/1000.0d) + " µs"); } } }
حالا نمودارهای عملکرد چه تغییری میکنند؟
وقتی بنچمارکها را آماده میکردم انتظار دیدن همچین چیزی را نداشتم! من نمیدانستم که On Stack Replacement (به اختصار OSR که میتوانید در این لینک یا این لینک بیشتر در موردش مطالعه کنید) به این خوبی کار میکند. من انتطار داشتم نمودار سمت چپ، کاملا آبی باشد. با این حال به اندازهای تفاوت بین ۲ نمودار وجود دارد که نکته را نشان دهد.
- در اجرای طولانی، به سختی میتوان تفاوتی بین نسخهٔ اولیه و نسخهٔ بهینهشده پیدا کرد.
- نسخه بهینهشده نیاز به زمان بیشتری نسبت به نسخه اولیه دارد تا توسط JIT سرعتش افزایش یابد.
- در اجرای طولانی، نسخه بهینهشده از نسخه اولیه سریعتر نیست. هر چند در پیمایشهای اولیه، نسخه بهینهشده واقعا سریعتر از نسخه اولیه است.
- در نمودار سمت راست، حداقل دو افزایش سرعت بزرگ دیده میشود در حالی که در نمودار سمت چپ، سرعت تقریبا ثابت میماند تا اینکه ناگهان ۱۰ تا ۲۰ مرتبه سریعتر میشود.
دلیل موفقیت متوسط بهینهسازی ما این است که ماشین مجازی، همان مراحلی را میرود که ما رفتیم. رفتار پیشفرض JIT، بهینهسازی فراخوانی توابع است.
- اگر یک متد بارها تکرار شود و به اندازی کافی کوتاه باشد، inline میشود.
- اگر یک متد ۱۰,۰۰۰ بار فراخوانی شود، به کد ماشین کامپایل میشود. فراخوانی بعدی، به جای نسخه تفسیرشده، نسخه کامپایلشده را اجرا میکند.
- اگر فراخوانی متد وجود نداشته باشد ولی برنامه مدت زمان زیادی را در یک متد بماند، آن متد کامپایل میشود. پس از اینکه برنامه متوقف شد، متغیرها به نسخه کامپایلشده (به کد ماشین) منتقل میشوند و نسخه کامپایلشده شروع به اجرا میکند. این عملیات، On Stack Replacement نامیده میشود. معمولا JIT تلاش میکند که قبل از انجام OSR، همه گزینههای دیگر بهینهسازی را امتحان کند.
جمعبندی من این است که وقت خود را بر روی بهینهسازیهای محلی تلف نکنید. در اکثر موارد ما نمیتوانیم بهتر از JIT عمل کنیم. یک روش منطقی (ولی همچنان غیرمشهود) برای بهینهسازی، شکستن متدهای طولانی به چند متد کوتاهتر است که JIT بتواند آنها را جداگانه بهینه کند.
درنهایت، بهتراست روی بهینهسازیهای کلی تمرکز کنید. در بیشتر مواقع تاثیر بسیار بیشتری دارند. مثال اولیه ما را اینگونه هم میتوان نوشت:
public class PerformanceTest4 { public static void main(String[] args) { for (int outer = 1; outer <= 100; outer++) { long start = System.nanoTime(); long sum = 10647704; long duration = System.nanoTime() - start; System.out.println("Loop # " + outer + " took " + ((duration) / 1000.0d) + " µs"); } } }
این برنامه دقیقا نتیجه مشابهی تولید میکند، ولی پیمایشها اینقدر سریع هستند که من حتی نتوانستم زمانشان روی سیستمم اندازهگیری کنم! هر پیمایش چیزی کمتر از 0.3µs طول میکشد که حداقل ۲۰ مرتبه سریعتر از نسخهٔ اولیه بهینهشده توسط JIT است و حتی کامپایل نمیشود.
این مقاله ترجمه با تلخیص از این مطلب وبسایت beyondjava است. نقل قول در تاریخ ۱۸ اسفند ۹۸ انجام شدهاست.
با ما همراه باشید
آدرس کانال تلگرام: JavaCupIR@
آدرس اکانت توییتر: JavaCupIR@
آدرس صفحه اینستاگرام: javacup.ir
آدرس گروه لینکدین: Iranian Java Developers