Când un developer este responsabil de alocarea și dezalocarea explicită a memoriei, vorbim de o gestionare manuală a memoriei. Cel mai mare avantaj este controlul deplin. Însă apare și cel mai mare dezavantaj: atenția developerului este împărțită între obiectivul principal, de business și administrarea memoriei programului.
Runtime-ul .NET - pe care îl vom detalia mai jos, preia responsabilitatea din urmă, oferă management automat al memoriei și își propune eliminarea problemelor obișnuite: uitarea dezalocării memoriei (memory leaks), accesarea memoriei invalide (colectată anterior) etc.
Există trei concepte, mai abstracte, ce se leagă de gestionarea automată a memoriei și anume: alocator, mutator (i.e. mutator) și colector. Un mutator, definit în termeni simpli, este entitatea responsabilă de execuția codului aplicației. Își primește numele din simplul fapt că mută (schimbă) starea memoriei - obiectele sunt alocate sau modificate, iar legăturile dintre ele sunt schimbate. Alocatorul reprezintă entitatea responsabilă de administrarea alocării și dezalocării dinamice a memoriei. Nu în ultimul rând, colectorul este entitatea responsabilă de execuția codului pentru colectarea memoriei care nu mai este folosită (mai exact, procesul de garbage collection).
Un sistem de operare oferă propriile mecanisme de alocare de memorie. Mediile negestionate (i.e. unmanaged) precum C/C++ depind direct de acele mecanisme pentru a-și obține memoria necesară. Mediul .NET introduce un nivel adițional între sistemul de operare și programul ce se execută, acesta fiind runtime-ul.
Când se inițializează un nou proces, se rezervă un spațiu contiguu de memorie pentru acesta. Acest spațiu este împărțit în diferite segmente de memorie, cele mai importante alcătuind așa numitul Managed Heap (heap gestionat). Alocatorul va aloca obiectele în acest heap. Colectorul va ține evidența accesibilității obiectelor aflate în acest heap și va revendica memoria ocupată de obiectele inaccesibile.
.NET implementează, intern, propriul mecanism de alocare de memorie. Este mult mai rapid decât a cere sistemului de operare memorie de fiecare dată când un obiect este creat.
Implementarea se face pe baza a două metode cunoscute:
alocare secvențială;
Cea mai simplă și rapidă cale de alocare de memorie în cadrul acestor segmente este folosirea și incrementarea unui pointer ce indică "sfârșitul" memoriei curente. Acest pointer poartă numele de "pointer de alocare" (allocation pointer). Când se dorește crearea unui obiect, pointerul se mută cu un număr de byți corespunzător mărimii obiectului.
Să presupunem că sunt unele obiecte deja create. Pointerul de alocare indică locația sfârșitului zonei de memorie ocupată de acele obiecte. Începând din acea locație vor fi alocate viitoare obiecte. Când e necesară memorie pentru un nou obiect (A), pointerul de alocare specifică adresa noului obiect, iar alocatorul incrementează valoarea pointerului cu numărul de byți corespunzător.
Fiind un algoritm secvențial , acesta oferă localitate bună a datelor. Este și supra optimist pentru că are nevoie de o disponibilitate infinită a memoriei. Aici intervine colectorul: obiectele ce nu mai sunt folosite sunt colectate, iar memoria (partea din stânga a pointerului) este compactată. În acest mod, pointerul de alocare este "derulat înapoi".
Pentru ca un obiect să fie într-o stare "curată" (clean), memoria trebuie reinițializată (zeroed). În avans, CLR-ul reinițializează o zonă de memorie, sfârșitul acesteia fiind reprezentat de un pointer adițional (sub numele de limită de alocare). Zona de memorie dintre pointerul de alocare și limita de alocare se numește context de alocare și este locul unde are loc alocarea rapidă, optimistă a obiectelor.
În cazul în care contextul de alocare este insuficient ca spațiu pentru obiectele ce urmează a fi create, fie acesta este extins, fie se creează unul nou. Un context de alocare nu trebuie să fie neapărat la sfârșitul segmentului de memorie, ci poate fi și într-o zonă liberă între obiecte existente. Pointerii (alocare și limită) vor reprezenta acel spațiu liber.
Fiecare context de alocare are o afinitate a threadului. Toate threadurile gestionate (managed, ce execută cod .NET) din aplicație au propriile contexte de alocare - un plus pentru performanță, eliminând necesitatea sincronizării.
Pe parcursul execuției aplicației, există multiple contexte de alocare ce se află în același segment. O parte dintre acestea se vor afla la finalul segmentului, iar o altă parte vor utiliza spațiul liber dintre obiectele existente.
Un dezavantaj al alocării secvențiale este fragmentarea memoriei. "Găurile" (obiectele nefolosite, ulterior colectate) de memorie trebuie cumva eliminate. Colectorul va compacta obiectele existente, iar contextele de alocare vor fi reorganizate într-un mod natural, și anume la finalul segmentului memoriei.
Ideea de bază este una trivială. Când CLR-ul cere să fie alocați un număr de byți, alocatorul caută într-o listă de "spații libere" unul îndeajuns pentru a acomoda numărul de byți. Căutarea se poate face pe baza a două strategii:
best-fit - se caută un spațiu cât mai apropiat ca mărime pentru a lăsa cât mai puțin spațiu nefolosit;
Un Managed heap este împărțit fizic în Small Object Heap (SOH) și Large Object Heap (LOH). Primul este împărțit la rândul lui, doar logic, în "generații" (0, 1 și 2, primele două alcătuind așa zisul segment efemer). Orice obiect mai mare de 85000 de byți va fi alocat în LOH, restul fiind alocate în SOH (mai exact în segmentul efemer).
Există mici diferențe între alocările din SOH și cele din LOH. În C#, operatorul new este tradus în limbaj intermediar (limbaj folosit de CLR) într-o instrucțiune newobj. Compilatorul JIT (just in time) emite invocarea adecvată a metodei pentru instrucțiunea newobj în funcție de varii condiții.
Alocarea obiectelor mici se bazează în mare pe alocare secvențială. Obiectivul este alocarea obiectele în cadrul contextului de alocare. În cazul în care alocarea nu poate fi făcută, o nouă cale de alocare, mai înceată, va fi executată. De aceea, pentru obiectele mici, metoda invocată de JIT este cea eficientă, având ca beneficiu "alocare ieftină".
se încearcă alocarea în cadrul contextului de alocare;
în cazul în care eșuează, o metodă mai generică este invocată de JIT (alocare slow-path). Această alocare încearcă utilizarea spațiului existent, dar nefolosit din segmentul efemer și va:
încerca să utilizeze alocarea free-list pentru a obține spațiul necesar pentru un nou context de alocare;
în cazul în care nu e posibil, va încerca să ajusteze limita de alocare în cadrul memoriei angajate (committed) ;
în cazul în care nu a reușit nici să creeze un context de alocare nou, nici să extindă unul deja existent, se declanșează procesul de garbage collection (proces ce se va executa cel puțin o dată);
Propoziția "Alocarea este ieftină în .NET" este adevărată până într-un punct. Alocările trebuie utilizate prudent și să nu aloce obiecte inutile. Într-o aplicație, în care performanța este critică, regula principală în ceea ce privește alocările este ca acestea să fie evitate.
Alocarea obiectelor mari (ce ajung în LOH) este bazată atât pe alocarea free-list, cât și pe o alocare secvențială mai simplificată (nu se folosește context de alocare).
Contextul de alocare și alte optimizări asociate nu sunt importante deoarece costul ștergerii unui obiect mare este dominant. Nu există diferență între alocarea rapidă și alocarea slow-path. Aceiași pași sunt executați, fiind asemănători cu pașii alocării slow-path în SOH:
se încearcă alocarea în spațiul liber nefolosit prin:
găsirea unui gol potrivit pentru obiect;
în cazul în care nu se găsește, se încearcă ajustarea limitei de alocare în memoria angajată (în cadrul LOH);
în caz de eșuare, procesul de garbage collection va fi declanșat (cel puțin o dată);
Deși nu se folosește contextul de alocare în LOH, pentru obiectele noi, memoria tot trebuie reinițializată, proces ce este costisitor pentru obiectele mari. De aceea, ca regulă, se încearcă evitarea excesivă a alocărilor în LOH și, în schimb, se preferă crearea unei rezerve de obiecte reutilizabile (reusable objects pool).
Memoria este și va fi întotdeauna o resursă limitată și mereu vor exista erori sau modalități de utilizare incorectă. Singurul lucru care se va schimba mereu privind gestionarea memoriei este cantitatea. Dimensiunea memoriei va crește, timpul de acces va scădea, făcând posibilă prelucrarea datelor într-un timp satisfăcător. Într-un articol viitor vom vorbi și despre alocarea pe stack și evitarea alocărilor.