دونستن معماری و ساختار جاوا به ما کمک میکنه برنامه های بهینهتری بنویسیم و وقتی با مشکلات مواجه میشیم فرضیاتی راجع به ریشه مشکل داشته باشیم.
کد جاوا رو با هر چیزی میتونیم بنویسیم، از ویرایشگر های ساده تا IDE های خفن، هیچ فرقی نمیکنه. فقط باید دستور زبان جاوا رو رعایت کنیم و فایل مون رو با پسوند java ذخیره کنیم.
اینکه ما میتونیم با یکبار نوشتن برنامه اون رو بر روی پلتفرمهای مختلف اجرا کنیم بزرگترین نقطه قوت جاواست که بهش میگم WORA که مخفف write once, run anywhere هستش. همون طور که میدونید زبانهایی مثل C برای یک پلتفرم خاص و متناسب با سختافزار کامپایل میشن. اما جاوا به یک زبان دیگه تبدیل میکنه که بهش میگن bytecode با پسوند class و برای این کار از کامپایلر جاوا یعنی javac استفاده میکنه. این فایل های hexadecimal هایی هستن که JVM میتونه اونها رو بدون نیاز به کامپایل مجدد به زبان قابل درک برای هر سیستم عامل و سخت افزاری تفسیر کنه. برای همین میگن bytecode غیر وابسته به پلتفرم هستش و قابلیت جابجایی بین JVM های مختلف رو داره. فقط لازمه ما JVM مناسب با سیستم عامل و سخت افزارمون رو انتخاب کنیم.
توی این مقاله میخوام اجزای مختلف JVM رو براتون شرح بدم، به نظر من شناخت بهتر JVM میتونه کمک کنه تا برنامههای کارآمدتری بنویسیم و توی پیدا کردن خطاها هم بهمون کمک میکنه.
پیاده سازی: JVM معمولا بین دستگاه مختلف تغییراتی توی پیاده سازی داره ولی متداول ترین حالت همینی هستش که میخوام توضیح بدم.
توضیح: JVM داخل رم سیستم بارگذاری میشه و با استفاده از Class Loader Subsystem کلاس های مورد نظر رو وارد رم میکنه و بهش میگن dynamic class loading که کلاسها رو load و link و initialize میکنه. البته این کار رو فقط یک بار در زمان اجرا انجام میده.
یک، یک: Loading: مسئولیت اصلیش بارگذاری فایل ها توی رم هستش. در ابتدا با بارگذاری کلاس اصلی پروژه شروع میکنه. کلاسی که حاوی static main هستش. و بعد با توجه به دستورها کلاسهای بعدی رو توی رم بارگذاری میکنه. سه class loader داریم که اونها از ۴ اصل پیروی میکنن:
در ادامه بجای نوشتن کامل اسم لایه از مخفف استفاده میکنم:
از ACL بجای Application Class Loader
از ECL بجای Extension Class Loader
از BCL بجای Bootstrap Class Loader
لایه های Class Loader:
علاوه بر اینها میتوانیم با ساخت یک User-defined Class Loader در کد برنامه. کاملا مستقل کلاس ها را بارگذاری کنیم. هر class loader از یک namespace استفاده میکنه که کلاس های بارگذاری شده خود رو در اون با روش Fully Qualified Class Name ذخیره میکنه. زمانی که یک class loader میخواهد یک کلاس را بارگذاری کنه، اون رو تو namespace خودش با روش FQCN جستجو میکنه. اگر اون رو پیدا کنه ولی namespace آن متفاوت باشد اون رو کلاس دیگهای در نظر میگیره. namespace متفاوت به این معنی است که class loader دیگری این کلاس را بارگذاری کرده.
یک، دو: Linking: در این مرحله کلاس ها و اینترفیسهای بارگذاری شده در مرحله قبل به شرط اینکه کامل بارگذاری شده باشن، بررسی و آماده میشن. باید به این نکته کاملا توجه کنیم که اگه توی این مرحله خطایی رخ بده، این خطا به سمت برنامه پرتاب خواهد شد. این مرحله از سه بخش زیر تشکیل شده:
یک، سه: Initialization: در این قسمت کلاس های بارگذاری شده ساخته میشوند (تابع سازنده آنها صدا زده میشود). این فرایند multi thread است ولی به صورت thread safe پیادهسازی شده و مانع از ساخت شدن چندباره کلاسها میشود. این مرحله نهایی بارگذاری کلاسها و اینترفیسهاست. متغیرهای استاتیک در این مرحله مقدار دهی میشوند و بلاکهای استاتیک اجرا میشوند. به صورت سلسله مراتبی کد ما خط به خط از بالا به پایین اجرا میشود، در کلاسها و فرزندان آنها.
توضیح: JVM داخل رم یک فضا برای بارگذاری دادهها در اختیار داره. class loader شی باینری متناظر هر کلاس رو تولید میکنه و اونها رو بصورت جداگانه و با اطلاعات زیر (class level information) توی method area ذخیره میکنه.
- نام کامل FQCN به همراه نام پدر
- تمام چیزهای مربوط به کلاس (کلاسهای فرزند، اینترفیسها، اینامها و ...)
- اطلاعات متدها و متغیر های استاتیک
هر کلاس نمایندهای در حافظه Heap داره که به class level information دسترسی داره.
دو، یک: Method Area: این فضا بین تمام thread ها مشترک است. دسترسی به داده توابع و پردازش آنها باید به صورت thread safe باشد. این قسمت class level data و متغیر های static را ذخیره میکند.
توضیح: class level data از بخش های زیر تشکیل شده:
- Classloader reference
- Run time constant pool: وقتی JVM به دنبال ادرس دقیق یک تابع یا متغیر است این قسمت استفاده میکند.
- Field data: نام، نوع، modifier و ویژگیها
- Method data: نام، نوع داده بازگشتی، ورودی ها به ترتیب، modifier و ویژگیها
- Method code: بایت کد، سایزها، جداول و …
دو، دو: Heap Area: این فضا بین تمام thread ها مشترک است. اطلاعات تمام اشیا و متغیرها و آرایهها را آن ذخیره میکنیم. ذخیره دادهها در این فضا thread safe نیست. این فضایی هستش که GC ازش استفاده میکنه.
دو، سه: Stack Area: به ازای هر thread اختصاصی است. در زمانی که یک thread ساخته میشه یک فضای مجزا stack به آن اختصاص داده میشه. به ازای هر بار صدا زدن توابع یک stack frame ایجاد میشه و به بالای stack اضافه میشه (push). هر stack frame رفرنس یک آرایه از متغیر های محلی هستش و سایزش وابسته به متغیرها عوض میشه. frame ها حذف میشن (pop) وقتی توابع به صورت معمولی به پایان میرسن. وقتی خطایی رخ میدهد با توجه به دادهای stack آنها را رهگیری میکنیم. همچنین این فضا thread safe است چون منابع آن مشترک نیست. بعد از نابودی هر thread فضای stack نابود میشه. اندازه فضای stack میتواند ثابت یا متغیر باشد. اگه یک thread به فضای بیشتری نیاز داشته باشه خطای StackOverflowError پرتاب میکنه و اگه یک thread بخواد یک frame جدید درست کنه و فضای کافی نداشته باشه خطای OutOfMemoryError پرتاب میکنه. هر stack frame از سه قسمت تشکیل شده.
دو، چهار: PC Registers: به ازای هر thread اختصاصی است. برای نگهداری آدرس های دستورات در حال اجرا استفاده میشود (آدرس حافظه در Method Area). اگه متد به صورت native باشد این فضا به آن اختصاص نمی یابد. بعد از پایان دستور جاری با آدرس دستور بعدی بروز میشود.
دو، پنج: Native Method Stack: به ازای هر thread اختصاصی است. یک نگاشت مستقیم بین thread جاوا و thread سیستم عامل است و داده های متد های native را نگهداری میکند که از JNI استفاده میکند. زمانی که یک native thread ساخته و اجرا میشه.این فضا بهش اختصاص داده میشه و زمانی که thread جاوایی که این متد رو فراخوانی کرده بسته میشه این native thread هم بسته میشه و تمام حافظهای که گرفته بودند هم آزاد میشه. زمانبندی و ارسال thread ها به CPU بر عهده سیستم عامل هستش.
توضیح: bytecode ها اینجا اجرا میشن. Execution Engine دستورات رو خط به خط اجرا میکنه. و از بخشهای زیر تشکیل شده.
سه، یک: Interpreter: این بخش bytecode ها رو تفسیر میکنه و دستورها رو خط به خط اجرا میکنه. تفسیر یک خط کار خیلی سریعی هستش ولی اجرای اون کنده. عیبش این هستش که وقتی ما یک متد رو چند بار صدا بزنیم هر بار کندتر میشه.
برداشت شخصیم متدهای بازگشتی هستش! ?
سه، دو: Just-In-Time Compiler: اگه فقط مفسر وجود داشته باشه وقتی یک متد رو چند بار فراخوانی کنیم هر بار تفسیر میشه و که اگه درست کنترل بشه یک عمل بیفایدهست. دلیل وجود JIT این هستش که این مشکل رو حل کنه. در ابتدا کل bytecode رو به زبان ماشین کامپایل میکنه، بعدش برای متدهای تکراری کد از قبل کامپایل شده رو ارائه میده که خیلی سریعتر از اونی هستش که بخواد دوباره کد رو تفسیر کنه. کد کامپایل شده رو توی cache نگهداری میشه تا بشه سریع اجرا بشه.
باید توجه کنیم که JIT به زمان بیشتری برای کامپایل نیاز داره تا interpreter برای تفسیر کد. اگه ی قطعه کد فقط یک بار لازمه اجرا بشه بهتر تفسیر بشه بجای اینکه کامپایل بشه و کد کامپایل شده توی cache ذخیره میشه که پر هزینه هستش. برای حل این مشکل JIT خودش چک میکنه و متد های که زیاد اجرا میشن رو پیدا میکنه و اونها رو برای کامپایل انتخاب میکنه. اسم این کار adaptive compiling هستش و توی Oracle Hotspot VM استفاده میشه.
توضیح: JVM کلی بهینهساز عملکرد (performance optimization) داره که چهارتاشون خیلی خوبن:
- Intermediate Code Generator: کد میانی درست میکنه
- Code Optimizer: کی که ICG ساخته رو بهینه میکنه
- Target Code Generator: کد ماشین رو تولید میکنه
- Profiler: نقطه ضعفهایی که پرفورمنس رو کاهش میدن رو پیدا میکنه
دوتا رویکرد برای بهینهسازی کامپایل داریم:
رویکرد اول: Oracle Hotspot VMs: اوراکل دوتا پیاده سازی از کامپایلر EES توی JVM داره که بهشون میگه Hotspot Compiler. تو مرحله profiling که میتونه نقطه ضعفها رو پیدا کنه به JIT میگه کدوم قسمت از کدها رو به کد ماشین کامپایل کنه و cache کنه، بعد یک مدت اگه ببین اشتباه کرده و این کدها پرکاربرد نیستن، اونها رو از cache پاک میکنه و اجرای اون قسمت از کد ها رو به interpreter واگذار میکنه. این کار با جلوگیری کردن از کامپایل بیمورد باعث بالا رفتن بازدهی میشه. در ضمن Hotspot Compiler خودش در لحظه تصمیم از چه تکنیکی برای کامپایل استفاده کنه. آنالیز عملکرد در زمان اجرا برنامه به ما این امکان رو میده که قاطعانه تصمیم بگیریم کدوم تکنیک بهتره.
رویکرد دوم: Ahead-Of-Time Compiling: این تکنولوژی رو IBM معرفی کرده و اینجوری هستش که کد کامپایل شده رو میریزه تو cache تا هر موقع لازم بود کد ازش استفاده کنه و البته میشه این کد کامپایل شده رو هم باقی JVM ها بدون نیاز به کامپایل انتقال داد. و ضمنا IBM یه راه سریع هم برای اجرا کدها ارائه داده که بهش میگه JXE که مخفف Java Executable هستش. فقط باید دقت کنید ک برای استفاده از این قابلیت باید با AOT کدتون رو کامپایل کرده باشید.
سه، سه: Garbage Collector: تا زمانی برنامه که به یک شی رجوع میکند (خوندن، تغییر دادن یا مقدار دهی مجدد کردن)، JVM اون شی رو زنده در نظر میگیره. وقتی پس از گذشت زمانی معین هیچکی به اون شی سر نزنه، اون شی رو غیر قابل دسترس در نظر میگیره و GC اون شی رو حذف میکنه و حافظه اون رو آزاد میکنه. در حقیقت GC به صورت یک daemon thread هستش و خود JVM استارت میکنه ولی ما هم میتونیم با system.gc اون رو فراخوانی کنیم.
این اینترفیس برای تعامل با متدهای native هستش. JVM با JNI متدهای native رو صدا میزنه و بیشتر برای ارتباط با سخت افزار ازش استفاده میکنه.
یک مجموعه از کتابخانه های native هستن که Execution Engine به اونها برای دسترسی به JNI نیاز داره.
ما در مورد اینکه برنامهها چجوری اجرا میشن صحبت کردیم ولی درباره اجرا کننده برنامهها هیچ صحبتی نکردیم. JVM همزمان چندین thread را اجرا میکند. بعضی از اونها منطق یک برنامه هستن و به وسیله برنامهها ساخته میشن که بهشون میگن application thread و باقی thread ها رو خود JVM برای کارهای خودش ایجاد میکنه که بهشون میگن system thread.
یک: application thread همون main thread هستش و تمام thread های برنامه به وسیله این thread ساخته میشن. به عنوان مثال اجرای برنامه، ساخت اشیا توی heap و … وظیفه Application thread هستش.
دو: system thread از قسمتهای زیر تشکیل شده:
- Compiler threads: کامپایل bytecode به native code در زمان اجرا رو این thread انجام میده.
- GC threads: تمام کارهایی که GC انجام میده رو این thread انجام میده.
- Periodic task thread: رخدادهای وابسته به زمان و کارهایی که به صورت دوره ای انجام میشه رو این thread انجام میده.
- Signal dispatcher thread: دریافت سیگنالهایی که به JVM ارسال میشه، مدیریت و پردازش اونها در درون JVM با فراخوانی متد مناسب در JVM
- VM thread: بعضی کارها به JVM نیاز دارن تا اونها رو به یک نقطه درست از Heap برسونه، جایی که تغییرات زیاد نیست. مثلا: stop-the-world garbage collections یا thread stack dumps یا thread suspension یا biased locking revocation. این دسته از عملیاتها برای اجرا به این thread نیاز دارند.
جاوا یک زبان مفسری و کامپایلری هستش.
طراحی جاوا جوری هستش که در پیوندهای پویا (dynamic linking) و تفسیر در زمان اجرا (run-time interpreting) کند هستش.
کامپایلر JIT نقطه ضعف جاوا رو در تفسیر کدها رو با cache کردن کدهای کامپایل شده بجای bytecode پوشش میده
توجه داشته باشید JVM فقط یک سند هستش، ارائه دهنده های اون میتونن چیزی رو تغییر بدن، نوآوری داشته باشن و یا در زمان پیاده سازی چیزی رو بهبود ببخشن.
لطفا اگه اشتباهات املایی یا انشایی داره بهم پیام بدید تا اصلاح کنم.