C, PHP, VB, .NET

Дневникът на Филип Петров


* Garbage Collection

Публикувано на 15 октомври 2009 в раздел ПИК3 Java.

В програмният език C++ съществува понятието "деструктор" - метод който "почиства" преди даден обект да бъде унищожен. Такъв метод е често използван там, защото в C++ програмистът сам контролира кога един обект да бъде изтрит (чрез "обратния" на new оператор delete). В Java програмистът няма контрол над унищожението на обектите. Цялата тежест над това е прехвърлена върху т.нар. Garbage Collector.

Добавено през 2023 г.: В по-стари версии на Java (преди JDK 18) имаше аналогия на "почистващ метод" (деструктор) - това е метод "finalize()". Именно той се извикваше в момента, в който даден обект е изтрит от Garbage Collector. Долният код вече не е актуален, но ще го оставя за историческа справка.

Програмистите, които преминават от C++ към Java ще забележат бързо, че Garbage Collector не се извиква често. При по-малките програми даже е възможно да не бъде извикан въобще. Ето ви един пример:

public class GCExample{
 public static void main(String[] args){
   User[] u = new User[200];
   for (int i=0; i<u.length; i++){
     u[i] = new User();
   }
   System.out.println("Users online: "+User.count);
   System.out.println("Last id: "+User.lastid);
   // "Изтриваме" 10ти елемент
   u[10] = null;
   System.out.println("Users online: "+User.count);
   System.out.println("Last id: "+User.lastid);
 }
}

class User{
  public static int count = 0;
  public static int lastid = 0;
  public int id;
  public User(){
    this.count++;
    this.lastid++;
    this.id = lastid;
  }
  protected void finalize(){
    count--;
  }
}

Освен ако нямате завиден късмет Garbage Collector да се е включил, то резултатът ще бъде следния:

Users online: 200
Last id: 200
Users online: 200
Last id: 200

Виждате, че finalize() методът не е извикан въобще. Това е защото Garbage Collector не се е стартирал все още. Честно казано при тази програма той не би трябвало да се включи. Опитайте чрез добавянето на следния код:

   while(User.count == 200){}
   System.out.println("Garbage Collection was triggered!");

Програмата почти 100% сигурно ще зацикли и никога няма да спре. Ако обаче ние собственоръчно предизвикаме Garbage Collector да се включи, то ще видим, че всичко е наред:

public class GCExample{
 public static void main(String[] args){
   User[] u = new User[200];
   for (int i=0; i<u.length; i++){
     u[i] = new User();
   }
   System.out.println("Users online: "+User.count);
   System.out.println("Last id: "+User.lastid);
   // "Изтриваме" 10ти елемент
   u[10] = null;
   System.gc();
   System.runFinalization();
   System.out.println("Users online: "+User.count);
   System.out.println("Last id: "+User.lastid);
 }
}

Тук резултатът категорично сигурно ще бъде:

Users online: 200
Last id: 200
Users online: 199
Last id: 200

Метод System.gc() казва на средата, че е подходящо да бъде извикан Garbage Collector, а System.runFinalization() му указва да почисти абсолютно всичко, което има в момента. Метод finalize() се е изпълнил и е намалил променливата usersonline с единица - точно това, което желаехме. Сега трябва да отговорим на въпроса "защо Garbage Collector не се включи в предишния пример" и по-скоро "кога въобще се включва Garbage Collector сам".

Отговорът е, че Garbage Collector се включва само и единствено тогава, когато е нужно освобождаване на памет. С други думи - ако JVM изпита недостиг на памет, то тя си освобождава такава, като почиства "боклука". Ето един пример, с който ще демонстрираме това:

public class GCExample{
 public static void main(String[] args){
   while (GCExample.continueFlag){
     new GCExample();
   }
   // "приспиваме програмата за 2 секунди
   try{
     Thread.sleep(2000);
   }
   catch(Exception e){}
   System.out.println(GCExample.ObjectsCreated);
   System.out.println(GCExample.ObjectsDeleted);
 }
}

class GCExample{
  public static boolean continueFlag = true;
  public static int ObjectsCreated = 0;
  public static int ObjectsDeleted = 0;
  public GCExample(){
    this.ObjectsCreated++;
  }
  protected void finalize(){
    continueFlag = false;
    ObjectsDeleted++;
  }
}

Едно примерно изпълнение би върнало следния резултат:

18580
1917

Виждаме, че общо са се създали 18580 обекта и чак тогава се е включил Garbage Collector. Към нито един от тези създадени обекти няма насочена променлива, но въпреки това Garbage Collector е "събрал" само 1917 от тях. Нарочно "приспахме" програмата за 2 секунди, за да сме сигурни, че той си е свършил работата и не сме го прекъснали с край на програмата ни. Нека все пак демонстрираме, че System.runFinalization() работи коректно:

public class GCExample{
 public static void main(String[] args){
   while (GCExample.continueFlag){
     new GCExample();
   }
   try{
     Thread.sleep(2000);
   }
   catch(Exception e){}

   System.gc();
   System.runFinalization();

   System.out.println(GCExample.ObjectsCreated);
   System.out.println(GCExample.ObjectsDeleted);
 }
}

Резултатът тук вече очаквано ще бъде нещо подобно на:

18688
18688

Първоначално бихме си казали, че "щом желаем да изтрием обект, то можем винаги да си извикваме сами System.gc() и System.runFinalization()". Това обаче е лоша практика, понеже "събирането на боклука" всъщност е доста тежка процедура, която отнема доста ресурси - повече от процедурата на създаване на обект:

public class GCExample{
 public static void main(String[] args){
   long startCreateObjectsTime = System.currentTimeMillis();
   while (GCExample.continueFlag){
     new GCExample();
   }
   long endCreateObjectsTime = System.currentTimeMillis();

   long startGCTime = System.currentTimeMillis();
   System.gc();
   System.runFinalization();
   long endGCTime = System.currentTimeMillis();

   System.out.println(endCreateObjectsTime - startCreateObjectsTime);
   System.out.println(endGCTime - startGCTime);
 }
}

Примерен резултат е:

15
94

С други думи събирането на боклука е отнело в пъти повече време отколкото създаването на обектите, които биват изтрити. Затова е редно да избягваме ръчно пускане на Garbage Collection без основателна причина. Такава би била ако знаем, че предстои създаване на голямо количество обекти, а много стари са станали излишни, т.е. със сигурност ще ни е нужно чистене на голямо количество памет. Това например може да се случва при прехода от едно ниво в компютърна игра на друго - в паузата между двете нива може да повикате GC ръчно, за да зачисти старите ресурси и да освободи памет за плавно протичане на следващото ниво (не бихте искали играта да "замръзне" по някое време заради GC, нали?).

Добавено декември 2015

Събирането на боклука е концепция, която казва "програмистът не се грижи за освобождаването на паметта - това става автоматично". Поради тази причина е много често срещано явление програмистите въобще да не знаят какво точно прави GC. Например много хора си мислят, че има един единствен GC, а това не е така - в Java 8 има цели 4 различни "събирачи на боклук" и програмистите могат да превключват между тях. При това в различни ситуации може да се окаже така, че смяната на един вид GC с друг довежда до драстично ускорение на софтуерния продукт. Изборът на типа GC е изцяло ръчен. Преди да ги разгледаме поотделно, ще дадем и малко по-обща теория.

При стартиране на Java програма можете да подавате различни аргументи от командния ред. Един важен аргумент е размера на heap паметта, с която Java приложението ще работи. Ако например искате да стартирате MyApp с 1GB памет, можете да направите това с:

java -Xmx:1g MyApp

Тази памет естествено може да се запълни над определен процент. Именно тогава се стартира GC, при това го прави достатъчно "нежно" - изчаква приложението да е в такъв режим, че GC да не го повреди. Представете си например, че програмата ви се опитва да създаде обект, но все още не е върнала референцията към него на стековата променлива. Ако GC мине в този моменти и изтрие обекта (към него все още нищо не сочи), а програмата ви довърши операцията, вашата стекова променлива ще сочи към несъществуващ обект. Затова не е добре GC да се пуска без приложението да е в моментно състояние, което не може да доведе до "счупването" му.

Освен това независимо от това кой Collector използвате, има две основни изисквания:

  • Да събира само "умрели" обекти;
  • Да е колкото се може по-бърз.

В теорията на GC съществуват два основни типа събирачи. Първият е "чрез броене на референции". При него всеки обект има брояч за това колко референции има към него. При извършване на операция "=" на някоя променлива от референтен тип, освен насочването на референцията към нов обект, JVM ще се свърже със стария и ще намали броя на референциите му с 1. По този начин имаме доста ясен и категоричен критерии за това кога един обект е "мъртъв" - когато брояча на референциите му е със стойност 0. Този метод може да звучи като много прост, но има значителни проблеми при неговата реализация - представете си например какво ще стане в циклични структури от данни (един обект има препратка към втори и същевременно втория също има препратка към първия). Справянето с такъв проблем изисква сложни алгоритми, които добавят значително натоварване за алгоритъма като цяло.

Вторият основен тип събирач на боклук е "чрез проследяване на живите обекти". Приема се, че всички "живи" обекти могат да бъдат достигнати по дървовидна структура започвайки от променливите в регистрите на процесора и стековите променливи. Например един свързан списък може да има 10 елемента и ние да нямаме стекова променлива сочеща директно към неговия 6-ти елемент. Този елемент обаче може да бъде достигнат - имайки стекова променлива сочеща съм първия обект от списъка можем да вървим по веригата на списъка и да достигнем до 6-тия. Такъв обект, който може да бъде достигнат, се счита за жив и не трябва да бъде изтриван. GC при такава система ще работи по следния начин - първо ще намери всички живи обекти, а после ще изтрие всички останали (или само колкото прецени от тях, както видяхме по-горе).

В практиката се е доказало, че събирачите от тип "чрез проследяване на живите обекти" са по-ефективни. Тук ще се фокусираме именно в тях. Има два основни подтипа, както и частични комбинации от тях: "копиращи" и "маркиращи". Копиращите събирачи на боклук използват техника за местене на обекти от един регион наречен "from space", който ще бъде почистван, в друг регион наречен "to space", в който обектите "са на сигурно място" (няма да бъдат трити). Идеята е GC да копира всички живи обекти от from space в to space. След като всички живи обекти са копирани и референциите на стековите променливи съответно са насочени към новото място, from space се изчиства напълно. Едно съществено предимство на този метод е, че освен със събирането на боклук се случва и дефрагментация на heap паметта. Недостатък е, че те са "stop-the-world" събирачи - програмата трябва да спре и да изчака докато местенето приключи.

Маркиращите събирачи намират всички живи обекти и им слагат отметка с един "live" бит. Този бит може да стои вътре в самия обект или да е в отделна структура от данни - това зависи от имплементацията на въпросния GC. След приключването на маркирането на обектите, обектите от цялата heap памет (не само живите) се обхождат и когато бъде засечен обект, който не е жив, той бива изтрит. Предимство спрямо копиращите събирачи е, че тук не изискваме допълнителна памет за копиране на обектите, както и, че "stop-the-world" може да бъде доста по-гъвкаво и "нежно" за програмата. Недостатък е, че при огромно количество обекти и голяма heap памет, двете фази отнемат повече време.

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

  • "Стари обекти" са такива, които са създадени от статични променливи, или такива, които са подавани на нишката "отвън", т.е. като нейни параметри по време на създаването й, или пък просто обекти, които са били "живи" над определено време (например ако се пази timestamp за времето, по което са били създадени или пък ако се пази брояч за това през колко пускания на GC са "оцелели");
  • "Млади обекти" ще са тези, които са създадени вътре в самата нишка или просто такива, които са били създадени наскоро. Например локалните променливи на методите почти винаги ще сочат към млади обекти.

Обикновено (но не задължително) разделящите на генерации събирачи използват комбинация от копиране и маркиране - използват маркиране при старите обекти и копиране при младите. Ще видите това по-долу. При тези GC се използва идеята, че повечето умиращи обекти са "млади". Затова предварително се разделя heap паметта на три части - памет за млади, памет за стари и памет за перманентни  (при Java 7 и по-стари) обекти. Перманентните обекти са такива, които съдържат системна информация (дефиниции на класове и обекти), както и целия String pool. От Java 8 паметта за перманентни обекти е заменена изцяло от нов регион наречен "метапространство" (metaspace), което е почти същото, но много по-гъвкаво (може да си променя размера по време на изпълнение и много други неща от ниско ниво, които няма да разглеждаме тук). Отделно от това, паметта за млади обекти при тази организация също е разделена на две - памет за почистване и памет за оцелели (survivors). Паметта за оцелели от своя страна също е разделена на две части, като една от тях е активна (няма да бъде трита), а другата е неактивна (ще бъде трита). При поредни пускания на GC активната и неактивната памет се разменят. Такова разделение на heap е т.нар. "hotspot heap" организация на паметта. Както ще видите по-долу, не е задължително да е такава.

Нека сега разгледаме четирите GC в Java 8 поотделно. Три от тях (SerialGC, ParallelGC и ConcMarkSweepGC) използват HotSpot Heap, а четвъртия - G1 - използва различна организация, за която ще говорим отделно.

Сериен GC (SerialGC)

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

Първоначално всички обекти се създават в паметта за млади обекти. В момента, в който тази памет се запълни, GC се пуска и преминава през тази памет. Когато намери живите ги премества в survivor паметта (активната ѝ част), а останалата част от heap за млади обекти се изчиства. Всичко това е "stop-the-world" последователност от операции - приложението се блокира докато се извършат.

При следващо повикване на GC втората survivor памет (в момента празна) се превключва като активна, а първата става неактивна (ще бъде претърсвана за мъртви обекти). Отново живите обекти прескачат в активната survivor памет, а всичко останало се почиства. Отново всичко това е stop-the-world последователност от операции. Както виждате почистването на паметта на младите обекти е винаги чрез метода на копиране.

След многократно извикване на GC, някои от оцеляващите обекти ще бъдат "спасявани" многократно. Такива се "промотират" като стари обекти - местят се в паметта за стари обекти. Тази памет обаче също може в един момент да бъде запълнена с мъртви обекти. Те също се чистят от GC, но по различен начин - чрез маркиране. Естествено можете да се досетите, че тук би имало значителна фрагментация с течение на времето. За да се справи с това, SerialGC понякога може да направи и трета фаза - compacting (дефрагментира, т.е. извършва известни копирания в паметта за стари обекти).

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

  • Блокира (замразява) всички приложения, които работят;
  • Маркира всички живи - стари и млади - обекти;
  • Събира боклука в старите обекти - мъртвите се изтриват. Ако е нужно, прави дефрагментация на паметта за стари обекти;
  • Събира боклука в областта на младите обекти, като младите живи се местят при survivors или се промотират (местят) като стари обекти, а останалата част на паметта за млади обекти се изтрива.
  • Отблокира (размразява) приложенията и те продължават да работят.

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

Ако искате да изпълните програмата MyProgram използвайки Сериен GC, правите това с аргумент -XX:+UseSerialGC от командния ред, т.е.:

java -XX:+UseSerialGC MyProgram

SerialGC се използва по подразбиране само при 32 битови Windows машини.

Паралелен GC (ParallelGC и ParallelOldGC)

Този GC работи на същия принцип като SerialGC, но този път многонишково (вместо само едно ядро на процесора да се използва за събиране на боклука се използват всички налични). Това е събирачът на боклук в JRE8 по подразбиране. Също както предишния, ParallelGC блокира приложението по време на събирането на боклука. Стартира се с аргументи -XX:+UseParallelGC в комбинация с -XX:+UseParallelOldGC. Първото е инструкция, че ще се използва за млади обекти, а второто е, че ще се използва и за стари обекти. Ако го включите само за стари (само UseParallelOldGC), другото ще се включи автоматично по подразбиране. Обобщеният алгоритъм би изглеждал така:

  • Блокира (замразява) всички приложения, които работят;
  • Маркира всички живи - стари и млади - обекти;
  • ParallelOldGC събира боклука в старите обекти - мъртвите се изтриват. Ако е нужно, прави дефрагментация на паметта за стари обекти;
  • ParallelGC събира боклука в областта на младите обекти, като младите живи се местят при survivors или се промотират (местят) като стари обекти, а останалата част на паметта за млади обекти се изтрива.
  • При нужда ParallelGC динамично преоразмерява различните части на heap паметта (включва се с -XX:+UseAdaptiveSizePolicy);
  • Отблокира (размразява) приложенията и те продължават да работят.

Въпреки, че също дефрагментира, за разлика от SerialGC, ParallelGC не създава един голям последователен блок от свободна памет. JVM не може да освобождава памет за операционната система в този режим.

ParallelGC се използва като основен GC по подразбиране за всички Linux/Unix и 64 битови Windows инсталации при Java 8 и по-стари версии.

Парарелен маркиращ GC (ParNewGC и ConcMarkSweepGC)

Както подсказва името му, това е GC проследяващ живи обекти, който е маркиращ, при това маркирането на обекти се прави многонишково (parallel/concurrent). Този събирач на боклук се стреми да оптимизира паралелния GC като се опитва да прави максимално кратък период на замръзване. Отново се състои от два компонента - ParNewGC за млади обекти и ConcMarkSweepGC за стари обекти.

При чистенето на млади обекти ParNewGC работи по почти същия начин, както ParallelGC - блокира програмата, пренася някои обекти като "оцеляли", а други ги промотира като "стари" и отблокира програмата. Разликата е, че ParNewGC НЕ преоразмерява различните части на heap паметта - тоест при този GC не може да се използва UseAdaptiveSizePolicy.

Чистенето на стари обекти с ConcMarkSweepGC върви по различен начин, наречен "кратка пауза" (low pause):

  • Фаза на първоначално маркиране;
    • Блокира програмата;
    • Извършва първоначално маркиране - маркират се само стари живи обекти, които гарантирано няма да бъдат трити. Например статичните обекти са такива, както и обекти създадени извън блокове в main и т.н. (има определени критерии);
    • Отблокира програмата;
  • Фаза на пълно сканиране: маркиране на всички обекти (паралелно с работещата програма) - маркира се абсолютно всичко;
  • Фаза на почистване обекти
    • Блокиране на програмата;
    • Ремаркиране - допълнително се маркират обекти, които евентуално са създадени от програмата по време на предишната фаза;
    • Почистване на пространството. Старите обекти само се трият и не се прави дефрагментация;
    • Отблокиране на програмата.

Основният проблем на този събирач спрямо ParallelGC са възможните "race conditions", които се създават между събирането на стари и нови обекти. Справянето с тях добавя значително повече работа на процесора и затова натоварването на CPU от този GC e значително по-голямо. Предимството е, че "stop-the-world" (замръзването) е съкратено като времетраене. Другият му проблем е, че води до голяма фрагментация на пространството за стари обекти. Ако често триете такива, това може да породи проблем, защото рано или късно ще се наложи stop-the-world операция, в която да се пренаредят (дефрагментират) всички обекти.

Правилото за избор на този GC би било "давам процесорно време, за да намаля замръзванията на програмата". Или по друг начин казано компютъра ви ще е по-натоварен като цяло, но за сметка на това няма да има прекалено дълги замръзвания при извикване на GC.

За да се използва ConcMarkSweepGC върху младите обекти се указва с -XX:+UseParNewGC, а за да се използва върху старите се указва -XX:+UseConcMarkSweepGC. Пускането само на UseConcMarkSweepGC ще включи и другото.

Боклука първи (G1GC)

Този събирач на боклук е представен за първи път с JDK7u4 и има насоченост за практическа употреба при heap по-голям от 4GB. Той НЕ използва HotSpot организация на паметта. Първоначално цялата heap памет се разделя на региони от 1 до 32 MB (в зависимост от големината на целия heap - обикновено регионите са общо около 2000 на брой). Тези региони имат съответни типове:

  • За млади обекти;
  • За стари обекти;
  • За "оцеляващи" обекти - те могат да се ползват както за млади оцеляващи, така и стари оцеляващи;
  • Памет за големи (humongous) обекти - такива, които са 50% и повече от големината на регион.

Регионите не са последователни в паметта, както е при HotSpot организацията, т.е. може да се редуват региони за млади с региони за стари обекти. Фазите на работа на G1 са почти същите, както при паралелния маркиращ GC. Отново имаме фаза на първоначално маркиране, фаза на пълно сканиране и фаза на крайно почистване, но тук има основни разлики спрямо ConcMarkSweepGC:

  • По време на маркиранията се пази допълнителна статистика. G1 брои в кои региони колко живи обекти има. После чистенето започва от регионите с най-малко живи обекти. В идеалния случай ще има региони, в които няма да има нито един жив обект - тези региони просто се изчистват без да се гледа въобще какво има вътре в тях. Това дава голямо количество свободно пространство в паметта за кратко време. Почистването на региони, които имат в себе си живи обекти, става чрез копиране на живите обекти в друг "survivor" регион;
  • Старите обекти също се местят в survivor региони за стари обекти, а не просто се трият. По този начин паметта се дефрагментира като цяло!
  • Принципно няма голяма разлика между чистенето на стари и млади обекти - тя е само, че всички млади ще бъдат почистени, докато някои региони с много регистрирани live стари обекти ще бъдат пропуснати (т.е. няма да бъде почистено абсолютно всичко от старите обекти). Последното се прави с цел по-бързо приключване на работата на GC.

Тенденцията е в бъдеще G1 да замени ConcMarkSweepGC. Вероятно е в Java 9 той да стане GC по подразбиране за всички видове инсталации на JVM. Засега е добре да го използвате в следните случаи:

  • Имате повече от 50% запълване на паметта и често създавате и триете обекти (предполага голяма фрагментация);
  • При ParallelGC имате прекалено дълги замръзвания на програмата;
  • Също както при ConcMarkSweepGC, вие сте готови да жертвате процесорно време срещу по-къси замръзвания.

Можете да очаквате, че при този G1 ще има повече, но по-краткотрайни пускания на GC спрямо ConcMarkSweepGC. Използването на G1 GC става чрез -XX:+UseG1GC

 



2 коментара


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

  2. Tози подход се използва при програмирането на мобилни приложения (Java -> Android), където имаш ограничени ресурси и извикването на GC би накарало телефона да забие.
    Ще си позволя малка забележка към статията:
    System.gc() не вика GC, ами казва "хубаво би било да се извика". Обикновенно проверката за боклук се прави когато се ползва new (било то по скрит начин, тук има интересни примери). Викането на gc() не гарантира извикването на повелителя на боклука.

    Методът runFinalization всъщност прави малко по-различно нещо, макар и със същият краен ефект, описан в статията. Всъщност GC не унищожава обекта на един дъх. Това позволява да разбереш кога твой обект ще бъде унищожен на следващата итерация на GC. (За любознателните Google -> weak reference). Викането на runFinalization всъщност казва на GC да не си оставя обекти за следващия път (грубо казано)

Добави коментар

Адресът на електронната поща няма да се публикува


*