Java

خطأ OutOfMemoryError في Java: ماذا يعني وكيف تصلحه

«OutOfMemoryError» عائلة من أعطال مختلفة — heap وMetaspace وGC overhead والذاكرة الأصلية — وكل واحد يشير إلى إصلاح مختلف.

ماذا يعني خطأ OutOfMemoryError فعليًا

يُرمى java.lang.OutOfMemoryError عندما لا يستطيع JVM تلبية تخصيص ذاكرة ولا يستطيع جامع المهملات تحرير ما يكفي. والرسالة بعد اسم الخطأ مهمة للغاية: "Java heap space" تعني نفاد heap الكائنات (المحدود بـ -Xmx)؛ و"GC overhead limit exceeded" تعني أن JVM يقضي جلّ وقته في الجمع دون استرداد يُذكر — أي heap على الحافة؛ و"Metaspace" تعني نفاد مساحة بيانات الأصناف، غالبًا من تسريبات classloader أو توليد أصناف ديناميكي كثيف؛ و"unable to create native thread" تعني أن نظام التشغيل، لا الـ heap، رفض الموارد.

وهناك عطل منفصل يُخلط بها جميعًا: قتل الحاوية OOMKilled (رمز الخروج 137). هنا تقتل نواة Linux العملية كاملة لأن إجمالي الذاكرة — heap زائد Metaspace زائد مكدّسات الخيوط زائد direct buffers زائد التخصيصات الأصلية — تجاوز حد cgroup. لا يُرمى أي استثناء Java إطلاقًا؛ تختفي العملية وحسب. وتشخيص مشاكل الذاكرة يبدأ بتحديد أيّ هذه الحالات لديك فعلًا.

الأسباب الجذرية الشائعة لخطأ OutOfMemoryError

تسريب ذاكرة يبقي الكائنات حيّة

كاشات وخرائط بلا حدود، ومجموعات لا تكفّ عن النمو، وحقول static تمسك رسومًا كبيرة من الكائنات، ومستمعون لا يُلغى تسجيلهم، وThreadLocal لا يُمسح في خيوط مجمّعة. جامع المهملات يعمل بشكل صحيح — شيفرتك ببساطة لا تفلت الكائنات. فيتصاعد استخدام heap بعد كل full GC حتى يصطدم بـ -Xmx.

heap أصغر من الحمل الحقيقي

لا تسريب — التطبيق يحتاج فعلًا أكثر مما يسمح به -Xmx: بيانات أكبر، أو مستخدمون متزامنون أكثر، أو مهام دفعية أضخم مما اختُبر تحت الحمل. والعلامة الفارقة: heap بعد full GC يبقى ثابتًا مع الوقت لكن الذروات تحت الضغط تلامس السقف.

نمو Metaspace وتسريبات classloader

أطر تولّد أصنافًا وقت التشغيل (proxies وتوليد bytecode)، أو إعادة نشر ساخنة متكررة داخل خادم تطبيقات، أو classloaders محتجَزة بمرجع واحد باقٍ. يعيش Metaspace خارج heap وينمو حتى ينفد MaxMetaspaceSize (أو الذاكرة الأصلية) — ورفع -Xmx لا يفيده بشيء.

ذاكرة أصلية وخارج heap تتجاوز حد الحاوية

تعيش direct ByteBuffers ومكتبات JNI ومكدّسات الخيوط وأعباء JVM نفسها خارج -Xmx. وضبط -Xmx مساويًا لحد ذاكرة الحاوية (أو قريبًا منه) لا يترك أي هامش، فتقتل النواة العملية — رمز 137، بلا مسار استدعاء، ويُقرأ غالبًا خطأً كانهيار عشوائي.

كيف تحقّق في OutOfMemoryError وتصلحه

حدّد النوع الدقيق أولًا، والتقط heap dump، واستخدمه للاختيار بين إصلاحين مختلفين جوهريًا: ترقيع تسريب أو إعادة ضبط الحجم.

  1. 1

    حدّد أي OOM لديك فعلًا

    اقرأ رسالة الخطأ كاملة ورمز خروج العملية. مسار استدعاء Java مع "Java heap space" أو "Metaspace" أو "GC overhead limit exceeded" خطأ على مستوى JVM؛ أما موت صامت برمز 137 وسطر oom-kill في dmesg أو kubectl describe فهو النواة تقتل الحاوية. لكلٍّ أسبابه وإصلاحه المختلفان.

  2. 2

    فعّل والتقط heap dump

    شغّل بـ -XX:+HeapDumpOnOutOfMemoryError و-XX:HeapDumpPath=/dumps ليكتب JVM ملف .hprof تلقائيًا لحظة الفشل. وللعملية الحيّة، يلتقط jcmd <pid> GC.heap_dump واحدًا عند الطلب. بلا dump أنت تخمّن؛ ومعه تكون الإجابة واضحة غالبًا.

  3. 3

    حلّل الـ dump: تسريب أم نقص حجم؟

    افتح الـ dump في Eclipse MAT أو محلّل مماثل وانظر إلى شجرة الهيمنة (dominator tree). رسم كائنات واحد يمسك 70% من heap — كاش أو خريطة أو قائمة — تسريبٌ له اسم ومسار في الشيفرة. أما ذاكرة موزّعة بانتظام على بيانات عمل مشروعة فتوحي بأن heap صغير وحسب.

  4. 4

    اقرأ سجلات GC لرؤية الاتجاه

    فعّل تسجيل GC (بـ -Xlog:gc*) وارسم استخدام heap بعد كل full GC. أرضية ترتفع باطّراد عبر الساعات أو الأيام هي توقيع التسريب؛ وأرضية ثابتة بذروات مرتبطة بالحمل تعني نقص الحجم. هذا الرسم الواحد هو أوثق كاشف تسريب موجود.

  5. 5

    قارن حدود الحاوية بأحجام JVM

    لحالات الخروج برمز 137، قارن حد ذاكرة الحاوية بمجموع -Xmx وMetaspace ومكدّسات الخيوط والذاكرة المباشرة. وفضّل -XX:MaxRAMPercentage (مثل 75%) على -Xmx مثبّت داخل الحاويات، تاركًا هامشًا حقيقيًا لكل ما يعيش خارج heap.

  6. 6

    أصلح ثم تحقّق تحت الحمل

    رقّع التسريب (حدّد سقف الكاش، ألغِ تسجيل المستمع، امسح ThreadLocal) أو أعد ضبط الحجم عن قصد — ثم أعد الحمل نفسه وتأكد أن أرضية ما بعد GC بقيت ثابتة. لا يكتمل إصلاح OOM حتى يثبته اتجاه الذاكرة.

كيف تمنع OutOfMemoryError

  • شغّل دائمًا بـ -XX:+HeapDumpOnOutOfMemoryError في الإنتاج — فالـ dump لا يكلّف شيئًا حتى اليوم الذي ينقذ فيه التحقيق.
  • راقب استخدام heap وزمن GC باستمرار، ونبّه على ارتفاع أرضية ما بعد GC — فهذا هو التسريب يعلن عن نفسه قبل أسابيع.
  • حدّد سقف كل كاش (حجمًا وعمرًا) وفضّل المراجع الضعيفة أو مكتبات كاش مخصّصة على خرائط static عارية.
  • اضبط حجم JVM نسبةً إلى حاويته بـ MaxRAMPercentage واترك هامشًا لـ Metaspace والخيوط والـ direct buffers.
  • اختبر سلوك الذاكرة تحت الحمل ببيانات بحجم الإنتاج قبل الإطلاق — فمعظم أخطاء OOM «المفاجئة» أحمالٌ لم يحاكِها أحد قط.

كيف يساعدك AllStak مع مشاكل ذاكرة Java

تتتبّع مراقبة البنية التحتية في AllStak استخدام الذاكرة على كل خادم وحاوية عبر الزمن، فيظهر التصاعد البطيء الذي يسبق OutOfMemoryError كاتجاه — قابل للتنبيه — قبل الانهيار بوقت طويل. وعندما تموت العملية، يخبرك رسم الذاكرة لحظة الفشل فورًا هل أمامك تسريب تدريجي أم قفزة مفاجئة.

يلتقط تتبّع الأخطاء أحداث OutOfMemoryError التي يتمكّن تطبيقك من إرسالها، بوسوم إصدار تُظهر تحت أي نشر بدأ النمو، وتحتفظ السجلات المركزية بأسطر GC ورسائل oom-kill من النواة على الخط الزمني نفسه. لن يحلّل AllStak ملف heap dump عنك — فتلك مهمة MAT — لكنه يخبرك متى تلتقطه ومن أي عملية.

أسئلة شائعة عن OutOfMemoryError في Java

هل تُصلح إعادة التشغيل خطأ OutOfMemoryError؟

تمسح العرَض لا السبب. إن كان heap ناقص الحجم فسيتكرّر الخطأ مع أول حمل مماثل؛ وإن كان ثمة تسريب فستعود أرضية ما بعد GC للارتفاع فورًا ويرجع الانهيار في موعده. أعد التشغيل لاستعادة الخدمة، لكن التقط heap dump واتجاه GC قبل ضياع الأدلة.

ما الفرق بين OutOfMemoryError وOOMKilled؟

OutOfMemoryError هو فشل JVM في التخصيص ضمن حدوده المضبوطة — وتحصل على مسار استدعاء Java. أما OOMKilled (رمز 137) فهو نواة Linux تقتل العملية كاملة لأن إجمالي ذاكرة الحاوية تجاوز حد cgroup — بلا أي خطأ Java. يُصلح الأول داخل أعلام JVM والشيفرة؛ والثاني بمواءمة حدود الحاوية مع البصمة الكلية لـ JVM.

هل تكفي زيادة -Xmx لحل المشكلة؟

فقط إذا كانت المشكلة نقص حجم حقيقيًا — أرضية ثابتة بعد GC وذروات حمل تلامس السقف. أمام تسريب، يكتفي heap الأكبر بتأجيل الانهيار وإطالة وقفات GC في الطريق إليه. أما أعطال Metaspace أو الخيوط الأصلية أو OOMKilled فـ -Xmx ليس مفتاحها أصلًا.

ماذا تعني "GC overhead limit exceeded"؟

اكتشف JVM أنه يقضي الغالبية الساحقة من الوقت (افتراضيًا أكثر من 98%) في جمع المهملات بينما يسترد جزءًا ضئيلًا من heap (أقل من 2%) — فبدل أن يواصل الطحن، يفشل سريعًا. تعامل معه تمامًا كـ "Java heap space": الـ heap ممتلئ فعليًا، إما بتسريب أو بنقص حجم.

شاهد صعود الذاكرة قبل الانهيار

يرسم AllStak الذاكرة على كل خادم وحاوية وينبّه على الاتجاه، فيتحوّل التسريب إلى رسم بياني تتصرّف حياله — لا انقطاع في الثالثة فجرًا.