كل متغير تكتبه وكل كائن تنشئه بالكلمة new يحتاج مكاناً في الذاكرة، ومعظم الوقت لا تفكر في هذا المكان لأن اللغة تتولاه عنك، إلى أن يظهر خطأ لا تفهمه: دالة غيرت قائمة لم تطلب منها أن تغيرها، أو برنامج انهار برسالة Stack overflow، أو خدمة يكبر استهلاكها للذاكرة كل يوم حتى يعاد تشغيلها في منتصف الليل، رغم أن اللغة «فيها جامع قمامة». وكل هذه المشاكل تفهمها بسهولة إذا عرفت أربعة أشياء: أين تعيش البيانات في ذاكرة البرنامج، وما الفرق بين ال Stack وال Heap، وما الذي ينسخه السطر b = a فعلاً، ومتى يقرر جامع القمامة أن الكائن لم يعد مطلوباً.
وهذا المقال من مرحلة أساسيات علوم الحاسب Computer Science Fundamentals في خارطة عرب رووت، ويكمل من حيث توقف مقالان فيها: مقال Hello World من الداخل الذي رأينا فيه خريطة ذاكرة البرنامج الحقيقية والذاكرة الافتراضية وهرم الذاكرة، ومقال الأنظمة العددية للمبرمجين الذي شرح البايتات وعناوين الـ Hex وأحجام الأنواع، فلن نعيد شرحها هنا. وسوف نناقش في هذا المقال ما يلي:
- ترتيب ذاكرة البرنامج: الكود والبيانات وال Heap وال Stack.
- ال Stack وإطارات الدوال Stack Frames، ولماذا ينهار البرنامج عند التكرار الذاتي Recursion العميق.
- ال Heap ومن يحرر الكائنات فيه: أنت، أو جامع القمامة Garbage Collector، أو المترجم كما في Rust.
- أنواع القيمة Value Types وأنواع المرجع Reference Types في C# وJava وPython وJavaScript، وماذا يحدث عند تمرير متغير إلى دالة.
- المؤشرات Pointers في C وفي C# بالكود unsafe، والفرق بينها وبين المراجع References.
- جامع القمامة بالأجيال Generational GC في .NET وJava وV8، وعد المراجع Reference Counting في Python.
- تسرب الذاكرة Memory Leak في لغة فيها جامع قمامة، بتجربة حقيقية في ثلاث لغات.
أين تعيش هذه البيانات فعلاً؟ الذاكرة RAM باختصار
كل ما سوف نتحدث عنه في هذا المقال يعيش في النهاية في وحدات الذاكرة العشوائية RAM المركبة في جهازك، وهي شرائح DRAM تخزن كل بت كشحنة صغيرة في مكثف Capacitor تتسرب مع الوقت، ولذلك تحتاج إلى تحديث Refresh دوري وتفقد كل ما فيها عندما ينقطع التيار. ولكن برنامجك لا يرى هذه الشرائح أبداً، وإنما يرى عناوين افتراضية Virtual Addresses يترجمها المعالج ونظام التشغيل إلى أماكن حقيقية، ولذلك يكفيك هنا أن تعرف أنها موجودة. وإذا أردت أن ترى الوحدة من الخارج والداخل، من الشريحة إلى البنوك Banks إلى الخلية الواحدة، ودورة القراءة وتصحيح الأخطاء ECC وأجيال DDR والقنوات Channels، فقد خصصنا لها مقالاً مستقلاً في مرحلة العتاد هو كيف تعمل الذاكرة RAM من الداخل.
أين يعيش برنامجك في الذاكرة؟
عندما يشغل نظام التشغيل برنامجك فإنه يعطيه مساحة عناوين افتراضية Virtual Address Space خاصة به، ويقسمها إلى مناطق Regions لكل منها وظيفة وصلاحيات مختلفة، وقد رأينا هذه المناطق حقيقية في ملف /proc/<pid>/maps في مقال Hello World. والصورة التالية تبين الترتيب التقليدي لهذه المناطق في عملية واحدة:

- الكود Text: تعليمات البرنامج نفسها بلغة الآلة، وهي للقراءة والتنفيذ فقط، فلا يستطيع البرنامج أن يكتب فوق كوده.
- البيانات Data وBSS: المتغيرات العامة Global والثابتة Static، وتبقى طوال عمر البرنامج، والفرق أن Data للمتغيرات التي لها قيمة أولية وBSS للتي تبدأ بالصفر.
- ال Heap: الذاكرة التي يطلبها البرنامج أثناء التشغيل بحجم لا يعرف مسبقاً، مثل الكائنات التي تنشئها بـ
newأوmalloc، وتكبر نحو العناوين الأكبر. - ال Stack: ذاكرة الدوال التي تعمل الآن، ومتغيراتها المحلية، وعنوان العودة لكل دالة، وتكبر نحو العناوين الأصغر، ولكل خيط Thread في البرنامج Stack خاص به كما شرحنا في مقال المعالج والخيوط.
ولاحظ أن الصورة تبين الفكرة وليس العناوين الحقيقية، فنظام التشغيل يغير أماكن هذه المناطق عشوائياً في كل تشغيل، وهذه الحماية اسمها Address Space Layout Randomization واختصاراً ASLR، والسبب أنها تصعب على المخترق أن يعرف مسبقاً أين يوجد الكود أو ال Stack. وقد رأينا ذلك في تجربتنا، فعنوان المتغير نفسه كان 0x1B7057E248 في تشغيل و0x433ABDE358 في التشغيل التالي.
ال Stack: ذاكرة الدالة التي تعمل الآن
ال Stack أو المكدس هو منطقة تعمل بمبدأ آخر من يدخل أول من يخرج Last In, First Out، تماماً مثل كومة الأطباق، فعندما تستدعي دالة يضاف لها إطار Stack Frame فوق الإطارات السابقة، فيه معاملاتها Parameters ومتغيراتها المحلية Local Variables وعنوان العودة Return Address، أي المكان الذي يكمل منه البرنامج بعد أن تنتهي. وعندما تعود الدالة يزال إطارها بالكامل في خطوة واحدة، فلا يحتاج أحد أن يبحث عما يجب تحريره.
وهذا يعطي ال Stack ثلاث خصائص يجب أن تعرفها:
- سريع جداً: الحجز فيه مجرد تحريك لمؤشر واحد في المعالج اسمه مؤشر ال Stack، وهو المسجل
rspعلى x86-64، والبيانات فيه متجاورة وقريبة من بعضها فتبقى غالباً في ال Cache. - عمره عمر الدالة: المتغير المحلي يختفي عندما تعود الدالة، فلا يمكنك أن تحتفظ بشيء في ال Stack بعد أن تنتهي الدالة التي أنشأته.
- حجمه محدود وثابت: كل خيط يحصل على Stack بحجم محدد عند إنشائه، فإذا استدعت دالة نفسها دون نهاية، أو بعمق كبير جداً، امتلأ ال Stack وانهار البرنامج، وهذا هو الخطأ المشهور Stack Overflow الذي أخذ منه الموقع المعروف اسمه.
وسلوك كل لغة عند امتلاء ال Stack مختلف، وقد جربنا الدالة نفسها التي تستدعي نفسها دون نهاية في ثلاث لغات. ففي Python توقفت عند حد العمق الذي يحدده sys.getrecursionlimit() وهو 1000 افتراضياً، ورمت استثناء يمكنك التقاطه:
recursion limit: 1000
RecursionError: maximum recursion depth exceededوفي Node.js رمى V8 استثناء يمكن التقاطه كذلك:
RangeError: Maximum call stack size exceededأما في .NET فالأمر مختلف تماماً، فـ StackOverflowException لا يمكن التقاطها بـ try/catch وتنهي العملية كلها كما في التوثيق، وهذه آخر أسطر المخرج عند تشغيل dotnet run memory.cs -- overflow:
recursing...
Stack overflow.
Repeated 24063 times:
--------------------------------
at Program.<<Main>$>g__Recurse|0_5(Int32)
--------------------------------
at Program.<Main>$(System.String[])ولاحظ أن .NET طبع اسم الدالة التي تكررت وعدد مرات التكرار في المخرج، 24063 مرة في هذا التشغيل، وهذا أول ما تبحث عنه في السجلات إذا اختفت خدمة .NET فجأة دون أي استثناء في سجلات التطبيق. والدرس العملي أن التكرار الذاتي على بيانات لا تتحكم في عمقها، مثل شجرة JSON يرسلها المستخدم أو تعليقات متداخلة، يجب أن يكون له حد، أو يكتب بحلقة وStack تديره أنت في ال Heap.
ال Heap: ذاكرة الكائنات التي تعيش أطول
المشكلة في ال Stack أن كل شيء فيه يموت مع الدالة، ولكن البرامج الحقيقية تحتاج بيانات تعيش أطول من الدالة التي أنشأتها، مثل طلب Order تنشئه دالة وتحفظه في قائمة وتستخدمه دالة أخرى بعد دقائق، أو بيانات لا يعرف حجمها إلا أثناء التشغيل، مثل ملف يرفعه المستخدم. وهذا هو دور ال Heap، فهو منطقة كبيرة يحجز فيها البرنامج قطعة بالحجم الذي يحتاجه في أي وقت، وتبقى القطعة محجوزة حتى يقرر أحد أنه لم يعد يحتاجها. والصورة التالية تبين العلاقة بين الاثنين:

في الصورة أعلاه لاحظ التالي:
- المتغير
countيحمل القيمة 3 نفسها داخل الإطار، بينما المتغيرorderيحمل عنواناً فقط، والكائن نفسه في ال Heap. - الإطاران
MainوCheckoutيشيران إلى الكائن نفسه، فإذا غيرتCheckoutشيئاً في الطلب فسوف تراهMainبعد العودة. - عندما تعود
Sumيختفي إطارها، ولكن الListتبقى لأن الطلب ما زال يشير إليها. - الكائن الرمادي لا يصل إليه أي مرجع، فلا يستطيع البرنامج استخدامه مرة أخرى، وهذا تعريف القمامة Garbage.
والسؤال المهم هنا: من يحرر القطعة من ال Heap عندما لا يحتاجها البرنامج؟ هناك ثلاث إجابات، وكل لغة اختارت واحدة منها، وقد لخصناها في جدول مقارنة اللغات:
- أنت، يدوياً: كما في C، تحجز بـ
mallocوتحرر بـfree، وكل خطأ في ذلك خطأ في البرنامج، وسوف نرى أمثلة عليه في قسم المؤشرات. - جامع القمامة Garbage Collector: كما في C# وJava وJavaScript وGo وPython، برنامج داخل ال Runtime يبحث عن الكائنات التي لم يعد يصل إليها أحد ويحررها.
- المترجم بقواعد الملكية Ownership: كما في Rust، حيث يقرر المترجم قبل التشغيل أين يحرر كل كائن.
نوع القيمة ونوع المرجع: ماذا ينسخ السطر b = a؟
هذا القسم هو مصدر أكثر الأخطاء التي يقع فيها المبتدئ والمحترف في يوم متعب: دالة تعدل قائمة لم تكن تقصد تعديلها، أو تعديل لا يظهر رغم أنك متأكد أنك قمت به. والسبب أن هناك نوعين من المتغيرات:
- نوع القيمة Value Type: المتغير يحمل القيمة نفسها، فإذا نسخته إلى متغير آخر نسخت القيمة كاملة، وكل منهما مستقل عن الآخر.
- نوع المرجع Reference Type: المتغير يحمل مرجعاً Reference، أي عنواناً يشير إلى الكائن في ال Heap، فإذا نسخته نسخت العنوان فقط، فيصل المتغيران إلى الكائن نفسه، وكل تعديل من أحدهما يراه الآخر.
وهذا التعريف بالنص في توثيق C#: متغير نوع القيمة يحتوي نسخة من النوع، ومتغير نوع المرجع يحتوي مرجعاً إليها. والصورة التالية تبين النتيجة الحقيقية من التجربة، حيث أنشأنا struct وclass بنفس الحقل ثم نسخنا كلاً منهما وغيرنا النسخة:

وكل لغة تقسم أنواعها بطريقة مختلفة قليلاً:
- C#: الأرقام و
boolوstructوenumأنواع قيمة، وclassوالمصفوفات وstringوما شابهها أنواع مرجع، ولاحظ أنstringنوع مرجع ولكنه غير قابل للتعديل Immutable، فيتصرف كأنه قيمة. - Java: مواصفة اللغة تقسم الأنواع إلى أنواع أساسية Primitive Types مثل
intوdoubleوboolean، وأنواع مرجع لكل ما عداها، بما فيها المصفوفات وStringوInteger. - JavaScript: القيم الأساسية Primitives مثل
numberوstringوbooleanوbigintتنسخ، والكائنات والمصفوفات والدوال تشترك بالمرجع، ولنسخ كائن نسخة مستقلة كاملة استخدمstructuredClone. - Python: كل شيء كائن، وكل اسم مرجع إلى كائن كما في نموذج البيانات، فلا يوجد نوع قيمة أصلاً. ولكن بعض الكائنات غير قابلة للتعديل مثل
intوstrوtuple، فإذا كتبتy += 1لا يتغير الكائن وإنما يشيرyإلى كائن جديد، ولذلك تبدو الأرقام كأنها تنسخ، بينماlistوdictقابلة للتعديل وتشترك بين كل الأسماء التي تشير إليها.
وقد يتساءل البعض: إذا مررت قائمة إلى دالة، هل تمر بالقيمة أم بالمرجع؟ والإجابة في أغلب هذه اللغات أن كل المعاملات تمرر بالقيمة By Value، ولكن «القيمة» في نوع المرجع هي المرجع نفسه. ولذلك في تجربة C# أضافت الدالة AddItem(list) عنصراً ظهر في القائمة الأصلية لأنها عدلت الكائن نفسه، بينما الدالة ReplaceList(list) التي كتبت l = new List<int>() لم تغير شيئاً خارجها، لأنها غيرت نسختها المحلية من المرجع فقط، وبقي Count=2. والنتيجة نفسها ظهرت في Python مع add_item وreplace. وإذا أردت فعلاً أن تغير المتغير الأصلي من داخل الدالة ففي C# الكلمة ref، كما رأينا مع AddOneRef(ref n) التي غيرت n من 5 إلى 6.
int داخل كائن من نوع class يعيش في ال Heap مع الكائن، والمترجم قد يضع كائناً من نوع مرجع في ال Stack إذا أثبت أنه لا يخرج من الدالة، وهذا ما يسمى تحليل الهروب Escape Analysis، وقد وسعته .NET 10 كما في ما الجديد في Runtime. الفرق الذي يجب أن تعتمد عليه في الكود هو معنى النسخ، أي هل ينسخ السطر b = a القيمة أم المرجع، وليس مكان التخزين.المؤشرات Pointers والمراجع References
المؤشر Pointer متغير قيمته عنوان في الذاكرة، أي رقم مثل 0x1B7057E248 يدل على مكان متغير آخر، ومن خلاله تستطيع أن تقرأ ذلك المتغير أو تكتب فيه. وهذه هي الطريقة التي تعمل بها لغة C مباشرة، والمثال التالي يبين الأساسيات، ولم نشغله في هذه التجربة لأن الجهاز لا يحتوي على مترجم C، وقد بنينا وشغلنا برنامج C كاملاً بـ gcc في مقال Hello World:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void) {
int a = 42;
int *p = &a; /* p holds the address of a */
*p = 7; /* writes a through the pointer */
printf("a=%d p=%p\n", a, (void *)p);
char *name = malloc(16); /* 16 bytes on the heap */
if (name == NULL) return 1;
strcpy(name, "ArabRoot"); /* 8 letters + the final '\0' = 9 bytes */
printf("%s\n", name);
free(name); /* nobody frees it for you */
name = NULL; /* so it cannot be used after free */
return 0;
}في الكود أعلاه لاحظ التالي:
&aتعني عنوانa، وint *pتعني أنpمؤشر إلىint، و*pتعني القيمة الموجودة في العنوان الذي يحملهp.malloc(16)تحجز 16 بايتاً في ال Heap وترجع عنوان أولها، أوNULLإذا فشلت، ولا يوجد أي شيء يحرر هذه البايتات إلاfree.- بعد
freeيبقى العنوان نفسه في المتغيرname، ولذلك نكتبname = NULL، لأن استخدام المؤشر بعد التحرير خطأ لا ينبهك إليه المترجم.
والصورة التالية تبين نفس الفكرة بالعناوين الحقيقية التي طبعتها تجربة C# في الكود unsafe، حيث تسمح C# بالمؤشرات داخل كتلة unsafe فقط وبعد تفعيل الخاصية AllowUnsafeBlocks:

ولاحظ في الصورة أن a مخزنة كـ 2A 00 00 00، أي 42 بترتيب Little-endian الذي شرحناه في مقال الأنظمة العددية، وأن arr + 1 لم يضف بايتاً واحداً إلى العنوان وإنما 4 بايتات، لأن المؤشر من نوع int، وهذا ما يسمى حساب المؤشرات Pointer Arithmetic. ولاحظ أيضاً أن لا شيء يمنعك من كتابة *(arr + 3) أو *(arr + 1000)، فالمؤشر سوف يقرأ أو يكتب أي ذاكرة يصل إليها دون أن يعرف أنها ليست لك، وهذا هو مصدر أخطر فئة من الثغرات في البرمجيات:
- تجاوز حدود الذاكرة Buffer Overflow: كتابة أو قراءة بعد نهاية المصفوفة، فتفسد بيانات أخرى أو تقرأ بيانات لا تخصك.
- الاستخدام بعد التحرير Use After Free: استخدام مؤشر بعد
free، والذاكرة ربما أعطيت لكائن آخر. - التحرير مرتين Double Free ونسيان التحرير الذي يسبب تسرب الذاكرة.
وهذه ليست مشاكل نظرية، فمشروع Chromium يذكر في صفحة أمان الذاكرة أن نحو 70% من ثغراته الأمنية عالية الخطورة هي مشاكل في أمان الذاكرة، أي أخطاء في مؤشرات C وC++، ونصفها تقريباً من نوع الاستخدام بعد التحرير.
أما المرجع Reference في C# وJava وJavaScript وPython فهو مؤشر «آمن»، فهو أيضاً يحمل مكان الكائن، ولكن اللغة لا تسمح لك بأن تعرف العنوان أو تجمع عليه أو تشير به إلى مكان عشوائي، ولا يمكن أن يشير إلى كائن محرر لأن جامع القمامة لا يحرر كائناً ما زال أحد يشير إليه. وبالمقابل يستطيع جامع القمامة أن ينقل الكائنات في الذاكرة ليرتبها، ثم يحدث كل المراجع إليها دون أن تشعر، وهذا مستحيل مع مؤشر حقيقي يحمل رقماً، ولهذا تستخدم C# الكلمة fixed لتثبيت الكائن عندما تأخذ مؤشراً إليه، والذاكرة التي تحجزها stackalloc في ال Stack لا يحركها أحد أصلاً. والقاعدة العملية: لا تستخدم unsafe في كود التطبيقات العادية، فقد كتبناها هنا لنرى العناوين فقط.
جامع القمامة Garbage Collector: كيف يعرف ما الذي لم تعد تحتاجه؟
جامع القمامة لا يعرف ما الذي «تحتاجه»، وإنما يعرف شيئاً أبسط: ما الذي يمكن الوصول إليه Reachable. فهو يبدأ من الجذور Roots، أي المتغيرات المحلية في ال Stack لكل الخيوط، والمتغيرات الثابتة Static، والمسجلات، ثم يتبع كل مرجع منها إلى كائن، ثم كل مرجع من ذلك الكائن، وهكذا، ويعلم Mark كل كائن يصل إليه. وفي النهاية كل كائن لم يعلم لا يمكن لبرنامجك أن يستخدمه أبداً، فيحرر مكانه، وغالباً ينقل الكائنات الحية لتتجاور ويضغط Compact الذاكرة حتى لا تبقى فيها فراغات.
والمشكلة في الطريقة البسيطة أن فحص ال Heap كاملاً في كل مرة مكلف جداً عندما يكون حجمه عدة جيجابايتات، والحل الذي تستخدمه أغلب الأنظمة الحديثة يقوم على ملاحظة يسميها فريق V8 فرضية الأجيال The Generational Hypothesis، وهي أن أغلب الكائنات تموت صغيرة، أي تنشأ ثم تصبح قمامة بعد وقت قصير جداً، مثل الكائنات المؤقتة داخل حلقة أو داخل طلب HTTP واحد. فيقسم ال Heap إلى أجيال Generations، ويجمع الجيل الجديد كثيراً لأنه صغير وأغلب ما فيه ميت، ويرقي Promote ما ينجو إلى جيل أقدم يجمع نادراً. والصورة التالية تبين ذلك كما في .NET:

- .NET: ثلاثة أجيال 0 و1 و2 كما في أساسيات جامع القمامة، والكائنات التي حجمها 85,000 بايت أو أكثر تذهب مباشرة إلى كومة الكائنات الكبيرة Large Object Heap التي تجمع مع الجيل 2، كما في توثيق LOH. وفي تجربتنا بدأ الكائن في الجيل 0 ثم انتقل إلى 1 بعد أول جمع ثم إلى 2 بعد الثاني، وظهرت المصفوفة
byte[85000]في الجيل 2 من البداية بينماbyte[84000]في الجيل 0، وإنشاء 5 ملايين كائن مؤقت سبب 28 جمعاً للجيل 0 فقط دون أي جمع للجيلين الآخرين. - Java: جامعات HotSpot تستخدم الأجيال مع التقادم Generational Scavenging كما في دليل ضبط جامع القمامة، والجامع الافتراضي هو G1 الذي يقسم ال Heap إلى مناطق Regions لجيل صغير Young وجيل قديم Old.
- JavaScript في V8: جيل صغير Young Generation يجمع بطريقة النسخ السريع Scavenge، وجيل قديم Old Generation، كما يشرح مقال فريق V8، وقد رأينا المساحتين
new_spaceوold_spaceفي مخرجv8.getHeapSpaceStatistics(). - Python: الآلية الأساسية فيها مختلفة، وهي عد المراجع Reference Counting، فكل كائن يحمل عدد المراجع إليه، وعندما يصل إلى الصفر يحرر فوراً، وقد رأينا العدد يزيد من 2 إلى 4 مع
sys.getrefcountثم يعود بعدdel. ولكن عد المراجع لا يحرر الحلقات Cycles، أي كائنين يشير كل منهما إلى الآخر، ولهذا يوجد جامع إضافي في الوحدةgcيكمل عد المراجع كما يقول توثيقها، وهو بثلاثة أجيال، ووجد في تجربتنا 2000 كائن في 1000 حلقة لم يحررها عد المراجع. ولاحظ أن Python 3.14.0 حتى 3.14.4 جاءت بجامع تدريجي جديد ثم عادت إلى جامع 3.13 في الإصدار 3.14.5 بسبب استهلاك الذاكرة، كما في ما الجديد في Python 3.14، فراجع إصدارك إذا كنت تضبط عتبات الجمع.
وقد يتساءل البعض: إذا كان جامع القمامة يتولى كل شيء، فلماذا أهتم؟ والإجابة أن الجمع له ثمن، فهو يستهلك المعالج وقد يوقف خيوط التطبيق Pause لفترة قصيرة، وكلما أنشأت كائنات أكثر جمع أكثر، فالكود الذي ينشئ ملايين الكائنات المؤقتة في مسار ساخن يدفع الثمن في زمن الاستجابة. والأهم من ذلك أن جامع القمامة لا يحميك من تسرب الذاكرة، وهو موضوع القسم التالي.
تسرب الذاكرة Memory Leak في لغة فيها جامع قمامة
في C يحدث التسرب عندما تنسى free، أما في اللغات التي فيها جامع قمامة فيحدث لسبب معاكس: كائن لا تحتاجه، ولكن ما زال هناك مرجع إليه من مكان يعيش طويلاً، فجامع القمامة يراه قابلاً للوصول ولا يستطيع أن يحرره. وأشهر الأماكن التي تحتفظ بهذه المراجع ثلاثة: متغير static أو حدث Event عام، وCache لا حد لحجمه، وقائمة مستمعين Listeners لا يزال منها أحد. والصورة التالية تبين أول حالة كما جربناها في C#:
![حدث static اسمه Settings.Changed كجذر برتقالي يشير إلى قائمة مشتركين فيها عشرة آلاف عنصر، وكل عنصر يشير إلى نافذة Window ومعها مصفوفة byte[1024]، وعلى اليمين ثلاثة حلول: إلغاء الاشتراك، والمراجع الضعيفة، والـ Cache محدود الحجم، وفي الأسفل أرقام التجربة في C# وNode.js وPython](https://arabroot.io/content/images/2026/10/memory-basics-08-leak.webp)
وفي التجربة فتحنا 10,000 نافذة كل منها تشترك في حدث static ولم نستدع Close()، فبقي +11,188 KB في الذاكرة حتى بعد جمع كامل بـ GC.GetTotalMemory(true)، وعدد المشتركين 10,000. وفي Node.js ملأنا Map بـ 200 ألف عنصر فارتفع heapUsed من 4.1 إلى 38.5 MB، ثم عاد إلى 4.1 بعد cache.clear()، وفي Python أظهرت الوحدة tracemalloc أن سطراً واحداً في دالة handle_request أضاف 10.4 MiB في Cache لا يحذف منه شيء. والحلول هي:
- ألغ الاشتراك Unsubscribe: كل
+=على حدث يقابله-=، وكل كائن يملك موارد ينفذDisposeويستخدم معusing، وكلaddEventListenerيقابلهremoveEventListener. - المراجع الضعيفة Weak References: مرجع لا يمنع جامع القمامة من تحرير الكائن، مثل
WeakReferenceفي .NET، وقد رأيناه في التجربة يصبحIsAlive = Falseبعد الجمع، وWeakMapفي JavaScript، وweakrefفي Python، وهي مناسبة لبيانات إضافية مرتبطة بكائن يملكه غيرك. - Cache له حدود: حدد عدد العناصر أو مدة الصلاحية Expiry، مثل
MemoryCacheمعSizeLimitفي .NET أو مكتبة LRU في Node.js، ولا تستخدمDictionaryأوMapعاماً كـ Cache أبداً.
وعلامة التسرب على السيرفر أن الذاكرة لا تعود إلى مستوى ثابت بعد انتهاء الضغط، وإنما تصعد مثل الدرج مع الوقت حتى يقتل نظام التشغيل العملية أو يعيد تشغيلها ال Orchestrator. ولقياسها تملك كل منصة أداة: dotnet-counters لمراقبة حجم ال Heap وعدد مرات الجمع لكل جيل، وprocess.memoryUsage() ولقطات ال Heap في Node.js، وtracemalloc في Python.
Rust والملكية Ownership باختصار
لغة Rust اختارت الطريق الثالث، فلا يوجد فيها جامع قمامة ولا free تكتبه بنفسك، وإنما ثلاث قواعد يفحصها المترجم قبل التشغيل كما في كتاب Rust الرسمي: لكل قيمة مالك Owner واحد، ولا يمكن أن يكون لها أكثر من مالك في الوقت نفسه، وعندما يخرج المالك من النطاق Scope تحرر القيمة تلقائياً. فإذا أسندت قيمة في ال Heap إلى متغير آخر فإنها تنتقل Move إليه ويصبح المتغير الأول غير صالح، وإذا أردت أن تستخدمها دون أن تملكها فإنك تستعيرها Borrow بمرجع يتحقق المترجم من أنه لن يعيش أطول من المالك. والنتيجة أن أخطاء الاستخدام بعد التحرير والتحرير مرتين لا تصل إلى التشغيل أصلاً، وثمنها قواعد يتعلمها المبتدئ ببطء. وهذا المقال هو الأساس فقط، أما المقارنة التفصيلية بين الإدارة اليدوية وجامع القمامة والملكية فهي موضوع مقال مخطط في مرحلة لغات البرمجة بعنوان «إدارة الذاكرة: يدوياً أم Garbage Collector أم الملكية Ownership في Rust».
التجربة العملية: الكود والمخرجات كاملة
كل الأرقام في هذا المقال من ثلاثة برامج شغلناها على جهاز Windows 11 بمعالج x86-64، باستخدام .NET SDK 10.0.400 مع Runtime 10.0.11، وPython 3.13.3، وNode.js 22.14.0. والبرنامج الأول ملف C# واحد يعمل بالأمر dotnet run memory.cs دون مشروع، والسطر الأول فيه يفعل الكود unsafe لهذا الملف فقط:
#:property AllowUnsafeBlocks=true
using System;
using System.Collections.Generic;
// 1) value type vs reference type: what does assignment copy?
var p1 = new PointStruct { X = 1 };
var p2 = p1; // copies the whole value
p2.X = 99;
var c1 = new PointClass { X = 1 };
var c2 = c1; // copies the reference only
c2.X = 99;
Console.WriteLine($"struct: p1.X={p1.X} p2.X={p2.X} | class: c1.X={c1.X} c2.X={c2.X} same object? {ReferenceEquals(c1, c2)}");
// 2) passing to a method: by value, by ref
int n = 5; AddOne(n); Console.WriteLine($"after AddOne(n): {n}");
AddOneRef(ref n); Console.WriteLine($"after AddOneRef(ref n): {n}");
var list = new List<int> { 1 }; AddItem(list); Console.WriteLine($"after AddItem(list): Count={list.Count}");
ReplaceList(list); Console.WriteLine($"after ReplaceList(list): Count={list.Count}");
// 3) pointers (unsafe): an address, dereference, pointer arithmetic
unsafe
{
int a = 42;
int* p = &a;
Console.WriteLine($"a={a} &a=0x{(ulong)p:X} *p={*p} sizeof(int)={sizeof(int)} sizeof(long)={sizeof(long)} pointer size={sizeof(nint)}");
*p = 7;
Console.WriteLine($"after *p = 7: a={a}");
int* arr = stackalloc int[3] { 10, 20, 30 };
Console.WriteLine($"arr[0] at 0x{(ulong)arr:X}, arr+1 at 0x{(ulong)(arr + 1):X}, *(arr+2)={*(arr + 2)}");
int b = 0;
Console.WriteLine($"two locals on the stack: &a=0x{(ulong)&a:X} &b=0x{(ulong)&b:X}");
}
// 4) GC generations: new objects start in gen 0 and are promoted when they survive
var survivor = new byte[1000];
Console.WriteLine($"new object: gen {GC.GetGeneration(survivor)}");
GC.Collect(); Console.WriteLine($"after 1st GC: gen {GC.GetGeneration(survivor)}");
GC.Collect(); Console.WriteLine($"after 2nd GC: gen {GC.GetGeneration(survivor)}");
var big = new byte[85_000];
Console.WriteLine($"byte[85000] starts in gen {GC.GetGeneration(big)} (large object heap); byte[84000] starts in gen {GC.GetGeneration(new byte[84_000])}");
GC.KeepAlive(survivor);
// 5) garbage: millions of short-lived objects, counted per generation
int g0 = GC.CollectionCount(0), g1 = GC.CollectionCount(1), g2 = GC.CollectionCount(2);
long sum = 0;
for (int i = 0; i < 5_000_000; i++) { var tmp = new PointClass { X = i }; sum += tmp.X; }
Console.WriteLine($"5M temporary objects: gen0 GCs={GC.CollectionCount(0) - g0} gen1={GC.CollectionCount(1) - g1} gen2={GC.CollectionCount(2) - g2}");
// 6) a leak in a managed language: a static event keeps every subscriber alive
long before = GC.GetTotalMemory(true);
for (int i = 0; i < 10_000; i++) { var w = new Window(); w.Open(); /* w.Close() forgotten */ }
long after = GC.GetTotalMemory(true);
Console.WriteLine($"10,000 windows opened, never closed: +{(after - before) / 1024} KB still reachable, subscribers={Settings.Subscribers}");
var weak = MakeWeak();
GC.Collect();
Console.WriteLine($"WeakReference alive after GC? {weak.IsAlive}");
// 7) stack overflow cannot be caught in .NET
if (args.Length > 0 && args[0] == "overflow") { Console.WriteLine("recursing..."); Recurse(0); }
static WeakReference MakeWeak() => new WeakReference(new byte[1000]);
static void AddOne(int x) => x++;
static void AddOneRef(ref int x) => x++;
static void AddItem(List<int> l) => l.Add(2);
static void ReplaceList(List<int> l) { l = new List<int>(); }
static int Recurse(int depth) => Recurse(depth + 1) + 1;
struct PointStruct { public int X; }
class PointClass { public int X; }
static class Settings
{
public static event EventHandler? Changed;
public static int Subscribers => Changed?.GetInvocationList().Length ?? 0;
}
class Window
{
private readonly byte[] _buffer = new byte[1024];
public void Open() => Settings.Changed += OnChanged;
public void Close() => Settings.Changed -= OnChanged;
private void OnChanged(object? s, EventArgs e) { }
}والمخرج سوف يكون كما يلي:
struct: p1.X=1 p2.X=99 | class: c1.X=99 c2.X=99 same object? True
after AddOne(n): 5
after AddOneRef(ref n): 6
after AddItem(list): Count=2
after ReplaceList(list): Count=2
a=42 &a=0x1B7057E248 *p=42 sizeof(int)=4 sizeof(long)=8 pointer size=8
after *p = 7: a=7
arr[0] at 0x1B7057DE40, arr+1 at 0x1B7057DE44, *(arr+2)=30
two locals on the stack: &a=0x1B7057E248 &b=0x1B7057E230
new object: gen 0
after 1st GC: gen 1
after 2nd GC: gen 2
byte[85000] starts in gen 2 (large object heap); byte[84000] starts in gen 0
5M temporary objects: gen0 GCs=28 gen1=0 gen2=0
10,000 windows opened, never closed: +11188 KB still reachable, subscribers=10000
WeakReference alive after GC? Falseفي المخرج أعلاه لاحظ التالي:
- السطر الأول هو الصورة الثالثة: النسخة من
structمستقلة، والنسخة منclassهي الكائن نفسه. AddOne(n)لم يغيرnلأنه أخذ نسخة، وrefغيره، وReplaceListلم يغير القائمة الأصلية.- المتغيران المحليان
aوbفي عنوانين متقاربين جداً، والثاني أصغر من الأول، لأن ال Stack يكبر نحو العناوين الأصغر، والمؤشر حجمه 8 بايتات على 64 بت. - أرقام الأجيال وال LOH وعدد مرات الجمع هي التي في الصورة الخامسة، والتسرب 11,188 KB هو الذي في الصورة السادسة، ولاحظ أن أرقام الذاكرة قد تختلف قليلاً بين تشغيل وآخر، فقد ظهر 11,192 KB في تشغيل آخر.
والبرنامج الثاني في Python:
import sys, gc, tracemalloc
# names are references to objects
a = [1, 2, 3]
b = a
b.append(4)
print("a =", a, " a is b:", a is b, " id(a) == id(b):", id(a) == id(b))
c = list(a)
print("copy: c is a:", c is a, " c == a:", c == a)
x = 10
y = x
y += 1
print("ints are immutable: x =", x, " y =", y)
def add_item(lst): lst.append("new")
def replace(lst): lst = ["other"]
add_item(a); replace(a); print("after add_item and replace:", a)
# reference counting
obj = object()
print("refcount:", sys.getrefcount(obj))
holder = [obj, obj]
print("refcount after 2 more refs:", sys.getrefcount(obj))
del holder
print("refcount after del:", sys.getrefcount(obj))
# cycles need the cyclic GC
print("gc thresholds:", gc.get_threshold(), " generations:", len(gc.get_count()))
class Node:
def __init__(self): self.other = None
gc.collect()
for _ in range(1000):
n1, n2 = Node(), Node()
n1.other, n2.other = n2, n1
del n1, n2
print("unreachable objects found by gc.collect():", gc.collect())
# a leak: a module-level cache that is never cleared
tracemalloc.start()
_cache = {}
def handle_request(i):
_cache[i] = bytearray(1000) # forgotten: nothing ever removes old keys
return len(_cache)
s1 = tracemalloc.take_snapshot()
for i in range(10_000): handle_request(i)
s2 = tracemalloc.take_snapshot()
top = s2.compare_to(s1, "lineno")[0]
print("top growth:", str(top).replace(__file__, "mem.py"))
# deep recursion
def deep(n): return deep(n + 1)
print("recursion limit:", sys.getrecursionlimit())
try:
deep(0)
except RecursionError as e:
print("RecursionError:", e)والمخرج سوف يكون كما يلي:
a = [1, 2, 3, 4] a is b: True id(a) == id(b): True
copy: c is a: False c == a: True
ints are immutable: x = 10 y = 11
after add_item and replace: [1, 2, 3, 4, 'new']
refcount: 2
refcount after 2 more refs: 4
refcount after del: 2
gc thresholds: (2000, 10, 10) generations: 3
unreachable objects found by gc.collect(): 2000
top growth: mem.py:43: size=10.4 MiB (+10.4 MiB), count=20002 (+20002), average=543 B
recursion limit: 1000
RecursionError: maximum recursion depth exceededفي المخرج أعلاه لاحظ التالي:
a is bصحيح لأنb = aلم ينسخ القائمة، أماlist(a)فأنشأت قائمة جديدة مساوية لها وليست هي.- عتبات الجمع في Python 3.13.3 هي
(2000, 10, 10)، أي يبدأ جمع الجيل الأول عندما يزيد عدد الكائنات المنشأة على المحررة بـ 2000. - في المحاولة الأولى كتبنا القيمة في ال Cache كنص ثابت
"x" * 1000، فأظهرtracemallocزيادة 304 KiB فقط، والسبب أن Python حسبت النص الثابت مرة واحدة واستخدمت الكائن نفسه لكل العناصر، فغيرناه إلىbytearray(1000)حتى يكون لكل عنصر كائن مستقل كما في الحياة الحقيقية.
والبرنامج الثالث في Node.js، ويحتاج الخيار --expose-gc حتى نستطيع طلب الجمع يدوياً بـ gc() قبل كل قياس:
import v8 from 'node:v8';
// primitives are copied, objects are shared by reference
let n1 = 1; let n2 = n1; n2++;
const o1 = { x: 1 }; const o2 = o1; o2.x = 99;
console.log('primitive:', n1, n2, '| object:', o1.x, o2.x, 'same?', o1 === o2);
const copy = structuredClone(o1); copy.x = 5; console.log('structuredClone:', o1.x, copy.x);
// the V8 heap is split into spaces: new (young generation) and old
const spaces = v8.getHeapSpaceStatistics().map(s => `${s.space_name}=${(s.space_size / 1024 / 1024).toFixed(1)}MB`);
console.log(spaces.join(' '));
// a leak: a Map used as a cache with no limit
const MB = (b) => (b / 1024 / 1024).toFixed(1) + ' MB';
const cache = new Map();
globalThis.gc?.();
const before = process.memoryUsage().heapUsed;
for (let i = 0; i < 200_000; i++) cache.set('user:' + i, { id: i, name: 'user' + i, tags: ['a', 'b'] });
globalThis.gc?.();
const after = process.memoryUsage().heapUsed;
console.log('cache of 200k entries: heapUsed', MB(before), '->', MB(after));
cache.clear(); globalThis.gc?.();
console.log('after cache.clear() + gc:', MB(process.memoryUsage().heapUsed));
// WeakMap does not keep its keys alive
const meta = new WeakMap();
let user = { id: 1 }; meta.set(user, { lastSeen: Date.now() });
user = null; // the entry can now be collected with the key
// stack overflow
function deep(n) { return deep(n + 1) + 1; }
try { deep(0); } catch (e) { console.log(e.constructor.name + ':', e.message); }وعند تشغيله بالأمر node --expose-gc mem.mjs سوف يكون المخرج كما يلي:
primitive: 1 2 | object: 99 99 same? true
structuredClone: 99 5
read_only_space=0.0MB new_space=1.0MB old_space=3.0MB code_space=0.3MB shared_space=0.0MB trusted_space=1.0MB new_large_object_space=0.0MB large_object_space=0.3MB code_large_object_space=0.0MB shared_large_object_space=0.0MB trusted_large_object_space=0.0MB
cache of 200k entries: heapUsed 4.1 MB -> 38.5 MB
after cache.clear() + gc: 4.1 MB
RangeError: Maximum call stack size exceededولاحظ أن structuredClone أنشأ نسخة مستقلة فلم يتأثر o1 بتغيير النسخة، وأن ال Heap في V8 مقسم إلى مساحات منها new_space للجيل الصغير وold_space للجيل القديم وlarge_object_space للكائنات الكبيرة، وهي الفكرة نفسها التي رأيناها في .NET.
أخطاء شائعة
- تعديل قائمة أو كائن داخل دالة دون قصد: المعامل يشير إلى الكائن نفسه، فإذا كانت الدالة لا يجب أن تغيره فانسخه أولاً أو استخدم نوعاً غير قابل للتعديل مثل
IReadOnlyListفي C# أوtupleفي Python. - القيمة الافتراضية القابلة للتعديل في Python:
def f(items=[])تنشئ القائمة مرة واحدة عند تعريف الدالة، فتشترك فيها كل الاستدعاءات، واستخدمNoneثم أنشئ القائمة داخل الدالة. - struct كبير يتغير: نوع القيمة ينسخ كاملاً في كل إسناد وتمرير، فإذا عدلت نسخة ظننت أنها الأصل ضاع التعديل، لذلك اجعل ال
structصغيراً وغير قابل للتعديلreadonly struct. - التكرار الذاتي على بيانات المستخدم دون حد: في .NET ينهي العملية كلها ولا يمكن التقاطه، فضع حداً للعمق أو استخدم حلقة.
- Cache أو حدث static بلا إلغاء: وهو أشهر سبب للتسرب في التطبيقات التي تعمل أياماً.
- استدعاء
GC.Collect()أوgc()لحل مشكلة أداء: استخدمناه في التجربة فقط لنقيس، أما في التطبيق فالجمع اليدوي يوقف التطبيق في وقت لم يختره جامع القمامة، ونادراً ما يكون هو الحل. - المؤشرات و
unsafeفي كود التطبيقات: اتركها للحالات النادرة التي تحتاجها فعلاً بعد القياس، مثل التعامل مع مكتبة C.
تدريب عملي
- اكتب دالة تستقبل قائمة وتضيف إليها عنصراً، ثم دالة تسند للمعامل قائمة جديدة، في اللغة التي تعمل بها يومياً، وتوقع النتيجة قبل التشغيل.
- شغل خدمة تعمل بها على بيئة التطوير، وراقب الذاكرة بـ
dotnet-countersأوprocess.memoryUsage()أثناء ضغط بسيط ثم بعده، وتأكد أنها تعود إلى مستوى ثابت. - ابحث في مشروعك عن كل
staticمن نوعDictionaryأوListأوMap، واسأل لكل واحد: من يحذف منه؟
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- ذاكرة البرنامج مقسمة إلى الكود والبيانات وال Heap وال Stack، والعناوين افتراضية وتتغير في كل تشغيل.
- ال Stack لكل خيط، سريع ومحدود، ويختفي إطار الدالة عند عودتها، وامتلاؤه يرمي استثناء في Python وJavaScript وينهي العملية في .NET.
- ال Heap للكائنات التي تعيش أطول من الدالة، ويحررها أنت في C، أو جامع القمامة في C# وJava وJavaScript وPython، أو المترجم بقواعد الملكية في Rust.
- نوع القيمة ينسخ كاملاً ونوع المرجع ينسخ العنوان، والمعاملات تمرر بالقيمة لكن قيمة المرجع هي المرجع، وهذا يفسر أغلب مفاجآت التعديل.
- المؤشر رقم يحمل عنواناً ولا يمنعك من الخروج عن الحدود، وهذا مصدر ثغرات مثل Heartbleed، والمرجع مؤشر آمن تديره اللغة.
- جامع القمامة يحرر ما لا يصل إليه أحد من الجذور، ويعمل بالأجيال لأن أغلب الكائنات تموت صغيرة، ولا يمنع التسرب إذا بقي مرجع من static أو Cache أو حدث.
وفي مرحلة أساسيات علوم الحاسب نفسها سوف نتحدث بإذن الله عن هياكل البيانات Data Structures، حيث نرى كيف تبنى المصفوفة والقائمة المرتبطة والجدول فوق ما تعلمناه هنا عن المؤشرات والمراجع وال Heap.
دليلالأنظمة العددية للمبرمجين: من Binary إلى Floating Pointلماذا يظهر القرص 465 بدل 500، ويصبح العداد سالباً فجأة، ولا يساوي 0.1 + 0.2 القيمة 0.3؟ الإجابة في طريقة تخزين الأرقام.
دليلماذا يحدث عندما تشغل برنامج Hello World؟نتتبع برنامج C صغيراً من المحرر إلى الشاشة، فترى كل طبقة يمر بها في الجهاز حتى تظهر الجملة أمامك.
دليلالتزامن Concurrency والخيوط: ماذا يعني لسيرفرك وتطبيقاتكلماذا يكون المعالج عند 100% والتطبيق بطيئاً، ولا تسرع الخيوط كود Python، وكيف تضبط عدد العمال وحدود ال Containers.
دليلأنماط البرمجة وأنظمة الأنواع: لماذا تختلف اللغات؟نحل مسألة واحدة بعدة أساليب برمجة، ونرى هل تكتشف اللغة أخطاءك قبل التشغيل أم بعده، وماذا يعني ذلك عند اختيار لغة لمشروعك.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ الأمثلة على Windows 11 (x86-64) مع .NET SDK 10.0.400 (Runtime 10.0.11) وPython 3.13.3 وNode.js 22.14.0، ومثال C من توثيق malloc دون تشغيل.
- أكتوبر 2026: نقل تشريح الذاكرة RAM بصوره ورسوماته إلى مقال مستقل هو «كيف تعمل الذاكرة RAM من الداخل»، وبقيت هنا فقرة قصيرة ترسل إليه.