Управление на паметта и ресурсите


Управление на паметта в .NET Framework



страница2/7
Дата25.02.2017
Размер0.85 Mb.
#15739
1   2   3   4   5   6   7

Управление на паметта в .NET Framework





Трябва да отучиш това, което си научил. Йода

В секциите до края на тази глава ще разгледаме особеностите на управлението на паметта в .NET Framework. Ще се спрем на процесите, протичащи зад сцената на автоматичното управление на паметта. Ще проследим жизнения цикъл на обектите – от заделянето на памет при тяхното създаване, до момента в който те умират и освобождават заетите от тях ресурси. Ще споменем за интересния случай, при който един обект може да се съживи, възкръсвайки от света на мъртвите, и да се използва отново от приложението.

Като цяло, управлението на паметта в .NET е интересна и вълнуваща тема. В настоящата глава ще се опитаме да ви дадем цялостна представа за това какво се случва в системата, докато се изпълнява управляван код, и ще навлезем в много от детайлите.

Това със сигурност ще ви помогне да разберете по-пълно .NET Framework, и може би, да пишете по-добър код.

И така, както несъмнено вече сте разбрали, управлението на паметта в .NET е автоматично. От гледна точка на разработчиците, това означава, че вече не е необходимо да се пише специален код, който да освобождава заетата от обектите памет.

Когато вашето приложение създава нов обект, паметта, необходима за него се заделя в регион, наречен managed heap. Заделянето на паметта и хийпът се разглеждат малко по-нататък. След като обектът е създаден, приложението използва неговата функционалност, и когато обектът стане ненужен, той просто се “изоставя”, и в по-късен етап се почиства автоматично от т.нар. garbage collector – системата за почистване на паметта.

Вероятно се досещате, че работата по почистването всъщност е най-трудоемката и най-отговорна част от управлението на паметта в .NET. Алгоритъмът, по който работи garbage collector ще разгледаме подробно след малко. Засега просто приемете, че винаги, когато има недостиг от памет, се стартира системата за почистване на паметта, която идентифи­цира всички отпадъци – т.е. обекти, които вече не се използват от приложението и освобождава заетата от тях памет. Като програмисти по принцип нямаме контрол върху това в кой момент ще започне почистване­то, нито колко време ще отнеме.

Естествено, за някои обекти не е достатъчно само да се освободи паметта. Ако например даден обект капсулира файлов манипулатор, със сигурност бихме искали да освободим и този ресурс, когато вече не ни е нужен. Това не може да бъде направено автоматично от garbage collector-а, тъй като той се грижи само за паметта и не знае какви други системни ресурси използва обектът. За освобождаването на тези ресурси все още трябва да се погрижим ръчно. За целта в .NET съществуват т.нар. финализатори (finalizers) – специални методи, които се изпълняват преди обектът да се унищожи.

В горните абзаци просто нахвърляхме някои от по-важните теми, които ще бъдат разгледани повече или по-малко детайлно в главата.

Нека преди да преминем към подробностите, да се спрем на предимствата и недостатъците на тази схема на управление на паметта.

Предимства и недостатъци на автоматичното управление на паметта


Както всяка технология, така и автоматичното управление на паметта има своите плюсове и минуси. В тази секция накратко ще разгледаме по-важните от тях:

Предимства


Най-голямото предимство на автоматичното управление на паметта, разбира се е това, че ние, като разработчици, сме освободени от грижата ръчно да почистваме ненужните обекти. Това ни позволява в по-голяма степен да се съсредоточим върху бизнес логиката на нашето приложение и да отделяме по-малко време в грижи за правилното управление на паметта. Това пък, от своя страна води до по-бързо писане на кода и като цяло до съкратен цикъл на разработка.

Тясно свързано с първото е и другото голямо предимство – предотвратя­ването на т.нар. “memory leaks” или изтичане на памет. Това е много неприятен проблем, който се получава, когато разработчиците забравят да почистват ненужните обекти. В резултат, приложението започва да заема все повече памет и с течение на времето се дестабилизира. Тази ситуация е особено критична при сървърни приложения, които трябва да работят дълго време (седмици и месеци) без да се рестартират. Освен всичко друго, това е проблем, който много трудно се открива (обикновено това става, когато приложението вече се използва от клиентите) и още по-трудно се дебъгва. Понякога, при големи системи са нужни дни и дори седмици за откриването и отстраняването на причината за проблема (в много случаи причината се оказва наглед невинна грешка, и то на мястото в кода, в което сте най-сигурни че работи правилно).

В .NET можем да сме сигурни, че ако един обект не се използва от приложението, той ще бъде освободен. Сравнително трудно (но не невъзможно, както сами ще се убедите по-нататък) е да постигнете изтичане на памет.

Друг често срещан проблем, е писането и четенето по вече освободена памет или повторно освобождаване на обект. Това, в зависимост от ситуацията може да доведе до срив на цялото приложение, или до дестабилизирането му с непредвидими последици. При автоматичното управление на паметта, обектът се унищожава само когато е гарантирано недостъпен (след малко ще видим как става това), така че няма как да достъпваме обекта, ако той вече е бил унищожен.

Един от неприятните проблеми при неуправляваните приложения е лип­сата на съгласуваност в стратегиите за отчитане на недостиг на памет. Почти всички библиотеки използват само две стратегии, но съчетаването им ви принуждава да взимате не винаги приятни решения за дизайна на приложението ви, а също така прави кода ви труден за поддръжка. В .NET може да бъдете сигурни, че винаги ще бъде изхвърлено изключението OutOfMemoryException (въпреки, че в този момент не можете да направите кой знае какво).

При ръчното управление на паметта един от най-бележитите проблеми е този с броене на референциите към обектите, както и частния случай с циклични референции (когато два или повече обекта съдържат референ­ции един към друг). Този проблем не съществува в .NET.

Друго, немаловажно предимство на автоматичното управление на паметта, е че паметта в хийпа се заделя много бързо. В следващата секция ще разберем защо това е така.

Е, разбира се, бързото заделяне на памет се компенсира от трудоемкото й освобождаване, което пък от своя страна е един от главните недостатъци на този модел за управление на паметта.


Недостатъци


Естествено, основният недостатък на автоматичното управление на паметта е, че почистването й е тежка и времеотнемаща операция. Когато е необходимо да се освободи памет, всички нишки на приложението заспиват и остават в това състояние докато garbage collector-ът завърши своята работа. И тъй като системата за почистване на паметта се стартира когато има недостиг на памет, ние нямаме контрол точно в кой момент нашето приложение ще “заспи”, за да се осъществи почистването, нито колко време ще трае това „заспиване”.

Въпреки, че е възможно “ръчно” да контролираме работата на garbage collector чрез статичните методи на класа GC, това в огромната част от случаите е непрепоръчително, тъй като CLR обикновено може по-добре да прецени кога трябва да се осъществи почистване. Все пак, “автоматично управление” означава и по-малък контрол върху системата, което не се харесва на някои програмисти.

Алгоритъмът, по който работи garbage collector е доста добре оптимизиран и вероятно ще се оптимизира още, в бъдещите версии на .NET Framework, така че за повечето приложения, известната загуба на контрол е приемлива цена за предимствата, които получаваме. От Microsoft твърдят, че при тестове на 200 MHz Pentium машина, почистването на Поколение 0, отнема по-малко от една милисекунда (какво е Поколение 0 ще стане дума малко по-нататък). Така че, когато по-горе казвам че приложението ще “заспи”, не оставайте с грешното впечатление, че програмите ви ще блокират за неопределен период от време – обикновено garbage collector се изпълнява достатъчно бързо за да не се забелязва с просто око.



Запомнете, че няма гаранция кога се изпълнява garbage collector и колко време отнема!

Въпреки, че е голямо предимство, високото ниво на абстракция е и огромен недостатък – неопитните програмисти, които не разбират (или по-лошо – не искат да разбират) как работи управлението на паметта в .NET и в частност системата за почистване на паметта, са способни да напишат силно неефективен по отношение на използването на паметта код, както и код, който да предизвика „изтичане” на памет дори в .NET.

Вече споменахме, че garbage collector се грижи за почистването на паметта. Все още много системни ресурси, обаче, трябва да се управляват ръчно. Не можете да очаквате от garbage collector автоматично да затвори мрежова връзка или файлов манипулатор. Когато програмирате обект, капсулиращ някакъв системен ресурс, трябва да имате това предвид и да вземете специални мерки за правилното му почистване. Как става това ще разгледаме по-нататък в настоящата тема.

Нека сега навлезем в детайлите на управлението на паметта в .NET Framework.

Как се заделя памет в .NET?


Когато CLR се инициализира, той заделя регион от последователни адреси в паметта. Това е т.нар. динамична памет или managed heap.

За разлика от стойностните типове, чиято памет се заделя в стека и се освобождава веднага, след като променливата излезе от обхват, паметта, нужна за референтните типове, винаги се заделя в managed heap.

В тази секция ще разгледаме как се осъществява заделянето на памет в хийпа.

В .NET, динамичната памет винаги се запълва последователно отляво надясно. Можете нагледно да си представите управлявания хийп като конвейер, при който обектите се добавят един след друг върху лентата (паметта), като всеки следващ е плътно долепен до предишния. За да е възможно това, хийпът поддържа указател, т.нар. NextObjPtr, който сочи адреса на който ще се добави следващият създаден обект. Фигурата илюстрира това описание:



Когато процесът се стартира, динамичната памет не съдържа никакви обекти и NextObjPtr е установен да сочи към базовия адрес от хийпа.



За да създадем обект в managed heap, използваме код, подобен на този:



SomeObject x = new SomeObject();

C# компилаторът превежда кода в IL newobj инструкция:

newobj instance void MyNamespace.SomeObject::.ctor()

Когато тази инструкция се изпълнява, CLR действа по следния начин:

  • Изчислява размера, необходим за полетата на новия обект и всичките му родителски обекти.

  • Към получения размер прибавя размера на MethodTablePointer и SyncBlockIndex (специални служебни полета). При 32-битовите системи, тези две полета добавят 8 байта към размера на всеки обект, а при 64-битовите системи – 16 байта.

  • Прибавя получената стойност към указателя NextObjPtr. Ако в managed heap има достатъчно място, паметта се заделя, извиква се конструкторът на обекта, който я инициализира, и адресът на обекта се връща от new оператора. Ако CLR установи, че мястото в паметта е недостатъчно, се стартира garbage collector. След като той приключи работа, CLR опитва отново да създаде обекта. Ако и тогава няма достатъчно памет, хийпът се увеличава, а ако това е невъзможно, new операторът предизвиква OutOfMemoryException.

Значението на полетата MethodTablePointer и SyncBlockIndex, които CLR създава за всеки обект от управлявания хийп, е извън темата на тази глава. Накратко, MethodTablePointer, както показва името му, съдържа указател към адреса на таблицата с методите на дадения тип, а SyncBlockIndex се използва при синхронизацията на обекта между нишките. За целите на настоящото изложение, просто трябва да запомните, че всеки един обект от хийпа съдържа тези две полета, които увеличават размера му с 8 или 16 байта, съответно при 32 и 64 битовите системи.

След като обектът е успешно създаден, CLR установява NextObjPtr на първия свободен адрес, непосредствено след края на новия обект, както е показано на следващата фигура.



Вероятно се досещате, че този начин за заделяне на памет в managed heap работи много бързо, защото физически се имплементира с прибавянето на стойност (размерът на обекта) към указателя NextObjPtr. Всъщност скоростта на създаване на референтен тип в managed heap е съпоставима със заделянето на памет в стека. За разлика от .NET, в C++ runtime heap заделянето на памет е значително по-тежка операция, при която след изчисляването на размера на обекта първо се търси достатъчно голям блок свободна памет и едва след това обектът може да бъде създаден.

Освен това, тъй като паметта се запълва последователно, когато създаваме обекти един след друг, те физически ще се намират на близки адреси в паметта. Това може значително да подобри производителността в някои ситуации, тъй като обектите, създадени приблизително по едно и също време обикновено са логически свързани и приложението често ги използва заедно (представете си например локални променливи в тялото на даден метод). Така е възможно всички обекти, които дадена част от кода използва, да се намират в кеша на процесора и работата с тях ще е много бърза.

Трябва да се има предвид, обаче, че освобождаването на памет от хийпа е сложна и времеотнемаща операция. Тя се извършва от системата за почистване на паметта, когато има недостиг на памет. Почистването на паметта и алгоритъмът, по който то се извършва, ще разгледаме подробно в следващите секции.


Как работи garbage collector?


В предишната секция описахме как се заделя памет, при създаването на обекти в управлявания хийп. Видяхме, че при достатъчно свободна памет това е много бърз процес, който практически се осъществява с премест­ването на един указател. Какво става, обаче, ако CLR установи, че в managed heap няма достатъчно място? Вече беше споменато, че ако добавянето на нов обект би довело до препълване на хийпа, трябва да се осъществи почистване на паметта. В този момент, CLR стартира системата за почистване на паметта, т.нар. garbage collector.



Всъщност това е опростено обяснение. Garbage collector се стартира когато Поколение 0 се запълни. Поколенията се разглеждат в следващата секция.

Носи се слух, че първоначално Garbage Collector в CLR е бил имплемен­тиран на езика Lisp от Patrick Dussud, а после кода е конвертиран до C код с помощта на автоматичен конвертор и “почистен” от студент, работещ в Microsoft.

Нишките трябва да се приспят


Първото нещо, което трябва да се направи, за да може системата за почистване на паметта да започне работа, това е да се приспят всички нишки на приложението, изпълняващи управляван код. Тъй като, както след малко ще видим, по време на събирането на отпадъци е твърде вероятно обектите да се преместят на нови адреси в динамичната памет, нишките не трябва да могат да достъпват и модифицират обекти докато трае почистването.

CLR изчаква всички нишки да достигнат в безопасно състояние, след което ги приспива. Съществуват няколко механизма, чрез които CLR може да приспи дадена нишка. Причината за тези различни механизми е стремежът да се намали колкото се може повече натоварването и нишките да останат активни възможно най-дълго.


Освобождаване на неизползваните обекти


След като всички управлявани нишки на приложението са безопасно “приспани”, garbage collector проверява дали в managed heap има обекти, които вече не се използват от приложението. Ако такива обекти съществуват, заетата от тях памет се освобождава. След приключване на работата по събиране на отпадъци се възобновява работата на всички нишки и приложението продължава своето изпълнение.

Както вероятно се досещате, откриването на ненужните обекти и освобождаването на ресурсите, заети от тях, не е проста задача. В тази секция накратко ще опишем алгоритъмът, който .NET garbage collector-ът използва за нейното решаване.

За да установи кои обекти подлежат на унищожение, garbage collector-ът построява граф на всички обекти, достъпни от нишките на приложението в дадения момент. Всички обекти от динамичната памет, които не са част от графа се считат за отпадъци и подлежат на унищожаване.

Възниква въпросът как garbage collector-ът може да знае кои обекти са достъпни и кои не? Корените на приложението са точката, от която системата за почистване на паметта започва своята работа.


Корени на приложението


Всяко приложение има набор от корени (application roots). Корените представляват области от паметта, които сочат към обекти от managed heap, или са установени на null. Например всички глобални и статични променливи, съдържащи референции към обекти се считат за корени на приложението. Всички локални променливи или параметри в стека към момента, в който се изпълнява garbage collector, които сочат към обекти, също принадлежат към корените. Регистрите на процесора, съдържащи указатели към обекти, също са част от корените. Към корените на приложението спада и Freachable queue (за Freachable queue по-подробно ще стане дума в секцията за финализация на обекти в настоящата глава. Засега просто приемете че тази опашка е част от вътрешните структури, поддържани от CLR и се счита за един от корените на приложението).

Когато JIT компилаторът компилира IL инструкциите на даден метод в процесорни инструкции, той също съставя и вътрешна таблица, съдържаща корените за съответния метод. Тази таблица е достъпна за garbage collector. Ако се случи garbage collector да започне работа, когато методът се изпълнява, той ще използва тази таблица, за да определи кои са корените на приложението към този момент. Освен това се обхожда и стекът на извикванията за съответната нишка и се определят корените за всички извикващи методи (като се използват техните вътрешни таблици). Към получения набор от корени, естествено, се включват и тези, намира­щи се в глобални и статични променливи.

Трябва да се помни, че не е задължително даден обект да излезе от обхват за да бъде считан за отпадък. JIT компилаторът може да определи кога този обект се достъпва от кода за последен път и веднага след това го изключва от вътрешната таблица на корените, с което той става кандидат за почистване от garbage collector. Изключение правят случаите, когато кодът е компилиран с /debug опция, която предотвратява почист­ването на обекти, които са в обхват. Това се прави за улеснение на процеса на дебъгване – все пак при трасиране на кода бихме искали да можем да следим състоянието на всички обекти, които са в обхват в дадения момент.

Алгоритъмът за почистване на паметта


Когато garbage collector започва своята работа, той предполага че всички обекти в managed heap са отпадъци, т.е. че никой от корените не сочи към обект от паметта. След това, системата за почистване на паметта започва да обхожда корените на приложението и да строи граф на обектите, достъпни от тях.

Нека разгледаме примера, показан на следващата фигура. Ако глобална променлива сочи към обект A от managed heap, то A ще се добави към графа. Ако A съдържа указател към C, а той от своя страна към обектите D и F, всички те също стават част от графа. Така garbage collector обхожда рекурсивно в дълбочина всички обекти, достъпни от глобалната промен­лива A:



Когато приключи с построяването на този клон от графа, garbage collector преминава към следващия корен и обхожда всички достъпни от него обекти. В нашия случай към графа ще бъде добавен обект E. Ако по време на работата garbage collector се опита да добави към графа обект, който вече е бил добавен, той спира обхождането на тази част от клона. Това се прави с две цели:



  • значително се увеличава производителността, тъй като не се преминава през даден набор от обекти повече от веднъж;

  • предотвратява се попадането в безкраен цикъл, ако съществуват циклично свързани обекти (например A сочи към B, B към C, C към D и D обратно към A).

След обхождането на всички корени на приложението, Графът съдържа всички обекти, които по някакъв начин са достъпни от приложението. В посочения на фигурата пример, това са обектите A, C, D, E и F.

Всички обекти, които не са част от този граф, не са достъпни и следователно се считат за отпадъци. В нашия пример това са обектите B, G, H и I.

След идентифицирането на достъпните от приложението обекти, garbage collector преминава през хийпа, търсейки последователни блокове от отпадъци, които вече се смятат за свободно пространство. Когато такава област се намери, всички обекти, намиращи се над нея се придвижват надолу в паметта, като се използва стандартната функция memcpy(). Крайният резултат е, че всички обекти, оцелели при преминаването на garbage collector, се разполагат в долната част на хийпа, а NextObjPtr се установява непосредствено след последния обект. Фигурата показва състоянието на динамичната памет след приключване на работата на garbage collector:





Описаният алгоритъм за почистване на паметта не взима предвид финализацията. Обектите, нуждаещи се от финализация не се унищожават веднага. Вместо това те остават в паметта и указатели към тях се добавят във т. нар. Freachable queue. Финализацията ще разгледаме подробно малко по-нататък.

Естествено, преместването на обект на друго място в паметта прави невалидни всички указатели, сочещи към него, така че част от “задълженията” на garbage collector е да коригира по подходящ начин указателите към оцелелите обекти.

Пренареждането на хийпа е трудоемка операция – трябва да се прид­вижват големи области от паметта и да се валидират указателите към преместените обекти. Затова ако garbage collector срещне малка област от незаета памет, той просто я игнорира и продължава нататък.

Като цяло, работата на garbage collector има значително отражение върху производителността на цялото приложение. Построяването на графа на достъпните обекти, обхождането и пренареждането на динамичната памет отнемат немалко процесорно време, през което нишките на приложението спят. Трябва да се има предвид, обаче, че garbage collector се стартира само когато има нужда от това (т.е. когато има недостиг на памет). През останалото време managed heap е доста по-бърз от C/C++ runtime heap.

В помощ на производителността са и някои оптимизации на алгоритъма на garbage collector, най-важната от които е концепцията за поколения. Нека разгледаме поколенията памет.


Поколения памет


Поколенията (generations) са механизъм в garbage collector, чиято единствена цел е подобряването на производителността. Основната идея е, че почистването на част от динамичната памет винаги е по-бързо от почистването на цялата памет. Вместо да обхожда всички обекти от хийпа, garbage collector обхожда само част от тях, класифицирайки ги по определен признак. В основата на механизма на поколенията стоят следните предположения:

  • колкото по-нов е един обект, толкова по-вероятно е животът му да е кратък. Типичен пример за такъв случай са локалните променливи, които се създават в тялото на даден метод и излизат от обхват при неговото напускане.

  • колкото по-стар е обектът, толкова по-големи са очакванията той да живее дълго. Пример за такива обекти са глобалните променливи.

  • обектите, създадени по едно и също време обикновено имат връзка помежду си и имат приблизително еднаква продължителност на живота.

Много изследвания потвърждават валидността на изброените твърдения за голям брой съществуващи приложения. Нека разгледаме по-подробно поколенията памет и това как те се използват за оптимизация на произво­дителността на .NET garbage collector.

Поколение 0


Когато приложението се стартира, първоначално динамичната памет не съдържа никакви обекти. Всички обекти, които се създават, стават част от Поколение 0. Казано накратко Поколение 0 съдържа новосъздадените обекти – тези, които никога не са били проверявани от garbage collector.

При инициализацията на CLR се определя праг за размера на Поколение 0. Точният размер на този праг не е от особено значение, тъй като може да се променя от garbage collector по време на работа с цел подобряване на производителността. Да предположим, че първоначално стойността на този праг е 256KB.

Следващата фигура показва състоянието на динамичната памет след като приложението е работило известно време. Виждаме, че са създадени известен брой обекти (всички част от Поколение 0), а обекти B и D вече са станали недостъпни (т.е. подлежат на почистване).

Да предположим, че приложението иска да създаде нов обект, F. Добавянето на този обект би предизвикало препълване на Поколение 0. В този момент трябва да започне събиране на отпадъци и се стартира garbage collector.


Почистване на Поколение 0


Garbage collector процедира по описания по-горе алгоритъм и установява че обекти B и D са отпадъци. Тези обекти се унищожават и оцелелите обекти A, C и E се пренареждат в долната (или лява) част на managed heap. Динамичната памет непосредствено след приключването на събирането на отпадъци изглежда по следния начин:

Сега оцелелите при преминаването на garbage collector обекти стават част от Поколение 1 (защото са оцелели при едно преминаване на garbage collector). Новият обект F, както и всички други новосъздадени обекти ще бъдат част от Поколение 0.

Нека сега предположим, че е минало още известно време, през което приложението е създавало обекти в динамичната памет. Managed heap сега изглежда по следния начин:

Добавянето на нов обект J, би предизвикало препълване на Поколение 0, така че отново трябва да се стартира събирането на отпадъци. Когато garbage collector се стартира, той трябва да реши кои обекти от паметта да прегледа. Както Поколение 0, така и Поколение 1 има праг за своя размер, който се определя от CLR при инициализацията. Този праг е по-голям от този на Поколение 0. Да предположим че той е 2MB.

В случая Поколение 1 не е достигнало прага си, така че garbage collector ще прегледа отново само обектите от Поколение 0. Това се диктува от правилото, че по-старите обекти обикновено имат по-дълъг живот и следователно почистването на Поколение 1 не е вероятно да освободи много памет, докато в Поколение 0 е твърде възможно много от обектите да са отпадъци. И така, garbage collector почиства отново Поколение 0, оцелелите обекти преминават в Поколение 1, а тези, които преди това са били в Поколение 1, просто си остават там.

Забележете, че обект C, който междувременно е станал недостъпен и следователно подлежи на унищожение, в този случай остава в динамич­ната памет, тъй като е част от Поколение 1 и не е проверен при това преминаване на garbage collector.

Следващата фигура показва състоянието на динамичната памет след това почистване на Поколение 0.

Както вероятно се досещате, с течение на времето Поколение 1 бавно ще расте. Идва момент, когато след поредното почистване на Поколение 0, Поколение 1 достига своя праг от 2 MB. В този случай приложението просто ще продължи да работи, тъй като Поколение 0 току-що е било почистено и е празно. Новите обекти, както винаги, ще се добавят в Поколение 0.


Почистване на Поколение 1 и Поколение 2


Когато Поколение 0 следващият път достигне своя праг и garbage collector се стартира, той ще провери размера на Поколение 1. Тъй като той е достигнал своя праг от 2 MB, garbage collector този път ще почисти както Поколение 0, така и Поколение 1. Забележете, че след като са минали няколко почиствания на Поколение 0, с течение на времето, е твърде вероятно Поколение 1 да съдържа много обекти, които са станали недостъпни и неговото почистване би освободило голямо количество памет.

И така, garbage collector почиства поколения 0 и 1. Обектите, оцелели от Поколение 0 преминават в Поколение 1, а тези, които преди това са били в Поколение 1 и са оцелели при почистването преминават в Поколение 2.

Следващата фигура показва примерното състояние на динамичната памет след почистването на поколения 0 и 1 (предполагаме, че обекти G и H с течение на времето са станали недостъпни и са били почистени от garbage collector, а обекти P и Q са нови обекти, оцелели от Поколение 0 и преминали в Поколение 1).

Текущата версия на CLR garbage collector поддържа три поколения – 0, 1 и 2. Обектите, които оцелеят при почистване на Поколение 2 просто си остават в Поколение 2.

Разбира се, Поколение 2 също има праг за своя размер и той е около 10 MB.

Поколение 0 се почиства най-често – в него се съдържат нови обекти и е най-вероятно те да имат кратък живот.

Поколение 2 се почиства най-рядко. Това поколение съдържа само стари обекти, преживели 2 или повече проверки от garbage collector.

Имплементация на поколенията в .NET


Както видяхме, поколенията значително подобряват производителността на garbage collector. Ако докато строи графа на достъпните обекти, garbage collector срещне референция към обект от по-горно поколение, той просто не продължава да строи тази част от клона. Това е безопасно, защото при преминаването през хийпа, garbage collector преглежда само обектите от поколението, което се почиства, следователно няма опасност да се унищожат обекти от горните поколения, дори и да не са част от графа.

Какво ще се случи, обаче, ако обект от по-старо поколение държи референция към по-млад обект? Ако референциите на стария обект не се проследят, младият обект погрешно ще бъде счетен за недостъпен, няма да бъде добавен към графа и ще бъде унищожен!

За да се избегнат подобни проблеми, JIT компилаторът поддържа механизъм, който установява флаг, когато някое от референтните полета на даден обект се промени. Така garbage collector може да установи референциите на кои обекти са променени от времето на последното събиране на отпадъци. Тези стари обекти ще бъдат инспектирани от garbage collector, за да се провери дали не съдържат референции към по-млади обекти.

Вече споменахме, че garbage collector динамично може да променя праговете за размера на отделните поколения. Ако например с течение на времето, системата установи, че при почистването на Поколение 0 оцеляват много малко обекти, прагът на Поколение 0 може да се намали, да речем на 128 KB. Така почистванията на Поколение 0 ще са по-чести, но ще отнемат по-малко време. При обратния случай – ако почистването на Поколение 0 освобождава много малко памет, а оцелелите са много, прагът ще бъде увеличен например на 512 KB. Така събирането на отпадъци ще е по-рядко и ще има по-голяма вероятност междувременно много обекти да станат недостъпни.

Горното важи, разбира се и за праговете на Поколения 1 и 2. Те също подлежат на промяна с цел оптимизация от страна на garbage collector.

Workstation и Server GC


В CLR всъщност съществуват две разновидности на garbage collector – Server GC и Workstation GC. Във версии 1.0 и 1.1 на .NET Framework, тези две разновидности се съдържат в двете библиотеки MSCorSvr.dll (Server GC) и MSCorWks.dll (Workstation GC). В Whidbey - версия 2.0 на .NET Framework, двете библиотеки са обединени в една.

Конзолните и Windows приложенията използват Workstation GC, който е оптимизиран за минимизиране на времето, през което нишките на приложението са приспани. Тъй като потребителят не трябва да вижда забележима пауза в работата на приложението, garbage collector построява графа на достъпните обекти докато нишките на приложението още работят. Нишките се приспиват едва, когато garbage collector започне истинското почистване на managed heap. Това е т.нар. конкурентно почистване на паметта.

Server GC се използва за сървърни приложения при многопроцесорни машини. В този случай, за всеки отделен процесор се построява отделен хийп, за чието почистване се грижи отделна нишка на garbage collector. Хийповете на отделните процесори се почистват паралелно, като през цялото време нишките на приложението спят. Тази техника показва добра производителност при многопроцесорни машини и има много по-добра скалируемост.

По подразбиране, режимът на работа на garbage collector е Workstation. При еднопроцесорните машини, това е единствения избор. В .NET Framework 1.1 SP1 и 2.0 съществува възможността режимът на работа на garbage collector да се посочи в конфигурационния файл на приложението по следния начин:




   
       
   


Блок памет за големи обекти





Размерът е без значение. Йода

Друга важна оптимизация, свързана с .NET Framework managed heap е т.нар. блок памет за големи обекти (large object heap, LOH). С цел подобряване на производителността всички големи обекти (с размер над 20 000 байта) се разполагат в отделен хийп. Разликата между него и стандартния managed heap е това, че хийпът за големи обекти не се дефрагментира. Преместването на тези големи блокове от паметта просто би отнело прекалено много процесорно време.

Всичко това става прозрачно за разработчиците. От гледна точка на приложението, нещата изглеждат така, сякаш има един единствен хийп.

Имайте предвид, че големите обекти винаги се считат за част от Поколение 2. Това означава, че по-възможност трябва да създаваме по-малко на брой големи обекти и да ги използваме в случаите, когато те ще живеят дълго време.

Създаването на голям брой големи обекти с кратък живот ще доведе до това, че Поколение 2 по-често ще достига прага за своя размер и по-често ще се почиства, което пък значително ще влоши производителността.


Увеличаване размера на хийпа


В случай, че след почистване на всички поколения, все още няма доста­тъчно памет за създаване на даден обект, необходим на приложението, CLR ще увеличава размера на managed heap и съответният процес, в който се изпълнява CLR, започва да заема повече памет от операционната система. Ако е необходимо, се използва виртуалната памет.

Виртуалната памет се съхранява на твърдия диск. Когато операционната система има нужда от памет, а физическата RAM памет на компютъра не е достатъчна, се извършва процес, при който неактивни страници от RAM паметта, се прехвърлят на твърдия диск. Когато тези страници от паметта трябва да се достъпят отново, те се копират обратно в RAM. Естествено дискът е много по-бавен от истинската RAM памет, така, че целият този процес може да отнеме доста време, през което приложенията работят много бавно (дори за известен период могат да спрат да опресняват интерфейса си и да изглеждат “увиснали”).





Сподели с приятели:
1   2   3   4   5   6   7




©obuch.info 2024
отнасят до администрацията

    Начална страница