سرعت اجرای کد در جاوا


سلام

توی این پست می‌خوام یه مقدار درباره‌ی سرعت اجرای کدهای جاوا صحبت کنم. البته در مورد optimize کردن کدهای جاوا خیلی می‌شه صحبت کرد، و ان‌شاءالله به مرور زمان این کار رو خواهیم کرد. فعلاً با یه بررسی اولیه شروع می‌کنیم. خیلی خوشحال می‌شیم نظرات شما رو هم بدونیم، و مخصوصاً اگه جایی احساس کردید اشتباه می‌کنم حتماً اطلاع بدید.


موقع محاسبه‌ی سرعت اجرای کد، یک نکته‌ی خیلی مهم اینه که تمرکزتون رو روی جاهایی بذارید که واقعاً از نظر اجرا زمان‌بر هستن. یعنی مثلاً مقداردهی به یه متغیر زمان خاصی نمی‌بره و اصلاً نباید بخش‌هایی از کد که دارن پارامترها رو مقداردهی می‌کنن جزو محاسبه‌ی سرعت اجرا در نظر بگیرید (حتی اگه ۱۰۰ تا متغیر دارن مقداردهی می‌شن، چون زمانش نسبت به کارهای دیگه بسیار ناچیز هست). همچنین بخش‌هایی رو باید بیشتر بهشون بپردازید که بیشتر اجرا می‌شن. مثلاً یه بخش از کد که برای initialization هست و فقط یک بار همون اول اجرا می‌شه، در صورتی که زمان startup برنامه‌تون قابل قبول هست، نیازی به optimize کردن نداره. عوضش جایی از کد که ۳ تا حلقه‌ی تو در تو داره و توش یه عملیات پیچیده انجام می‌شه، و این کد هم مثلاً هر چند ثانیه یه بار اجرا می‌شه (یا مثلاً روی هر درخواست کاربر یه بار اجرا می‌شه) قطعاً کاندید بهتری برای optimize کردن هست.

مهمترین عمیلات زمان‌بر توی برنامه اینها هستن:

عملیات I/O

یکی از زمان‌برترین کارهای ممکن توی رایانه، استفاده از دیسک هست (که معمولاً «نوشتن توی دیسک»، کندتر از «خوندن از دیسک» هست). شما وقتی یه کدی دارید که توش از فایل می‌خونید، اول از همه باید به حجم فایل و نحوه‌ی خوندن از فایل توجه کنید. البته توجه کنید که توی سیستم‌عامل‌های امروزی، فایل‌ها توسط سیستم‌عامل به صورت خودکار توی حافظه cache می‌شن و در نتیجه دفعه‌ی اولی که فایل استفاده می‌شه (چه موقع نوشتن فایل، چه موقع خوندن) محتویاتش میاد داخل RAM و وقتی توسط برنامه‌ی شما خونده می‌شه دیگه شاید کندی‌ای احساس نکنید. البته کافیه یه بار سیستم رو hibernate کنید تا کل cache سیستم بپره! یا اگه محتویات فایل زیاد باشه و توی RAM جا نشه، باز هم کندیش رو خواهید دید. یا اگه از آخرین بار که فایل مورد نظر خونده شده، فایل‌های دیگه‌ای هم خونده بشن، محتویات اونها میاد توی cache و اگه دیگه جا نداشته باشه محتویات قبلی رو از cache خارج می‌کنه.
در مورد نحوه‌ی خوندن از فایل می‌شه گفت که اولاً خوندن به صورت random از یه فایل، به شدت کندتر از خوندن به صورت sequential هست (داریم در مورد دیسک‌های معمولی SATA صحبت می‌کنیم، در مورد دیسک‌های SSD شدت این موضوع خیلی کمتر هست). ثانیاً اگه مثلاً فایل رو به صورت buffered نخونید سرعت خوندنش خیلی پایین‌تر میاد.

استفاده از شبکه هم به شدت نسبت به کارهای معمولی کند هست. دقت کنید که این کندی نسبی هست و داریم با سرعت اجرای دستورات توسط CPU مقایسه‌اش می‌کنیم. بله، شبکه‌های امروزی خیلی سریع هستند، مخصوصاً اگه local باشن. الان دیگه به راحتی شبکه‌های گیگ همه جا پیدا می‌شن (یعنی ۱ گیگابیت در ثانیه، که می‌شه ۱۲۸ مگابایت در ثانیه)، اگه ۱۰ گیگ باشه که دیگه هیچی! (میشه ۱.۲ گیگابایت در ثانیه). اما پارامترهای دیگه‌ای هم توی محاسبه‌ی سرعت عملیات شبکه دخیل هستن، مثل پردازش‌هایی که توی stack شبکه روی داده انجام می‌شه تا به دست برنامه برسه، یا سرعت رفت و برگشت packet توی شبکه (RTT). به صورت متوسط این عدد رو می‌تونیم توی یه دیتاسنتر نیم میلی‌ثانیه در نظر بگیریم (۱). حالا یه محاسبه‌ی کوچیک بکنیم: اگه بخوایم مثلاً یک کیلوبایت داده رو از شبکه‌ی ۱Gbps بخونیم، زمان خوندنش می‌شه ۱۰۰۰ میلی‌ثانیه تقسیم بر ۱۲۸*۱۰۲۴ که می‌شه حدود ۷۶۰۰ نانوثانیه. وقتی بقیه‌ی overhead ها رو هم در نظر بگیریم، این عدد خیلی بالاتر می‌ره. حالا شما این عدد رو مقایسه کنید با سرعت اجرای یک دستور در پردازنده‌ی ۲ گیگاهرتز، که در حد یک نانوثانیه هست.
پس نتیجه شد این: زمان خوندن داده از شبکه، هزاران برابر زمان اجرای یه دستور هست.

یکی دیگه از کارهایی که به شدت می‌تونه اجرای برنامه رو کند کنه، خروجی گرفتن توی کنسول هست. باز هم تأکید می‌کنم: این کار به صورت نسبی کند هست. یعنی وقتی اثرش رو می‌بینید که مقدار خروجی‌های کنسولتون خیلی زیاد باشه (مخصوصاً اگه از داخل IDE تون برنامه رو اجرا می‌کنید، در این حالت سرعت کنسول خییییییییییلی کندتر از یه کنسول عادی هست). مثلاً وقتی که تند و تند توی کنسول لاگ می‌گیرید، در حدی که سرعتش انقدر زیاده که نمی‌تونید بخونید چی نوشته (و همینطور تند و تند خطوط رد می‌شن و می‌رن)، این کار به شدت سرعت اجرای برنامه رو پایین میاره. اما خروجی در حد معمول چیزی نیست که سرعت اجرای برنامه رو پایین بیاره. مثلاً اگه در هر ثانیه ۱۰ خط هم توی کنسول بنویسید اتفاق خاصی نمی‌افته. اما اگه هر ثانیه ۱۰۰۰ خط بنویسید دیگه کم کم سیستم از دستتون ناراحت می‌شه!

بحثی که بالاتر مطرح کردیم، بیشتر به عواملی به غیر از CPU مربوط بود. کارهایی که با بخش‌های دیگه‌ی سخت‌افزار (مثل دیسک و شبکه) انجام می‌شه، به مراتب کندتر از عملیات CPU هست. بنابراین بعد از این که به اون بخش‌ها رسیدگی کردیم، به سراغ بخش‌ CPU می‌ریم.

استفاده از Lock ها و Synchronization

یکی از سنگین‌ترین عملیات CPU، استفاده از Lock ها و Synchronization هست. هر بار lock یا unlock کردن یک mutex، زمان خیلی زیادی می‌گیره. علاوه بر این، بخش‌های critical section کد باعث می‌شن اون بخش از برنامه به صورت تک-ریسمانی اجرا بشه و در نتیجه بقیه‌ی ریسمان‌ها پشت سرش منتظر بمونن. بنابراین باید سعی کنید حتی‌الامکان استفاده از lock ها رو محدود کنید (یعنی هم هر چه کمتر استفاده کنید، و هم این که critical section ها رو حتی‌الامکان کوچیک کنید).

توی جاوا چندین نوع استفاده از synchronization داریم (که سرعتشون با هم فرق می‌کنه):

  • synchronized (به صورت بلاک مجزا، یا توی تعریف تابع)
  • Lock ها (به همراه خانواده‌اش، مثل ReentrantLock یا ReadWriteLock)
  • Semaphore

متغیرهای Atomic (مثل AtomicInteger, AtomicLong, …) توی پیاده‌سازیشون از lock استفاده نمی‌کنن، عوضش از compareAndSwap استفاده می‌کنن. در نتیجه این متغیرها هم به شدت از متغیرهای معمولی کندتر هستن (ولی نسبت به استفاده از lock خیلی سریع‌تر هستن).

دقت کنید وقتی از synchronization استفاده می‌کنید، لزوماً CPU درگیر نمی‌شه، ولی می‌بینید که سیستم کلاً کند شده. دلیلش اینه که چند-ریسمانی برنامه اینجوری مشکل پیدا می‌کنه. یعنی CPU در این حالت bottleneck نمی‌شه، بلکه نحوه‌ی استفاده از اون باعث می‌شه که برنامه کند بشه.

عملیات محاسباتی پیچیده

عملیاتی که شامل محاسبات زیادی هستند (ضرب و تقسیم‌های زیاد) زمان نسبتاً زیادی از سیستم می‌گیرن (جمع و تفریق از ضرب و تقسیم خیلی سبک‌تر هستن). البته دقت کنید که نیازی نیست که حتماً این عملیات ضرب و تقسیم توی کد شما باشه. ممکنه این کار توی دل یه کتابخونه انجام بشه و شما فقط در حد یه function call ببینیدش. مثلاً استفاده از تابع Math.log نسبتاً زمان‌بر هست، چون توی دلش عملیات محاسباتی زیادی داره.
همچنین عملیات ضرب و تقسیم اعداد اعشاری نسبت به اعداد طبیعی سنگین‌تر هستند.

Garbage Collection

عملیات Garbage Collection توی جاوا، می‌تونه تأثیر منفی روی سرعت برنامه داشته باشه. البته از GC گریزی نیست! ولی می‌شه یه جوری تنظیمش کرد که کمترین تأثیر منفی رو روی سرعت اجرای برنامه داشته باشه. همچنین می‌تونیم برنامه رو جوری بنویسیم که کمتر garbage تولید کنه.

در مورد تنظیمات GC ان‌شاءالله توی یه پست دیگه صحبت خواهم کرد.

ساختن اشیاء بیش از حد!

جاوا یه زبون کاملاً شیء گرا هست و در نتیجه به صورت کاملاً معمول، در اثر انجام کارهای مختلف، اشیاء مختلفی هم ساخته می‌شن. اما بعضی وقتها حواسمون نیست و همینطور شیء درست می‌کنیم! یه مثال خیلی متداولش اینه:

کلاس String یه کلاس Immutable هست، یعنی بعد از ساخته شدن، به هیچ وجه نمی‌تونید تغییرش بدید. در اثر فراخوانی هر تابع تغییر دهنده روی یه String (مثلاً substring یا replace یا trim)، یه شیء جدید ساخته می‌شه و داده‌های String قبلی توی این شیء جدید کپی می‌شه و تغییرات توی شیء جدید اعمال می‌شه. نکته‌ای که اینجا هست اینه که شما با این کار چند تا هزینه می‌پردازید:

  • یه شیء جدید ساخته می‌شه و در نتیجه هزینه‌ی GC رو بالا می‌برید.
  • ساختن یه شیء جدید خودش یه مقدار (خیلی کوچیک) زمان می‌بره، که وقتی تعداد اشیاء ساخته شده خییییییلی زیاد بشه، این زمان قابل توجه می‌شه.
  • کپی کردن داده‌ی شیء قبلی توی شیء جدید عملیات نسبتاً زمان‌بری هست.

ممکنه بگید «خوب به من چه! من بالاخره مجبورم این توابع رو فراخوانی کنم وگرنه کدم کار نمی‌کنه!». بعضی وقتها می‌شه به روشهایی کاری کرد که این اشیاء اضافی ساخته نشن. مثلاً ممکنه بتونید تعداد replace ها رو کم کنید، یا از روشهای دیگه‌ای استفاده کنید. توی این پست خیلی نمی‌تونم وارد جزئیاتش بشم، شاید بعداً توی یه پست دیگه به این موضوع پرداختم.

در نهایت یکی از بخش‌هایی که می‌شه با optimize کردنش سرعت کد رو بالا برد، عملیات اضافه‌ای هست که انجام می‌شه و نیازی بهشون نیست. یه مثالش توابع replace و replaceAll در کلاس String هست. عملیات جایگزینی در String سه نوع داره:

  • جایگزین کردن یه کاراکتر با یه کاراکتر دیگه
  • جایگزین کردن یه رشته با یه رشته‌ی دیگه
  • جایگزین کردن یه عبارت منظم (regular expression) با یه رشته‌ی دیگه

نوع اول بسیار سریع‌تره، و نوع آخر بسیار کندتر. بنابراین اگه می‌خواین یه کاراکتر رو با یه کاراکتر دیگه جایگزین کنید، به جای استفاده از double quotation (یعنی ” )، از single quotation (یعنی ‘ ) استفاده کنید تا سرعت اجرای اون دستور بسیار بالاتر بره. نکته‌ی دیگه اینه که حواستون باشه وقتی نیازی به عبارات منظم ندارید، بیخودی از روش سوم استفاده نکنید.

ان‌شاءالله بعداً در پست‌های دیگه بیشتر به مباحث optimization خواهیم پرداخت.

باز هم می‌گم که خیلی خوشحال می‌شم نظرات دوستان رو هم بدونم. نظرات شما به ما برای ادامه‌ی کار روحیه می‌ده.

(۱) منبع: صحبت‌های Jeff Dean، مهندس ارشد گوگل (البته ایشون یه کم فراتر از یه «مهندس ارشد» در گوگل هست! در واقع بخش عمده‌ی طراحی و پیاده‌سازی سیستم‌های مختلف گوگل (مخصوصاً موتور جستجو) بر عهده‌ی ایشون بوده)، در دانشگاه استنفورد، سال ۲۰۰۷ – اسلایدهای این صحبت‌ها در صفحه‌ی خود Jeff Dean هست – اسلاید ۱۳، بخش Numbers Everyone Should Know – اینجا هم این اسلاید رو میارم:

Numbers Everyone Should Know

L1 cache reference ۰٫۵ ns
Branch mispredict ۵ ns
L2 cache reference ۷ ns
Mutex lock/unlock ۱۰۰ ns
Main memory reference ۱۰۰ ns
Compress 1K bytes with Zippy ۱۰,۰۰۰ ns
Send 2K bytes over 1 Gbps network ۲۰,۰۰۰ ns
Read 1 MB sequentially from memory ۲۵۰,۰۰۰ ns
Round trip within same datacenter ۵۰۰,۰۰۰ ns
Disk seek ۱۰,۰۰۰,۰۰۰ ns
Read 1 MB sequentially from network ۱۰,۰۰۰,۰۰۰ ns
Read 1 MB sequentially from disk ۳۰,۰۰۰,۰۰۰ ns
Send packet CA->Netherlands->CA ۱۵۰,۰۰۰,۰۰۰ ns

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

منبع : http://blog.yooz.ir/


About admin

مهندس ناصر نیازی متولد وساکن روستای قایش شهرستان رزن استان همدان در 2 امین روز سال 67 و برنامه نویس و طراح وبسایت

‎پیام بگذارید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *