بنية JVM والأداء

تسرّب الذاكرة في Java

15 دقيقة الدرس 7 من 13

تسرّب الذاكرة في Java

يمتلك Java مُجمِّع قمامة (Garbage Collector)، لذا يظنّ كثير من المطوّرين أن تسرّب الذاكرة مستحيل. هذا الافتراض خاطئ وخطير. تسرّب الذاكرة في Java يعني أن كائنات لا تحتاجها التطبيق بعد الآن لا تزال يمكن الوصول إليها عبر رسم بيان المراجع ولذلك لا يُحرَّر ذاكرتها أبدًا. يُحرِّر مُجمِّع القمامة فقط ما لا يمكن الوصول إليه؛ فإن احتفظ كائن طويل العمر بمرجع إلى كائن قصير العمر، فإن الكائن القصير سيبقى حيًا إلى الأبد.

ضمان مُجمِّع القمامة: يضمن مُجمِّع القمامة تحرير الكائنات التي لا توجد مراجع حيّة تشير إليها. أما الكائنات التي لا تزال تحمل مراجع — حتى لو كانت تلك المراجع لا معنى لها منطقيًا — فلا يضمن تحريرها. التسرّب مشكلة إدارة مراجع، وليس عيبًا في مُجمِّع القمامة.

لماذا يصعب اكتشاف التسرّبات؟

التسرّبات لا تُوقف الـ JVM فورًا. بل تتسبّب في ارتفاع تدريجي مستمر في استخدام الكومة (Heap) — ظاهرة تُعرف بـ memory creep. يعمل التطبيق بشكل طبيعي لساعات أو أيام، ثم تزداد فترات توقف GC في الجيل القديم (Old Generation)، وينخفض معدل الأداء، وفي النهاية يظهر خطأ OutOfMemoryError. بحلول ذلك الوقت قد يكون السبب الجذري كامنًا في ذاكرة تخزين مؤقت أو مستمع سُجِّل قبل أيام.

النمط الأول: المجموعات الساكنة كذاكرة تخزين مؤقت

أكثر التسرّبات شيوعًا: حقل static يحمل مجموعة تنمو بلا حدود لأن العناصر تُضاف إليها ولا تُزال منها أبدًا.

public class RequestTracker { // static = يعيش طوال عمر الكلاس، أي طوال عمر الـ JVM private static final Map<String, Request> ACTIVE = new HashMap<>(); public static void register(String id, Request req) { ACTIVE.put(id, req); } // خلل: remove() لا تُستدعى أبدًا بعد انتهاء الطلب }

الحل: احذف المدخلات حين لا تحتاجها، أو استخدم هيكل بيانات محدود الحجم. إن أردت ذاكرة تخزين مؤقت تحذف المدخلات القديمة تلقائيًا، استخدم LinkedHashMap مع تجاوز removeEldestEntry، أو مكتبة متخصصة مثل Caffeine.

// ذاكرة تخزين مؤقت LRU تحذف ذاتيًا — تحتفظ بأقصى MAX_SIZE مدخلة private static final int MAX_SIZE = 1_000; private static final Map<String, Request> CACHE = Collections.synchronizedMap(new LinkedHashMap<>(MAX_SIZE, 0.75f, true) { @Override protected boolean removeEldestEntry(Map.Entry<String, Request> eldest) { return size() > MAX_SIZE; } });

النمط الثاني: مستمعو الأحداث والاستدعاءات المنسيّة

كلما سجّل كائن نفسه كمستمع لناشر أطول عمرًا منه، فإن الناشر يحتفظ بمرجع إليه. إن لم يُلغَ تسجيل المستمع قط، فلا يمكن تحريره — ولا أي شيء يشير إليه.

class Dashboard implements MetricsListener { Dashboard(MetricsService svc) { svc.addListener(this); // svc على مستوى التطبيق (يعيش إلى الأبد) // تسرّب: عند "إغلاق" Dashboard، يبقى svc محتفظًا بمرجع إليه } @Override public void onMetric(Metric m) { /* تحديث الواجهة */ } // الحل: توفير دالة close() public void close(MetricsService svc) { svc.removeListener(this); } }
نفّذ AutoCloseable على أي كائن يسجّل استدعاءات أو يحصل على موارد. يستطيع حينئذٍ المستدعون استخدام try-with-resources لضمان التنظيف، وستحذّر أدوات التحليل الساكن مثل SpotBugs حين لا يُغلق الكائن.

النمط الثالث: الكلاسات الداخلية التي تلتقط النسخة الخارجية

الكلاس الداخلي غير الساكن يحمل دائمًا مرجعًا ضمنيًا للنسخة التي تحتويه. إن خرج الكلاس الداخلي إلى نطاق أطول عمرًا (خيط، مهمة مؤقتة، استدعاء إطار عمل)، فإنه يجرّ الكلاس الخارجي معه.

class ReportGenerator { private final byte[] largeDataSet = new byte[50 * 1024 * 1024]; // 50 ميغابايت void scheduleReport(ScheduledExecutorService scheduler) { // كلاس مجهول غير ساكن — يلتقط `this` (ReportGenerator) scheduler.scheduleAtFixedRate(new Runnable() { @Override public void run() { generateReport(); } }, 0, 1, TimeUnit.HOURS); // تسرّب: حتى لو سقطت جميع المراجع الخارجية لـ ReportGenerator، // يبقي المجدوَل Runnable حيًا، مما يبقي `this` حيًا، // مما يبقي largeDataSet حيًا إلى الأبد. } private void generateReport() { /* ... */ } }

الحل: استخدم كلاسًا داخليًا static، أو تعبير لامبدا يلتقط فقط البيانات التي يحتاجها وليس النسخة الخارجية بأكملها، أو احتفظ فقط بمرجع ضعيف للكائن الخارجي.

class ReportGenerator { private final byte[] largeDataSet = new byte[50 * 1024 * 1024]; void scheduleReport(ScheduledExecutorService scheduler) { WeakReference<ReportGenerator> weakSelf = new WeakReference<>(this); scheduler.scheduleAtFixedRate(() -> { ReportGenerator self = weakSelf.get(); if (self != null) self.generateReport(); }, 0, 1, TimeUnit.HOURS); } private void generateReport() { /* ... */ } }

النمط الرابع: متغيرات ThreadLocal

مجمعات الخيوط (Thread Pools) تُعيد استخدام الخيوط. قيمة ThreadLocal المحدَّدة على خيط في المجمع تبقى إلى الأبد ما لم تُحذف صراحةً. في حاويات الويب حيث يديرها الخادم، هذا تسرّب بالغ الشيوع.

private static final ThreadLocal<DatabaseConnection> CONN_HOLDER = new ThreadLocal<>(); // في servlet أو filter: public void service(Request req, Response res) { CONN_HOLDER.set(openConnection()); try { handleRequest(req, res); } finally { CONN_HOLDER.remove(); // مطلوب — بدونه تبقى الاتصال على الخيط إلى الأبد } }
لا تتخطَّ ThreadLocal.remove() داخل كتلة try-finally أبدًا. الخيوط في المجمع تعيش طوال عمر التطبيق، لذا فإن غياب remove() يثبّت القيمة — وكل ما تشير إليه — في الذاكرة لنفس المدة. يمكن أن يتسبّب ذلك أيضًا في أخطاء صحة حين يعالج نفس الخيط لاحقًا طلبًا غير ذي صلة ويرى بيانات قديمة.

النمط الخامس: سوء استخدام WeakHashMap

يُوصى بـ WeakHashMap كثيرًا باعتبارها "ذاكرة تخزين مؤقت تنظّف نفسها"، لكنها تسمح فقط بمراجع ضعيفة للمفاتيح. إن كانت القيم تحمل مرجعًا قويًا للمفتاح (مباشرةً أو غير مباشر)، فالمفتاح دائمًا قابل للوصول ولن يُحرَّر — لن يتقلّص الـ Map أبدًا.

// ذاكرة تخزين مؤقت مكسورة Map<Widget, WidgetCache> cache = new WeakHashMap<>(); class WidgetCache { final Widget owner; // مرجع قوي يعود إلى المفتاح! WidgetCache(Widget w) { this.owner = w; } } // لن يُحرَّر Widget أبدًا لأن WidgetCache.owner يُبقيه قابلًا للوصول، // لذا لن تنتهي صلاحية مدخلة WeakHashMap أبدًا.

كيف تكتشف التسرّبات: العلامات الأساسية

  • ارتفاع استخدام الكومة بشكل رتيب عبر دورات GC كاملة متعددة دون أن ينخفض مجددًا.
  • معدل امتلاء الجيل القديم يزيد بمرور الوقت حتى تحت حمل ثابت.
  • سجلات GC تُظهر الـ Full GC يعمل بتكرار متزايد مع استرداد ذاكرة أقل في كل دورة.
  • مخطط الكومة (jmap -histo:live <pid>) يُظهر كلاسًا يزداد عدد نسخه باستمرار رغم أنه يجب أن يكون محدودًا.
  • مقارنة لقطتي Heap Dump مأخوذتين بفارق دقائق تكشف أنواع الكائنات التي تراكمت.
مخطط الكومة هو أول ما تلجأ إليه. نفّذ jcmd <pid> GC.heap_info لرؤية الأحجام الإجمالية، ثم jmap -histo:live <pid> | head -30 لترتيب أنواع الكائنات حسب العدد المحتجز. قارن النتيجة بين أخذها مرتين بفارق خمس دقائق تحت الحمل — النوع الذي يظل عدده يتصاعد هو المشتبه به.

أنماط دفاعية للوقاية من التسرّبات

  1. فضّل ذاكرات التخزين المؤقت المحدودة (Caffeine، Guava Cache) على الـ Maps الخام لأي حالة على مستوى التطبيق.
  2. ألغِ دائمًا تسجيل المستمعات والاستدعاءات في دالة دورة حياة close() / destroy().
  3. استخدم الكلاسات الداخلية الساكنة (أو اللامبدا التي تلتقط قيمًا محددة فقط) بدلًا من الكلاسات الداخلية غير الساكنة الممرَّرة لكائنات طويلة العمر.
  4. اقرن كل ThreadLocal.set() بـ ThreadLocal.remove() داخل كتلة finally.
  5. استخدم المراجع الضعيفة أو اللينة بقصد: WeakReference للتخزين المؤقت الذي يُقبل فيه الحذف تحت ضغط GC؛ SoftReference للتخزين المؤقت الحساس للذاكرة الذي يجب أن يصمد أمام GC الثانوي.
  6. أجرِ تحليل Heap Dump كجزء من خط أنابيب اختبار الحمل — اكتشاف التسرّب عند 1,000 طلب أرخص بكثير من اكتشافه عند 10,000,000.

الخلاصة

تسرّبات الذاكرة في Java تتعلق كليًا برسم بيان المراجع. يُحرِّر مُجمِّع القمامة كل ما لا يمكن الوصول إليه؛ والتسرّبات تحدث حين تُبقي جذور طويلة العمر سلسلة مراجع حيّة لكائنات ميتة منطقيًا. الأنماط الخمسة التي يجب مراقبتها — المجموعات الساكنة غير المحدودة، والمستمعات المنسيّة، والكلاسات الداخلية الهاربة لنطاقات طويلة العمر، وقيم ThreadLocal غير المحذوفة، وسوء استخدام WeakHashMap — تغطّي الغالبية العظمى من تسرّبات الإنتاج. جهّز تطبيقاتك بمخططات الكومة وسجلات GC وتفريغات الكومة، وعامِل الارتفاع المستمر في حجم الجيل القديم باعتباره خللًا برمجيًا لا مشكلة ضبط.