تسريبات الذاكرة في Java: الأسباب الشائعة وكيفية اكتشافها

Backend2026-09-27TryQuickToolBox

يبدأ تطبيق Java الخاص بك بسرعة، لكن بعد ساعات أو أيام يتباطأ حتى يكاد يتوقف، أو يرمي OutOfMemoryError، أو يتم إنهاؤه بواسطة الحاوية. تعيد تشغيله، وتتكرر الدورة. هذه هي العلامة الكلاسيكية لتسريب الذاكرة في Java: كائنات لم تعد مطلوبة لكنها تبقى قابلة للوصول، فلا يستطيع جامع القمامة (GC) استعادتها. على عكس C/C++، يتعامل GC في Java مع معظم الذاكرة تلقائيًا، لكن التسريبات تحدث رغم ذلك عندما تُحتفظ بالمراجع عن غير قصد. يشرح هذا المقال الأسباب الأكثر شيوعًا ويمنحك سير عمل عمليًا لاكتشافها وإصلاحها.

ما هو تسريب الذاكرة في Java؟

يحدث تسريب الذاكرة عندما لا يعود التطبيق يستخدم كائنات معينة لكنها تظل مُشارًا إليها، مما يمنع جمع القمامة. مع مرور الوقت، ينمو استخدام الـ heap، ويعمل GC بشكل أكثر تكرارًا، وفي النهاية يرمي JVM الخطأ OutOfMemoryError: Java heap space. يمكن أن تكون التسريبات صغيرة وبطيئة (بضعة كيلوبايتات لكل طلب) أو كبيرة وسريعة (تخزين مجموعات نتائج كاملة في الذاكرة المؤقتة).

الأسباب الشائعة لتسريبات الذاكرة في Java

1. المجموعات الساكنة التي تنمو إلى الأبد

تعيش الحقول الساكنة (static) طوال عمر JVM. استخدام Map ساكن كذاكرة تخزين مؤقت دون إخلاء هو تسريب كلاسيكي. على سبيل المثال:

public class UserCache {
    private static final Map<Long, User> CACHE = new HashMap<>();

    public static void put(Long id, User user) {
        CACHE.put(id, user); // never removed
    }
}

كل مستخدم يُضاف يبقى إلى الأبد. الحل: استخدم ذاكرة تخزين مؤقت محدودة الحجم مثل Caffeine أو Guava مع حدود للحجم وانتهاء صلاحية.

2. الموارد غير المغلقة

يجب إغلاق التدفقات (streams) والاتصالات والقارئات (readers). إذا نسيت، تتسرب الموارد الأصلية وأغلفتها في Java. استخدم دائمًا try-with-resources:

try (InputStream in = new FileInputStream(file)) {
    // use in
} // automatically closed

ينطبق هذا أيضًا على اتصالات قواعد البيانات وعملاء HTTP ومجموعات الخيوط (thread pools).

3. المستمعون (Listeners) والدوال الاستدعائية (Callbacks) التي لا تُزال

عندما تسجّل مستمعًا (مثل addListener) لكنك لا تزيله أبدًا، يحتفظ الناشر بمرجع إلى المستمع، والذي قد يحتفظ بدوره بمرجع إلى شجرة كائناتك بالكامل. هذا شائع في واجهات المستخدم الرسومية وناقلات الأحداث (event buses) والسياقات طويلة الأمد.

4. متغيرات ThreadLocal

تُخزَّن قيم ThreadLocal في ThreadLocalMap الخاص بالخيط. في مجموعات الخيوط، تُعاد الخيوط للاستخدام، لذا إذا لم تستدعِ remove()، تبقى القيمة مرتبطة بالخيط وتتسرب. نظّف دائمًا:

try {
    threadLocal.set(context);
    // work
} finally {
    threadLocal.remove();
}

5. الأصناف الداخلية التي تحتفظ بمراجع للخارج

تحتفظ الأصناف الداخلية غير الساكنة (بما في ذلك الأصناف المجهولة) بمرجع ضمني إلى نسخة الصنف الخارجي. إذا عاشت نسخة الصنف الداخلي أطول من الكائن الخارجي (مثل تخزينها في قائمة ساكنة)، فلا يمكن جمع الكائن الخارجي. اجعل الأصناف الداخلية ساكنة (static) عندما لا تحتاج إلى النسخة الخارجية.

6. تطبيق غير صحيح لـ equals() و hashCode()

إذا استخدمت كائنات كمفاتيح في HashMap لكنك تجاوزت equals() دون hashCode() (أو العكس)، تفشل عمليات البحث وتتراكم المدخلات. تجاوز كليهما دائمًا بشكل متسق.

7. String Interning والسلاسل الفرعية (Substrings)

قبل Java 7، كانت String.substring() تشارك مصفوفة الأحرف الأصلية، لذا يمكن لسلسلة فرعية صغيرة أن تحتفظ بمصفوفة كبيرة. تنسخ Java الحديثة ذلك، لكن عمل interning للعديد من السلاسل الفريدة (مثل String.intern()) قد يملأ مخزن السلاسل (string pool).

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

يتبع الاكتشاف عملية منهجية. إليك نهجًا خطوة بخطوة:

  1. راقب استخدام الـ heap بمرور الوقت. استخدم JVisualVM أو JConsole أو APM الخاص بك. يُظهر التطبيق السليم نمطًا مسننًا: ينمو الـ heap، يستعيد GC، ينخفض الـ heap. يُظهر التسريب ارتفاعًا في خط الأساس بعد كل عملية GC.
  2. فعّل تسجيل GC. أضف -Xlog:gc*:file=gc.log:time,uptime,level,tags (Java 9+) أو -XX:+PrintGCDetails (Java 8). حلّل السجلات بأدوات مثل GCeasy لمعرفة ما إذا كان الجيل القديم (old gen) يستمر في النمو.
  3. التقط heap dump. عندما يكون الـ heap مرتفعًا، نفّذ jmap -dump:live,format=b,file=heap.hprof <pid> أو استخدم jcmd <pid> GC.heap_dump heap.hprof. يمكنك أيضًا تشغيل dump عند حدوث OutOfMemoryError باستخدام -XX:+HeapDumpOnOutOfMemoryError.
  4. حلّل heap dump. افتحه في Eclipse MAT أو VisualVM. ابحث عن dominator tree للعثور على الكائنات التي تحتفظ بأكبر قدر من الذاكرة. افحص المجموعات المشبوهة ذات الأحجام المحتفظ بها الكبيرة.
  5. التقط dump ثانيًا وقارن. إذا نمت نفس الكائنات بين الـ dumps، فقد وجدت التسريب.
  6. استخدم profiler للتحليل المباشر. أدوات مثل YourKit أو JProfiler أو async-profiler يمكنها تتبع مواقع التخصيص (allocation sites) وسلاسل المراجع دون إيقاف التطبيق.

إليك مقارنة سريعة للأدوات الشائعة:

الأداةالنوعالأفضل لـ
Eclipse MATمحلل heap dumpالعثور على dominators ومشتبهي التسريب
VisualVMمراقبة + heap dumpفحوصات مباشرة سريعة وتحليل أساسي
JProfiler / YourKitProfilerتتبع التخصيص وسلاسل المراجع
async-profilerProfiler منخفض الحملالتحليل في بيئة الإنتاج باستخدام flame graphs

إصلاح التسريبات والوقاية منها

بمجرد تحديد التسريب، أصلحه بإزالة المرجع غير المقصود. الإصلاحات الشائعة:

الوقاية خير من العلاج. أضف مراقبة الذاكرة إلى خط أنابيب CI/CD الخاص بك، وشغّل اختبارات الحمل مع فحوصات الـ heap، واضبط -Xmx بشكل مناسب. استخدم أدوات التحليل الساكن مثل SpotBugs لاكتشاف الأنماط الشائعة.

الأسئلة الشائعة

ما الفرق بين تسريب الذاكرة وارتفاع الذاكرة المفاجئ (memory spike)؟

ارتفاع الذاكرة المفاجئ هو زيادة مؤقتة في استخدام الـ heap يستعيدها GC. أما التسريب فهو زيادة مطردة بمرور الوقت لأن الكائنات تبقى قابلة للوصول ولا يمكن جمعها.

هل يمكن أن يسبب تسريب الذاكرة في Java الخطأ OutOfMemoryError حتى لو كان الـ heap كبيرًا؟

نعم. إذا تجاوز معدل التسريب قدرة GC على الاستعادة، سيمتلئ الـ heap في النهاية بغض النظر عن حجمه. زيادة الـ heap تؤخر الفشل فقط.

كيف أجد تسريب ذاكرة في بيئة الإنتاج دون توقف؟

استخدم أدوات منخفضة الحمل مثل async-profiler أو JFR (Java Flight Recorder) لتسجيل عمليات التخصيص وإحصاءات الـ heap. يمكنك أيضًا تشغيل heap dump باستخدام jcmd أثناء عمل التطبيق، رغم أنه قد يتوقف مؤقتًا لفترة وجيزة.

هل تحتاج إلى تحليل سجلات GC أو سجلات نصية أخرى؟ جرّب محلل سجلات Nginx الخاص بنا لتحليل أنماط السجلات وتصورها بسرعة.