نشت حافظه در جاوا
یکی از مزایای اصلی جاوا JVM آن است که به طور پیشفرض مدیریت حافظه را هم برعهده میگیرد. در واقع ما اشیا را میسازیم و زباله روب جاوا مواظب تخصیص و آزاد کردن حافظه برای ما خواهد بود.
با تمام این اوصاف باز هم در جاوا نشتی حافظه میتواند رخ دهد. قبلا ابزارها و روشهایی برای شناسایی این نشتیها معرفی کردیم. اما در این مقاله در مورد نشتیهای حافظه رایجی که رخ میدهد توضیح میدهیم و دلیل آنها و تکنیکهایی برای تشخیص یا جلوگیری از وقوع آنها بحث میکنیم. در این جا از ابزار Java YourKit profiler که قبلا در مطلبی جداگانه معرفی شده است، برای تحلیل وضعیت حافظه در زمان اجرا استفاده میکنیم.
نشتی حافظه در جاوا چیست؟
تعریف استاندارد از نشتی حافظه، سناریویی است که زمانی رخ میدهد که اشیایی دیگر توسط برنامه استفاده نمیشوند اما از آنجایی که در برنامه مورد ارجاع است، زبالهروب هم نمیتواند آنها را از حافظه حذف کند. به همین دلیل برنامه بیشتر و بیشتر از حافظه استفاده میکند و در نهایت به خطای OutOfMemoryError منتهی میگردد.
برای یک فهم راحتتر به این شکل نگاه کنید:
همانطور که میتوانید ببینید اشیا یا مورد ارجاع قرار گرفتهاند ویا ارجاع داده نشدهاند. زباله روب میتواند تنها دسته دوم را پاک کند. اشیا ارجاع داده شده حتی اگر توسط برنامه دیگر استفاده نشوند، نمیتوانند جمعآوری شوند.
تشخیص نشتی حافظه میتواند دشوار باشد. ابزارهای زیادی تحلیل استاتیک برای تشخیص نشتیهای احتمالی انجام می دهند اما این تکنیکها خیلی عالی نیستند چراکه مهمترین جنبه رفتار زمان اجرای سیستم است.
پس بیایید با تحلیل چند سناریو رایج، به روشهایی برای جلوگیری از نشتی حافظه دقت کنیم.
نشتی حافظه هیپ
در این بخش به سناریو کلاسیک نشتی حافظه در جایی که اشیا جاوا بی وقفه، بدون آزادسازی ساخته میشوند، تمرکز داریم.
یک تکنیک سودمند برای فهمیدن سناریوهای نشتی حافظه، تلاش برای تکرار ایجاد آنان با کاهش فضای هیپ است. هنگام شروع به اجرای برنامه می توانیم با flagهایی به JVM میزان حافظه مورد نیاز را بدهیم.
-Xms<size>
-Xmx<size>
که حجم اولیه سایز هیپ و ماکزیمم سایز آن را مشخص میکند.
۱. فیلد استاتیک
اولین سناریو، ارجاع به یک شی سنگین در یک فیلد استاتیک است. به عنوان مثال:
private Random random = new Random(); public static final ArrayList<Double> list = new ArrayList<Double>(1000000); @Test public void givenStaticField_whenLotsOfOperations_thenMemoryLeak() throws InterruptedException { for (int i = 0; i < 1000000; i++) { list.add(random.nextDouble()); } System.gc(); Thread.sleep(10000); // to allow GC do its job }
در اینجا ArrayList یک فیلد استاتیک است، که هیچوقت توسط زبالهروب در طول حیات پروسه JVM، حتی بعد از اتمام محاسباتی که در آن استفاده شده، جمع نمیشود.
از Thread.sleep(10000) نیز استفاده شده تا زمان کافی برای جمعآوری هرچیزی که میتواند جمع شود وجود داشته باشد.
حال پروفایلر را بررسی میکنیم.
در زمان شروع همانطور که میبینید تمام حافظه خالی است.
سپس در ثانیه ۲ فرایند iteration اجرا و به اتمام رسیده است و همه چیز در لیست بارگذاری شده است. (البته طول زمان اجرا به سیستمی که اجرا روی آن انجام میشود بستگی دارد)
بعد از آن، یک سیکل کامل زباله روب اجرا شده و اجرای تست ادامه پیدا میکند تا این سیکل اجرا و اتمام یابد. همانطور که میبینید، لیست آزاد نشده و مصرف حافظه پایین نمیآید.
حال بیایید همان مثال را بازنویسی کنیم.
@Test public void givenNormalField_whenLotsOfOperations_thenGCWorksFine() throws InterruptedException { addElementsToTheList(); System.gc(); Thread.sleep(10000); // to allow GC do its job } private void addElementsToTheList(){ ArrayList<Double> list = new ArrayList<Double>(1000000); for (int i = 0; i < 1000000; i++) { list.add(random.nextDouble()); } }
به محض اینکه متد کار خود را به پایان برد، زباله روب کار خود را انجام میدهد و حافظه را آزاد میکند.
نحوه جلوگیری از آن
اولین کار برای جلوگیری از وقوع این مشکلات، دقت به استفاده از فیلدهای static است. با تعریف هر collection یا اشیا سنگین به عنوان استاتیک، طول حیات آنها به JVM متصل میشود و امکان آزادسازی حافظه آنها وجود نخواهد داشت.
علاوه بر این لازم است به طور کلی به استفاده از collectionها دقت کرد. collectionها یک راه معمول برای نگهداری رفرنسها برای مدتی طولانیتر از مدتی است که به آن نیاز داریم، پس لازم است با دقت استفاده شوند.
۲. فراخوانی String.intern روی رشته طولانی
دومین دسته از سناریوهایی که موجب نشتی حافظه میشود، عملیاتهای روی رشتههاست – به خصوص String.intern
به این مثال نگاه کنید:
@Test public void givenLengthString_whenIntern_thenOutOfMemory() throws IOException, InterruptedException { Thread.sleep(15000); String str = new Scanner(new File("src/test/resources/large.txt"), "UTF-8") .useDelimiter("\\A").next(); str.intern(); System.gc(); Thread.sleep(15000); }
در اینجا ما تلاش کردیم یک فایل متنی بزرگ را درون حافظه بارگذاری کنیم و فرم کانونی آن را با استفاده از intern برگردانیم.
API تابع intern رشته str را در استخر حافظه JVM قرار میدهد، جایی که قابل جمع کردن نیست و باز هم زبالهروب را در آزاد کردن حافظه ناتوان میکند.
به وضوح دیده میشود که در ۱۵ ثانیه اول JVM پایدار است و سپس فایل لود شده و JVM زبالهروبی میکند (ثانیه ۲۰ام)
در نهایت str.intern() فراخوانی شده و نشتی حافظه را پدید میآورد. خط پایدار نشان از مصرف هیپ بالا دارد که هیچ وقت آزاد نخواهد شد.
نحوه جلوگیری از آن
لطفا به یاد داشته باشید که اشیا رشته در فضای PermGen ذخیره میشوند و اگر برنامه ما عملیات زیادی روی رشته انجام میدهد لازم است سایز این فضا را افزایش دهیم.
-XX:MaxPermSize=<size>
راه حل دوم استفاده از جاوا ۸ است که PermGen با Metaspace جایگزین شده است و به خطای outOfMemoryError منجر نخواهد شد.
در نهایت گزینههای زیادی هم وجود دارد که اصلا از intern استفاده نکنید.
۳. استریمهای بسته نشده
یک سناریو رایج فراموش کردن بستن استریمهاست و خیلی از توسعهدهندگان آن را انجام میدهند.این مشکل در جاوا ۷ با قابلیت بستن خودکار هر نوع از استریمها با استفاده از try-with-resource تا حدودی مرتفع شد. به این دلیل تا حدودی، چون استفاده از این عبارت اختیاری است!
@Test(expected = OutOfMemoryError.class) public void givenURL_whenUnclosedStream_thenOutOfMemory() throws IOException, URISyntaxException { String str = ""; URLConnection conn = new URL("https://norvig.com/big.txt").openConnection(); BufferedReader br = new BufferedReader( new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8)); while (br.readLine() != null) { str += br.readLine(); } // }
بیایید وضعیت حافظه را وقتی یک فایل بزرگ از URL بارگذاری میکنیم بررسی کنیم.
همانطور که میبینید سایز هیپ در گذر زمان زیاد میشود که تاثیر مستقیم نشتی حافظه به خاطر نبستن استریم است.
نحوه جلوگیری از آن
لازم است همواره به خاطر داشته باشیم که استریمها را ببندیم یا از ویژگی auto-close در جاوا ۸ استفاده کنیم:
try (BufferedReader br = new BufferedReader( new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { // further implementation } catch (IOException e) { e.printStackTrace(); }
در این حالت BufferedReader به شکل خودکار در انتهای عبارت try بسته میشود بدون اینکه نیاز به بستن صریح آن در بلوک finally باشد.
۴. کانکشنهای بسته نشده
این سناریو تقریبا مشابه قبلی است ولی فقط با اتصالات مثل پایگاه داده، سرور FTP و … سرو کار دارد. دوباره پیادهسازی اشتباه میتواند مشکلات زیادی برای حافظه پدید آورد.
این مثال را ببینید:
@Test(expected = OutOfMemoryError.class) public void givenConnection_whenUnclosed_thenOutOfMemory() throws IOException, URISyntaxException { URL url = new URL("ftp://speedtest.tele2.net"); URLConnection urlc = url.openConnection(); InputStream is = urlc.getInputStream(); String str = ""; // }
URLConnection باز میماند و در نتیجه نشتی حافظه رخ میدهد.
نحوه جلوگیری از آن
پاسخ ساده است. کافی است اتصالات را به نحو مناسب ببندیم.
۵. اضافه کردن اشیائی بدون hashCode() یا equals() به HashSet
یک مثال ساده اما رایج که میتواند منجر به نشتی حافظه شود استفاده از HashSet با اشیائی است که پیادهسازی equals() و hashCode() ندارند.
مشخصا وقتی ما شروع به اضافه کردن چنین اشیاء تکراری به Set میکنیم، مجموعه به جای نادیده گرفتن تکراریها مدام بزرگتر میشود و امکان حذف این اشیا بعد از اضافه شدن آنها نیز نیست.
کلاس زیر را ببینید:
public class Key { public String key; public Key(String key) { Key.key = key; } }
حال سناریو زیر را در نظر بگیرید.
@Test(expected = OutOfMemoryError.class) public void givenMap_whenNoEqualsNoHashCodeMethods_thenOutOfMemory() throws IOException, URISyntaxException { Map < Object, Object > map = System.getProperties(); while (true) { map.put(new Key("key"), "value"); } }
پیادهسازی ساده آن در زمان اجرا به چنین وضعیتی دچار میشود.
میتوانید ببینید که چطور در 1m 40s زبالهروب متوقف شده و به ۱/۴ میرسد.
نحوه جلوگیری از آن
در این شرایط راه حل اضافه کردن equals() و hashcode() است. یک ابزار خوب برای این کار Project Lombok هست.