تسرّب الذاكرة في Java
تسرّب الذاكرة في Java
يمتلك Java مُجمِّع قمامة (Garbage Collector)، لذا يظنّ كثير من المطوّرين أن تسرّب الذاكرة مستحيل. هذا الافتراض خاطئ وخطير. تسرّب الذاكرة في Java يعني أن كائنات لا تحتاجها التطبيق بعد الآن لا تزال يمكن الوصول إليها عبر رسم بيان المراجع ولذلك لا يُحرَّر ذاكرتها أبدًا. يُحرِّر مُجمِّع القمامة فقط ما لا يمكن الوصول إليه؛ فإن احتفظ كائن طويل العمر بمرجع إلى كائن قصير العمر، فإن الكائن القصير سيبقى حيًا إلى الأبد.
لماذا يصعب اكتشاف التسرّبات؟
التسرّبات لا تُوقف الـ JVM فورًا. بل تتسبّب في ارتفاع تدريجي مستمر في استخدام الكومة (Heap) — ظاهرة تُعرف بـ memory creep. يعمل التطبيق بشكل طبيعي لساعات أو أيام، ثم تزداد فترات توقف GC في الجيل القديم (Old Generation)، وينخفض معدل الأداء، وفي النهاية يظهر خطأ OutOfMemoryError. بحلول ذلك الوقت قد يكون السبب الجذري كامنًا في ذاكرة تخزين مؤقت أو مستمع سُجِّل قبل أيام.
النمط الأول: المجموعات الساكنة كذاكرة تخزين مؤقت
أكثر التسرّبات شيوعًا: حقل static يحمل مجموعة تنمو بلا حدود لأن العناصر تُضاف إليها ولا تُزال منها أبدًا.
الحل: احذف المدخلات حين لا تحتاجها، أو استخدم هيكل بيانات محدود الحجم. إن أردت ذاكرة تخزين مؤقت تحذف المدخلات القديمة تلقائيًا، استخدم LinkedHashMap مع تجاوز removeEldestEntry، أو مكتبة متخصصة مثل Caffeine.
النمط الثاني: مستمعو الأحداث والاستدعاءات المنسيّة
كلما سجّل كائن نفسه كمستمع لناشر أطول عمرًا منه، فإن الناشر يحتفظ بمرجع إليه. إن لم يُلغَ تسجيل المستمع قط، فلا يمكن تحريره — ولا أي شيء يشير إليه.
AutoCloseable على أي كائن يسجّل استدعاءات أو يحصل على موارد. يستطيع حينئذٍ المستدعون استخدام try-with-resources لضمان التنظيف، وستحذّر أدوات التحليل الساكن مثل SpotBugs حين لا يُغلق الكائن.
النمط الثالث: الكلاسات الداخلية التي تلتقط النسخة الخارجية
الكلاس الداخلي غير الساكن يحمل دائمًا مرجعًا ضمنيًا للنسخة التي تحتويه. إن خرج الكلاس الداخلي إلى نطاق أطول عمرًا (خيط، مهمة مؤقتة، استدعاء إطار عمل)، فإنه يجرّ الكلاس الخارجي معه.
الحل: استخدم كلاسًا داخليًا static، أو تعبير لامبدا يلتقط فقط البيانات التي يحتاجها وليس النسخة الخارجية بأكملها، أو احتفظ فقط بمرجع ضعيف للكائن الخارجي.
النمط الرابع: متغيرات ThreadLocal
مجمعات الخيوط (Thread Pools) تُعيد استخدام الخيوط. قيمة ThreadLocal المحدَّدة على خيط في المجمع تبقى إلى الأبد ما لم تُحذف صراحةً. في حاويات الويب حيث يديرها الخادم، هذا تسرّب بالغ الشيوع.
ThreadLocal.remove() داخل كتلة try-finally أبدًا. الخيوط في المجمع تعيش طوال عمر التطبيق، لذا فإن غياب remove() يثبّت القيمة — وكل ما تشير إليه — في الذاكرة لنفس المدة. يمكن أن يتسبّب ذلك أيضًا في أخطاء صحة حين يعالج نفس الخيط لاحقًا طلبًا غير ذي صلة ويرى بيانات قديمة.
النمط الخامس: سوء استخدام WeakHashMap
يُوصى بـ WeakHashMap كثيرًا باعتبارها "ذاكرة تخزين مؤقت تنظّف نفسها"، لكنها تسمح فقط بمراجع ضعيفة للمفاتيح. إن كانت القيم تحمل مرجعًا قويًا للمفتاح (مباشرةً أو غير مباشر)، فالمفتاح دائمًا قابل للوصول ولن يُحرَّر — لن يتقلّص الـ Map أبدًا.
كيف تكتشف التسرّبات: العلامات الأساسية
- ارتفاع استخدام الكومة بشكل رتيب عبر دورات GC كاملة متعددة دون أن ينخفض مجددًا.
- معدل امتلاء الجيل القديم يزيد بمرور الوقت حتى تحت حمل ثابت.
- سجلات GC تُظهر الـ Full GC يعمل بتكرار متزايد مع استرداد ذاكرة أقل في كل دورة.
- مخطط الكومة (
jmap -histo:live <pid>) يُظهر كلاسًا يزداد عدد نسخه باستمرار رغم أنه يجب أن يكون محدودًا. - مقارنة لقطتي Heap Dump مأخوذتين بفارق دقائق تكشف أنواع الكائنات التي تراكمت.
jcmd <pid> GC.heap_info لرؤية الأحجام الإجمالية، ثم jmap -histo:live <pid> | head -30 لترتيب أنواع الكائنات حسب العدد المحتجز. قارن النتيجة بين أخذها مرتين بفارق خمس دقائق تحت الحمل — النوع الذي يظل عدده يتصاعد هو المشتبه به.
أنماط دفاعية للوقاية من التسرّبات
- فضّل ذاكرات التخزين المؤقت المحدودة (Caffeine، Guava Cache) على الـ Maps الخام لأي حالة على مستوى التطبيق.
- ألغِ دائمًا تسجيل المستمعات والاستدعاءات في دالة دورة حياة
close()/destroy(). - استخدم الكلاسات الداخلية الساكنة (أو اللامبدا التي تلتقط قيمًا محددة فقط) بدلًا من الكلاسات الداخلية غير الساكنة الممرَّرة لكائنات طويلة العمر.
- اقرن كل
ThreadLocal.set()بـThreadLocal.remove()داخل كتلةfinally. - استخدم المراجع الضعيفة أو اللينة بقصد:
WeakReferenceللتخزين المؤقت الذي يُقبل فيه الحذف تحت ضغط GC؛SoftReferenceللتخزين المؤقت الحساس للذاكرة الذي يجب أن يصمد أمام GC الثانوي. - أجرِ تحليل Heap Dump كجزء من خط أنابيب اختبار الحمل — اكتشاف التسرّب عند 1,000 طلب أرخص بكثير من اكتشافه عند 10,000,000.
الخلاصة
تسرّبات الذاكرة في Java تتعلق كليًا برسم بيان المراجع. يُحرِّر مُجمِّع القمامة كل ما لا يمكن الوصول إليه؛ والتسرّبات تحدث حين تُبقي جذور طويلة العمر سلسلة مراجع حيّة لكائنات ميتة منطقيًا. الأنماط الخمسة التي يجب مراقبتها — المجموعات الساكنة غير المحدودة، والمستمعات المنسيّة، والكلاسات الداخلية الهاربة لنطاقات طويلة العمر، وقيم ThreadLocal غير المحذوفة، وسوء استخدام WeakHashMap — تغطّي الغالبية العظمى من تسرّبات الإنتاج. جهّز تطبيقاتك بمخططات الكومة وسجلات GC وتفريغات الكومة، وعامِل الارتفاع المستمر في حجم الجيل القديم باعتباره خللًا برمجيًا لا مشكلة ضبط.