حسام درویشیان
حسام درویشیان
خواندن ۱۳ دقیقه·۲ سال پیش

معماری جاوا

دونستن معماری و ساختار جاوا به ما کمک می‌کنه برنامه های بهینه‌تری بنویسیم و وقتی با مشکلات مواجه می‌شیم فرضیاتی راجع به ریشه مشکل داشته باشیم.


کد جاوا رو با هر چیزی می‌تونیم بنویسیم، از ویرایشگر های ساده تا IDE های خفن، هیچ فرقی نمی‌کنه. فقط باید دستور زبان جاوا رو رعایت کنیم و فایل مون رو با پسوند java ذخیره کنیم.

اینکه ما می‌تونیم با یکبار نوشتن برنامه اون رو بر روی پلتفرم‌های مختلف اجرا کنیم بزرگترین نقطه قوت جاواست که بهش می‌گم WORA که مخفف write once, run anywhere هستش. همون طور که می‌دونید زبان‌هایی مثل C برای یک پلتفرم خاص و متناسب با سخت‌افزار کامپایل می‌شن. اما جاوا به یک زبان دیگه تبدیل می‌کنه که بهش می‌گن ‌bytecode با پسوند class و برای این کار از کامپایلر جاوا یعنی javac استفاده می‌کنه. این فایل های hexadecimal هایی هستن که JVM می‌تونه اونها رو بدون نیاز به کامپایل مجدد به زبان قابل درک برای هر سیستم عامل و سخت افزاری تفسیر کنه. برای همین می‌گن bytecode غیر وابسته به پلتفرم هستش و قابلیت جابجایی بین JVM های مختلف رو داره. فقط لازمه ما JVM مناسب با سیستم عامل و سخت افزارمون رو انتخاب کنیم.

توی این مقاله می‌خوام اجزای مختلف JVM رو براتون شرح بدم، به نظر من شناخت بهتر JVM می‌تونه کمک کنه تا برنامه‌های کارآمدتری بنویسیم و توی پیدا کردن خطاها هم بهمون کمک می‌کنه.

پیاده سازی: JVM معمولا بین دستگاه مختلف تغییراتی توی پیاده سازی داره ولی متداول ترین حالت همینی هستش که می‌خوام توضیح بدم.

یک: Class Loader Subsystem

توضیح: JVM داخل رم سیستم بارگذاری می‌شه و با استفاده از Class Loader Subsystem کلاس های مورد نظر رو وارد رم می‌کنه و بهش می‌گن dynamic class loading که کلاس‌ها رو load و link و initialize می‌کنه. البته این کار رو فقط یک بار در زمان اجرا انجام می‌ده.

یک، یک: Loading: مسئولیت اصلیش بارگذاری فایل ها توی رم هستش. در ابتدا با بارگذاری کلاس اصلی پروژه شروع می‌کنه. کلاسی که حاوی static main هستش. و بعد با توجه به دستورها کلاس‌های بعدی رو توی رم بارگذاری می‌کنه. سه class loader داریم که اونها از ۴ اصل پیروی می‌کنن:

  • اصل اول: Visibility Principle: این اصل می‌گه که child class loader می‌تونه کلاس هایی که parent class loader بارگذاری کرده رو ببینه ولی parent class loader نمی‌تونه کلاس هایی رو که child class loader بارگذاری کردند رو پیدا کنه.
  • اصل دوم: Uniqueness Principle: این اصل به بارگذاری یکتا کلاس ها اشاره می‌کنه و می‌گه یک child class loader نباید بتونه کلاسی که توسط پدرش بارگذاری شده رو دوباره بارگذاری کنه. و هیچ کلاسی نباید بیش از یکبار بارگذاری بشه.
در ادامه بجای نوشتن کامل اسم لایه از مخفف استفاده می‌کنم:
از ACL بجای Application Class Loader
از ECL بجای Extension Class Loader
از BCL بجای Bootstrap Class Loader
  • اصل سوم: Delegation Hierarchy Principle: این اصل برای اطمینان از دو اصل قبل است. JVM به صورت سلسله مراتبی کلاس ها رو بارگذاری می‌کنه و برای هر بارگذاری یک درخواست می‌فرسته. ACL درخواست رو دریافت می‌کنه و اون رو برای ECL ارسال می‌کنه و اون هم سپس درخواست رو برای BCL ارسال می‌کنه. اگر کلاس درخواست شده در Bootstrap path پیدا شد یعنی کلاس پیدا و بارگذاری شده است. در غیر این صورت درخواست برگشت داده می‌شه به ECL تا کلاس را در Extension path یا custom specific path جستجو و بارگذاری کنه. اگر این تلاش موفقیت آمیز نباشه درخواست برگشت داده می‌شه به ACL تا کلاس را در System class path جستجو و بارگذاری کنه، اگر این جستجو موفقیت آمیز نباشه ما با خطای ClassNotFoundException در زمان اجرا روبرو می‌شویم.
  • اصل چهارم: No Unloading Principle: یک class loader می‌تونه کلاس ها رو بارگذاری کنه ولی نمی‌تونه اون رو تخلیه کنه. بجای تخلیه کلاس های بارگذاری شده ما می‌تونیم class loader رو حذف کنیم و یک class loader جدید ایجاد کنیم.

لایه های Class Loader:

  • لایه Bootstrap Class Loader: کلاس های استاندارد JDK و هسته جاوا را در آدرس JAVA_HOME/jre/lib بارگذاری می‌کنه. همچنین با زبان ها محلی مثلا C پیاده‌سازی شده و در جاوا به عنوان والد تمام class loader هاست.
  • لایه Extension Class Loader: وظیفه داره درخواست بارگذاری را برای BCL ارسال کند و در صورت موفقیت آمیز نبودن آن را در آدرس JAVA_HOME/jre/lib/ext یا هر آدرس دیگری که در java.ext.dirs در تنظیمات سیستم قرار داره جستجو و بارگذاری می‌کنه. همچنین با زبان جاوا پیاده‌سازی شده و از ExtClassLoader مشتق شده است.
  • لایه Application Class Loader: بارگذاری کلاس‌های خاص برنامه را از system class path بر عهده داره که می‌تونیم این آدرس را در زمان اجرا با فرمان cp یا classpath تغییر بدیم. به صورت پیشفرض از آدرس java.class.path استفاده می‌کنه. همچنین با زبان جاوا پیاده‌سازی شده و از AppClassLoader مشتق شده است.

علاوه بر اینها می‌توانیم با ساخت یک User-defined Class Loader در کد برنامه. کاملا مستقل کلاس ها را بارگذاری کنیم. هر class loader از یک namespace استفاده می‌کنه که کلاس های بارگذاری شده خود رو در اون با روش Fully Qualified Class Name ذخیره می‌کنه. زمانی که یک class loader می‌خواهد یک کلاس را بارگذاری کنه، اون رو تو namespace خودش با روش FQCN جستجو می‌کنه. اگر اون رو پیدا کنه ولی namespace آن متفاوت باشد اون رو کلاس دیگه‌ای در نظر می‌گیره. namespace متفاوت به این معنی است که class loader دیگری این کلاس را بارگذاری کرده.

یک، دو: Linking: در این مرحله کلاس ها و اینترفیس‌های بارگذاری شده در مرحله قبل به شرط اینکه کامل بارگذاری شده باشن، بررسی و آماده میشن. باید به این نکته کاملا توجه کنیم‌ که اگه توی این مرحله خطایی رخ بده، این خطا به سمت برنامه پرتاب خواهد شد. این مرحله از سه بخش زیر تشکیل شده:

  • بخش اول: Verification: این سخت‌ترین و سنگین ترین قسمت هستش و زمان زیاری می‌بره البته بهینه هستش و فقط یک بار انجام میشه. در این مرحله مطمئن می‌شیم که کلاس های بارگذاری شده قواعد جاوا رو دارند و با یک کامپایلر معتبر کامپایل شدن یا نه. اگه مشکلی توی این مرحله به‌وجود بیاد خطای java.lang.VerifyError پرتاب می‌شه.
  • بخش دوم: Preparation: در این مرحله حافظه مورد نیاز کلاس‌ها شامل فضای استاتیک و فضای داده‌های کلاس به اونها تخصیص داده می‌شه. دقت کنید متغیر های استاتیک در این مرحله ساخته نمی‌شن و این کار بر عهده قسمت دیگه‌ای از برنامه‌ست.
  • بخش سوم: Resolution: در این مرحله نماد رفرنس داده ها با رفرنس مستقیم اونها جایگزین می‌شه.

یک، سه: Initialization: در این قسمت کلاس های بارگذاری شده ساخته می‌شوند (تابع سازنده آنها صدا زده می‌شود). این فرایند multi thread است ولی به صورت thread safe پیاده‌سازی شده و مانع از ساخت شدن چندباره کلاس‌ها می‌شود. این مرحله نهایی بارگذاری کلاس‌ها و اینترفیس‌هاست. متغیرهای استاتیک در این مرحله مقدار دهی می‌شوند و بلاک‌های استاتیک اجرا می‌شوند. به صورت سلسله مراتبی کد ما خط به خط از بالا به پایین اجرا می‌شود، در کلاس‌ها و فرزندان آنها.

دو: Runtime Data Area

توضیح: 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 از سه قسمت تشکیل شده.

  • یک: Local Variable Array: یک آرایه است که ایندکس ۰ (صفر) رفرنس کلاسی است که متد در آن است و از ایندکس ۱ (یک) پارامتر هایی که به متد ارسال می‌شن شروع می‌شن و سپس متغیر های محلی در آن قرار دارد.
  • دو: Operand Stack: به عنوان یک فضای کاری در زمان اجراست که اگر نیاز باشد برای انجام عملیات استفاده می‌شود. تمام متد‌ها داده‌ها را بین operand stack و local variable array و دیگر متد‌ها تبادل می‌کنند. اندازه این فضا را در زمان اجرا مشخص می‌شود مگر اینکه در زمان کامپایل تعیین شده باشد.
  • سه: Frame Data: تمام نماد‌های متد و بلاک های catch در این فضا نگهداری می‌شود.

دو، چهار: PC Registers: به ازای هر thread اختصاصی است. برای نگهداری آدرس های دستورات در حال اجرا استفاده می‌شود (آدرس حافظه در Method Area). اگه متد به صورت native باشد این فضا به آن اختصاص نمی یابد. بعد از پایان دستور جاری با آدرس دستور بعدی بروز می‌شود.

دو، پنج: Native Method Stack: به ازای هر thread اختصاصی است. یک نگاشت مستقیم بین thread جاوا و thread سیستم عامل است و داده های متد های native را نگهداری می‌کند که از JNI استفاده می‌کند. زمانی که یک native thread ساخته و اجرا می‌شه.این فضا بهش اختصاص داده می‌شه و زمانی که thread جاوایی که این متد رو فراخوانی کرده بسته می‌شه این native thread هم بسته می‌شه و تمام حافظه‌ای که گرفته بودند هم آزاد می‌شه. زمان‌بندی و ارسال thread ها به CPU بر عهده سیستم عامل هستش.

سه: Execution Engine

توضیح: ‍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 خودش در لحظه تصمیم از چه تکنیکی برای کامپایل استفاده کنه. آنالیز عملکرد در زمان اجرا برنامه به ما این امکان رو می‌ده که قاطعانه تصمیم بگیریم کدوم تکنیک بهتره.

  • پیاده سازی اول:‌ Oracle Java Hotspot Client: این تکنولوژی بهینه شده تا با کاهش زمان اجرا و حافظه مصرفی بازدهی رو برای سرویس گیرنده ها بیشتر کنه.
  • پیاده سازی دوم: Oracle Java Hotspot Server: این تکنولوژی برای بالاترین بازدهی بر روی سرورها طراحی شده است و بهش می‌گن Advanced Dynamic Optimizing Compiler و از کلی تکنیک‌های بهینه سازی پیچیده استفاده می‌کنه و موقع اجرا کردنش توی خط فرمان مثلا باید بگیم java server MyApp
    این تکنولوژی اوراکل معروف هستش به اینکه خیلی سریع حافظه رو تخصص می‌ده و خیلی سریع اون رو آزاد می‌کنه و به صورت خیلی مقیاس‌پذیری می‌تونه پردازش‌های موازی رو سرورهایی با CPU های خفن مدیریت کنه.

رویکرد دوم: Ahead-Of-Time Compiling: این تکنولوژی رو IBM معرفی کرده و اینجوری هستش که کد کامپایل شده رو می‌ریزه تو cache تا هر موقع لازم بود کد ازش استفاده کنه و البته میشه این کد کامپایل شده رو هم باقی JVM ها بدون نیاز به کامپایل انتقال داد. و ضمنا IBM یه راه سریع هم برای اجرا کدها ارائه داده که بهش می‌گه JXE که مخفف Java Executable هستش. فقط باید دقت کنید ک برای استفاده از این قابلیت باید با AOT کدتون رو کامپایل کرده باشید.

سه، سه: Garbage Collector: تا زمانی برنامه که به یک شی رجوع می‌کند (خوندن، تغییر دادن یا مقدار دهی مجدد کردن)، JVM اون شی رو زنده در نظر می‌گیره. وقتی پس از گذشت زمانی معین هیچکی به اون شی سر نزنه، اون شی رو غیر قابل دسترس در نظر می‌گیره و GC اون شی رو حذف می‌کنه و حافظه اون رو آزاد می‌کنه. در حقیقت GC به صورت یک daemon thread هستش و خود JVM استارت می‌کنه ولی ما هم می‌تونیم با system.gc اون رو فراخوانی کنیم.

چهار:‌ Java Native Interface

این اینترفیس برای تعامل با متد‌های native هستش. JVM با JNI متدهای native رو صدا میزنه و بیشتر برای ارتباط با سخت افزار ازش استفاده می‌کنه.

پنج: Native Method Libraries

یک مجموعه از کتابخانه های native هستن که Execution Engine به اونها برای دسترسی به JNI نیاز داره.


درباره JVM Threads

ما در مورد اینکه برنامه‌ها چجوری اجرا می‌شن صحبت کردیم ولی درباره اجرا کننده برنامه‌ها هیچ صحبتی نکردیم. 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 فقط یک سند هستش، ارائه دهنده های اون می‌تونن چیزی رو تغییر بدن، نوآوری داشته باشن و یا در زمان پیاده سازی چیزی رو بهبود ببخشن.

لطفا اگه اشتباهات املایی یا انشایی داره بهم پیام بدید تا اصلاح کنم.
javajvm
Android Engineer at Adevinta
شاید از این پست‌ها خوشتان بیاید