Въведение в ZGC: Мащабируем и експериментален колектор за боклук JVM с ниска латентност

1. Въведение

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

За да се справи с този проблем, Java 11 представи Z Garbage Collector (ZGC) като експериментална реализация на колектор за боклук (GC).

В този урок ще видим как ZGC успява да запази ниски времена на пауза дори при многотерабайтни купчини .

2. Основни понятия

За да разберем как работи ZGC, трябва да разберем основните понятия и терминология зад управлението на паметта и събирачите на боклук.

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

Физическата памет е RAM, която предоставя нашият хардуер.

Операционната система (OS) разпределя пространство за виртуална памет за всяко приложение.

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

2.2. Multi-Mapping

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

На практика ние съпоставяме множество диапазони на виртуалната памет към същия диапазон във физическата памет:

На пръв поглед случаите на използване не са очевидни, но по-късно ще видим, че ZGC се нуждае от него, за да направи магията си. Освен това осигурява известна сигурност, защото разделя пространствата на паметта на приложенията.

2.3. Преместване

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

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

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

2.4. Събиране на боклук

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

GC трябва да проследява състоянието на обектите в купчината, за да върши работата си. Например, възможно състояние е достижимо. Това означава, че приложението съдържа препратка към обекта. Тази препратка може да е преходна. Единственото нещо, което има значение, е, че приложението може да осъществи достъп до тези обекти чрез препратки. Друг пример е финализируем: обекти, до които нямаме достъп. Това са обектите, които считаме за боклук.

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

2.5. Свойства на фазата на GC

GC фазите могат да имат различни свойства:

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

Обърнете внимание, че всички горепосочени техники имат своите силни и слаби страни. Да кажем например, че имаме фаза, която може да работи едновременно с нашето приложение. Серийното внедряване на тази фаза изисква 1% от общата производителност на процесора и работи за 1000ms. За разлика от това, паралелно внедряване използва 30% от процесора и завършва работата си за 50ms.

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

Разбира се, този пример има измислени номера. Ясно е обаче, че всички приложения имат своите характеристики, така че имат различни изисквания за GC.

За по-подробни описания, моля, посетете нашата статия за управление на паметта в Java.

3. Концепции на ZGC

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

В допълнение към изпитаните GC техники, ZGC въвежда нови концепции, които ще разгледаме в следващите раздели.

Но засега нека да разгледаме цялостната картина на това как работи ZGC.

3.1. Голяма картина

ZGC има фаза, наречена маркиране, където намираме достижимите обекти. GC може да съхранява информация за състоянието на обекта по множество начини. Например, бихме могли да създадем Map, където ключовете са адреси на паметта, а стойността е състоянието на обекта на този адрес. Това е просто, но се нуждае от допълнителна памет за съхраняване на тази информация. Също така поддържането на такава карта може да бъде предизвикателство.

ZGC използва различен подход: съхранява референтното състояние като битове на референцията. Нарича се референтно оцветяване. Но по този начин имаме ново предизвикателство. Задаването на битове на препратка за съхраняване на метаданни за обект означава, че множество препратки могат да сочат към един и същ обект, тъй като държавните битове не съдържат информация за местоположението на обекта. Мултимапиране на помощ!

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

Да приемем, че имаме препратка към обект. ZGC го премества и възниква контекстен превключвател, където нишката на приложението се изпълнява и се опитва да осъществи достъп до този обект чрез стария си адрес. ZGC използва бариери за натоварване, за да реши това. Бариерата за натоварване е парче код, което се изпълнява, когато нишка зарежда препратка от купчината - например, когато имаме достъп до непримитивно поле на обект.

В ZGC бариерите за натоварване проверяват битовете за метаданни на референцията. В зависимост от тези битове, ZGC може да извърши някаква обработка на референцията, преди да я получим. Следователно може да се получи съвсем различна справка. Ние наричаме това преназначаване.

3.2. Маркиране

ZGC разделя маркировката на три фази.

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

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

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

На този етап знаем до кои обекти можем да достигнем.

ZGC използва маркираните0 и маркираните1 бита за метаданни за маркиране.

3.3. Референтно оцветяване

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

С 32 бита можем да адресираме 4 гигабайта. Тъй като в наши дни е широко разпространено компютърът да има повече памет от тази, очевидно не можем да използваме нито един от тези 32 бита за оцветяване. Следователно ZGC използва 64-битови препратки. Това означава, че ZGC е достъпен само на 64-битови платформи:

Референциите на ZGC използват 42 бита за представяне на самия адрес. В резултат на това референциите на ZGC могат да адресират 4 терабайта памет.

На всичкото отгоре имаме 4 бита за съхраняване на референтни състояния:

  • финализируем бит - обектът е достъпен само чрез финализатор
  • бит за ремап - препратката е актуална и сочи към текущото местоположение на обекта (вижте преместване)
  • маркирани0 и маркирани1 бита - те се използват за маркиране на достижими обекти

Наричахме тези битове и метаданни. В ZGC точно един от тези битове на метаданни е 1.

3.4. Преместване

В ZGC преместването се състои от следните фази:

  1. Една паралелна фаза, която търси блокове, искаме да ги преместим и ги поставя в набора за преместване.
  2. Фаза „спиране на света“ премества всички коренови препратки в набора за преместване и ги актуализира.
  3. Една паралелна фаза премества всички останали обекти в набора за преместване и съхранява съпоставянето между стария и новия адрес в таблицата за препращане.
  4. Пренаписването на останалите референции се случва в следващата фаза на маркиране. По този начин не е нужно да пресичаме дървото на обектите два пъти. Като алтернатива това могат да го направят и бариерите за натоварване.

3.5. Преграждане и бариери за натоварване

Имайте предвид, че във фазата на преместване не сме пренаписвали повечето препратки към преместените адреси. Следователно, използвайки тези препратки, няма да имаме достъп до обектите, които искаме. Още по-лошо е, че имаме достъп до боклука.

ZGC използва бариери за натоварване, за да реши този проблем. Бариерите за натоварване фиксират препратките, сочещи към преместени обекти с техника, наречена преназначаване.

Когато приложението зарежда референция, то задейства бариерата за натоварване, която след това следва следните стъпки, за да върне правилната референция:

  1. Проверява дали битът за ремап е зададен на 1. Ако е така, това означава, че референтът е актуален, така че спокойно можем да го върнем.
  2. След това проверяваме дали посоченият обект е бил в комплекта за преместване или не. Ако не беше, това означава, че не сме искали да го преместим. За да избегнем тази проверка следващия път, когато заредим тази препратка, задаваме бита за преназначаване на 1 и връщаме актуализираната препратка.
  3. Сега знаем, че обектът, до който искаме достъп, е бил целта на преместването. Въпросът е само дали преместването е станало или не? Ако обектът е преместен, преминаваме към следващата стъпка. В противен случай го преместваме сега и създаваме запис в таблицата за препращане, който съхранява новия адрес за всеки преместен обект. След това продължаваме със следващата стъпка.
  4. Сега знаем, че обектът е преместен. Или от ZGC, нас в предишната стъпка, или бариерата за натоварване по време на по-ранно попадение на този обект. Ние актуализираме тази препратка към новото местоположение на обекта (или с адреса от предишната стъпка, или като го гледам в таблицата за препращане), задайте оставам малко, и да се върне препратката.

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

4. Как да активирам ZGC?

Можем да активираме ZGC със следните опции на командния ред при стартиране на нашето приложение:

-XX:+UnlockExperimentalVMOptions -XX:+UseZGC

Имайте предвид, че тъй като ZGC е експериментален GC, ще отнеме известно време, за да стане официално поддържан.

5. Заключение

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

За да постигне тази цел, той използва техники, включително цветни 64-битови препратки, бариери за натоварване, преместване и пренасочване.