Podstawy modułu odśmiecającego pamięci i wskazówki dotyczące wydajności

 

Rico Mariani
Microsoft Corporation

Kwiecień 2003 r.

Krótki opis: Moduł odśmiecania pamięci platformy .NET zapewnia szybką usługę alokacji z dobrym użyciem pamięci i nie ma długoterminowych problemów z fragmentacją. W tym artykule wyjaśniono, jak działają moduły odśmiecane pamięci, a następnie omówiono niektóre problemy z wydajnością, które mogą wystąpić w środowisku zbieranym przez śmieci. (10 drukowanych stron)

Dotyczy:
   Microsoft® .NET Framework

Zawartość

Wprowadzenie
Model uproszczony
Zbieranie pamięci
Wydajność
Finalizacji
Podsumowanie

Wprowadzenie

Aby zrozumieć, jak dobrze korzystać z modułu odśmiecanie pamięci i jakie problemy z wydajnością mogą wystąpić podczas uruchamiania w środowisku zbieranym przez śmieci, ważne jest, aby zrozumieć podstawy działania modułów odśmiecanie pamięci i jak te wewnętrzne funkcje wpływają na uruchomione programy.

Ten artykuł jest podzielony na dwie części: Najpierw omówię charakter środowiska uruchomieniowego języka wspólnego (CLR) modułu odśmiecanie pamięci w ogólnych kategoriach przy użyciu uproszczonego modelu, a następnie omówię pewne implikacje wydajności tej struktury.

Model uproszczony

W celach objaśniających należy wziąć pod uwagę następujący uproszczony model zarządzanego stert. Należy pamiętać, że nie jest to, co jest rzeczywiście zaimplementowane.

Rysunek 1. Uproszczony model zarządzanej sterty

Reguły dla tego uproszczonego modelu są następujące:

  • Wszystkie obiekty, które można zbierać śmieci, są przydzielane z jednego ciągłego zakresu przestrzeni adresowej.
  • Stertę dzieli się na pokolenia (więcej na ten temat później), aby można było wyeliminować większość śmieci, patrząc tylko na niewielką część stert.
  • Obiekty w ramach generacji są mniej więcej w tym samym wieku.
  • Generacje o większej liczbie wskazują obszary stert ze starszymi obiektami — te obiekty są znacznie bardziej stabilne.
  • Najstarsze obiekty znajdują się na najniższych adresach, podczas gdy nowe obiekty są tworzone pod rosnącymi adresami. (Adresy rosną w dół na rysunku 1 powyżej).
  • Wskaźnik alokacji dla nowych obiektów oznacza granicę między używanymi (przydzielonymi) i nieużywanym (wolnym) obszarem pamięci.
  • Okresowo sterta jest kompaktowana przez usunięcie martwych obiektów i przesuwanie aktywnych obiektów w kierunku końca stosu o niskim adresie. Spowoduje to rozwinięcie nieużywanego obszaru w dolnej części diagramu, w którym tworzone są nowe obiekty.
  • Kolejność obiektów w pamięci pozostaje kolejnością, w jakiej zostały utworzone, dla dobrej lokalizacji.
  • Nigdy nie ma żadnych przerw między obiektami w stercie.
  • Zatwierdzana jest tylko część wolnego miejsca. W razie potrzeby ** więcej pamięci jest uzyskiwana z systemu operacyjnego w zakresie zarezerwowanych adresów.

Zbieranie pamięci

Najprostszym rodzajem kolekcji do zrozumienia jest w pełni kompaktowanie odzyskiwania pamięci, więc zacznę od omówienia tego.

Pełne kolekcje

W pełnej kolekcji musimy zatrzymać wykonywanie programu i znaleźć wszystkie korzenie w stercie GC. Te korzenie mają różne formy, ale są przede wszystkim stosem i zmiennymi globalnymi, które wskazują na stertę. Począwszy od korzeni, odwiedzamy każdy obiekt i podążamy za każdym wskaźnikiem obiektu zawartym w każdym obiekcie odwiedzanym oznaczając obiekty podczas przechodzenia. W ten sposób moduł zbierający znajdzie każdy osiągalny lub żywy obiekt. Inne obiekty, te nie do osiągnięcia , są teraz potępiane.

Rysunek 2. Korzenie w stercie GC

Po zidentyfikowaniu nieochodnych obiektów chcemy odzyskać to miejsce do późniejszego użycia; celem modułu zbierającego w tym momencie jest przesuwanie obiektów na żywo i eliminowanie zmarnowanego miejsca. Po zatrzymaniu wykonywania moduł zbierający może bezpiecznie przenieść wszystkie te obiekty i naprawić wszystkie wskaźniki, aby wszystko było prawidłowo połączone w nowej lokalizacji. Obiekty, które przeżyły, są promowane do numeru następnej generacji (co oznacza, że granice dla pokoleń są aktualizowane) i wykonywanie może być wznawiane.

Kolekcje częściowe

Niestety, pełne odzyskiwanie pamięci jest po prostu zbyt drogie, aby zrobić za każdym razem, więc teraz należy omówić, w jaki sposób generowanie w kolekcji pomaga nam.

Najpierw rozważmy wyimaginowany przypadek, w którym jesteśmy niezwykle szczęśliwi. Załóżmy, że była najnowsza pełna kolekcja, a sterta jest ładnie kompaktowana. Wykonywanie programu jest wznawiane, a niektóre alokacje są wykonywane. W rzeczywistości wiele i wiele alokacji odbywa się, a po wystarczającej alokacji system zarządzania pamięcią decyduje, że nadszedł czas na zebranie.

Teraz mamy szczęście. Załóżmy, że przez cały czas, kiedy uruchamialiśmy od ostatniej kolekcji, do której w ogóle nie napisaliśmy żadnego ze starszych obiektów, tylko nowo przydzielone, zero generacji (gen0), do których zostały zapisane obiekty. Gdyby tak się stało, byłoby to w świetnej sytuacji, ponieważ możemy uprościć proces odzyskiwania pamięci masowo.

Zamiast zwykłego pełnego zbierania możemy po prostu założyć, że wszystkie starsze obiekty (gen1, gen2) są nadal aktywne — lub przynajmniej tyle z nich żyje, że nie warto patrzeć na te obiekty. Ponadto, ponieważ żaden z nich nie został napisany (pamiętaj, jak mamy szczęście?) nie ma wskaźników ze starszych obiektów do nowszych obiektów. Więc to, co możemy zrobić, to spojrzeć na wszystkie korzenie jak zwykle, a jeśli jakiekolwiek korzenie wskazują na stare obiekty po prostu zignorować te. Dla innych korzeni (tych wskazujących na gen0) postępujemy jak zwykle, podążając za wszystkimi wskaźnikami. Za każdym razem, gdy znajdziemy wewnętrzny wskaźnik, który wraca do starszych obiektów, ignorujemy go.

Po zakończeniu tego procesu będziemy odwiedzać każdy obiekt na żywo w generacji0 bez odwiedzania żadnych obiektów ze starszych pokoleń. Obiektygen 0 można następnie potępić jak zwykle i przesuwamy w górę tylko ten region pamięci, pozostawiając starsze obiekty niezakłócone.

Teraz jest to dla nas naprawdę świetna sytuacja, ponieważ wiemy, że większość martwej przestrzeni może znajdować się w młodszych obiektach, gdzie istnieje wiele zmian. Wiele klas tworzy obiekty tymczasowe dla ich wartości zwracanych, ciągów tymczasowych i różne inne klasy narzędzi, takie jak moduły wyliczania i conot. Patrząc na tylko gen0 daje nam łatwy sposób, aby wrócić większość martwej przestrzeni, patrząc tylko na kilka obiektów.

Niestety, nigdy nie mamy szczęścia, aby użyć tego podejścia, ponieważ co najmniej niektóre starsze obiekty są powiązane ze zmianą, tak aby wskazywały nowe obiekty. Jeśli tak się stanie, nie wystarczy je po prostu zignorować.

Tworzenie pokoleń pracy z barierami zapisu

Aby algorytm działał powyżej, musimy wiedzieć, które starsze obiekty zostały zmodyfikowane. Aby zapamiętać lokalizację zanieczyszczonych obiektów, używamy struktury danych o nazwie tabela kart, a w celu zachowania tej struktury danych kompilator kodu zarządzanego generuje tak zwane bariery zapisu. Te dwa pojęcia są centralnym elementem sukcesu zbierania pamięci opartych na generowaniu.

Tabelę kart można zaimplementować na różne sposoby, ale najprostszym sposobem myślenia o niej jest tablica bitów. Każdy bit w tabeli kart reprezentuje zakres pamięci na stercie — załóżmy, że 128 bajtów. Za każdym razem, gdy program zapisuje obiekt w pewnym adresie, kod bariery zapisu musi obliczyć, który fragment 128-bajtowy został zapisany, a następnie ustawić odpowiedni bit w tabeli kart.

Dzięki temu mechanizmowi możemy teraz ponownie wrócić do algorytmu kolekcji. Jeśli robimy odzyskiwanie pamięcigeneracji 0 , możemy użyć algorytmu, jak wspomniano powyżej, ignorując wszelkie wskaźniki do starszych generacji, ale po wykonaniu tej czynności musimy również znaleźć każdy wskaźnik obiektu w każdym obiekcie, który znajduje się na fragment, który został oznaczony jako zmodyfikowany w tabeli kart. Musimy traktować tych, jak korzenie. Jeśli rozważymy również te wskaźniki, będziemy poprawnie zbierać tylko obiekty0. generacji.

Takie podejście nie pomogłoby w ogóle, gdyby tabela kart była zawsze pełna, ale w praktyce niewiele wskaźników ze starszych pokoleń rzeczywiście zostało zmodyfikowanych, więc istnieje znaczne oszczędności z tego podejścia.

Wydajność

Teraz, gdy mamy podstawowy model działania rzeczy, rozważmy pewne rzeczy, które mogłyby pójść źle, co spowodowałoby spowolnienie. To da nam dobry pomysł, jakiego rodzaju rzeczy powinniśmy spróbować uniknąć, aby uzyskać najlepszą wydajność z modułu zbierającego.

Zbyt wiele alokacji

To jest naprawdę najbardziej podstawowa rzecz, która może pójść nie tak. Przydzielanie nowej pamięci za pomocą modułu odśmiecania pamięci jest naprawdę dość szybkie. Jak widać na rysunku 2 powyżej, to wszystko, co zwykle musi się zdarzyć, jest przeniesienie wskaźnika alokacji w celu utworzenia miejsca dla nowego obiektu po stronie "przydzielone" — nie jest to znacznie szybsze. Jednak wcześniej lub później trzeba się zdarzyć zbieranie pamięci i wszystkie rzeczy są równe, lepiej, aby stało się to później niż wcześniej. Dlatego chcesz upewnić się, że podczas tworzenia nowych obiektów jest to naprawdę konieczne i odpowiednie do tego celu, mimo że tworzenie tylko jednego jest szybkie.

Może to brzmieć jak oczywiste porady, ale w rzeczywistości jest niezwykle łatwo zapomnieć, że jeden mały wiersz kodu, który piszesz, może wyzwolić wiele alokacji. Załóżmy na przykład, że piszesz funkcję porównania pewnego rodzaju i załóżmy, że obiekty mają pole słów kluczowych i chcesz, aby porównanie nie uwzględniało wielkości liter słowa kluczowego w podanej kolejności. Teraz w tym przypadku nie można porównać całego ciągu słów kluczowych, ponieważ pierwsze słowo kluczowe może być bardzo krótkie. Użycie ciągu String.Split byłoby kuszące, aby podzielić ciąg słowa kluczowego na elementy, a następnie porównać każdy element w kolejności przy użyciu normalnego porównania bez uwzględniania wielkości liter. Brzmi świetnie?

Cóż, jak się okazuje, nie jest to taki dobry pomysł. Zobaczysz, że funkcja String.Split utworzy tablicę ciągów, co oznacza, że jeden nowy obiekt ciągu dla każdego słowa kluczowego pierwotnie w ciągu słów kluczowych oraz jeszcze jeden obiekt dla tablicy. Yikes! Jeśli robimy to w kontekście sortowania, jest to wiele porównań, a funkcja porównania dwuwierszowego tworzy teraz bardzo dużą liczbę obiektów tymczasowych. Nagle moduł odśmiecania pamięci będzie pracować bardzo ciężko w Twoim imieniu, a nawet z najmądrzejszym systemem zbierania jest tylko dużo śmieci do oczyszczenia. Lepiej napisać funkcję porównania, która w ogóle nie wymaga alokacji.

alokacje Too-Large

Podczas pracy z tradycyjnym alokatorem, takim jak malloc(), programiści często piszą kod, który wykonuje jak najmniej wywołań do malloc(), ponieważ wiedzą, że koszt alokacji jest stosunkowo wysoki. Przekłada się to na praktykę przydzielania fragmentów, często spekulatywne przydzielanie obiektów, których możemy potrzebować, dzięki czemu możemy wykonać mniej alokacji całkowitej. Obiekty wstępnie przydzielone są następnie ręcznie zarządzane z pewnego rodzaju puli, co umożliwia efektywne tworzenie niestandardowego alokatora o dużej szybkości.

W zarządzanym świecie ta praktyka jest znacznie mniej atrakcyjna z kilku powodów:

Po pierwsze koszt alokacji jest bardzo niski — nie ma wyszukiwania wolnych bloków, tak jak w przypadku tradycyjnych alokatorów; Wszystko, co musi się zdarzyć, to granica między obszarami wolnymi i przydzielonych musi się poruszać. Niski koszt alokacji oznacza, że najbardziej atrakcyjny powód, dla którego pula po prostu nie jest obecna.

Po drugie, jeśli zdecydujesz się wstępnie przydzielić, będziesz oczywiście wykonywać więcej alokacji niż są wymagane dla Twoich natychmiastowych potrzeb, co z kolei może wymusić dodatkowe odzyskiwanie pamięci, które w przeciwnym razie mogło być niepotrzebne.

Na koniec moduł odśmiecenia pamięci nie będzie mógł odzyskać miejsca dla obiektów, które są ręcznie odtwarzane, ponieważ z perspektywy globalnej wszystkie te obiekty, w tym te, które nie są obecnie używane, są nadal aktywne. Może się okazać, że duża ilość pamięci jest marnowana przy zachowaniu gotowości do użycia, ale nie w użyciu obiektów.

To nie znaczy, że przydzielenie wstępne jest zawsze złym pomysłem. Można to zrobić, aby wymusić początkowe przydzielanie niektórych obiektów razem, na przykład, ale prawdopodobnie okaże się, że jest to mniej atrakcyjne jako ogólna strategia, niż w kodzie niezarządzanym.

Zbyt wiele wskaźników

Jeśli tworzysz strukturę danych, która jest dużą siatką wskaźników, będziesz mieć dwa problemy. Po pierwsze, będzie wiele zapisów obiektów (zobacz Rysunek 3 poniżej), a po drugie, gdy nadejdzie czas na zebranie tej struktury danych, sprawisz, że moduł odśmiecenia pamięci będzie przestrzegać wszystkich tych wskaźników i w razie potrzeby zmienić je wszystkie w miarę poruszania się. Jeśli struktura danych jest długotrwała i nie zmieni się zbytnio, moduł zbierający będzie musiał odwiedzić wszystkie te wskaźniki, gdy wystąpią pełne kolekcje (na poziomie2 . generacji). Ale jeśli utworzysz taką strukturę na zasadzie przechodniej, powiedzmy w ramach przetwarzania transakcji, to będziesz płacić koszt znacznie częściej.

Rysunek 3. Struktura danych jest duża w wskaźnikach

Struktury danych, które są duże w wskaźnikach, mogą również mieć inne problemy, a nie związane z czasem odzyskiwania pamięci. Ponownie, jak wspomniano wcześniej, podczas tworzenia obiektów są przydzielane w sposób ciągły w kolejności alokacji. Jest to doskonałe rozwiązanie, jeśli tworzysz dużą, prawdopodobnie złożoną strukturę danych, na przykład przywracając informacje z pliku. Mimo że masz różne typy danych, wszystkie obiekty będą blisko siebie w pamięci, co z kolei pomoże procesorowi mieć szybki dostęp do tych obiektów. Jednak w miarę upływu czasu i modyfikacji struktury danych nowe obiekty prawdopodobnie będą musiały zostać dołączone do starych obiektów. Te nowe obiekty zostaną utworzone znacznie później, więc nie będą w pobliżu oryginalnych obiektów w pamięci. Nawet jeśli moduł odśmiecania pamięci nie zwarty pamięci, obiekty nie zostaną przetasowane w pamięci, tylko "przesuwają się" razem, aby usunąć zmarnowane miejsce. Wynikowe zaburzenia mogą być tak złe w czasie, że może być skłonny do utworzenia nowej kopii całej struktury danych, wszystkie ładnie zapakowane i niech stary nieuporządkowane przez kolekcjonera w odpowiednim czasie.

Zbyt wiele korzeni

Moduł odśmiecania pamięci musi oczywiście dać korzeniom specjalne traktowanie w czasie zbierania — zawsze muszą być wyliczane i należycie brane pod uwagę. Kolekcjagen 0 może być szybka tylko w zakresie, w jakim nie dajesz jej powódź korzeni do rozważenia. Jeśli chcesz utworzyć głęboko rekursywną funkcję, która ma wiele wskaźników obiektów wśród zmiennych lokalnych, wynik może być w rzeczywistości dość kosztowny. Koszty te są naliczane nie tylko w celu rozważenia wszystkich tych korzeni, ale także w bardzo dużej liczbie obiektów gen0 , które te korzenie mogą być utrzymanie przy życiu przez nie bardzo długo (omówione poniżej).

Zbyt wiele zapisów obiektów

Ponownie odwołując się do naszej wcześniejszej dyskusji, należy pamiętać, że za każdym razem, gdy zarządzany program zmodyfikował wskaźnik obiektu, kod bariery zapisu jest również wyzwalany. Może to być złe z dwóch powodów:

Po pierwsze, koszt bariery zapisu może być porównywalny z kosztem tego, co próbujesz zrobić w pierwszej kolejności. Jeśli na przykład wykonujesz proste operacje w jakiejś klasie modułu wyliczającego, może się okazać, że konieczne może być przeniesienie niektórych wskaźników kluczy z kolekcji głównej do modułu wyliczającego na każdym kroku. Jest to rzeczywiście coś, czego można uniknąć, ponieważ skutecznie podwoić koszt kopiowania tych wskaźników ze względu na barierę zapisu i może być konieczne wykonanie go co najmniej raz na pętlę w module wyliczającym.

Po drugie, wyzwalanie barier zapisu jest podwójnie złe, jeśli w rzeczywistości piszesz na starszych obiektach. Podczas modyfikowania starszych obiektów efektywnie tworzy się dodatkowe katalogi root w celu sprawdzenia (omówionego powyżej) podczas następnego odzyskiwania pamięci. Jeśli zmodyfikowano wystarczająco dużo starych obiektów, można skutecznie negować zwykłe ulepszenia prędkości związane ze zbieraniem tylko najmłodszego pokolenia.

Te dwa powody są oczywiście uzupełniane przez zwykłe powody, dla których nie robi zbyt wielu zapisów w jakimkolwiek programie. Wszystkie rzeczy są równe, lepiej dotknąć mniej pamięci (odczyt lub zapis, w rzeczywistości), aby zwiększyć ekonomiczne wykorzystanie pamięci podręcznej procesora.

Zbyt wiele prawie długich obiektów

Wreszcie, być może największą pułapką modułu odśmiecającego pamięci generowania jest tworzenie wielu obiektów, które nie są ani dokładnie tymczasowe, ani nie są dokładnie długotrwałe. Te obiekty mogą powodować wiele problemów, ponieważ nie zostaną wyczyszczone przez kolekcję gen0 (najtańszą), ponieważ będą one nadal konieczne, a nawet mogą przetrwać kolekcję gen1 , ponieważ są one nadal używane, ale wkrótce umierają po tym.

Problem polega na tym, że gdy obiekt dotarł na poziom2 . generacji, tylko pełna kolekcja się go pozbywa, a pełne kolekcje są wystarczająco kosztowne, że moduł odśmiecania pamięci opóźni je tak długo, jak jest to możliwe. Dlatego wynikiem posiadania wielu obiektów "prawie długotrwałych" jest to, że twój gen2 będzie miał tendencję do wzrostu, potencjalnie w niepokojącym tempie; może nie zostać wyczyszczone prawie tak szybko, jak chcesz, a kiedy to zostanie oczyszczone, z pewnością będzie o wiele bardziej kosztowne, aby to zrobić, niż można by tego życzyć.

Aby uniknąć tego rodzaju obiektów, najlepsze linie obrony idą w następujący sposób:

  1. Przydziel jak najmniej obiektów ze względu na ilość używanego miejsca tymczasowego.
  2. Zachowaj rozmiary obiektów o dłuższym stanie do minimum.
  3. Zachowaj jak najmniej wskaźników obiektów na stosie (są to katalogi root).

Jeśli wykonasz te czynności, kolekcje0. generacji będą bardziej skuteczne, a1 . generacja nie będzie rosła bardzo szybko. W związku z tym kolekcje1 . generacji mogą być wykonywane rzadziej, a kiedy rozsądne jest wykonanie kolekcji gen1 , obiekty o średnim okresie istnienia będą już martwe i można je odzyskać, tanie, w tym czasie.

Jeśli coś się świetnie dzieje, to podczas operacji na stałym stanie rozmiar2 generacji w ogóle nie będzie rosnąć!

Finalizacji

Teraz, gdy omówiliśmy kilka tematów z uproszczonym modelem alokacji, chciałbym nieco skomplikować rzeczy, abyśmy mogli omówić jeszcze jedno ważne zjawisko i to jest koszt finalizatorów i finalizacji. Na krótko finalizator może być obecny w dowolnej klasie — jest to opcjonalny element członkowski, który moduł odśmiecowania pamięci obiecuje wywołać w inny sposób martwe obiekty, zanim odzyska pamięć dla tego obiektu. W języku C# użyjesz składni ~Class, aby określić finalizator.

Wpływ finalizacji na kolekcję

Gdy moduł odśmiecenia pamięci najpierw napotka obiekt, który jest w przeciwnym razie martwy, ale nadal musi zostać sfinalizowany, musi porzucić próbę odzyskania miejsca dla tego obiektu w tym czasie. Obiekt jest zamiast tego dodawany do listy obiektów wymagających finalizacji, a ponadto moduł zbierający musi upewnić się, że wszystkie wskaźniki w obiekcie pozostają prawidłowe do momentu zakończenia finalizacji. Jest to w zasadzie to samo, co mówienie, że każdy obiekt potrzebny do finalizacji jest jak tymczasowy obiekt główny z perspektywy modułu zbierającego.

Po zakończeniu zbierania trafnie nazwany wątek finalizacji przejdzie przez listę obiektów wymagających finalizacji i wywoła finalizatory. Po wykonaniu tej czynności obiekty po raz kolejny staną się martwe i będą naturalnie zbierane w normalny sposób.

Finalizacja i wydajność

Mając tę podstawową wiedzę na temat finalizacji, możemy już wymyślić kilka bardzo ważnych rzeczy:

Po pierwsze, obiekty, które wymagają finalizacji, działają dłużej niż obiekty, które nie. W rzeczywistości mogą żyć o wiele dłużej. Załóżmy na przykład, że obiekt, który znajduje się w generacji2 , musi zostać sfinalizowany. Finalizacja zostanie zaplanowana, ale obiekt nadal znajduje się w generacji2, więc nie zostanie ponownie zebrany do momentu, aż kolejna kolekcja2 . generacji zostanie wykonana. To może być bardzo długi czas rzeczywiście, a w rzeczywistości, jeśli rzeczy idą dobrze, będzie to długi czas, ponieważ kolekcjegen 2 są kosztowne, a tym samym chcemy , aby miały one miejsce bardzo rzadko. Starsze obiekty wymagające finalizacji mogą wymagać oczekiwania na dziesiątki, jeśli nie setki kolekcji gen0 , zanim ich miejsce zostanie odzyskane.

Po drugie, obiekty, które wymagają finalizacji, powodują uszkodzenia zabezpieczeń. Ponieważ wskaźniki obiektów wewnętrznych muszą pozostać prawidłowe, nie tylko obiekty, które wymagają finalizacji, pozostają w pamięci, ale wszystkie obiekty odwołują się bezpośrednio i pośrednio do pamięci. Jeśli ogromne drzewo obiektów zostało zakotwiczone przez pojedynczy obiekt, który wymagał finalizacji, całe drzewo będzie utrzymywać się, potencjalnie przez długi czas, jak właśnie omówiliśmy. Dlatego ważne jest, aby używać finalizatorów oszczędnie i umieszczać je na obiektach, które mają jak najmniej wewnętrznych wskaźników obiektów. W przykładzie drzewa, który właśnie dałem, można łatwo uniknąć problemu, przenosząc zasoby potrzebne do finalizacji do oddzielnego obiektu i zachowując odwołanie do tego obiektu w katalogu głównym drzewa. Z tej skromnej zmiany tylko jeden obiekt (miejmy nadzieję, że ładny mały obiekt) będzie utrzymywał się, a koszt finalizacji jest zminimalizowany.

Na koniec obiekty wymagające finalizacji tworzą pracę dla wątku finalizatora. Jeśli proces finalizacji jest złożony, jeden i tylko wątek finalizatora będzie poświęcać dużo czasu na wykonanie tych kroków, co może spowodować zaległości pracy i w związku z tym spowodować, że więcej obiektów będzie czekać na finalizację. Dlatego ważne jest, aby finalizatory wykonywać jak najmniej pracy. Należy również pamiętać, że mimo że wszystkie wskaźniki obiektów pozostają prawidłowe podczas finalizacji, może się okazać, że wskaźniki te prowadzą do obiektów, które zostały już sfinalizowane i dlatego mogą być mniej niż przydatne. Zwykle najbezpieczniej jest unikać następujących wskaźników obiektów w kodzie finalizacji, mimo że wskaźniki są prawidłowe. Bezpieczna, krótka ścieżka kodu finalizacji jest najlepsza.

IDisposable i Dispose

W wielu przypadkach istnieje możliwość, aby obiekty, które w przeciwnym razie zawsze musiałyby zostać sfinalizowane, aby uniknąć tego kosztu przez zaimplementowanie interfejsu IDisposable . Ten interfejs zapewnia alternatywną metodę odzyskiwania zasobów, których okres istnienia jest dobrze znany programisty, i że faktycznie dzieje się to dość dużo. Oczywiście jest jeszcze lepiej, jeśli obiekty po prostu używają tylko pamięci i dlatego nie wymagają finalizacji ani nie dysponowania w ogóle; ale jeśli finalizacja jest konieczna i istnieje wiele przypadków, w których jawne zarządzanie obiektami jest łatwe i praktyczne, implementacja interfejsu IDisposable jest doskonałym sposobem na uniknięcie lub przynajmniej zmniejszenie kosztów finalizacji.

W języku C# ten wzorzec może być dość przydatny:

class X:  IDisposable
{
   public X(…)
   {
   … initialize resources … 
   }

   ~X()
   {
   … release resources … 
   }

   public void Dispose()
   {
// this is the same as calling ~X()
        Finalize(); 

// no need to finalize later
System.GC.SuppressFinalize(this); 
   }
};

W przypadku ręcznego wywołania metody Dispose usuwa potrzebę utrzymania obiektu przy życiu i wywołania finalizatora.

Podsumowanie

Moduł odśmiecania pamięci platformy .NET zapewnia szybką usługę alokacji z dobrym użyciem pamięci i nie ma długoterminowych problemów z fragmentacją, jednak istnieje możliwość wykonania czynności, które zapewnią znacznie mniej niż optymalną wydajność.

Aby uzyskać najlepsze wyniki z alokatora, należy wziąć pod uwagę praktyki, takie jak:

  • Przydziel całą pamięć (lub jak najwięcej) do użycia z daną strukturą danych w tym samym czasie.
  • Usuń tymczasowe alokacje, których można uniknąć z niewielkimi karami w złożoności.
  • Zminimalizuj liczbę zapisywanych wskaźników obiektów, zwłaszcza tych zapisów wykonanych w starszych obiektach.
  • Zmniejsz gęstość wskaźników w strukturach danych.
  • Używaj finalizatorów, a następnie tylko na obiektach "liścia", jak najwięcej. Przerwij obiekty w razie potrzeby, aby pomóc w tym.

Regularna praktyka przeglądania kluczowych struktur danych i przeprowadzania profilów użycia pamięci za pomocą narzędzi, takich jak Allocation Profiler, będzie długa droga do efektywnego użycia pamięci i posiadania modułu odśmiecania pamięci, który działa najlepiej dla Ciebie.