Выпуск 61 · подкаст «Тысяча фичей»

#61: Лучший GC: Java и Go

2:49:37
↓ скачать mp3

Александр Пахомов и Александр «Саша» Ланцов (Java-разработчик в финтехе, Мир Plat.Form) на примере двух рантаймов — HotSpot JVM и Go — прослеживают всю эволюцию сборщиков мусора: от фундаментальных алгоритмов (reference counting, mark-and-sweep, mark-compact, копирующий) и трёхцветной маркировки до `Serial`/`Parallel`, `CMS`, `G1`, `Shenandoah`, `ZGC` и нового `Green Tea` в Go. По пути — гипотеза о поколениях и почему её нет в Go, card table и GC-барьеры, фрагментация и спаны, указатели Брукса, цветные указатели и трюк с виртуальной памятью, спираль смерти. В финале — как осознанно выбрать GC под latency, пропускную способность и размер хипа.

Главное

  • Трассирующие сборщики мусора ищут не мусор, а живые объекты: стоимость маркировки пропорциональна количеству достижимых объектов, а не общему размеру хипа.
  • `Mark-and-Sweep` прост и не двигает объекты (указатели не инвалидируются), но страдает от фрагментации; `Mark-Compact` и копирующий коллектор её решают ценой переноса объектов и позволяют дешёвую аллокацию через bumping pointer.
  • Гипотеза о поколениях работает только вместе с card table и write-барьером, отслеживающими редкие ссылки из старого поколения в молодое; в Go её нет, потому что escape-анализ и структуры уводят большинство короткоживущих объектов на стек.
  • Конкурентная маркировка требует барьеров: `SATB` (snapshot-at-the-beginning) ловит запись ссылки из чёрного объекта в белый и перекрашивает, сохраняя инвариант; барьеры дешевы за счёт цепочки ранних `if`.
  • `CMS` давал конкурентную маркировку, но без дефрагментации накапливал фрагментацию и падал в однопоточный `Full GC` с `Mark-Compact`; `G1` разбивает хип на регионы с remembered set и регулирует паузу, выбирая, сколько регионов собрать (garbage first).
  • `Shenandoah` сначала защищал перенос указателями Брукса (лишний указатель раздувал объект), в версии 2.0 переехал в `mark word`; поддерживает сжатые указатели, поэтому хорош для небольших хипов с низким latency.
  • `ZGC` хранит цвет прямо в 64-битном указателе и умеет переносить объекты силами мутаторов; из-за этого не поддерживает сжатые указатели, зато проектировался под гигантские хипы; трюк с мульти-маппингом виртуальной памяти из версии 1.0 в версии 2.0 убрали.
  • Единого «лучшего» GC нет: `Parallel` — под пропускную способность (Spark), `ZGC`/`Shenandoah` — под низкий latency, `G1` — гибрид и дефолт в Java; выбор нужно проверять профилированием на своей нагрузке, а не «астрологией».

В выпуске

  • Александр ЛанцовJava-разработчик в финтехе: писал low-latency-алготрейдинг и работает над распределённой БД и расчётно-платёжными системами; интересуется рантаймами Java и Go, low-latency и конкурентным программированием. Спикер JPoint. LinkedIn ↗
Расшифровка

[00:00] Александр: У нас есть два самых популярных языка программирования со сборщиками мусора. Первый — это, естественно, Java, как прародитель и вообще популяризатор идеи, что garbage collector — это технология, которая реально работает. Ещё, наверное, C#. Но сегодня мы говорим про Java и про Go, и на примере этих двух экосистем будем рассматривать эволюцию сборщиков мусора вместе с Сашей. Привет, Саш.

[00:25] Саша: Привет, уважаемые слушатели подкаста. Давайте поговорим про сборку мусора в Java и Go с точки зрения прикладного разработчика, потому что я всё-таки скорее прикладной разработчик, чем какой-то системный хардкорщик. Попробуем рассказать так, чтобы было глубоко, но при этом понятно.

[00:55] Саша: С точки зрения прикладного разработчика, на первый взгляд, все эти рассуждения о сборщиках мусора кажутся бессмысленными. Ну потому что смотрите, что мы вообще можем сделать, будучи прикладным разработчиком? У нас же нет никаких непосредственных ручек для управления garbage collection. То есть можно, конечно, вызвать System.gc() и понадеяться, что он сработает, но на самом деле рычагов воздействия у нас очень мало. Как мы можем с ним взаимодействовать? Мы можем создать какой-нибудь объект, можем записать ссылку на этот объект куда-нибудь. Или, например, удалить этот объект из какого-нибудь поля. Короче, всё, что мы можем, — это, по сути, сохранять ссылки. Я очень часто буду говорить «ссылка» или «указатель». Давайте иметь в виду, что имеется в виду всё-таки указатель, потому что в Java ссылки, а в Go указатели. Я могу иногда эту терминологию смешивать, но это про одно и то же. Короче, мы можем работать с указателями. Можно вспомнить, что в Java — на самом деле в GC — есть всякие специфичные soft- и weak-референсы и так далее, но сейчас мы про них говорить не будем.

[01:56] Александр: Нет, ну подожди, смотри: мы можем выбирать, какой garbage collector использовать, при настройке Java.

[02:01] Саша: Да-да-да. Имеется в виду, что при запуске нашего кода, уже в рантайме, мы действительно можем выставить какие-то флажки. Но именно при написании кода у нас как будто бы нет какого-нибудь API «уменьши сборку мусора в юном поколении на 20 миллисекунд». Такого вызова просто нет. У нас очень мало точек соприкосновения с garbage collection. По сути, единственное, что мы можем, — это как-то спроектировать свой код, чтобы он хорошо работал со сборкой мусора. А чтобы это получилось, нам надо иметь базовые представления о том, как эта штука работает, от чего она зависит, в каких условиях она может работать хорошо, а в каких нет. Потому что, как мы увидим дальше, многие сборщики мусора используют те или иные assumptions, и если эти assumptions нарушаются, всё может пойти не очень хорошо.

[02:42] Саша: Ещё одна цель моего сегодняшнего спича — сравнить Java и Go в плане рантаймов. Я не хочу сейчас заниматься языковым срачем, сравнивать обработку ошибок или прочие вещи — мне это не очень интересно. А вот рантаймы, что в Java, что в Go, очень интересны. Java, с одной стороны, — если мы говорим про HotSpot (я дальше буду говорить именно про HotSpot-реализацию Java-машины), — это как будто бы довольно выдающееся достижение человечества, потому что на него было потрачено просто огромное количество человеко-часов, там очень много разных механизмов, они все как-то интересно работают, и это всё не просто так. С другой стороны, Go, конечно же, гораздо новее Java, но у него, пожалуй, тоже один из самых интересных рантаймов, потому что там тоже очень много всяких фишечек. А ещё что мне нравится в рантайме Go — это то, что ты можешь зайти в исходники рантайма: поскольку рантайм Go написан на Go, тебе всё понятно. А если ты захочешь покопаться в каких-нибудь потрохах Java, придётся ещё плюсы немножко вспоминать. Поэтому Go мне тоже интересен с точки зрения рантайма — интересно смотреть, что там к чему устроено.

[03:53] Саша: Я предлагаю построить наш разговор по некоторой схеме. План примерно такой. Сначала мы поговорим про некоторые фундаментальные основы, потому что, как мы поймём чуть позже, основные идеи garbage collection придумали какое-то время назад — и по большей части они сейчас так и используются. Они как-то модифицируются, улучшаются сами по себе, но базисы, на которых всё построено, как остались, так и остаются до сих пор. Кстати, используя уже эти базисы, можно делать некоторые интересные выводы для прикладных разработчиков. Мы попробуем это сделать. Это первая часть. Затем поговорим про stop-the-world-коллекторы — то есть те, которые останавливают наше приложение, чтобы полностью сделать сборку мусора, — как они устроены. В Java это, например, Serial и Parallel. Ну и немного затронем древние времена Go, примерно версию 1.4. А сейчас не так давно вышла версия 1.26, в которой появился обновлённый коллектор Go — Green Tea. Про это мы тоже немножко поговорим. Поговорив про stop-the-world-коллекторы, я предлагаю обсудить частично конкурентные сборщики мусора. Вспомним CMS, который какое-то время назад выпилили из Java. Поговорим про G1 и тоже вспомним какие-то древние времена Go, но уже не такие древние, — когда там уже появилась так называемая конкурентная маркировка, в которой мы будем разбираться, что это вообще такое. А затем перейдём к тому, что, наверное, самое интересное, — полностью конкурентным сборщикам мусора. Под словом «полностью» я бы поставил звёздочку: там есть небольшое примечание, что это не всегда так. Как раз про это тоже, мне кажется, будет интересно поговорить. В Java самые известные алгоритмы — это Shenandoah и ZGC. В Go, как я говорил, есть Green Tea. И, разумеется, в конце надо поставить точку в споре, какой алгоритм сборки мусора самый лучший, — потому что это же какая-то фигня, что понапридумывали кучу целых алгоритмов. Какой-то из них должен быть, очевидно, самым лучшим, чтобы мы его взяли и использовали всегда. Или, вообще-то, давайте определимся, надо всё писать на Java или на Go. Попробуем ответить на этот вопрос — так оно на самом деле или нет.

[06:03] Александр: Слушай, мне план очень нравится. Я бы ещё подсветил здесь такую мысль для сомневающихся слушателей, которые, возможно, подумали: «Зачем мне вообще всё это знать?» Вопрос довольно логичный и справедливый. «А нахрена я буду сейчас два часа своего времени слушать, помимо того что мне просто нравится подкаст „Тысяча фичей“? Какая практическая польза от всего этого будет?» Я от себя сразу скажу, в чём я вижу пользу для себя. До сих пор про garbage collector на собеседованиях как минимум бэкендеров точно спрашивают. На Java — я думаю, и на Go, может быть, не так часто, но в Java меня всегда спрашивают: «Расскажи, как работает garbage collector». Такой открытый вопрос, который подразумевает, что ты покажешь свою эрудицию, свои знания по теме. И да, то, что сейчас везде по дефолту используется один коллектор, а остальные не используются, и он работает сносно в большинстве случаев, — это факт. Но показать свою инженерную эрудицию всегда круто, и вероятность пройти собеседование повышается, если вы слушаете подобные подкасты. Но я думаю, у Саши тоже есть что сказать по поводу «а нахрена».

[07:17] Саша: Да, вопрос «нахрена» хороший, потому что действительно зачастую достаточно того ответа, который идёт из коробки, — и он будет хорошо работать. Но бывают такие доменные области, где это всё-таки важно. Я, например, довольно долго работаю в финтехе и на удивление умудрился поработать в стороне, где очень важен latency. То есть я писал всяких алгороботов на Java. Это не ultra-HFT, а скорее low-latency HFT. И там, соответственно, мне были нужны низкие задержки. А с другой стороны, я сейчас, например, работаю в крупных расчётных, платёжных системах. И там у меня задачи ровно наоборот. Мне приходит, условно говоря, ночью большой объём вычислений. Мне надо провести их за всю ночь максимально быстро, но мне неважно, как быстро я обрабатываю одну конкретную транзакцию. Мне важно, за сколько я успею обработать их целиком. То есть мне нужна пропускная способность. И вообще, зная, как устроен garbage collection, можно иногда осуществлять более правильную организацию кода. Это тоже бывает важно. В общем, резюмирую ответ на твой вопрос. Скорее всего, для, не знаю, 80% разработчиков это знание никогда в жизни не пригодится на практике. Оно, во-первых, полезно для собеседования, а во-вторых, бывают доменные области, где это важно до сих пор. Мне вот в таких доменных областях как-то интереснее работать.

[08:36] Александр: Согласен. Ну и, в конце концов, это просто интересно — про кишочки поговорить всегда. Где, если не в этом подкасте? Скажите мне, в каких других подкастах люди говорят про GC несколько часов и даже находят в этом какой-то интерес? Поэтому полностью оправданно. Да, я думаю, мы можем начинать.

[08:56] Саша: Окей, хорошо. Погнали тогда по нашему плану. Сначала поговорим про фундаментальный алгоритм. Как я сказал, вся эта идея сборки мусора появилась на самом деле очень давно. Насколько я прикидываю, это а-ля 70-е годы. И какие-то фундаментальные подходы к сборке мусора с тех времён концептуально прям не поменялись. Есть очень хорошая книжка, которая называется The Garbage Collection Handbook. Она не так давно получила переиздание, по-моему, в 2023 году. И там как раз есть описание, начиная с этих фундаментальных алгоритмов, про которые мы сейчас будем говорить, и до состояния актуальных garbage collection в Java. Поэтому, мне кажется, сейчас довольно неплохой момент, чтобы эту книжку — если вам эта тема интересна — скачать и почитать, потому что она пока не сильно отстала от реальности. Меня немножко огорчает, когда я скачиваю какую-нибудь книжку, которой уже 20 лет. Я понимаю, что там какая-то база и так далее, но всё равно есть ощущение: неужели ничего нового не придумали? Поэтому, когда возникает переиздание таких прикольных книг, всегда здорово взять и посмотреть, что изменилось.

[10:00] Александр: Плюс сто процентов. У меня такое было с книгой The Pragmatic Programmer — «Программист-прагматик» по-русски. Я читал первую версию, которая вышла в начале 2000-х, а потом в 2024-м или 2023 году вышла вторая версия. Я с таким удовольствием её прочитал. Это реально круто — когда ты читаешь свежепереизданную книгу, но уже с хорошей базой. Прям всем советую.

[10:28] Саша: Ну и, собственно, The Garbage Collection Handbook — гуглите, пожалуйста, свежее издание. И вот если открыть эту книжку, там перечисляются четыре фундаментальных подхода к garbage collection. Первый подход называется reference counting. Я, наверное, про него сильно углубляться не буду, идея очень простая. Давайте сначала всё-таки обозначим, в чём вообще цель алгоритма garbage collection, чтобы было совсем понятно. У нас есть объекты, которые больше не достижимы, — то есть мы из нашего кода программы никак не можем до них достучаться. Это значит, что память, которую они занимали, больше никому не нужна, и её можно освободить, чтобы на их месте создать какие-то новые объекты. Наша задача — найти эти мёртвые объекты и сделать так, чтобы память, которую они занимали, снова стала доступна.

[11:14] Александр: Да. И давай вспомним экосистему C, например, или C++, где нет garbage collector. Как там эти объекты удаляются? Они удаляются прямо в коде почти всегда. Если это не какие-то паттерны с ресурсами и так далее, то ты просто на выходе из функции… То есть функция, её переменные лежат на стеке. Когда этот стек разматывается, в конце должен быть код — не обязательно в самом конце, но по факту, — который, когда стек стёрся, удаляет объекты в хипе, на которые из этого стека ссылались. В Java, как мы знаем, у нас нет функции деструктора, поэтому кто-то за нас должен эти объекты из хипа удалять. Получается, у нас даже эту свободу отняли, как у прикладных разработчиков: объект создать можем, а вот удалить — не можем. Обидно как-то. Это делает за нас garbage collector.

[12:08] Саша: Ну и, как я говорил, есть четыре подхода к этому. Reference counting очень прост идейно. Что является мусором? То, что больше не используется, — то есть то, на что нет никаких входящих ссылок. Давайте просто рядом с каждым объектом заведём счётчик, в котором будем хранить, сколько на него есть входящих ссылок из других объектов. И тут базовая очевидная идея: когда этот счётчик обнуляется, объект недостижим, значит, его можно освободить, и всё будет работать хорошо. Есть всякие подводные камни, начиная с того, что могут быть циклические зависимости, — это надо как-то удалять, это можно делать с помощью специальных ссылок, обозначая, что вот такие графы считаются как единый объект, а не как отдельные. Есть проблемы, связанные с конкурентным доступом: если у нас много потоков, то очевидно этот счётчик может инкрементироваться или декрементироваться из нескольких потоков, — это тоже надо синхронизировать. Есть и другие проблемы. Например, представим себе огромный linked list, и мы удаляем его голову — и получается такой «бах»: нам надо отрезать все элементы один за другим, и получается каскадное, очень сильное удаление. Мы думали, что reference counting простой, а на самом деле мы какую-нибудь ссылочку неудачно обнулили — и целая куча операций стала выполняться. Поэтому, когда про reference counting говорят, что он простой, это не вполне так. Тем не менее, я про него сильно рассказывать не буду, потому что ни в Java, ни в Go он не используется в алгоритмах сборки мусора. Поговорим про другие три алгоритма.

[13:44] Саша: Три остальных фундаментальных алгоритма называются Mark-and-Sweep, Mark-Compact и копирующий коллектор. В первых двух есть слово Mark, в третьем слова Mark явно нет. Но на самом деле, если читать и разбираться дальше — мы про это тоже поговорим, — его тоже можно при желании логически разделить на две стадии, а именно маркирование и эвакуацию объектов в другую область. Поэтому там тоже так или иначе есть маркировка. Значит, нам надо по-хорошему сначала разобраться, а что такое маркировка, как это устроено, почему это вообще нужно.

[14:19] Саша: Идея маркировки, то есть, по-другому, трассирующих сборщиков мусора, которые используют алгоритм маркирования, очень простая. У нас есть некоторое изначальное множество безусловно достижимых объектов. Что это может быть? Например, какие-нибудь локальные переменные, которые хранят ссылки на объекты. Или какие-нибудь статические переменные. В общем, у нас есть множество объектов, про которые мы заранее знаем, что оно безусловно достижимо. И из этого множества есть какие-то исходящие ссылки в другие объекты. Весь наш архив объектов можно представить как граф: отдельные ноды — это отдельные объекты, а ссылки из одного объекта в другой являются связями внутри этого графа. В чём идея маркировки? В том, чтобы после отрабатывания алгоритма мы неким специальным образом пометили, что определённые ноды из нашего графа достижимы из рутов, из корней, — то есть они являются живыми объектами, — а какие-то объекты недостижимы, а значит, они мусор.

[15:26] Саша: Хочу сразу тут отметить следующее замечание: у этого алгоритма тоже есть довольно очевидные подводные камни. Если вы возьмёте какой-нибудь ArrayList и начнёте в него фигачить объекты, но забудете их оттуда удалять, — и вы вроде бы в теории знаете, что они вам больше не нужны, но поскольку ссылку на них не удалили, — никакой garbage collector вам не поможет. Поэтому утечки возможны даже в системах с garbage collection. За этим надо всегда осторожно следить.

[15:53] Александр: Да, утечки бывают разные. Одна из утечек, по сути, просто логическая: по факту тебе эти объекты не нужны, но garbage collector знает, что они достижимы. И с точки зрения garbage collector всё окей, и с твоей точки зрения вроде как тоже всё окей, но объекты не нужны. И тут как раз — зачем нам этот подкаст? — нужно понимать, что происходит, как система работает, чтобы эффективно с ней взаимодействовать. Если у тебя объекты какие-то ненужные — не знаю, ты обрабатываешь пользовательский запрос на входе, — ну так не складывай часть этих объектов в общий ArrayList, потому что они там останутся навсегда, если ты руками их оттуда не удалишь.

[16:37] Александр: А с другой стороны, что бы я хотел добавить слушателям, которым, возможно, немного сложновато сразу представить то, о чём мы говорим. Я бы нарисовал в голове такую модель, которая на протяжении всего подкаста может быть очень полезной. И она довольно простая. Вот я так всегда представляю. Кстати, когда меня спрашивали — я сейчас не собеседуюсь, но раньше собеседовался, — спрашивали: «Расскажи про garbage collector». Я теорию реально никогда не знал наизусть, я её читал, но воспроизводил своим ртом из той картинки, которая у меня была в голове. Потому что, представив картинку, уже можно плюс-минус понять, как это работает, — и, может быть, в терминах быть не столь точным, но зато по факту и по сути быть точным. Короче, картинка такая. Есть тёмная комната. В ней летают шарики — вот те самые вершины графа, про который ты говорил, кружочки, ноды. У меня это шарики. Они летают — это наши объекты в куче. Наша программа эти объекты так или иначе генерирует. У шариков между собой есть связи у некоторых — то есть это вершины, связанные рёбрами графа. Некоторые связаны, некоторые нет. Но мы не знаем. Мы, как garbage collector, который зашёл в эту комнату, вообще не знаем, какие шарики здесь мусор, а какие нет. И вот для того, чтобы узнать, у нас есть полка с шариками, которые всегда точно в программе есть, — это, как ты сказал, статические переменные, стопроцентно достижимые вещи. Берём за основу статические переменные классов, они всегда достижимы, и начинаем по ниточке находить все шарики. Мы просто от них двигаемся — и все связанные шарики в итоге найдём. То есть мы реализуем обычный обход графа. И вот те шарики, которые нашли по ниточкам, мы называем достижимыми, складываем в кучу, говорим: эти объекты нужны, — а всё, что осталось, помечаем как мусор и удаляем. Вот эта картинка мне помогает поддерживать дискуссию про garbage collector. Если вам, дорогие слушатели, она тоже немножко поможет, я рад. А мы можем идти дальше.

[16:45] Саша: Слушай, да, прикольная картинка. Тут я не могу не вмешаться. А вот если ниточка идёт в тот шарик, где ты уже был? Ты запутаешься и будешь всё время по шарикам ходить, длину перебирать.

[18:53] Александр: У меня в руках есть маркер, которым я помечаю крестиком шарик, который уже увидел. И поэтому, когда я вижу новый шарик, а на нём есть крестик, я такой: «А, я здесь уже был». Значит, это типа цикл.

[19:07] Саша: Неплохо. Ну вот видишь, ты почти как Дейкстра переизобрёл алгоритм маркировки с помощью трёх цветов. На самом деле всё так и есть. Мы каждому шарику присваиваем определённый цвет в зависимости от того, в каком он состоянии с точки зрения garbage collection. У нас может быть либо ситуация, что мы пока вообще не знаем, что с этим шариком — мусорный он или нет, мы его ещё не рассмотрели. Либо у нас может быть метка, что мы уже поняли, что этот шарик достижим, — то есть мы уже взяли его в руку, но ещё не прошли по всем нитям, которые из него выходят. Дейкстра называл такие шарики серого цвета. У него трёхцветный алгоритм, поэтому было три цвета: белый, серый и чёрный.

[19:46] Александр: Понятно. Мир оптимистов, короче, как обычно.

[19:49] Саша: И когда мы полностью рассмотрели все нити на нашем шарике, исходящие в другие шарики, мы помечаем объект чёрным цветом. Таким образом, когда наш алгоритм отработает, все шарики в итоге оказываются либо чёрного цвета, либо белого. И понятно, что все чёрные шарики — это те, до которых мы дошли и полностью перебрали все ниточки, ведущие в другие шарики, значит, с ними всё окей. А те шарики, до которых мы не дошли, соответственно, мусорные, и место, которое было под ними, можно освободить.

[20:21] Саша: И тут появляется, наверное, ещё одно слово, оно будет довольно часто сегодня повторяться. Жаль, что у меня нет ручного попугая, который бы говорил его вместо меня, потому что эта штука довольно часто используется во всех garbage collection. Слово «инвариант». Короче, многие сборщики мусора используют те или иные инварианты, и давайте введём сейчас первый инвариант, который должен соблюдаться в процессе такой маркировки. А именно: у нас никогда не должно быть ниточки между чёрным шариком — тем, который мы уже взяли в руку, посмотрели, что он достижим, и рассмотрели все его ниточки, — и белым шариком. Потому что это какая-то фигня: мы взяли чёрный шарик, пошли по всем нитям, и у нас оказался шарик, до которого мы не дошли по этой нитке. Пока что кажется, что это очевидный инвариант, вообще не стоящий выеденного яйца, но на самом деле, когда мы будем разговаривать про конкурентную сборку мусора, про конкурентное маркирование, это станет очень важным.

[20:57] Саша: Маркировать объекты мы научились. И сразу тут, наверное, стоит понять, что стоимость маркирования — сколько это у нас займёт по какому-то ресурсу: CPU, времени или чему-нибудь такому — строго пропорциональна количеству живых шариков, которые у нас достижимы. То есть если у нас есть огромная комната, заполненная огромным количеством шариков, но там нет нитей, то мы просто придём, скажем: «А делать-то нечего», — и всё, ничего страшного. А с другой стороны, если мы придём, а там абсолютно всё перезапутано, это будет очень тяжело делать.

[21:45] Александр: Да, это важное уточнение, потому что на первый взгляд интуитивно, когда только начинаешь думать о сложности этого алгоритма, ты такой: «Ну, наверное, чем больше шариков, тем сложнее». Логично же: чем больше объектов, тем сложнее их всех обойти. Но потом ты понимаешь, что нас интересуют только те объекты, которые безусловно достижимы, и всё, что мы от них можем пройти. Если граф со ста объектами, то у нас и сложность будет как обход графа со ста объектами. А то, что у нас миллион объектов, нас — как алгоритм, который ходит по этим объектам, — совершенно не интересует, потому что мы, грубо говоря, не видим эти миллионы объектов, пока в них не пришли. Для нас их не существует.

[22:25] Саша: Важный момент: сейчас мы говорим именно про маркировку. С точки зрения маркировки действительно это так. То есть мы научились маркировать наш граф объектов, научились находить, какие объекты у нас живые, а какие мёртвые. Но само по себе это знание как будто бессмысленно, потому что надо же что-то дальше с этим делать. Надо как-то память освободить от тех объектов, которые нам больше не нужны. И тут возникает уже конкретный алгоритм — Mark-and-Sweep, Mark-Compact и копирующий коллектор. Давайте разберём их по очереди.

[22:56] Саша: Mark-and-Sweep на самом деле идейно очень простой. Предположим, мы поняли, что какие-то объекты достижимы, а какие-то нет, — то есть у нас есть белые шарики, — и надо как-то сказать, что место, которое занимали эти белые шарики, вновь для кого-то доступно. Если мы захотим создать новый объект, мы можем создать его на месте, которое занимали белые шарики. Идея Sweep очень простая. Давайте заведём какую-нибудь структуру данных, в которой будем хранить информацию, какие места у нас сейчас свободны.

[23:43] Александр: Теперь комната у нас не совсем хаотичная, а разлинованная какой-нибудь прямоугольной сеткой. И у каждой ячейки, где лежит шарик, есть определённая координата. Можно представить в виде тира, где есть полки, по шарикам на которых надо метать дротики, — я думаю, все видели, — и каждый шарик лежит в своей квадратной полке, то есть в ячейке.

[24:04] Саша: Да-да-да, получается, мы можем сказать про конкретный шарик, указав его номер полки и конкретную позицию на этой полке. Собственно, давайте заведём какой-нибудь блокнотик, в котором будем писать, какие ячейки у нас сейчас свободны. Тогда Mark-and-Sweep — идея очень простая: когда мы находим объекты, которые недостижимы, то места, где они находились, мы помечаем как свободные, что их можно будет использовать ещё раз.

[24:30] Александр: Вопрос: а как мы находим объекты, которые недостижимы? Они же недостижимы. Как мы их нашли?

[24:36] Саша: Да-да-да, слушай, это хороший вопрос. Проблема как раз в том, что слово «сборка мусора» подразумевает, будто мы находим именно мусорные объекты. А на самом деле все трассирующие алгоритмы сборки мусора находят объекты, которые живые. Это поиск выживших, нежели чем сборка мусора.

[24:54] Александр: Да, или отслеживание выживших.

[24:56] Саша: Отслеживание выживших идёт само по себе, а не поиск мусора, потому что мусор недостижим. Короче, если ты недостижим, то ты мусор, и я на тебя внимания не обращаю, и могу перезаписать ту ячейку, в которой ты есть. Положим, мы научились как-то записывать информацию о том, что какие-то объекты являются мусорными и что мы можем снова писать в эти ячейки. И на первый взгляд с этим алгоритмом всё хорошо, никаких проблем. Но проблем никаких нет, только когда шарики в голове у нас имеют одинаковый размер. Пока всё разлиновано на одинаковые ячейки, все шарики занимают одну ячейку, всё окей, потому что, если нам надо найти место для нового шарика, мы посмотрим на нашу структуру данных, наш блокнотик, найдём свободное место и скажем: «Ага, шарик теперь будет здесь», — и положим его туда. Но всё очень сильно меняется, если предположить, что шарики бывают разных размеров. Давайте в нашей модельной вселенной скажем, что теперь есть шарики размером один на один, две на две ячейки и так далее.

[26:00] Александр: Ну, как в морском бою.

[26:03] Саша: Ну, в морском бою это значит, что шарик будет какой-то эллиптичный, скорее не шарик, а эллипсоид. Или как клоуны, из которых делают вот эти длинные носы. Такие шарики тоже могут быть, не только круглые. Окей, хорошо, такие тоже разрешаем. И здесь возникает проблема. Поскольку объекты, которые удалялись, — ну, мы же не знаем, в каком порядке они удалялись, как это распределение дырок вообще находится. Может получиться дурацкая ситуация: представим, у нас была комната, полностью заполненная шариками, а затем каждый шарик, у которого координата, определяемая полочкой и местом, чётная, мы сделали мусорным, отрезали ниточку, вызвали garbage collection, — и после этого с точки зрения логики половина места свободна. То есть у нас занята только каждая нечётная ячейка, а чётная свободна.

[27:00] Александр: Это так называемая фрагментация памяти произошла.

[27:02] Саша: Абсолютно верно. И теперь, если мы захотим поставить шарик размером, например, 2×2, с точки зрения смысла всё должно получиться, потому что, блин, у нас свободна половина всего пространства. А на самом деле ничего не получится, потому что у нас нет одной непрерывной области, в которую вместился бы этот шарик. Это как раз проблема базовой версии этого алгоритма: он, конечно же, простой, вам не надо заниматься какими-то дополнительными стадиями, но за это надо платить тем, что придётся как-то бороться с фрагментацией. Как именно бороться, мы поговорим чуть позже, когда будем рассматривать, например, GC Go в первых версиях.

[27:45] Александр: Проблема с фрагментацией в том, что ты думаешь, что у тебя, условно, 100 мегабайт свободной памяти, но это 100 мегабайт непоследовательной памяти. Я представляю память как ленту, на которую мы записываем: это не треугольник, не квадрат, не куб, а именно лента. И эти 100 мегабайт — не непрерывная лента, а кусочки, вырезки в ленте. И когда ты захочешь положить объект в 100 мегабайт — или даже в 90, — ты думаешь, что памяти вроде хватает, но по факту объект туда не разместишь, потому что его придётся резать на куски. Так, конечно, можно сделать, и, наверное, некоторые так делают, но базово ты так не сделаешь. И ещё проблема в том, что чем дольше твоя программа живёт, тем больше степень фрагментации, тем больше этих дробей, и в какой-то момент не то что 90 мегабайт ты не положишь — ты и 5 не положишь, потому что все кусочки будут по, не знаю, 26 килобайт, по 32 килобайта. Настолько маленькие дырочки будут, что ты уже работать с памятью не можешь. А по факту у тебя всё ещё есть 100 мегабайт свободной памяти. То есть на долгой перспективе это почти саморазрушающаяся система. И для серверов, которые пишутся на Java и на Go, это неприемлемо.

[29:08] Саша: Да, такое решение было бы неприемлемым в долгосроке. Кстати, мы поговорим потом про один garbage collector в Java, которого больше с нами нет, в котором как раз-таки была такая яма. Ты им пользуешься, запустил, сначала кажется, что всё нормально. Я тоже про это потом немножко расскажу. Короче, ты его настраиваешь, думаешь, что у тебя всё зашибись, а потом спустя пару дней он взрывается, и наступает полный крах. Не такой уж безопасный алгоритм. Но у него на самом деле есть определённые плюсы. С одной стороны, мы поговорили про минусы — фрагментацию. А плюс в том, что он, во-первых, идейно прост, а во-вторых — довольно важное свойство, которое потом сильно пригодится, — указатели, которые у нас есть из одного объекта в другой, вот эти наши ниточки, никак не инвалидируются. Если у нас был указатель из одного объекта в другой и оба объекта живые, то как этот указатель был, так он и останется, пока оба объекта достижимы. То есть его не надо никак обновлять, менять и так далее.

[30:10] Саша: Почему я это сейчас проговорил? Потому что мы начинаем рассматривать следующий алгоритм, а именно Mark-Compact. По сути, достижимые шарики в алгоритме Mark-and-Sweep всегда лежат на одних и тех же местах. Мы их не двигаем. Потому что зачем?

[30:26] Александр: Я думаю, ты именно это и хотел сказать: как мы дальше посмотрим, двигать объекты в рантайме — это гемор. И его надо аккуратно всё время обвязывать машинерией, и это перформанс в том числе. А когда они лежат все на одном месте, все поместились в кэш, — то есть для процессора и эффективнее будет, и меньше ифов, меньше всяких логических ветвлений внутри рантайма происходит.

[30:52] Саша: А вот про это мы чуть попозже поговорим — так ли это на самом деле, что компактизирующие коллекторы лучше для кэша, или всё-таки те, которые делают Sweep. Потому что тоже всё не так однозначно.

[31:00] Александр: Согласен, согласен. Переходим к компактизации.

[31:07] Саша: Значит, мы поняли, что фрагментация — это то, с чем надо бороться, и что бороться с этим можно, просто сказав: давайте наш алгоритм как-то усложним. Мы всё так же маркируем наши объекты. После этого мы сказали, что хотим, чтобы после сборки мусора все объекты лежали как-то рядом, вместе. В этой аналогии мы уже отказываемся от идеи с комнатой, с двухмерным пространством. Теперь у нас просто есть ленточка, на которой находятся свободные ячейки, куда мы кладём объектики. И мы хотим, чтобы все наши живые объекты после сборки мусора оказались в левой части ленты максимально близко друг к другу. И как будто бы это можно сделать. Каким образом? Мы сначала маркируем все объекты, затем должны каким-то образом рассчитать, на каких местах они будут находиться после сборки мусора. Но сначала только рассчитать. После этого мы должны перезаписать существующие указатели в существующих объектах, которые всё ещё находятся на старых местах, и только после этого сделать перенос объектов. То есть в алгоритме получается целых четыре шага, которые выполняются последовательно. То, что я сейчас описал, — это алгоритм, который использовался в каких-то первых версиях LISP лохматое количество лет назад, но идейно он как раз такой: у него есть четыре шага. Маркировка, рассчитывание новых адресов объектов, перезапись указателей и перенос.

[32:46] Саша: Рассчитать новые адреса можно за счёт того, что у нас, например, есть в начале указатель, где лежит последняя занятая ячейка последнего живого объекта на ленте. В начале сборки мусора она, очевидно, находится на нулевой ячейке. И постепенно, когда мы начинаем находить объектики, этот указатель просто перемещаем вперёд. Так что рассчитать новые адреса не так уж сложно. Дальше мы эти новые адреса записываем в указатели существующих объектов, которые указывают на те объекты, которые перенесутся, и затем, собственно, переносим объектики. Идейно алгоритм звучит уже сложнее, чем просто Mark-and-Sweep, потому что тут четыре стадии. У него есть достоинства и недостатки. Очевидно, что поскольку он компактизирующий, то после сборки мусора куча находится в таком состоянии, что в начале ленты лежат живые объекты, у них корректные ссылки друг на друга, всё хорошо работает. И после того, как мы отпустим garbage collector и запустим наше основное приложение, всё будет работать, как и было. Минус в том, что надо провернуть чертову прорву работы: не только маркирование, но ещё и расчёт указателей, перенос. С другой стороны, у нас нет никаких проблем с фрагментацией, потому что после каждой сборки мусора у нас соблюдается инвариант: сначала в куче лежат живые объекты подряд, а потом, после какой-то ячейки, полностью свободное пространство. Значит, это будет улучшать локальность данных.

[34:14] Александр: Ну, потому что ты только что говорил, что в Sweep вроде всё хорошо с кэшем, но как будто можно придумать пример, что у нас был какой-нибудь linked list, у которого так неудачно расположились ноды, что в случае Sweep он будет просто разорван по всему хипу. То есть вроде бы ноды идут одна за другой, но на самом деле нам придётся прыгать туда-сюда. А в случае компактизации мы как будто вызовемся один раз, ссылочки будут, объектики станут рядом друг к другу и будут находиться в одних и тех же кэш-линиях.

[34:45] Саша: Ну смотри, если у нас блочок однопоточный, то, поскольку мы идём по исходящим ссылкам, объекты, у которых есть ссылки друг на друга, очевидно будут находиться ближе, чем те, которые не связаны. Мы пройдёмся по linked list, по его элементам друг за другом подряд и поэтому подряд их сложим. На самом деле мы всё ещё попадём на кэш-миссы в первый раз, когда будем ходить по этому разорванному linked list, маркировать его, но зато потом, когда его соберём, всё будет уже окей, и он как будто улучшит нам локальность данных. Единственное — мы сейчас говорим про однопоточную сборку. В многопоточной тут возникают вопросики, но про это, наверное, чуть попозже подумаем. Пока что мы в простом, ванильном мире, в котором всё легко.

[35:43] Александр: Ты, кстати, упомянул так вскользь: сначала мы там останавливаем, потом отпускаем приложение, потом отпускаем сборку мусора. Мы не сказали, что вся эта работа, которую мы с шариками в комнате производим, делается однопоточно во время паузы. Правильно я понимаю? То есть приложение в этот момент в комнате ничего не делает.

[36:00] Саша: Да, сейчас мы рассматриваем базовый алгоритм в stop-the-world-парадигме. У нас есть какая-нибудь кнопка или ключ. Мы, значит, выгоняем того, кто эти шарики раскладывал, расклеивал ниточки между шариками, говорим: «Пошёл вон», — закрываем комнату на ключ и делаем свою работу. Выходим, отдаём ему ключ, говорим: «Давай, приятель, работай снова». То есть он нам никак не может помешать в процессе нашей работы.

[36:24] Александр: А это кто? Это наше приложение?

[36:26] Саша: Да, это наше приложение. И в этот момент все наши реквесты, вся полезная работа, которую приложение делает, просто ждёт. По факту снаружи это будет выглядеть так, что вы пингуете свой веб-сервер, и если вы делаете это часто и с бенчмарками, то можете увидеть паузу в пингах. То есть у вас пинг, время, не знаю, 10 миллисекунд, 10-10-10, потом бах — 15, и дальше опять 10-10-10. Если у вас довольно большой хип, то пауза в 5 миллисекунд — это вообще база. Поэтому снаружи это будет так наблюдаться. То есть это уже не просто какие-то рассуждения — это то, как ваше приложение начинает себя вести для ваших пользователей. И это может быть проблемой.

[37:04] Александр: Ну да, ну это и бывает проблемой.

[37:06] Саша: Вот, например, в одном проекте есть задачи, где очень большая батч-обработка. Там целенаправленно используется stop-the-world-коллектор, а именно Parallel. Чуть попозже объясню почему. И как раз есть проблема. Ну, сейчас я её уже починил. Там была некоторая перформансная бага, связанная с тем, что иногда приложение попадало в такую штуку, как называется, спираль смерти. Короче, у него GC-паузы всё увеличивались и увеличивались, и там начинали отваливаться таймауты на открытых сокетах, например. То есть у тебя был открыт сетевой сокет, ты хотел что-то по нему отправить, а у тебя приложение попало в GC, не знаю, условно на десятки секунд, и у тебя этот сокет уже закрылся прямо ядром операционной системы. Потому что ядро сказало: «Ну, блин, сокетом никто не пользуется, ничего не посылается, фигня какая-то, закрываем его». И приходится всякие таймауты выкручивать. Поэтому такие большие паузы неприятны тем, что ты думаешь, что к этому готов, а на самом деле вокруг тебя есть огромное количество машинерии, которая может решить, что если кто-то не работал какое-то время, то он, скорее всего, умер. И это может находиться как на твоей же машине, в твоей операционной системе, так и на каких-нибудь балансировщиках. Короче, опасность в том, что ты не знаешь, где это выстрелит, и это может быть очень больно.

[38:18] Александр: Совершенно точно. Я как разработчик распределённой базы данных на Java могу сказать, что самая распространённая проблема распределённой БД на Java — это когда просто у тебя, когда stop-the-world garbage collector начинает работать на какой-то из нод, другие ноды не могут её пингануть, считают её мёртвой и исключают из топологии. А по факту она просто зависла. Дайте ей время. И это такая проблема, которая решается уже совершенно другими способами. Мы поговорим, как можно снизить эффект от этих пауз, далее, но вообще говоря, это почти фундаментальная проблема подобных систем, написанных на Java.

[38:57] Саша: Да, причём смотри, у неё есть механизм обратной связи, потому что получается, что если мы попали в stop-the-world, значит, мы что-то овердофига работы делали, и как будто бы мы ещё и отказали в самый нужный момент. То есть мы пошли пыхтеть делать работу, и нам сказали: «Зря ты пыхтел, приятель, начинай всё заново».

[39:13] Александр: Да, база данных разваливается именно в момент выполнения запроса.

[39:17] Саша: Да. Окей. Мы говорили про достоинства и минусы компактизации. Говорили, что она довольно сложная с вычислительной точки зрения, потому что там надо сделать целую прорву шагов. Поняли, что, с другой стороны, она улучшает локальность данных. Есть ещё один большой плюс. Снова вспоминаем слово «инвариант». У нас есть инвариант, что в нашем простейшем случае на ленточке — то есть в хипе — сначала лежат какие-то живые объекты подряд, а потом всё пространство становится свободным. Это значит, что аллокация очень простая. Когда мы хотим создать новый объект, нам надо только знать точку, где лежит последняя занятая ячейка самого последнего объекта. Мы просто сдвигаем этот указатель на определённое количество позиций — насколько хотим создать новый объект — и говорим, что это место занято. Такая схема называется bumping pointer, она очень простая и на самом деле сейчас в Java тоже активно используется при аллокации.

[40:12] Александр: Не зависит от того, какой ты garbage collector используешь. У вас будут так называемые TLAB’ы, будет вот эта схема с bumping pointer. В общем, аллокация в Java — за счёт этой идеи.

[40:22] Саша: Да, про Java мы пока ничего не говорили, какие сборщики мусора, — это, наверное, спойлер на будущее. Но, короче, именно за счёт идеи bumping pointer аллокация в Java очень дешёвая и простая.

[40:33] Саша: Так, мы поговорили про Sweep и про Compact, но есть ещё один коллектор, который мы называли, — копирующий, в котором нет явного слова Mark, потому что стадия маркировки совмещена ещё кое с чем. В чём идея копирующего коллектора? Давайте вот у нас есть пространство хипа, это наша ленточка. Давайте разрежем её на две половинки — она же какого-то фиксированного размера. Делим на две равные части, называем одну половинку from space, другую to space. И все объекты, которые будем создавать, мы будем создавать в половинке, которая называется from space. Создаём объектики, всё хорошо. В какой-то момент понимаем, что нам нужна сборка мусора. Мы останавливаем приложение, потому что пока находимся в эпохе stop-the-world-коллекторов, вызываем маркирование, но сразу же при маркировке можно делать очень простую вещь. Как только мы дотыкаемся до какого-то объектика, мы красим наш шарик в серый цвет, понимаем, что объект достижим, и сразу же копируем его как есть в нашу вторую половинку хипа, которая называется to space, куда мы записываем живые объектики. То есть мы взяли объектик, рассмотрели, увидели, что он живой, и записали в свежий, созданный to space.

[41:53] Саша: Тут может возникнуть проблема, что вдруг мы наткнёмся на один и тот же объектик два раза подряд. Но это решается за счёт того, что в нашем from space, на том месте, где объектик исходно лежал, мы записываем некую метаинформацию о том, куда этот объектик был перенесён, и вообще о том, что он был перенесён. Таким образом, во from space остаётся информация о том, был ли объектик перемещён и куда именно. И это ещё одно достоинство: когда мы будем перемещать объектики, копировать из from space в to space, мы помимо того, что будем понимать, перемещён ли он сам, будем понимать, перенесены ли объекты, на которые он сам ссылается, и надо ли переписывать нам указатели. Если он уже был перенесён, все его указатели тоже были перенесены. А если у нас в свежеперенесённом объекте есть указатель на объект, который ещё не перенесён, мы просто-напросто посмотрим на месте того старого объекта, перенесён ли он уже; если да — возьмём оттуда эту информацию и запишем, что его надо представлять на том месте, где теперь находится свежая копия.

[43:09] Александр: Слушай, я, возможно, и понял, точнее, вспомнил, потому что такие вещи я так или иначе как Java-разработчик знаю. Но слушателям, которые, например, гуляют с собакой по лесу, или на пробежке сейчас, или на велосипеде крутят, — возможно, это было too much, и можно было бы переговорить более простым языком. Но в целом ментальная модель такая: есть лево и право — ну, я так думаю, у кого-то, может, вверх и вниз. У меня всегда слева был from space, где сердце, оттуда, а справа to space. Право сначала пустое, а from заполняется объектами: я слева эти объектики друг за другом пишу, пишу, пишу, потом в какой-то момент — не обязательно дошёл до конца, но понимаю, что уже пора, — приходит garbage collector, и из этого from space точно так же, как до этого, начинает происходить обход шариков. Просто у него теперь половина хипа для этого, а не весь. Он в этой половине ходит, связи находит, и в момент, когда находит связь, переносит в правую часть эти объекты. И если он опять приходит в тот же самый объект в левой части, то там уже видит некоторую метаинформацию, которая говорит: «Чел, этот объект уже перенесён, так что ты там ссылочками пошуруди, чтобы всё окей было, но больше переносить не надо». Я вот так просто это понимаю.

[44:29] Саша: Да-да-да, ты, наверное, даже объяснил лучше, чем я. Но, в общем, тут два самых важных факта. Первое — то, что нам теперь можно в таком простейшем случае не отделять маркировку от переноса объекта: можем сразу, как только натыкаемся на новый объект, куда-то его копировать. И второй факт — эта идея будет потом довольно часто у нас всплывать — то, что на месте старого объекта можно записать какую-то метаинформацию о том, что с этим объектом произошло, куда мы его перенесли. Эта вещь потом будет очень сильно помогать в понимании уже более преклонных алгоритмов. Резюмируя по копирующему коллектору: у нас есть две области, from space и to space, и мы переносим живые объекты при сборке мусора из from space в to space. После сборки мусора во from space ничего больше нет, это место полностью свободное, а значит, эти два места можно между собой поменять местами логически. То есть у нас есть два указателя, один на from space, другой на to space, и после того, как сделали алгоритм, мы просто засвапили эти указатели, и всё работает точно так же, как раньше.

[45:37] Саша: Здесь есть, опять же, достоинство такое же, как в Mark-Compact: мы будем компактизировать данные в процессе своей работы. Это значит, что будет соблюдаться инвариант, что в начале нашего кусочка to space находятся живые объекты подряд, а потом полностью пространство свободно. Значит, мы всё ещё можем пользоваться bumping pointer. В общем, достоинство очень похоже на то, что было в предыдущем алгоритме Mark-Compact, но идейно чуть-чуть проще, потому что мы можем сразу совместить две операции. Нам не надо делать многопроходный алгоритм, многопроходные расчёты и так далее. Но есть и минус. Минус в том, что нам надо теперь иметь какую-то зарезервированную часть хипа. Ну, короче, мы не можем использовать весь хип целиком: нам нужно какое-то место, to space, в которое мы будем что-то писать. То есть нам надо, чтобы было пространство для манёвра.

[46:28] Саша: И у garbage collector’ов есть такое свойство, что они не очень хорошо себя ведут, когда ваш хип очень сильно заполнен. То есть когда ваш liveset — тот объём, который занимается живыми объектами, — если размер хипа очень близок к этому liveset’у, то ваш garbage collector будет работать не очень хорошо. Есть такое выражение, что garbage collector’ам нужно пространство, чтобы дышать. И по факту это действительно так. Если вы хотите, чтобы ваш garbage collector работал нормально, вам придётся под его хип выделить какое-то пространство, которое больше, чем ваш ожидаемый live-датасет. И из этого следует некоторый уже прикладной вывод, прикладное явление, которое называется спираль смерти. В чём идея? Давайте представим, что у нас в системе есть какие-то два компонента — продюсер и консюмер, — которые обмениваются друг с другом какими-нибудь объектиками, которые обрабатываются. И, например, они обмениваются через очередь, причём очередь неограниченного размера. Если консюмер работает быстрее, чем продюсер, никаких проблем — всё работает хорошо. А если продюсер работает быстрее, чем консюмер, то у нас, очевидно, будут скапливаться объекты в этой очереди, в этом буфере между продюсером и консюмером. И если всё пойдёт хорошо — я говорю именно «хорошо», — то у вас просто случится out of memory: очень быстро забьётся эта очередь объектиками, garbage collector потужится, поднатужится, поймёт, что он практически ничего не может освободить, выпадет с out of memory, и вы увидите эту ошибку.

[48:04] Саша: Но бывает иногда, к сожалению, не такой явный эффект. Он базируется на следующей петле обратной связи. У нас есть некоторая фундаментальная разница между двумя компонентами в системе: есть продюсер, который быстрее консюмера. То есть мы не успеваем обрабатывать какую-то работу. Это значит, что мы создаём всё больше и больше живых объектов памяти, копим объекты в памяти. А как мы сказали, алгоритмы, конечно, все очень разные, но основной принцип маркировки — что надо пройтись по всем живым объектикам — это факт, от которого нельзя отказаться. Это значит, что нагрузка на наш garbage collector будет всё сильнее и сильнее увеличиваться, и мы будем отнимать всё больше ресурсов у нашего приложения. У нас, конечно же, garbage collector может быть stop-the-world, который будет всё останавливать, — мы будем останавливаться чаще. Или, если у нас будет конкурентный garbage collector, о котором мы поговорим чуть попозже, у нас будет просто тратиться больше CPU, но тем не менее ресурсы у системы будут отниматься. И это значит, что вся система в целом станет работать медленнее. Но беда в том, что вот эта фундаментальная разница между продюсером и консюмером никак не поменяется. Это соотношение никак не улучшится. И это приведёт к замкнутому кругу: мы начинаем работать медленнее, но всё ещё накапливаем объекты в памяти, у нас всё ещё возрастает датасет живых объектов, всё ещё возрастает нагрузка на GC, мы работаем всё медленнее, медленнее, медленнее. И когда такая ситуация возникает, ты начинаешь жалеть о том, что твоё приложение не упало с out of memory. Потому что оно просто настолько чертовски замедляется, что ничего не делает и помирает. Оно может даже в метриках светиться, что оно живое, но по факту ничего не делает. В системах со сборкой мусора — и в Java, и в Go — такая ситуация вполне возможна. Я на такую ситуацию натыкался один разок. И по опыту скажу, что она настолько неприятная и вязкая, потому что ты, как человек, который такую систему эксплуатирует, поддерживает, можешь и не понять, что происходит. Вот что-то тупит. И пока ты реально не начнёшь профилировать… А ты попробуй некоторые контейнеры в продакшене попрофилировать в Kubernetes — удачи тебе. Ты столько времени потратишь на то, чтобы понять, что не так, и то, поймёшь ли, — большой вопрос.

[50:28] Александр: Так ты ещё, как бизнес, если на это смотришь, просто тратишь деньги разработчику, который стоит как конь, на то, чтобы он там палочками водил и пытался понять, что происходит, волшебными методами. Ну это, по сути, в современном мире такая ситуация почти неприемлема, потому что я не хочу тратить на это деньги. Почему я должен тратить деньги своих разработчиков на то, что они пытались что-то понять? Ну, прям возмущаюсь.

[50:54] Саша: Ну да, ещё обидно, что в этой ситуации тебе никакой статанализ не поможет, не поможет, если ты весь свой код в нейронку забьёшь. У тебя просто прямо в рантайме есть какая-то петля обратной связи. И ты можешь её увидеть действительно через профилирование. А профилированием, скажем честно, мало кто из разработчиков занимается, и когда до этого доходят руки, обычно это значит, что всё плохо. Короче, это стрёмная ситуация, на неё попадать не стоит. Но, с другой стороны, может быть, если у вас будет ситуация, что всё хреново работает, а вроде нормально должно, — возьмите профайлер, посмотрите на самом деле.

[51:27] Александр: Я когда-то в своей карьере встречал людей, которые перформансные проблемы пытались решить путём вдумчивого взгляда в код и попытки найти то место, которое тормозит, но это никогда не заканчивалось успехом. Короче, для таких ситуаций надо использовать правильный инструмент. Правильный инструмент — профилировщики.

[51:45] Саша: Да, знаешь, у меня такая аналогия: это как позвать экстрасенса, когда у тебя что-то болит, живот, — чтобы он тебе погадал, а что у тебя там болит, вместо того чтобы пойти сдать анализы, пройти исследование и понять, что реально не так. Ты занимаешься вот этим гаданием.

[52:02] Александр: Сливанием ресурсов, это же ещё и не бесплатно бывает зачастую. Вообще обидно будет потом. Будешь искать этих людей, которые будут тебе помогать. Вот типа всё. И все пожимают друг другу руки и идут. И на этом всё закончилось.

[52:42] Саша: Давайте подумаем немножко об этом с другой стороны. Действительно, есть гипотеза о поколениях, и даже не одна. Есть две гипотезы о поколениях — сильная и слабая. Слабая гипотеза о поколениях утверждает, что большая часть объектов, которая была создана, будет быстро потеряна, то есть быстро превратится в мусор, быстро умрёт. Есть строгая, сильная гипотеза о поколениях, она говорит, что если у нас есть какой-то старый объект-долгожитель, то, скорее всего, он будет долгожителем всё время, будет жить столько же, сколько живёт наша программа. Можно очень легко придумать контрпример к сильной гипотезе о поколениях. Как ты думаешь, какой? Сильная гипотеза говорит, что чем старше объект, тем меньше вероятность его смерти. То есть если мой объект дожил, скажем, до 20 сборок мусора…

[53:36] Александр: Двадцати сборок мусора, да, итераций.

[53:39] Саша: …то, скорее всего, на 150-ю итерацию он тоже там будет присутствовать. Такая гипотеза. Контрпример?

[53:45] Александр: Ну, какие-то долгоживущие, возможно, сессии пользователей? Может быть, ты запрос, сокет открыл, поработал с ним, закрыл. Для garbage collector это уже много: то есть для нас прошло, может быть, несколько секунд, а там уже много итераций могло пройти.

[54:01] Саша: Ну да, как гипотеза нормальная, неплохая. А вот эти сессии, они, скорее всего, будут лежать в каком-нибудь кэше, да?

[54:08] Александр: В кэше, ну, в хешмапе там где-нибудь, если это Java.

[54:13] Саша: Да-да-да. И вот, кстати, с LinkedHashMap можно очень легко сделать LRU-кэш. И LRU-кэш является, наверное, хорошим примером нарушения строгой гипотезы о поколениях.

[54:24] Александр: То есть это полное противоречие.

[54:27] Саша: Да-да-да. Мы запихнули какой-то объектик в кэш, он там проторчал чёрт знает какое количество времени. Потом в наш кэш приходят свежие, молодые объекты, и у них будет всё хорошо, они попали в этот кэш. Но тут сразу проблема: во-первых, они вполне могут быстро и умереть, а мы их засунули в кэш. Во-вторых, нам ещё придётся выпнуть старый объект, который долго не использовался. То есть он долгое время был старым, а потом это всё сломалось. Поэтому есть сценарии, на которых эти гипотезы работают не очень хорошо, и всякие люди, которые занимаются разработкой сборщиков мусора, разумеется, имеют разные бенчмарки. И один из бенчмарков — как раз такой патологический случай а-ля LRU-кэша, сильно нагруженного, в котором постоянно есть объекты, которые долго живут, вылетают в какой-то момент, и это влияет на наш сборщик.

[55:14] Саша: Но даже этого недостаточно для того, чтобы пользоваться гипотезой о поколениях. Почему? Давай подумаем об этом с такой точки зрения. Мы сейчас сказали, что у нас есть какое-то множество объектов, которые, условно говоря, молодые, — они быстро умрут. А есть множество объектов, которые, условно говоря, старички, и они, скорее всего, никогда не умрут. Давайте пока предполагаем, что гипотеза о поколениях у нас всё-таки выполняется. Мы не пишем LRU-кэш, у нас обычное Java-приложение без патологий в перформансе. И вот с точки зрения инвестиций или чего-то финансового: сборка мусора, которая происходит по молодым объектам, — это хорошая инвестиция. Почему? Потому что молодых живых объектов очень мало. Это значит, что мы потратим не очень много CPU, а с другой стороны, получим очень много свободной памяти. Хорошая инвестиция: потратили немного CPU, получили много свободной памяти. С другой стороны, сборка мусора по старым объектам — как будто сомнительная инвестиция, потому что мы не знаем, сколько там объектов, много или мало живых. Это зависит от приложения. Но если гипотеза о поколениях строго выполняется, то, скорее всего, эти объекты как были живыми, так и останутся. То есть мы просто потратили CPU, а места никакого не освободили. Как будто это не очень хорошая инвестиция.

[56:34] Саша: И когда наш сборщик этой гипотезой не пользуется, можно на первый взгляд подумать, что мы смешиваем хорошую инвестицию — сборку по молодым объектам — и бесполезную инвестицию — сборку по старым. А хочется как будто бы делать только хорошие инвестиции. Но, к сожалению, сами по себе эти мысли довольно бесполезны. Ну потому что, окей, мы сказали, что у нас есть какая-то часть хипа, которая содержит молодые объекты. А как ты поймёшь, живы они или нет? Ведь тебе надо пройтись по всем ссылкам, в том числе по ссылкам из старого поколения. Это значит, что тебе надо будет пройтись и по всему хипу, и по молодым, и по старым объектам. То есть тут какое-то противоречие, что как будто сами по себе эти гипотезы бесполезны. Здесь возникает ещё одно условие, которое должно выполняться, которое почему-то особо явно не декларируют, когда рассуждают про сборку по поколениям. Но на самом деле оно очень важно и лежит прямо в основе почти всех существующих сборщиков, которые пользуются гипотезой о поколениях. Утверждение состоит в следующем: на самом деле у нас ещё очень маленькое количество указателей из старого поколения в молодое. У нас есть множество старых объектов и множество молодых, и мы утверждаем, что эти ниточки между ними не чёрт знает как пронизаны, а что у нас очень маленькое количество ниточек, которые соединяют объекты старого поколения с молодым. Это, как ты понимаешь, тоже некоторая эвристика. Мы считаем, что оно выполняется. Когда оно не выполняется, у нас всё тоже будет работать не очень хорошо.

[58:03] Саша: Но вот если эта эвристика у нас выполняется, тогда мы можем сделать некоторую специальную вещь. Она заключается в следующем. У нас проблема в том, что нам надо понять, какие объекты из молодого поколения живы, а какие уже недостижимы. И для этого нам надо обработать все ссылки, которые идут в наши молодые объекты. Но мы не хотим обрабатывать старое поколение, потому что мы поняли, что это довольно бессмысленно. А давайте заведём некоторую вспомогательную структуру данных, которая будет хранить информацию в некотором упрощённом, грубом виде. Что я имею в виду? Давайте предположим, что наш хип представляет из себя ленточку, и он разбит на две половинки — старшее поколение и младшее поколение. Половинки условно, просто на две какие-то части. Давайте заведём вспомогательную структуру данных и теперь будем хранить информацию не об отдельной ссылке из старого поколения в молодое, а, например, о каком-то куске хипа: и если у нас есть хотя бы одна ссылочка из этого большого куска хипа из старого поколения в молодое, мы будем писать единичку. Условно говоря, мы возьмём 512 байт, и для каждых 512 байт будем смотреть, есть ли у нас ссылочка из этих 512 байт хипа из старого поколения в молодое. Если есть — записываем в определённой структуре данных единичку, если нет — нолик. И, как ты понимаешь, это очень хорошо сжимается в структуру данных.

[59:28] Александр: Такая структура данных, например, в Java, если поискать документацию, называется…

[59:33] Саша: Card table, она очень простая. Мы берём последовательно куски хипа по 512 байт, и если у нас есть хотя бы одна ссылочка из этих 512 байт хипа из старого поколения в молодое, мы пишем единичку. Если нет — нолик. И таким образом мы можем информацию даже о большом хипе сжать в довольно компактный card table, в котором совсем небольшое количество единичек, если у нас выполняется assumption о том, что у нас мало указателей из старого поколения в молодое. Таким образом, когда мы будем делать сборку по младшему поколению, мы можем не проходиться по всему хипу целиком, а проходиться только по нашим корневым достижимым объектам и по card table: посмотреть, где стоит единичка, и рассмотреть уже конкретно эту часть хипа. То есть мы смотрим в card table, видим, что там, например, в четвёртую ячейку записана единичка. Это значит, что мы должны взять кусочек хипа — получается, 2048 плюс какие-то байтики, — посмотреть все объекты, которые находятся в этой части хипа в старом поколении, посмотреть, есть ли у них ссылки на молодое поколение. Таким образом, за счёт использования этой грубой структуры данных мы можем рассматривать не весь хип в старом поколении, а только его кусочек. И таким образом мы снимаем с себя эту проблему.

[1:00:56] Саша: Но, как вы понимаете, для того чтобы всё это работало, нам нужна вспомогательная структура данных. Введение поколений используется для того, чтобы делать сборку по младшему поколению, чтобы у нас всё это было более эффективным. Потому что сборка по младшему поколению приносит нам больше памяти при обычно меньших тратах CPU. То есть сборка по младшему поколению используется для эффективности. Но, как вы понимаете, это не бесплатно. Нам, во-первых, нужна какая-то структура данных для хранения ссылок из старого поколения в молодое — это раз, то есть мы на это будем тратить какое-то пространство. Во-вторых, нам нужно что-то, что будет поддерживать эту структуру данных в актуальном состоянии. Нам нужна какая-то промежуточная операция, которая будет всегда срабатывать, когда у нас будет появляться очередная ссылочка из старого поколения в молодое. Вот эти операции, которые срабатывают при появлении таких ссылок, называются барьером. Это не тот же самый барьер, который используется в многопоточности, несмотря на то что слово то же самое. Это именно GC-барьер. GC-барьеры бывают разные: бывают барьеры на запись ссылки, бывают барьеры на чтение ссылки. В нашем конкретном случае нам нужен барьер на запись ссылочки. Когда мы записываем ссылочку в какой-то объект к себе в поле, мы должны проверить, находимся ли мы в старом поколении и является ли тот объект, на который мы записываем ссылку, объектом в молодом поколении. И если да, нам надо записать информацию в card table.

[1:01:48] Саша: И причём заметь: важно, что эту информацию надо записывать не только тогда, когда работает наш garbage collector, а значит, основное приложение остановлено. Теперь у нас появляется необходимость обновлять эту информацию, даже когда garbage collector не работает. Наше основное приложение работает, а garbage collector уже не работает. Таким образом, введение сборки мусора по поколениям уже вносит некоторый небольшой runtime cost в работу нашего основного приложения.

[1:02:46] Александр: Да, это важное уточнение. И надо понимать, что не только сам garbage collector своим алгоритмом и своим исполняемым кодом может нам внести трудности помимо GC-паузы. Но и теперь наш код, который, казалось бы, ничего не знает о garbage collector, на самом деле в рантайме исполняется так, что при создании нового объекта — если этот объект мы ссылаем из старого объекта, который уже в области памяти, где старые объекты живут, — то теперь при создании мы не просто записываем ссылку, а делаем так называемый барьер, то есть, по сути, комплексный if. И если этот if подходит, то мы делаем дополнительные действия. Уже, извините, это не бесплатно. Так что это важно понимать.

[1:03:35] Саша: Да, и эти действия могут выглядеть простыми, как, например, в нашем случае: нам надо просто какие-то байтики срезать, округлить, потому что card table сейчас пока что очень простой. Но если у нас будут какие-то сложные garbage collector’ы, там на это может уйти довольно большое количество инструкций. Мы про это чуть попозже тоже поговорим, когда будем говорить уже про конкретные garbage collector’ы. Получается, что гипотеза о поколениях вносит нам какой-то runtime cost на наше основное приложение, даже если у нас stop-the-world-коллектор. И, как мы говорили, гипотеза о поколениях может нарушаться. Самый яркий пример — это LRU-кэш. Но тем не менее в Java сейчас во всех реализациях garbage collector’ов так или иначе есть поддержка сборки по поколениям. Опять же, это связано с некоторыми причинами, про которые мы поговорим чуть попозже. Но тут, наверное, есть уже первая большая разница между Java и Go, в том, что в Go сборки по поколениям нет, и она не планируется. Когда-то были времена, когда garbage collector в Go планировали переделать так, чтобы он поддерживал сборку по поколениям, но в какой-то момент они поняли, что это им не нужно. Почему это им не нужно? Давайте будем говорить уже в следующих частях.

[1:04:45] Саша: Первую часть мы вроде закончили, поговорили про фундаментальные алгоритмы, посмотрели маркирование, поняли, что на маркирование тратится количество ресурсов пропорционально количеству живых объектов, что garbage collector’ам нужно какое-то пространство, чтобы дышать, — не надо делать хип очень маленьким, если у нас большой датасет объектов. Garbage collector может привносить latency, это тоже надо иметь в виду. Из таких высокоуровневых выводов всё. Поэтому давай поговорим уже про конкретные сборщики мусора в Java и в Go. Предлагаю начать с stop-the-world-коллекторов.

[1:05:20] Саша: Вторая часть — говорим уже про какие-то конкретные коллекторы, а именно пока что про stop-the-world-коллекторы, то есть те, которые останавливают наше приложение. В терминологии сборщиков мусора наше основное приложение, которое делает ту полезную работу, ради которой мы это приложение написали, тем не менее почему-то называется мутатором. Ну, не почему-то, но как-то грубо, как будто это не самая важная часть приложения, хотя на самом деле она для нас и важна. По сути, это и есть приложение.

[1:05:49] Александр: Да, это и есть приложение, его так назвали.

[1:05:51] Саша: Ну, мы будем пользоваться этой терминологией, тем не менее. Не обессудьте, простите, что мы весь ваш труд назвали мутатором и изобидели его. Stop-the-world-коллектор: останавливаем все наши мутаторы, выполняем наш алгоритм сборки мусора и возобновляем работу всех мутаторов. Собственно, в Java до сих пор есть и будут алгоритмы, связанные с остановкой, а именно Serial и Parallel. В Go когда-то stop-the-world-коллектор тоже использовался, это было в версии 1.4 что ли, что-то такое. В общем, он был очень давно. Давайте начнём с Java. Serial и Parallel идейно не очень различаются, только Parallel те стадии, которые он может выполнять внутри сборки мусора параллельно, в несколько потоков, пытается выполнить параллельно. Поэтому для простоты пока что поговорим про Serial. Serial устроен очень просто. Во-первых, это сборщик по поколениям, то есть весь heap разбивается на две части: та, которая используется для хранения младшего поколения, и та, которая используется для хранения старшего. В младшем поколении у нас есть три области. Первая называется Eden — область, в которой аллоцируются свежесозданные объекты. И у нас есть два пространства, которые называются Survivor Space 0 и 1. И сборщик мусора по младшему поколению как раз-таки является копирующим коллектором — тем самым, который мы рассмотрели чуть раньше. То есть мы устанавливаем наше приложение, и у нас все живые объекты есть, например, в Eden и в Survivor 0. А в Survivor 1 ни одного живого объекта нет. Это значит, что мы можем переносить все живые объекты туда. Классический копирующий алгоритм. У нас есть сборка по старшему поколению, и там уже такой роскоши нет. Там нет никаких дополнительных Survivor Space, там просто одно пространство, в котором хранятся объекты. И когда у нас срабатывает Full GC — то есть та ситуация, когда надо сделать сборку и по старшему, и по младшему поколению, — у нас используется традиционный Mark-Compact-алгоритм. Всё как будто бы идейно просто. Есть Parallel, в котором те стадии, которые можно делать параллельно, выполняются в несколько потоков. И там тоже можно себе представить ситуацию, когда это не очень хорошо работает: если, например, у нас есть какой-то гигантский связанный список, то как ты его обойдёшь параллельно при маркировании? Какому-то одному потоку придётся идти последовательно.

[1:08:25] Александр: Получается, если подвязать ту нашу базу, которую мы предыдущий час обсуждали, к уже конкретному Serial и Parallel GC в Java, то по сути там выполняется… ну, если мы сейчас берём, например, Serial, то он делает и то, и другое. То есть по сути он работает в двух режимах. В первом режиме он делает младшую сборку — я думаю, что она просто более частая, такая, что меньшая GC-пауза возникает. Он делает копинг, то есть то, что мы копирующим до этого называли. У него в Eden’е несколько областей памяти: Survivor 0 и Survivor 1, как ты сказал. И вот он между этими двумя областями копирует из Eden’а.

[1:09:00] Саша: Ну, у него три области, то есть Eden, S0 и S1. Это три разные области. В Eden’е мы создаём объекты, то есть наш bumping-аллокатор как раз-таки находится там: когда мы создаём новый объектик, мы его туда бахаем, и, соответственно, когда делаем сборку, переносим объектики, которые остались выжившими из прошлых итераций, в S0 или S1, и из этого нашего Eden’а. Всё так и есть.

[1:09:35] Александр: Да. И это как бы первый режим работы этого GC. Но этого недостаточно, потому что у нас есть ещё старшее поколение, про которое мы ничего не сказали. А как туда попадают объекты? Насколько я понимаю, как раз во время Mark-Compact.

[1:09:50] Саша: Объекты попадают как раз-таки во время младших сборок.

[1:09:53] Александр: Не, подожди. Во время младшей сборки или во время полной сборки?

[1:10:01] Саша: Во время младшей сборки он тоже может переноситься. Ну, потому что смотри, во-первых, важный сразу момент. У нас, как вы знаете, в объектах есть всякие служебные хидеры. Например, в mark word есть служебная информация о том, сколько сборок пережил наш текущий объектик. Туда может храниться, по-моему, информация о том, что объект пережил до 16 сборок. И когда наш сборщик по младшему поколению срабатывает, он может посмотреть в эту служебную информацию каждого отдельного объекта и сказать, достаточно ли он старый уже или нет. Если он достаточно старый, то — если мы считаем, что наша гипотеза о поколениях сильно выполняется, — этот объект проживёт чертовски долгое время. Значит, имеет смысл перенести его в старшее поколение. И как раз тогда он переносится в старшее поколение и остаётся уже там. И пока у нас будут младшие сборки, с ним больше пока ничего не будет происходить. Он будет находиться в старшем поколении, и всё с ним будет хорошо. Поэтому, отвечая на твой вопрос: да, младшая сборка, во-первых, делает всю полезную работу в этом Eden’е, Survivor 0 и Survivor 1, и, во-вторых, отвечает за то, чтобы переносить достаточно старые объекты в старшее поколение, чтобы они жили там хорошо и счастливо.

[1:11:11] Александр: Ну, а вот эта ситуация, когда у нас срабатывает Full GC, на самом деле значит, что что-то пошло не так, да? Потому что по-хорошему Full GC редко когда должен вызываться. У нас постоянно создаются какие-то свежие объекты, они быстро умирают, и мы должны делать только младшие сборки. Они очень быстро отрабатывают, потому что там почти всё мёртвое. Всё работает хорошо. А Full GC, грубо говоря, забивает на это деление на Survivor и на старое, а просто делает тот самый Mark-Compact, который мы в самом начале обсуждали.

[1:11:38] Саша: Да-да-да, так и есть. Ну, потому что у него уже нет пространства для манёвров, у него нет никакого дополнительного Survivor для старого поколения и так далее. Ему придётся всю эту машинерию вручную заново делать. Ну, он может почистить, например, старшее поколение, если там объекты уже настолько устарели, что их надо чистить. Вот как раз во время Full GC оно там и почистится. А во время младшей сборки старое поколение не трогается, и там могут лежать просто уже устаревшие объекты, на которые надо бы забить, а мы их всё ещё храним.

[1:12:11] Александр: Да, эта ситуация называется, наверное, floating garbage, когда у нас есть какие-то объекты, которые мы всё ещё не собрали, и будем их какое-то время не собирать, но в теории можно было бы догадаться, что это уже мусор.

[1:12:22] Саша: Да. И в принципе у garbage collector’ов, если их рассматривать с академической точки зрения, есть ещё одна метрика — как раз floating garbage, и тут он у нас тоже присутствует, когда объект в старом поколении остаётся. Ну и ещё напомню: у нас должна храниться информация о том, какие есть ссылочки из старшего поколения в младшее. Это значит, что у нас есть так называемый card table, который активно обновляется в процессе работы нашего приложения. Про это тоже не стоит забывать. Почему-то многие люди думают, что Serial/Parallel — это что-то старое и отстойное в Java, какое-то legacy, которое никому не нужно и не обновляется. На самом деле, если посмотреть актуальные изменения в JDK, какие-то улучшения происходят даже сейчас. Там постоянно чинят какие-то перформансные баги. И вот из забавного, что я недавно обнаружил: оказывается, в Parallel GC долгое время был баг в случае Full GC, полной сборки мусора. Если очень высокоуровнево это описать, там используется специальная структура данных для того, чтобы размечать, где у объекта начало и где конец. Если у нас была сборка мусора Full GC по очень-очень большим объектам, там был не очень удачный алгоритм, которому нужна была линейная сложность для каждого объекта в отдельности, чтобы понять его длину. И когда у нас таких объектов было очень много и они все очень длинные, сложность становилась квадратичной, и Full GC начинал работать на Parallel GC хуже, чем в Serial даже, если у нас один поток. Такой вот забавный баг. Но это, кстати, починили тем, что механизм Full GC просто взяли из G1. Таким образом получается, что на самом деле многие garbage collector’ы, которые у нас имплементированы, переиспользуют те или иные части друг друга. Забавная вещь, что её никто не замечал долгое время.

[1:14:09] Александр: Да, это интересно.

[1:14:11] Саша: Теперь немножко поговорим про древнючие времена Go. Это было, я уже не знаю, лет 10 назад, когда выходили только-только первые версии. Им надо было очень быстро спроектировать язык, написать его runtime, потом переписать его с сишки на Go. В общем, времени у них было мало, поэтому вначале не спроектировали ничего изощрённого, а использовали классический алгоритм — Mark-and-Sweep. Приложение всё останавливается, мы делаем маркировку, обозначаем те места, в которых больше не находятся живые объекты, как свободные. Но нам надо как-то победить фрагментацию, потому что, как мы говорили, в долгосроке это как будто нежизнеспособное решение, если мы просто ничего с этим не делаем. Поэтому в Go пошли на определённый манёвр. Давай вернёмся к нашей аналогии с шариками — когда всё было спокойно и хорошо в нашем мире, когда все шарики были одинакового размера, проблем фрагментации у нас не было. Потому что если место свободное, то оно всегда вместит шарик. Проблема возникала из-за того, что мы в одно и то же место пытались вместить самые разные шарики. Большие, маленькие, какие-то шарики исчезали, появлялись дырки разных размеров. И получалось, что какой-то конкретный шарик нельзя записать в какое-то конкретное место, потому что там просто нет пространства.

[1:15:26] Саша: И как раз разработчики Go пошли на очень хитрый приём. Точнее, они его взяли из аллокатора, который называется TCMalloc и который уже использовал эту идею достаточно давно. А давайте вместо того, чтобы делать один общий хип для всех объектов самого разного размера, сделаем некоторую классификацию. То есть у каждого нашего объектика, который мы храним в хипе, есть некоторый условный класс, который зависит от размера этого объекта. Например, если объект занимает от 16 до 24 байт, он имеет класс номер 3. Если объект занимает от 48 до 64 байт, он имеет класс номер 6. Короче, по размеру объекта можно сопоставить ему какое-то конкретное число, класс. Получается, что тогда каждый объектик имеет какой-то характерный размер, в который ему точно хватит пространства. Ну, потому что если мы сказали, что все объекты от 48 до 64 байт имеют класс номер 6, то, собственно, если мы хотим создать объект размером 47 байт, нам 64 байт, а точнее эти четыре свободных байта, всегда заведомо хватит. Мы поняли, что каждому объекту по его размеру можно сопоставить какой-то конкретный характерный класс, в котором мы говорим, что вот такого-то фиксированного размера всегда хватит для всех объектов этого типа. Давайте тогда вместо того, чтобы использовать один единый хип для всего подряд, будем использовать отдельные структуры данных для отдельных классов. То есть у нас теперь будет использоваться, условно говоря, какой-то мини-хип для объектов одного класса, мини-хип для объектов другого класса, третьего и так далее. И тут как будто с одной стороны проблема фрагментации побеждена, потому что мы, например, не можем положить в один и тот же мини-хип объект здоровущего размера и очень маленького размера, — они попадут в разные мини-хипы. Таким образом, у нас не будет проблемы с тем, что у нас дырки очень разных размеров, а везде всё одинаковое, и всегда будет находиться место.

[1:17:26] Саша: Но у этого есть и определённая цена. Именно то, что от этой фрагментации мы избавились, но появилась другая фрагментация — внутренняя. Теперь, когда мы создаём какой-то объектик, если он чуть меньше, чем размер характерного класса, — например, мы создали объект размером 47 байт, а каждый слот в этом мини-хипе занимает 64 байта, — это значит, что какое-то количество байт мы просто бесцельно потеряли.

[1:17:52] Александр: Да, что мы сделали? Мы легализовали фрагментацию, на самом деле. Или сказали, что её нет, путём её принятия.

[1:17:59] Саша: Путём её ограничения.

[1:18:00] Александр: Сказали: она, конечно, есть, но за счёт построения этой таблички классов определённым образом можно её каким-то образом лимитировать. То есть сказать, что, например, в теории для класса номер 6 мы можем потерять 20% хипа на эту внутреннюю фрагментацию. И нам типа это окей.

[1:18:16] Саша: В Go, конечно же, есть определённая оптимизация для этого. Например, для очень маленьких объектов используется немножко другая схема, где он всё-таки пытается несколько объектов вместе держать. Для, наоборот, очень больших объектов, которые вылезают за все размеры наших классов, создаётся специальная область памяти, которая хранит именно конкретно этот объектик. Причём эта область памяти тоже может иметь внутреннюю фрагментацию. Но тем не менее для большинства объектов используется как раз этот подход. И что прикольно, это можно прямо посмотреть в исходниках Go. Вот это разбиение на классы находится в файле, который называется sizeclasses.go. И забавно то, что он не менялся уже большое количество времени. Там последний коммит, я не помню, лет 10 назад. В общем, одна из тех частей Go, которая до сих пор осталась актуальной.

[1:18:53] Александр: Слушай, ну мне кажется, ты просто уже это не можешь сделать путём просто изменения этого класса, потому что ты поломаешь обратную совместимость.

[1:19:12] Саша: Ну да-да. Но это и design decision, который уже зафиксирован раз и навсегда: борьба с фрагментацией будет устроена именно таким образом, не каким-либо иначе. То есть, резюмируя: чтобы бороться с фрагментацией, мы все наши объектики распределили по определённым классам, которые зависят от размера объекта. И мы будем хранить объектики разных классов в разных мини-хипах. Я это сейчас называл очень грубо мини-хипами. На самом деле у этого есть конкретное имя. Вот эти непрерывные кусочки памяти по определённому количеству килобайт, которые хранят отдельные объекты нашего размерного класса, называются спанами. То есть когда мы храним объекты класса, например, 2, у нас есть спаны — непрерывные кусочки памяти размером 8 килобайт, — и там последовательно лежат все объектики размером до 16 байт. То есть у нас идут последовательно 16 байт, 16 байт, ещё 16 байт и так далее. И когда мы хотим аллоцировать какой-нибудь объект, мы просто должны найти свободное место в этом спане. Как ты понимаешь, это должно делаться довольно легко: нам теперь не надо мучительно идти по всему хипу, искать место, потому что надо просто знать, где сейчас свободный слот. И всё будет работать хорошо. Но, как я говорил, цена у этого — некоторая внутренняя фрагментация. Здесь возникает ещё одна проблемка. Как мы говорили, Sweep не перемещает объект. То есть как мы создали объектик, так эти указатели остаются, пока оба объекта живы. Получается, если у нас был объектик, который указывает на объектик в другом спане, то в случае маркировки нам придётся прыгать по памяти из одного спана в другой. И если у нас так получилось из-за аллокации, что у нас плохая кэш-локальность данных, то это и будет оставаться дальше так. То есть наш алгоритм маркировки будет постоянно прыгать по объектикам туда-сюда, маркируя их, и таким образом делать большое количество кэш-миссов.

[1:21:22] Александр: Давай для тех слушателей, которые немного поплыли, возможно, уже на втором часу, в середине второго часа прослушивания подкаста про garbage collector’ы, и введения новых терминов типа «спан» и «классы объектов», — я чуть-чуть упрощу вот эту аналогию, то, как я себе это представляю от самого начала. Короче, у меня это просто пункт приёма товаров — Озон, Яндекс.Маркет и так далее. Вот это, знаете, в пятёрочках стоит вот этот шкаф с ячейками. И представьте, что ячейки в этом шкафу могут быть разных размеров. Допустим, от S до XL. И это и есть как раз те самые классы: маленькие, супермаленькие, чуть побольше, средние, большие, супербольшие. И у нас есть коробки соответствующие, прям тютелька в тютельку, миллиметр к миллиметру к этим ячейкам. Мы можем коробку прям вот засунуть, аж воздух оттуда весь выйдет — настолько идеально она туда заходит. Соответственно, мы берём эти коробки и раскладываем те, которые в соответствующие ячейки идеально входят. Таким образом мы максимально эффективно этот шкаф, с нашей точки зрения, в текущий момент используем. Прям идеально коробочки положили. Но прикол в том, что внутри коробок у нас могут лежать разные товары. Это наши как раз объекты. И некоторые объекты могут быть меньше, чем сама коробка. И поэтому мы вот эту фрагментацию, про которую говорили ранее, размыли так, что она теперь внутри коробочек, но она немного более контролируемая. Мы можем спрогнозировать, что в среднем там от 30 до 20% свободного места в итоге будет присутствовать, но не больше. Это прям можно доказать математически. Спаны, которые ты говоришь, — это по сути вот ячейки этих разных размеров. Один спан хранит только коробки размера S, другой спан хранит только размеры коробки XL.

[1:22:53] Саша: Да, слушай, отличная аналогия. Вообще прям мне даже в голову не пришло. А на самом деле она подходит отлично. Причём и на жизнь тоже хорошо подходит, потому что у меня бывали ситуации, когда приходит какая-нибудь коробка размером, не знаю, метр на метр, а внутри лежит, условно говоря, кружка и всё. И всё вокруг в упаковочной бумаге. Поэтому в принципе аналогия действительно довольно неплохая.

[1:23:23] Саша: Мы поговорили немножко про обычные stop-the-world-коллекторы. Наверное, я ещё что хочу упомянуть сразу же — почему они не так бесполезны, как может показаться на первый взгляд? Потому что некоторые люди говорят, что раз они такие старые… Ну, как я уже сказал, они, конечно, активно обновляются, но кто думает, что нет? На самом деле они даже сейчас активно применяются. Почему? Потому что, как мы увидим позже, здесь стадии работы приложения максимально друг от друга разделены. Наше основное приложение работает, делает какую-то полезную работу. В какой-то момент мы понимаем, что надо вызвать сборку мусора. Мы полностью всё останавливаем и сосредотачиваем все свои ресурсы только на том, чтобы у нас работал garbage collector. Это значит, что garbage collector’у не надо будет сильно заботиться о том, чтобы наше основное приложение, которое сейчас остановлено, не пострадало, — он может сосредоточиться максимально именно на том, чтобы убирать мусор. Существование stop-the-world-коллектора даже до сих пор предназначено для этой ситуации, когда вам нужна максимальная пропускная способность, когда вам надо, чтобы ваше приложение — пофиг, что у вас будет большая пауза, — сделало какой-то объём работы за какое-то фиксированное время. Если вам нужна именно такая метрика, то как раз пропускную способность вам и надо оптимизировать, и для этого подходят такие коллекторы. Поэтому про них всё ещё не стоит забывать. Но, с другой стороны, не стоит забывать и про то, что могут быть большие таймауты, что они где-нибудь развалятся, и будет очень больно. В общем, тут такое обоюдоострое орудие.

[1:24:44] Александр: Да, мне, знаешь, ещё вспомнилось, что в целом такие garbage collector’ы можно попробовать использовать для приложений типа command-line tool, который просто один раз исполнился, и всё. То есть у него паттерн использования не как у сервера, который бежит и работает сутками и годами, а это просто 10 миллисекунд, ну ладно, 10 для Java нет, но там 3 секунды — ладно, силы на Java, всё-таки class loading и всё такое. Но, короче, типа 3 секунды отработало, и, может быть, ни разу garbage collector не вызовется. Поэтому некоторые garbage collector’ы, как мы посмотрим дальше, могут накладывать в рантайме ещё больше ограничений, которые нам в таком рантайме просто нафиг не нужны. У нас можно в экстремальной ситуации вообще zero GC использовать, просто отключить его. Но тоже вот такой класс приложений существует. Хотя на Java писать такое странновато, лучше на Go. Не знаю, были, конечно, когда-то люди, которые пытались играть в VM делать, но что-то этот хайп уже давным-давно сник, я про него уже давно не слышал.

[1:25:45] Саша: Да, «Тысяча фичей» играли в VM. Google, открывайте: это я тот самый человек, который пытался делать пару-тройку лет назад. Послушайте, как было весело.

[1:25:54] Александр: Окей, хорошо.

[1:25:57] Саша: Поняли, что stop-the-world-коллектор не бесполезный, но тем не менее иногда всё-таки хочется, чтобы паузы были поменьше. Тут появляется уже следующая часть нашего рассказа — про частично конкурентные сборщики мусора. В чём идея? Что теперь мы, по крайней мере для некоторых стадий нашего garbage collector, не будем останавливать наше приложение по возможности, а будем делать так, чтобы одновременно с нашим приложением работал garbage collector, по крайней мере какие-то его части. В этом и есть смысл слова «частично конкурентный». Самая очевидная часть, которую можно попытаться сделать параллельно с нашим основным приложением, — это, конечно же, маркировка. Именно рассматривая маркировку, давайте подумаем вот о чём. Что у нас может пойти не так, если наше приложение будет работать одновременно с запущенной маркировкой? Положим, что мы взяли какой-то шарик с ниточками, поняли, что он чёрный, больше на него не будем смотреть. А потом наше основное приложение — у нас теперь ключа от комнаты нет, туда может забегать наше приложение — взяло какой-нибудь шарик и прицепило его к нашему чёрному, уже полностью рассмотренному шарику, к которому мы больше никогда не прикоснёмся и чьи нити мы уже никогда не пройдём.

[1:27:09] Александр: Мне кажется, ты тут ошибся. Не мусор оно взяло, потому что оно не может взять мусор. Оно взяло существующий и отвязало. И теперь существующий оказался мусором.

[1:27:17] Саша: Да, я неправильно сказал. Спасибо. Он не мусор.

[1:27:18] Александр: А мы считаем, что он не мусор. Перепривязала. Получается, шарик был привязан каким-то другим шариком, был достижим, но потом мы взяли эту ниточку, перецепили к другому шарику, тоже полностью рассмотренному.

[1:27:23] Саша: Это первая проблема, которая может возникнуть, — вот это нарушение инварианта, что у нас нет больше связей между чёрными шариками и белыми. Это первая проблема. Но есть ещё вторая. А именно смотри: у нас, помнишь, есть шарики, которые мы не до конца рассмотрели, — то есть мы смотрим ещё какие-то нити, которые ведут из этих шариков, мы взяли их серого цвета. И у нас был шарик белого цвета, и всё должно было быть хорошо, но, например, мутатор уничтожил пути от серого объекта к этому белому объекту каким-то образом. То есть мы больше его как будто бы… Даже что-то я тут запутался, давай подожди, я вспомню.

[1:28:17] Александр: Серый шарик, параллельно происходит что-то, мы что-то увидели, что-то не увидели от него. По факту то, что мы увидели, может разорваться, и мы это не увидим. Это единственное, что я могу сказать.

[1:28:31] Саша: Ну да, смотри: мы взяли серый шарик, непонятно куда мы его перецепили. Потому что у нас был серый шарик, была ниточка к белому шарику, который мы ещё не рассмотрели, — мы эту ниточку расцепили. Это пофиг. Может быть, мы его и не рассмотрели. Мы можем его прицепить к другому серому шарику, у которого уже часть нитей рассмотрели, и так прицепим, что он больше не будет рассмотрен. Блин, что-то я тут уже запутался, честно тебе скажу, надо это вспоминать, как оно было устроено. Тут, короче, есть как раз второй способ нарушения этого инварианта. Я, собственно, к чему это веду? К тому, что есть две ситуации, в которых инвариант ломается. Не зря мы говорили про инварианты в начале. То есть существует ситуация при подходе с конкурентной маркировкой, что инварианты, на которые мы рассчитываем, в ходе нашей работы могут меняться. А мы исходим из того, что алгоритм garbage collector, ну, вот такой доисторический, — что инварианты не меняются в момент, пока мы работаем. Но нет, они могут меняться в случае конкурентности. Короче, нам надо как-то эту ситуацию уметь подхватывать.

[1:29:39] Саша: И здесь нам на выручку снова приходят так называемые барьеры. Как вы помните, у нас уже появился барьер, когда мы поняли, что нам надо отслеживать ссылки старого поколения в молодое. Но почему тогда не воспользоваться механизмом барьеров, когда у нас происходят какие-то мутации? Давайте будем ловить эти ситуации, когда появляются нарушения инвариантов. Например, когда мы записываем ссылочку, куда-то её сохраняем, давайте проверим, что та ссылочка, куда мы сохраняем, указывает на ещё не рассмотренный объект, на ещё белый; а значение поля, в котором мы сохраняем эту ссылку, является чёрным объектом. И в случае этой ситуации давайте тот шарик, который у нас белый, покрасим в серый цвет. Сразу же, не будем ничего ждать, а просто вот это пофиксим на месте. И эта ситуация называется snapshot-at-the-beginning-алгоритм. Он как раз ловит эту ситуацию и исправляет её следующим образом. Ну, собственно, это та ситуация, которую ты пытался рассказать и не рассказал: из чёрного шарика мы добавляем ссылку на белый, и, по идее, этот белый должен стать серым в этот момент.

[1:30:47] Александр: То есть инвариант поменялся, но по факту эта работа garbage collector размазывается, часть её на рантайм и в барьеры уходит. То есть алгоритм расплывается между барьерами и той логикой, что в них, и самим алгоритмом.

[1:30:59] Саша: Скорее, часть логики переносится теперь на логику наших мутаторов. То есть теперь мутаторы при обращении со ссылками должны иметь в виду, что та ссылка, на которую они смотрят, на самом деле с ней что-то может пойти не так. И они должны это проверить. Если что-то пошло не так, они должны помочь garbage collector что-то поменять, чтобы он всё-таки все свои инварианты соблюдал и всё работало полностью корректно.

[1:31:19] Саша: И, собственно, первый алгоритм, про который, наверное, можно вспомнить, — это то, что в Java называлось concurrent mark and sweep, CMS. Собственно, этого алгоритма сейчас уже с нами нет. Но я так понимаю, что его убрали по причине того, что его просто некому было развивать, и по каким-то ещё причинам. В чём была логика CMS? Сборка по младшему поколению была такая же по логике, как в Parallel и Serial. То есть у нас есть точно такое же разбиение на Eden, точно такое же разбиение с Survivor 0 и Survivor 1, у нас есть перенос копирующим коллектором в S1 или S0, в зависимости от того, какое пространство используется, from-to space и так далее. Здесь ничего не поменялось. Здесь всё тот же stop-the-world на младшем поколении. А вот в старшем поколении как раз использовалась конкурентная маркировка. То есть у нас были специальные барьеры, которые отслеживали ситуацию, когда появляются потенциальные нарушения инвариантов, и они красили объекты в этот серый цвет. Это приводило к тому, что при маркировании не надо останавливать основное приложение. Ну, точнее, там всё ещё была некоторая дополнительная пауза в конце.

[1:32:16] Александр: Смотри, во-первых, пауза всё ещё на самом деле необходима. Когда мы начинаем про всё это рассуждать, конкурентное маркирование: во-первых, тебе надо всё ещё пройтись по корневым объектам, например, локальным переменным. Почему тебе надо всё это останавливать? Потому что ты же не будешь пихать барьер на каждое взаимодействие с какой-то своей локальной переменной. Это как будто бы не очень имеет смысл, иначе всё будет довольно медленно работать. Ты хочешь делать вот эти барьеры только при взаимодействии с какими-то объектами в хипе, до которых ты стучался по какой-нибудь ссылочке. Поэтому, когда ты рассматриваешь начальный рутсет, тебе всё ещё желательно как-то остановиться, чтобы понять, какое у тебя начальное множество объектов. После этого ты своё основное приложение запускаешь дальше, оно начинает работать, и у тебя работает конкурентная маркировка. Пока эта конкурентная маркировка работает, работает твоё основное приложение, оно может в процессе своей работы нарушать инварианты, но мы будем ловить их за счёт работы этих барьеров. И будем обновлять метаинформацию о том, что какие-то объекты, которые были белого цвета, на самом деле серые. И вот у этой конкурентной маркировки есть стадия, когда она проработала до конца: она в конце останавливается и смотрит на эту дополнительную информацию — не появились ли у неё ещё какие-то объекты, которые были пойманы write-барьерами в основных потоках-мутаторах. Если появились, он дорабатывает эту часть работы, то есть домаркирует их, и после этого делает то, что мы говорили, Mark-Sweep-алгоритм: просто размечает то пространство, которое свободное, как свободное, там, где был мусор. И как раз в момент доработки этой дельты происходит пауза, потому что иначе, когда ты дорабатываешь дельту, у тебя появится новая мини-дельта, с этой мини-дельтой ещё новая, и в какой-то момент тебе надо всё-таки попросить остановиться: «Дайте, я доделаю, потом продолжайте». Поэтому пауза будет не очень большая по сравнению с основной остановкой.

[1:34:25] Саша: И какая проблема была у CMS — то, на что я тоже наталкивался в своё время? То, что в CMS можно подкрутить так, что будет казаться, что у него очень маленькие паузы, например, уменьшив очень сильно младшее поколение, чтобы всё оставалось в старшем поколении. И всё будет работать хорошо. Но на самом деле, поскольку у нас нет никакой конкурентной дефрагментации, у нас фрагментация всё будет накапливаться, накапливаться. У нас всё будут появляться дырки в непрерывном пространстве, в которое мы ничего не можем сохранить. И в какой-то момент наш garbage collector в старшем поколении, когда сработает, увидит, что ему больше некуда писать никакой объект. У него куча очень сильно фрагментирована. Её надо дефрагментировать. Но поскольку конкурентно это сделать нельзя, надо всё полностью остановить и начать традиционный алгоритм Mark-Compact. То есть компактизацию всю эту начать при Full GC. И что самое стрёмное — насколько я помню, эта компактизация была сделана в один поток. То есть, как ты понимаешь, это всё было очень небыстро. Тут как раз была та беда, что ты настроил, вроде всё работает хорошо, а потом спустя какое-то время всё взрывается. Из-за этого у CMS было огромное количество всяких опций, которые я уже не запомнил. Я только помню, что вот, например, у Алексея Рагозина — это человек, который занимается перформансом, — у него есть бложик, в котором есть пост уже очень далёкой давности про то, как настраивать CMS. И там буквально лист А4, в котором мелким 10-пунктным шрифтом написаны на весь лист всякие опции, которые можно выставить в ту или иную сторону, когда ему срабатывать так или иначе. В общем, это был такой полуфабрикат, из которого надо было ещё исхитриться что-то заготовить. Мне кажется, вот те легенды об аксакалах-джавистах, которые настраивали garbage collector, остались как раз с тех времён, когда был такой garbage collector, который вроде давал конкурентное маркирование, но если ты что-то сделал не так, у тебя случилась фрагментация, всё развалилось, и становилось не очень хорошо.

[1:36:35] Александр: Окей.

[1:36:36] Саша: Теперь немножко про Go поговорим, потому что там тоже используется Mark-Sweep-алгоритм. Как мы говорили, мы поняли, как бороться с фрагментацией за счёт разбиения объектов на определённые спаны по их размеру. И, разумеется, поскольку нам нужно соблюдать инвариант при этом маркировании, там тоже надо было добавить барьер в случае записи какой-то ссылочки, чтобы у нас не появилась лишняя связь между чёрными и белыми объектами. Единственное, что там сразу сделали так, что барьер был адаптивный. В каком смысле: если мы сейчас видим, что у нас работает в параллель с нашим мутатором конкурентное маркирование, то мы уже начинаем эту проверку включать, этот барьер у нас включён. А если у нас сейчас не работает конкурентное маркирование с нашим основным приложением, то мы просто этот барьер скипаем. То есть там всё ещё есть ифчик, один небольшой, и он стоит недорого, пока у нас маркирования нет.

[1:37:36] Александр: Странно, что в Java так не сделали.

[1:37:38] Саша: Да сделали. Конечно, сейчас во всех этих алгоритмах тоже вся эта проверка есть.

[1:37:42] Александр: Ну, это я скорее про то, что вообще тебе не обязательно делать тяжёлый барьер во всех случаях. У тебя есть очень много условий, по которым ты можешь выйти раньше и не осуществлять тяжёлой работы. И на самом деле в этом и состоит преимущество этих барьерных схем: вроде кажется, что барьер — это какое-то количество работы, потому что тебе надо сходить в какие-то структуры данных, что-нибудь обновить, и как будто это всё сложно, если ты будешь это делать на каждое обновление ссылочки, — как будто ты замучаешься. На самом деле у тебя в каждом барьере целая куча ифов, которые откидывают тебя в 90% случаев, тебе вообще не надо заходить в этот медленный путь. И за счёт этого всё работает нормально.

[1:38:22] Саша: Ну и, наверное, тут ещё стоит сказать про историю тех времён. Как я говорил, Go как раз во время выхода версии 1.4, если почитать его релиз-ноты, там видно, что разработчики сначала планировали выпустить следующую версию garbage collector уже со сборкой по поколениям, и они планировали делать компактизирующий коллектор. То есть всё то же самое, что мы видели в Java до этого. Но они на самом деле отказались от этого по нескольким причинам. Причина первая. Оказалось, что в Go — ну, как ты знаешь, там у нас есть полноценная поддержка структур, в отличие от Java, где, если мы хотим сделать какие-то объектики, это будут именно объектики: ты не можешь заэмбедить один объектик в другой. В общем, в Go есть структуры, и из этого следуют всякие продвинутые escape-анализы, когда у тебя объект не создаётся в хипе: когда ты можешь понять, что у тебя время жизни такое, что можно создать объект на стеке и хранить его только там, — ты вообще не пользуешься хипом совсем. И вот разработчики Go тогда производили исследования — я на их гитхабе находил упоминания об этих исследованиях тоже не так давно, что они всё ещё это проверяют периодически. Они смотрели, а выполняется ли у них вообще гипотеза о поколениях? Выполняется ли у них условие, что большая часть молодых объектов, которая была создана в хипе, очень быстро умрёт?

[1:39:45] Александр: Вот я бы тут паузу поставил. На самом деле это очень интересно для понимания. У нас, по идее, есть два рантайма — Java и Go. И в одном рантайме, в Java, мы знаем, как устроены объекты: по сути, любой объект аллоцируется в хипе, и у тебя ссылочки друг на друга. Если ты хочешь, например, структуру данных Person делать, то с адресом, с городом, — и это тоже объекты, — у тебя будут из этого Person ссылочки на другие объекты. То есть ты не можешь заэмбедить внутрь Person адрес его, вот прям, знаешь, положить туда. Потому что это разные объекты.

[1:40:25] Саша: Да, абсолютно верно. То есть в Java как будто…

[1:40:27] Александр: Да, это то, как в Java. Мы это всё знаем. А в Go есть структуры. Вот это принципиальный момент в контексте того, что мы сейчас обсуждаем. Потому что структуры позволяют тебе, а, заэмбедить их друг в друга, поэтому у тебя адрес может быть частью Person — прям подряд лежать поля. И б — аллоцировать их на стеке. То есть не делать ссылку в heap, а там, где у тебя ссылка в Java, в Go у тебя будет сразу объект, если ты его на heap не положил. И вот эти различия между двумя экосистемами привели к тому, что в Go, исследовав вот эту гипотезу о поколениях, пришли к какому выводу? Что она не выполняется у них.

[1:41:08] Саша: Да. У них, я видел, что примерно 70 процентов свежесозданных объектов становится мусором. За счёт всех этих механизмов, за счёт хорошего escape-анализа, за счёт использования структур, за счёт аллокации на стеке, у них большая часть таких мусорных аллокаций отсекается, её вообще не происходит в heap — она происходит только на стеке. Соответственно, у них гипотеза о поколениях нарушается, и получается, что все эти приседания с поколениями им становятся просто-напросто не нужны. Они этим всем не пользуются. Собственно, из-за этого сборки по поколениям в Go и нет, и, скорее всего, не будет.

[1:41:47] Александр: Ну, тут, наверное, можно вспомнить про Java, что же там у Valhalla, когда она будет.

[1:41:51] Саша: Не дождёмся, я думаю.

[1:41:53] Александр: Да нет. Всё перепишем на Go.

[1:41:55] Саша: Ну, посмотрим, посмотрим.

[1:41:57] Александр: Но, кстати, знаешь, вот мне тут ещё в голову пришла такая мысль, когда ты это сейчас озвучил. Это же на самом деле довольно глубокая вещь. Смотри, что я имею в виду. Вот когда мы делаем в Java два объектика — Person и его адрес, ссылочку, — у нас есть ссылочка из одного объекта в другой, но мы же ничего не знаем про время жизни этих объектов, как они друг с другом связаны. То есть мы создали адрес, создали Person, сцепили их друг с другом, но не знаем, что там, например, когда Person не станет, у адреса тоже не станет. А в Go за счёт того, что мы эмбедим одни структуры в другие, они все идут скопом, и умирают тоже вместе скопом. И получается, нам действительно поддержка garbage collector в таких ситуациях не нужна. То есть это действительно, казалось бы, дизайн-фича языка, но за счёт того, что он вот так сделан, можно было отказаться от выделения на хипе в определённых ситуациях.

[1:42:49] Саша: Да-да, мне это очень нравится. С тех пор как я начал программировать на Rust в свободное время, я оценил структуры. Это реально классная штука.

[1:42:59] Александр: Ну, в Java, кстати, тоже есть возможность как-то это пытаться эмулировать — делать отдельные массивы для наборов полей или пытаться наследоваться друг от друга. Но, блин, это всегда так больно и костыльно, что, конечно, когда есть альтернативы, как-то уже не очень хочется этим заниматься. Согласен. Тут подождём, к чему это приведёт.

[1:43:20] Саша: Окей, ну и теперь поговорим, наверное, про G1. Это тоже частично конкурентный сборщик мусора. У него тоже происходит конкурентное маркирование, но тут, наверное, происходит довольно сильное отличие от всего, что мы до этого рассматривали. У нас нет больше какой-то конкретной области, которую мы заводим для старого поколения, нет какой-то конкретной области для молодого поколения. Теперь мы разбиваем весь хип на некоторые кусочки. Например, разобьём весь наш хип в несколько гигабайт на 2000 кусочков, непрерывно идущих друг за другом. Равнозначные, то есть мы между ними не говорим, что «ты Eden, а ты Survivor», — вы все одинаковые на первоначальном этапе. Вот здесь пока что мы разбили их пространственно, кусочки одинакового размера, но роль у каждого этого кусочка на самом деле может быть разная. Как раз здесь фишка в том, что вот эти наши небольшие регионы могут иметь ту или иную роль. У них всё ещё есть те же самые роли — Eden, Survivor или Old-регион для старых объектов, там ещё есть специальный случай для хранения гигантских объектов, — но теперь у нас нет фиксированного распределения между тем, что там, не знаю, одна часть хипа, 70% на молодое поколение, 30% на старое. Каждый регион имеет свою какую-то принадлежность, и эта принадлежность в процессе работы может очень сильно меняться. Но в остальном логика работы очень похожа на то, что мы видели до этого. Когда мы делаем сборку по младшему поколению, мы, во-первых, должны определить множество регионов, по которым сделаем сборку. Мы всегда проходимся по множеству молодых объектов. То есть смотри: у нас есть регионы, которые называются Eden и Survivor. Там находятся молодые объекты, свежесозданные. Когда мы делаем младшую сборку, то есть только по этому младшему поколению, мы проходимся по всем этим регионам, и все живые объекты переносим в какой-нибудь новый чистый регион, в котором пока ничего не было, и называем его Survivor. Там будут храниться такие наши объекты. То есть очень похоже на то, что было в Parallel/Serial GC. Только теперь мы работаем не с фиксированными областями хипа, а с этими отдельными регионами, у каждого из которых есть своя конкретная роль.

[1:45:23] Александр: Как на шахматной доске получается: у нас много этих ячеек, они в самом начале, грубо говоря, все одинаковые, а потом мы их помечаем цветами и назначаем им роли. И у нас может быть там 5 ячеек Survivor, 5 ячеек или там 6-7 Eden, например, и мы их между собой можем тасовать, перекрашивать, и у нас просто появилось больше пространства для манёвра и гибкости. Но суть осталась та же самая, что была в предыдущих.

[1:45:50] Саша: Да, всё ещё та же самая суть копирующего коллектора, который просто переносит объекты в чистую область, в которой пока ничего не было. То есть там всё ещё есть разделение from space, to space и так далее. Но тут, наверное, есть несколько сразу замечаний, про которые мы должны вспомнить, которые были до этого. Первое замечание — это то, что у нас всё ещё есть разделение на поколения. Это значит, что нам надо как-то хранить информацию о том, что у нас есть ссылки из старого поколения в новое. Но теперь-то у нас регионы все эти разные, и в одну card table, как мы рассматривали до этого, это не так легко вписать. Здесь появляется то, что на самом деле у каждой ячейки теперь есть метаинформация о том, из каких регионов старого поколения в неё есть ссылки. У каждого нашего региона в хипе. Эта метаинформация называется remembered set.

[1:46:38] Александр: То есть мы, как бы, насколько я понимаю, инвертировали логику этой структуры данных. То есть до этого мы записывали все старые поколения, в которых есть ссылки на Eden. А, нет, мы не инвертировали. Логика вся та же самая. Просто у нас их теперь стало равное количество ячеек, по сути, этих структур данных.

[1:46:56] Саша: Да-да. Теперь у нас не одна большая структура. Ну, то есть там на самом деле во внутрянке G1 есть всё ещё card table. Он используется для этой грубой разметки, потому что это происходит несколько синхронно, там довольно сложная машинерия. Но самый важный тут факт в том, что именно у каждого региона есть своя собственная структура данных для хранения именно его информации, откуда в него идут ссылки старшего поколения. И только он знает, кто в него повёл. Таким образом, когда мы делаем сборку по какой-то части только регионов, а не по всем, мы смотрим именно на конкретный регион и говорим: «Ага, у тебя откуда есть ссылки». Берём другой регион, смотрим: «А у тебя откуда?» — и так далее. И мы можем таким образом собрать только какое-то ограниченное количество регионов при сборке мусора. За счёт того, что мы будем не полностью просматривать наши регионы, мы можем регулировать размер паузы. Собственно, из этого G1 называется garbage first. Во-первых, он выбирает эти наши регионы, в которых будет собирать мусор, по степени их заполненности. Потому что если у нас есть какие-то регионы, в которых много мусора, их, очевидно, надо попытаться собрать в первую очередь. А если есть регионы, в которых полно старых объектов, то как будто это не имеет смысла. И, во-вторых, за счёт объёма этой работы — за счёт того, что он будет решать, сколько сейчас возьмёт регионов в процессе своей сборки, — он как раз может регулировать размер своей паузы. То есть он эвристически пытается посчитать, сколько у него уходит времени на сборку отдельного набора регионов, и в зависимости от этого делать у себя определённый объём работы ограниченного размера.

[1:48:28] Александр: Да, тут, наверное, важно ещё озвучить, я это не озвучил. У нас всё ещё конкурентная маркировка, то есть всё ещё есть барьеры. Но компактизация у нас уже происходит под паузой. То есть когда мы разметили наши регионы, поняли, что там какие-то объекты в них живы, мы останавливаем всё наше приложение и переносим эти объектики в наш Survivor space, чтобы там они уже жили. То есть это всё ещё пауза. Маркировка у нас конкурентная, компактизация делается под паузой.

[1:48:56] Саша: Поскольку у каждого региона есть remembered set — структура данных, которая хранит метаинформацию о том, какие есть входящие указатели из старого поколения, — нам надо это всё обновлять. И вот тут есть у G1 определённая болячка. Если почитать свежие джепы, там как раз это хотят починить. В общем, у G1, как и, например, у Parallel GC, есть барьеры для того, чтобы обновлять информацию об этих ссылочках из старого поколения в новое, — то же самое есть и у G1. Но поскольку у него всё гораздо сложнее устроено, у него есть отдельные структуры данных для регионов, отдельные потоки, — короче, обновление этих remembered set происходит в некоторых вспомогательных потоках, которые подают таску о том, что надо обновить информацию об этом remembered set. В общем, проблема в том, что в G1, в общем случае, если у нас попадётся всё по самому худшему пути — то есть там всё ещё много ифов, мы там сначала проверяем, вообще, есть ли сейчас конкурентная маркировка, проверяем, находимся ли мы сейчас в регионе, который будут собирать, потому что если его не будут собирать, там можно не сильно париться, записываем мы туда null-значение или не null, — там есть много разных ифов. И вот если они все срабатывают, если мы попадаем по медленному пути, то получилось так, что в случае G1 там было какое-то большое количество ассемблерных инструкций. Я что-то видел в джепах упоминание о порядке 50 инструкций, то есть как будто это довольно большое количество дополнительной работы, которую мы должны делать при записях.

[1:50:28] Саша: И из-за этого у G1, поскольку он постоянно обновляется, когда-то — когда его сделали дефолтным коллектором какое-то время назад, я не помню, в какой-то джепе, то ли 13-й, то ли 12-й, — была такая проблема, что его сделали дефолтным коллектором, но никому про это не рассказали. А люди, которые просто меряют перформанс Java, запуская какие-то бенчи и не разбираясь, что произошло, заметили, что снизилась пропускная способность Java. То есть там прямо видно, что восьмёрка даёт там 100 попугаев, потом эта версия Java, в которой вышел G1, — там произошёл дропаут, пропускная способность ухудшилась. Из-за этого было много новостей, что джависты совсем там безрукие и так далее. Но на самом деле дело было не в этом. Просто сменили алгоритм, он действительно привносит большее количество оверхеда, потому что в нём барьеры более тяжёлые, и, соответственно, снижает нашу пропускную способность. Но с другой стороны, в G1 есть плюс в том, что там уже паузы становятся меньше. Характерный размер пауз, который декларировали сами разработчики, — порядка 100–200 миллисекунд, это сильно лучше, чем несколько секунд в случае Parallel GC, если у нас какие-то очень большие хипы. Поэтому тут надо иметь в виду, что была такая история. И G1 постоянно на самом деле улучшается. Я вот тоже смотрел блог человека, который занимается разработкой G1, там постоянно происходят какие-то свежие изменения. И вот то, что я сейчас озвучил про то, что у G1 очень тяжёлый барьер на запись, — это как раз то, что они прямо сейчас исправляют, и, скорее всего, там будет использоваться какой-то ещё более хитрый подход, про который я имею очень слабое представление, который будет работать гораздо быстрее. Поэтому всё ещё как будто бы, если у вас было какое-то впечатление о G1 не очень хорошее, оно, может, уже исправилось. И, может, стоит дать ему ещё один шанс. Это мы поговорили про G1 в Java.

[1:52:16] Александр: Да. Ну и про Go, про старый Go, мы поговорили тоже до этого, что сначала у нас был stop-the-world, а потом появилось конкурентное маркирование. И Sweep, который не очень сложный за счёт того, что у нас используется хитрая схема с этими спанами, разделёнными, за счёт которой мы побеждаем фрагментацию. То, что я запомнил про Go, — потому что я всё-таки профессионально долго на Go не писал, только решал для себя задачки. Ну и сейчас с LLM, конечно, я на Go программирую активно, потому что языкового барьера нет, и я выбираю технологию по инфраструктуре и по соотношению value, которое она мне принесёт. И, не зная нормально Go, я всё равно выбираю Go, потому что инфраструктура классная. Бинари, быстро всё, язык простой, читать могу. Короче, частенько на нём делаю что-то для себя. И вот мне полезно узнать про G1, даже про старый, для общего развития. И то, что я запомнил, — это про то, что там есть вот эти коробочки размера S, M, XL, что фрагментация таким образом решается. И что там за счёт того, что структуры нет, не работает теория о поколениях, и просто там их нет. По сути, вот это главное отличие я для себя сейчас усвоил.

[1:53:28] Саша: Да-да-да. Но и ещё замечу всё-таки, что внутренняя фрагментация есть, то есть с этим тоже надо уметь бороться. Если ты в Go будешь писать как в стиле Java — вводить объект на объект, пиндюрить указатели друг на друга, — то как будто ты потеряешь все преимущества, которые у тебя есть, и будет не очень хорошо работать. Лучше использовать структуры. Ну, ты понял меня. Если есть возможность, лучше писать на Go как на Go, а не как на Java.

[1:53:54] Александр: Да-да-да.

[1:53:56] Саша: Когда мы говорили про G1, что у нас конкурентное маркирование идёт, — может получиться так неудачно… Получается, у нас теперь опять в системе появилось две части взаимосвязи. У нас есть наше приложение-мутатор, которое активно что-то аллоцирует, создаёт, меняет ссылочки. И у нас есть сборщик мусора, частично параллельно, конкурентно работающий с ним. И может получиться так, что этот частично конкурентный сборщик мусора не будет справляться с той нагрузкой, которую ему даёт наше основное приложение. И вот в такой ситуации не стоит надеяться на чудо. Конкурентность в какой-то момент прекратится. У вас случится Full GC. То есть у вас полностью останавливается всё приложение, полностью будет сделана сборка мусора. И вы должны понимать, что этот механизм не волшебный. Он работает, когда garbage collector справляется с той нагрузкой, которую вы на него подаёте. Но если он начинает не справляться, у вас всё ещё могут появиться паузы, всё ещё это может начать тормозить. Грубо говоря, если мы пишем приложение неаккуратно, по-дилетантски, и совсем не думаем о количестве новых объектов, которые создаём, — условно, вместо того чтобы использовать int в функции подсчёта какого-то алгоритма обхода графа, я беру и аллоцирую объект, внутри которого лежит int, просто потому что это клин-код, ё-моё, я решил всё объектами, — ну вот, пожалуйста, в цикле я буду генерировать эти объекты, по ним бегать, и GC просто будет не успевать работать. То есть для меня, как для прикладного инженера, вывод следующий: плоди поменьше объектов, если есть такая возможность.

[1:55:29] Александр: Ну да, получается, на гипотезу о поколениях надейся, а сам не плошай.

[1:55:33] Саша: Абсолютно точно.

[1:55:36] Александр: Запишем в код МД.

[1:55:39] Саша: Окей, хорошо. Так, ну и теперь, как я говорил, переходим уже к рассмотрению полностью конкурентных коллекторов, то есть тех, которые делают почти всю работу параллельно с нашим приложением. Это значит, что у нас должно быть минимальное влияние на latency основного приложения. И сначала давайте поговорим про Shenandoah. Shenandoah — это алгоритм, который появился уже достаточно большое количество времени назад. У него было несколько версий, и я хотел бы немножко обозначить, в чём идея Shenandoah — сначала первая версия, а потом уже её обновление. Значит, мы поняли, что конкурентное маркирование мы уже умеем делать: в Java, например, надо поставить правильный барьер, правильно подхватывать ситуацию, когда нарушается инвариант маркирования, и размечать объекты. Но мы пока не умеем корректно переносить объекты. Если бы мы это делали в Java конкурентно с нашим основным приложением, какая была бы проблема? Мы, типа, взяли какой-нибудь объект из региона, из которого эвакуируем объекты, перенесли его в другое место — то есть у него теперь другой, корректный указатель, — и в это время пришёл наш основной мутатор, пошёл по старому указателю и, например, записал в старую копию объекта, которая уже невалидна, какое-то значение. То есть у нас случилось потерянное обновление. Таким образом, нам нужно что-то, что защитит нас от этих потерянных обновлений. Мы всегда должны считывать самую актуальную копию объекта. Получается, когда у нас есть конкурентная стадия переноса объектов, у нас существует несколько копий одного и того же объекта, но только одна из них является актуальной, текущей, и мы должны работать всегда с ней, потому что иначе это может привести к проблемам.

[1:57:15] Саша: Первое решение, которое здесь приходит на ум, стандартное: если ты не можешь решить проблему с каким-то объектом, введи ещё один уровень indirection, добавь ещё один указатель — и всё будет работать хорошо.

[1:57:28] Александр: Виртуальные таблицы C++ welcome.

[1:57:32] Саша: Да-да-да. Ну, здесь не виртуальная таблица, здесь используется концепция, которая называется указатели Брукса (Brooks pointers). Её придумали ещё в 80-е годы. Давайте сделаем следующий финт ушами. Поскольку мы с вами знаем, что у каждого объекта уже есть служебный заголовок со всякой метаинформацией — не знаю, сколько поколений, сколько сборок он прошёл и так далее, — давайте заведём ещё одно служебное поле у каждого объекта, в котором будем хранить указатель на самую актуальную копию этого объекта. Как это выглядит? У нас есть объектик, и прямо рядом с ним лежит указатель, который указывает на этот же самый объектик, если это актуальная копия. Если это не актуальная копия, то этот указатель указывает на какой-то другой объектик, который лежит в правильном месте. И здесь, разумеется, возникает несколько проблем. Во-первых, как нам понять, что наша копия самая актуальная? Ну, это делается очень просто. Если наш указатель указывает на тот же объект, который лежит рядом с нами, то это и есть актуальная копия объекта. Логично. Единственное, что нам надо делать, — это атомарно обновлять этот указатель.

[1:58:36] Саша: Ну, потому что, например, вот мы мутатор. Мы увидели, что у нас есть объектик, который всё ещё на своём месте, и хотим перенести его в другое пространство. Точнее, наш сборщик мусора хочет это сделать. Он сделал копию в новом регионе, в котором будет храниться эта копия. И только после этого он пытается заместить указатель на старом месте, который всё ещё указывал на старую копию объекта. Он это пытается делать атомарной операцией, то есть той, которая выполняется абсолютно одинаково для всех потоков, и видимость этого изменения будет видна всем потокам. Если у него получилось — всё нормально. Это значит, что все, кто пройдут по этому указателю, попадут в новую копию, будут видеть там актуальное значение и работать с ним. А вот если мы через этот указатель пытались сделать CAS и увидели, что там уже что-то изменилось, значит, кто-то другой создал копию этого же объекта и уже успешно поменял указатель на корректную копию. Тогда мы про нашу свежесозданную копию просто забываем, ничего с ней не делаем — это теперь у нас мусор, который будет удалён в следующей сборке. Такая вот, на первый взгляд, нетривиальная схема, но это же Shenandoah 1.0. Как ты думаешь, какие у неё минусы?

[1:59:47] Александр: Слушай, ну как минимум — проверять каждый раз, актуальный указатель или нет. Вот что первое прям в голову приходит. То есть это, по сути, барьер такой, лёгкий, но всё же на доступ к каждому объекту.

[2:00:01] Саша: Ну да. Причём если тебе везёт и ты указываешь на тот же самый свой объект, то, скорее всего, у тебя и указатель, и сам объект лежат в одной кэш-линии, и всё хорошо. А если он находится где-то в другом месте — у тебя сразу кэш-мисс и так далее.

[2:00:16] Александр: Вот тут есть ещё одно. Это очень беда, да.

[2:00:18] Саша: Да, тут ещё одна проблема есть. В чём? В том, что мы раздуваем размер объекта.

[2:00:23] Александр: Ну да, а где это лежит, получается, указатель? В хедере объекта, правильно?

[2:00:27] Саша: Вот именно, что не в хедере. В первой версии Shenandoah для этого использовался ещё один отдельный 8-байтный указатель. Ну или 4-байтный, в зависимости от того, какие у нас указатели использовались. То есть размер объекта у нас ещё сильнее увеличился. Как вы знаете, в Java есть такая оптимизация, как сжатые ссылки (compressed oops): вот у нас есть объектик, в нём есть ссылочное поле, по сути, внутри это указатель. В общем случае это 64-битный указатель, но если у нас хип очень маленького размера, то за счёт того, что все объекты выровнены по границе 8 байт, мы на самом деле можем вписать в 32-битный указатель абсолютно любой корректный указатель. Соответственно, все наши указатели становятся в два раза меньше размером, все объекты начинают занимать гораздо меньше пространства. Короче, когда объект становится меньше — это хорошо, потому что больше объектов влезает в кэш-линии, всё начинает работать лучше и лучше. А тут у нас ситуация строго противоположная: мы вместо того, чтобы сжимать объект, его раздули, добавили ещё один указатель. Получается, мы ещё и ухудшили утилизацию кэшей.

[2:01:31] Саша: И, в общем, покумекав над всем этим, разработчики сделали новую версию — Shenandoah 2.0, — и там как раз-таки воспользовались той идеей, которую ты высказал: что это всё хранится в mark word. Теперь у нас нет отдельного слота, который используется для того, чтобы хранить информацию о том, где сейчас находится самая корректная, актуальная копия объекта. Поскольку мы знаем, что, когда объект уже куда-то перенесли, там хранится абсолютно корректная копия, — на месте старого объекта мы можем хранить любую метаинформацию, в том числе и в mark word, например информацию о перемещении и о том, куда этот объект был перемещён.

[2:02:08] Александр: То есть, по сути, mark word лежит как раз в хедере, да?

[2:02:12] Саша: Да, именно так. Потому что там ещё есть место под это, спасибо.

[2:02:18] Александр: И когда мы первый раз обращаемся к объекту — ну, не мы, а рантайм, — мы пытаемся его интерпретировать, что это такое, а там, вообще говоря, может лежать не наш объект. Там может быть выставлен mark word, и тогда на месте этого объекта уже не объект, там не наш адрес, там лежит адрес другого объекта — типа, иди туда.

[2:02:38] Саша: Да, там хранится факт о том, что наш объект уже был перемещён в какое-то другое место, и вот в это место надо сходить и посмотреть, что там находится. Но, как ты понимаешь, у этого есть цена — как ты правильно говорил, барьера. Теперь, помимо того что нам нужен барьер на запись — ну потому что в Shenandoah тоже есть разделение на поколения…

[2:02:58] Александр: Да-да-да.

[2:02:59] Саша: Это не включено по умолчанию, но я сейчас рассматриваю сценарий, когда оно включено. Получается, если оно включено, нам надо иметь барьер на запись, чтобы отслеживать, опять же, ссылки из старого поколения в молодое. Но ещё, поскольку теперь у нас есть эта сложная схема с редирекшеном, нам даже при чтении надо смотреть, куда, собственно, указывает актуальное содержимое объекта, в каком месте оно находится. И, как ты понимаешь, при любом обращении к этой ссылке нам надо делать эту проверку. Единственное — там тоже есть эта многослойная луковица из if‘ов. То есть мы там проверяем, работали ли у нас сейчас конкурентные эвакуации объектов, шёл ли GC, находимся ли мы в том регионе, из которого производится эвакуация, и так далее. Там есть много if‘ов, которые все эти ситуации отбрасывают. Тем не менее, барьер всё ещё есть.

[2:03:42] Саша: Как я сказал, в Shenandoah есть поддержка поколений. И что, на мой взгляд, ещё довольно интересно: как я говорил, в Java есть оптимизация, когда вы включаете флаг JVM, поддержку сжатых ссылок, — и вот Shenandoah её поддерживает. Потому что она не использует никакую метаинформацию, хранящуюся в указателях. Почему я про это говорю? Потому что сейчас мы перейдём к рассмотрению следующего garbage collector’а, который использует совсем другой подход. Этот коллектор называется ZGC. В Java он тоже появился уже довольно давно и тоже пережил выход нескольких версий. Сначала был ZGC версии 1.0, и в нём использовалась следующая идея. Смотри, у нас указатель размером 64 бита — про 32-битные Java мы сейчас не будем вспоминать, они уже давно забыты, и их больше нет с нами. Так вот, указатель у нас размером 64 бита. Насколько известно, для адресного пространства реально можно использовать только 48 бит, потому что у нас есть некоторые ограничения на объём оперативной памяти — если мы говорим про x86. Ну и в ARM, на самом деле, то же самое. Короче говоря, из этих 64 бит по факту под адрес используется только 48 или 44, в зависимости от нашей архитектуры. И как будто бы какое-то количество битиков, которые есть в указателе, можно попытаться использовать под что-нибудь полезное. Интересная идея, да? В самом указателе под адрес используется а-ля 48 бит, и, зная это, мы понимаем, что адрес, грубо говоря, лежит в конце. А начало этого указателя — это, по сути, битовый массив. Вот первые сколько-то бит, 16, например, или 24, мы можем использовать для своих нужд, менять их, как нам удобно, и в то же время не меняя адрес, потому что адрес лежит в последующих битах.

[2:05:45] Александр: Это интересная идея.

[2:05:47] Саша: Да, но тут есть некоторые подводные камни, про которые мы чуть попозже скажем — за счёт чего это должно работать. Но идея ZGC заключается в следующем. Поскольку у нас в указателе можно хранить какую-то метаинформацию, давайте в нём будем хранить эту метаинформацию. Эта метаинформация называется цветом. То есть у каждого указателя теперь есть свой собственный цвет. Что это такое? В начале указателя есть 16 неиспользуемых битов — мы сейчас говорим про ZGC версии 1.0, — а после этого есть 4 бита. И каждый из этих битиков может быть выставлен только один. То есть если у нас выставлен первый бит, это значит, что цвет связан с финализацией объекта, про это сейчас не будем говорить. А остальные три бита относятся к тому, был ли перемещён объект или нет и был ли промаркирован объект одним цветом или вторым. В случае маркировки у нас есть два цвета, и для информации о том, что объект был перемещён (remapped), есть ещё один специальный цвет. Если этот указатель имеет один выставленный бит в той или иной позиции, мы понимаем, что указатель имеет тот или иной цвет.

[2:06:57] Саша: Главная идея ZGC состоит в том, что на каждом этапе конкурентной работы сборщика мусора в системе только один какой-то фиксированный цвет является корректным. То есть когда наше основное приложение работает, наш мутатор, у него есть ссылочки на объекты; он берёт очередную ссылочку, берёт цвет этого указателя и смотрит, правильный этот цвет или нет. Если цвет правильный, то он с этим ничего не делает и продолжает своё чтение дальше. А вот если цвет неправильный, то он понимает, что этот объект надо перенести в другое пространство, и, собственно, осуществляет всю эту работу. То есть теперь мутаторы тоже могут переносить объекты по нашему адресному пространству — тоже могут помогать GC делать определённую работу.

[2:07:43] Александр: Интересно. И это всё хранится внутри ссылки на объект. То есть это принципиально не в хедере объекта. То есть мы даже ещё не перешли к объекту, а уже можем понять его цвет.

[2:07:53] Саша: Да, как раз-таки в этом и есть идея этого барьера на чтение — что прежде чем взять адрес любого нашего объекта, мы проверяем, корректна ли эта ссылочка или нет. Если нет — мы её фиксим, например, путём релокации нашего объекта в правильное пространство, и работаем после этого только с правильным цветом. Но у этого есть определённая цена. Как ты понимаешь, теперь у нас потенциально ссылки могут идти… Ну вот представь: у нас была одна стадия сборки GC, был корректный один цвет, потом мы пошли, сделали следующую итерацию — и у нас корректен уже другой цвет. То есть битики чередуются, какие корректны в случае маркирования.

[2:08:31] Александр: Слушай, получается, что цвет — это тоже новое что-то, что мы вводим. Мы до этого про цвета в таком разрезе не говорили. По сути это, знаете, — может, кто играл в Fortnite, я вспоминаю. Это игра такая, где ты играешь, у тебя реальный мир, а потом наступает некоторое время, и у тебя мир начинает менять цвет в некоторой области. Он становится фиолетовым, и у тебя всё вокруг фиолетовое. И вот я так это представляю, что в каждый момент работы GC у нас мир как бы меняет цвет. И в зависимости от цвета мира у нас должны либо соответствовать, либо нет цвета указателей на объекты. И если они не соответствуют, то мы их фиксим.

[2:09:15] Саша: Да-да-да. У тебя есть информация о том, какой цвет корректный. И каждый раз, обращаясь к очередному указателю, ты понимаешь, корректен ли его цвет или нет. Если корректен — ты ничего не делаешь. Если некорректен — делаешь исправление. Но здесь есть проблема. Получается, у тебя в системе — ну вот представь — был указатель, в котором был выставлен один бит, потом поменялся цвет, и у тебя должен быть другой бит. И если это всё вот так наивно должно работать, ты должен постоянно перемещать объекты туда-обратно. Потому что у него был один адрес, а после очередного отрабатывания цикла GC у тебя все цвета изменились — а это значит, что указатель на этот объект изменился, значит, ты должен переместить объект по новому адресу. Битики у тебя постоянно чередуются в процессе работы твоей сборки мусора. И как будто бы это не очень эффективно.

[2:10:03] Саша: Поэтому используют следующий трюк. Есть, как вы знаете, виртуальная память. Виртуальная память — это некоторое виртуальное адресное пространство, которое отображается на нашу физическую память. То есть когда мы делаем какие-нибудь ссылочки в нашей программе, мы не оперируем реальными физическими адресами — на самом деле это адреса в виртуальном адресном пространстве. И когда мы обращаемся к этим виртуальным адресам, используется специальная машинерия, которая есть и внутри операционной системы, и даже внутри процессора, — этот механизм как раз-таки резолвит наше виртуальное адресное пространство в физическое. Устройство, которое делает такие отображения, называется MMU (memory management unit). Оно осуществляет ремаппинг виртуального указателя в физический. Но на самом деле виртуальная память поддерживает большое количество фишек. Ну, начиная от того, что вы можете взять файл с диска и отмапить его в виртуальное адресное пространство: для вас это будет выглядеть как просто память, а по факту это файл, лежащий на файловой системе. А ещё один трюк, который используется как раз-таки в ZGC, — это то, что вы можете создать несколько виртуальных адресных пространств и замапить их на одно и то же физическое пространство. То есть у вас есть как будто бы два разных адреса, но они на самом деле указывают на один и тот же физический адрес. И когда вы что-то меняете по одному, а запрашиваете по другому виртуальному адресу, вы видите это изменение за счёт того, что они смапились в одни и те же страницы.

[2:11:34] Саша: Это, наверное, не очень простая концепция для тех, кто про неё раньше не слышал. Надо попытаться придумать какую-нибудь хорошую аналогию. Давай попробуем.

[2:11:42] Александр: Да, и раньше не слышал, и сейчас услышал, а не увидел.

[2:11:47] Саша: Да-да-да.

[2:11:49] Александр: Ну, по сути, это что-то… Действительно, виртуальную память я представляю как просто ещё один уровень индирекции на объекты. То есть я иду не напрямую туда, где объект располагается в памяти, а иду сначала в некоторую структуру данных по своему указателю и уже там вижу реальный адрес. То есть реальный объект один, а указателей на него много; реальный указатель на реальный объект один внутри этой виртуальной таблицы, а указателей на этот указатель может быть много. Я вот так это вижу.

[2:12:24] Саша: Слушай, я пытаюсь придумать какую-то аналогию. Давай попробую высказать, а ты скажешь, насколько она удачна. Вот представь, что мы приезжаем на многоуровневую парковку, на которой много этажей, но теперь для указания этажа мы будем использовать не номер этажа — 1, 2, 3, 4, 5, — а, например, букву. Этаж номер А, этаж номер Б и так далее. И когда мы получаем парковочный билет — предположим, что у нас есть какие-то очереди на въезд, и билет нам сразу дают на въезде…

[2:12:57] Александр: Электронный билет.

[2:12:58] Саша: Электронный билет, да, на котором написано «этаж номер С, паркуйтесь там». Но когда мы будем подъезжать к самой парковке, там уже будет нарисовано, какая буква мапится в какой этаж. Например, сейчас буква С — это пятый этаж, условно говоря. И ты заезжаешь на пятый этаж и паркуешь там свою машину. То есть у тебя это отображение находится там. И если ты в какой-то момент поймёшь, что у тебя большая толпа, ты назначил этаж номер С, а там случилась авария — кто-нибудь заглох или не может выехать, — и надо что-то срочно делать, то вместо того, чтобы ходить по этим людям и выдавать им новые билетики, ты просто на этом входном табло перерисовываешь и говоришь, что теперь этаж С — это, например, этаж номер 4. Едьте все на четвёртый этаж. Вот это как раз-таки отображение буквы на цифру, по сути, и есть аналогия отображения виртуального адреса на физический.

[2:13:50] Саша: И даже более того — в операционной системе в специальных структурах данных хранятся вот эти отображения виртуальных адресов на физические. И даже в процессоре есть специальный кэш. Все знают, что в процессорах есть кэш первого уровня, второго и третьего, но на самом деле есть специальный кэш, который называется TLB, который хранит самые последние обновления отображений виртуальных страниц на физические. Собственно, к чему это всё? К тому, что в ZGC как раз-таки используется эта идея. У нас есть три виртуальных адресных пространства, которые мапятся на одно и то же физическое. И теперь, когда мы работаем с каким-то указателем, нам не надо париться, что у него есть какой-то там цвет — там один, маркированный один или ноль. То есть мы всё ещё проверим, корректен он или нет, но при доступе к этому указателю, поскольку у нас всё смаплено в одну и ту же физическую память, мы будем попадать в одни и те же физические страницы всё время.

[2:14:46] Александр: Так, а цвет мы теперь вообще на него не смотрим, или он хранится только там, где на физические указывают?

[2:14:51] Саша: Сейчас, я не помню точно… По-моему, это всё используется чисто как метаинформация. То есть физический адрес мы вообще не знаем какой, на что это будет отображаться. Но сама логика отображения будет такая, что вот эти битики не будут учитываться при отображении. Как будто бы их не было, как будто там был выставлен нолик на всех этих четырёх цветах. То есть у нас там есть четыре битика для хранения цвета, и как будто мы туда выставили нолики. Мы могли бы, при обращении к указателю, этот цвет очищать сами, чтобы получать реальное значение указателя и физический доступ к нему.

[2:15:29] Александр: Ну, по сути, мы просто забили на цвет, правильно?

[2:15:32] Саша: Да, мы таким образом хитроумно забили на цвет в такой схеме. Мы просто переложили на логику работы виртуальной памяти то, что теперь нам не надо самим очищать эти битики цветов при реальном доступе к реальной памяти.

[2:15:44] Александр: То есть, смотри, здесь получается двухпроходная схема. Мы получили какой-то указатель, на нём есть какой-то конкретный цвет. Мы, исходя из этого цвета, поняли, надо ли нам что-то делать или нет. И даже если надо что-то делать, мы это сделали, но после этого информация о цвете нам больше не нужна. Мы должны просто получить доступ уже непосредственно по этому адресу, чтобы оттуда получить какое-то значение. И чтобы это физическое значение получить, мы могли бы, наверное, в теории все эти битики, связанные с цветами, обнулять. Но мы можем это сделать через трюк с виртуальным адресным пространством.

[2:16:17] Саша: Окей. Да, я понимаю, звучит это всё непросто. И на самом деле из этого растут корни некоторых болячек ZGC версии 1.0. Как раз из-за того, что там используется виртуальное адресное пространство, во-первых, люди, которые запускали ZGC, зачастую, глядя на RSS процесса — то есть на то, сколько он занял памяти, — могли удивиться. Например, ты запустил ZGC с хипом в 10 гигабайт, но поскольку у тебя было создано 3 виртуальных адресных пространства для одной и той же физической памяти, у тебя отображалось, как будто процесс занимает 30 гигабайт. Выглядит как будто бы не очень здорово.

[2:16:58] Александр: Особенно для людей, которые не знают, как работает виртуальная память. Ну да, испугаться можно.

[2:17:02] Саша: Да, но тут есть ещё один минус. Например, в Linux есть тоже стратегия управления виртуальной памятью, и есть, например, стратегия, связанная с оверкоммитом. В чём здесь смысл? Ты можешь заллоцировать гигантское адресное пространство, просто терабайты памяти. Но пока ты реально к ней физически не обратишься, она не будет проаллоцирована на уровне физических страниц. То есть у тебя как будто бы в теории есть очень много хипа, но пока ты его не трогаешь, он не используется. И как раз-таки эта стратегия оверкоммита в Linux бывает адаптивной, её можно регулировать. Например, ты можешь выключить оверкоммит совсем. И когда ты скажешь, что хочешь заллоцировать, не знаю, 1 терабайт виртуального адресного пространства, он посмотрит на твою свободную физическую память, скажет, что у тебя столько нет: «всё, иди нафиг». Оверкоммит выключать не рекомендуется, например, во всяких базах данных типа Redis, потому что Redis очень любит форкаться в процессе своей работы, любит занять очень много виртуального адресного пространства. И здесь та же самая болячка у ZGC. Если вы запустите ZGC версии 1.0 с отключённым оверкоммитом — например, у вас на машине 4 гигабайта свободной памяти, вы думаете: «ну, значит, моему Java-процессу за глаза хватит 2 гигабайт памяти для хипа, это вписывается», — но поскольку вы отключили оверкоммит, у вас на самом деле попыталось заллоцироваться 6 гигабайт (2 умножаем на 3) виртуального адресного пространства. Оверкоммит сказал, что это запрещено, потому что вы его выключили. И Linux у вас упадёт просто с out of memory, а ваш процесс будет убит специальным механизмом в ядре Linux, который называется out of memory killer. Он как раз-таки найдёт такой процесс, который занял много места, и просто его заглушит. Короче, как вы понимаете, вся эта машинерия с виртуальным адресным пространством — это сложно.

[2:18:47] Александр: Это сложно.

[2:18:49] Саша: Вызывало у людей много вопросов, особенно у тех, которые не знали, как это работает. Но, как я ещё упоминал, она немножко ухудшает производительность в том плане, что утилизация нашего специального TLB-кэша, который находится в процессоре и имеет фиксированный размер и который как раз-таки используется для хранения отображений виртуальных адресных пространств в физические, — она хуже, потому что мы, по сути, одну и ту же информацию храним 3 раза. То есть у нас есть теперь 3 виртуальных адресных пространства, которые указывают на одно и то же. А могло бы храниться гораздо больше чего-то конкретного, одного.

[2:19:19] Александр: Ну, то есть, по сути, у нас оно и так было, но мы просто ещё одно добавили. Частая штука с рантаймами, что они, по сути, дублируют работу операционной системы.

[2:19:29] Саша: Ну, это скорее такой непредумышленный эффект. То есть просто TLB-кэш рассчитан из того, что у тебя на одну виртуальную страничку мапится одна физическая. У тебя не будет двух виртуальных страничек, мапящихся на одну физическую. То есть это всё ещё можно сделать, но это нетипично для приложения.

[2:19:46] Александр: И кэш не предназначен для этой ситуации.

[2:19:47] Саша: Он в этой ситуации будет работать не так хорошо, как если бы этой ситуации не было. А здесь как раз-таки ровно эта ситуация. Поскольку мы пользуемся всякими хитроумными трюками, это выглядит прикольно, и с инженерной точки зрения это здорово, но это ухудшает типичный случай, для которого наш TLB-кэш был спроектирован в процессоре. И таким образом его утилизация ухудшается, и всё работает чуть-чуть медленнее.

[2:20:11] Саша: Так, ну, это была первая версия ZGC. Короче, вышла вторая версия ZGC, в которой как раз-таки всю эту схему пересмотрели и очень много чего изменили. Во-первых, теперь решили хранить информацию о цвете не в старших битах, а в младших. Во-вторых, полностью отказались от схемы с мультимаппингом. То, что мы сейчас долго рассказывали про три виртуальных адресных пространства, как они мапятся друг к другу, — всё, на это забили. Это уже не работает, забудьте, ребята.

[2:20:42] Александр: Да-да-да, это работает в старых версиях ZGC, больше этого нет.

[2:20:46] Саша: И теперь всё гораздо проще. Мы с тобой говорили, что, по сути, этот трюк используют для того, чтобы убрать информацию об этих метабитиках, которые находились в указателе. Что у нас есть такой-то цвет, и мы всё это должны или счистить руками, или через трюк с виртуальной памятью просто обратиться к памяти напрямую. Они отказались от трюков с виртуальной памятью. Они сказали: теперь всегда, когда мы будем обращаться к указателю, мы будем эти битики, которые находятся в конце для хранения информации о цвете, всегда счищать. Это всё ещё занимает небольшое количество ассемблерных инструкций, потому что, по сути, тебе надо просто весь указатель сдвинуть на определённое количество позиций вправо и проверить, что у тебя там находилось до этого. Меняли схему очень сильно. Больше виртуального адресного пространства им не нужно. Но все те же самые барьеры, что у них были — на чтение, на запись, — всё это осталось. Поменялась сама логика этого барьера и того места, где хранится информация об этом цвете указателя.

[2:21:40] Саша: Ещё что важно — что в ZGC появилась поддержка поколений, и для того, чтобы эта поддержка поколений работала, там уже используются не вот эти четыре цвета, а, по-моему, там используются 12 битов для хранения этих цветов. Но, честно тебе скажу, там уже какая-то довольно запутанная схема, я до сих пор ещё не вкурил до конца, как она устроена. Короче, вот этих битиков, которых раньше было четыре для хранения цвета, теперь там 12. И всё ещё у нас соблюдается инвариант, что в какой-то момент работы ZGC есть только один корректный цвет, но как вот эти цвета друг с другом связаны, я не могу сейчас точно сказать, это надо ещё доразбираться.

[2:22:20] Александр: Чёрт, много слов.

[2:22:21] Саша: Да, по крайней мере, я пока ещё не до конца вкурил. Но важное отличие в том, что эти битики находятся уже не в начале указателя, а в конце, и мы каждый раз их счищаем. Как я говорил, у нас теперь есть поддержка поколений, и всё та же самая болячка, которая была и в ZGC версии 1.0: поскольку мы должны использовать 64-битный указатель для того, чтобы в нём хранить какую-то метаинформацию, у нас нет никакой поддержки сжатых указателей. То есть то, что было возможно у нас в Shenandoah — что мы могли использовать 32-битный указатель для хранения ссылок, — в ZGC это невозможно, потому что теперь в указателях хранится метаинформация, и нам, к сожалению, нужно это пространство. Никаких сжатых ссылок нет.

[2:23:00] Александр: Какой это у нас уже идёт час записи? Четвёртый?

[2:23:03] Саша: Ну, третий час идёт у нас, если так считать.

[2:23:06] Александр: А, нормально, нормально. На тайме.

[2:23:08] Саша: Ну ладно, мы всё равно должны пройти этот путь до конца. Мы только что поговорили про Shenandoah и ZGC, поняли, что теперь наши потоки мутатора могут помогать нашему основному потоку garbage collection — то есть они могут переносить объекты, если что-то пошло не так. Они понимают это по всяким хитроумным барьерам, которые срабатывают при чтении и при записи по этим указателям. Теперь немножко про Go. А именно: в Go не так давно вышло обновление garbage collector, называется Green Tea. И на первый взгляд там довольно много информации о том, что всё радикально поменялось, что это стопроцентное изменение. Но на самом деле это не совсем так, как мне кажется. Потому что если сравнивать эти изменения с тем, что происходило, например, в ZGC, когда они выкинули этот виртуальный маппинг, или в Shenandoah, когда они выкинули указатели Брукса, то в Green Tea на самом деле произошло не очень много изменений. Какая у нас была проблема в garbage collector в Go? Я напомню, что у нас там concurrent mark-and-sweep. У нас используются отдельные спаны, и, поскольку объекты не перемещаются в процессе сборки мусора, если у нас было какое-то неудачное распределение объектов, у нас было постоянно большое количество кэш-миссов. Ну, например, мы взяли какой-то объект, хотим промаркировать все объекты, на которые из него есть ссылки, значит, мы начинаем прыгать в какие-то спаны, непонятно какие. То есть нашему кэшу процессора не очень хорошо.

[2:24:33] Александр: Да, напомню, что в Go у нас спаны — это различные области памяти, в которых лежат в определённых коробках одинакового размера наши объекты. И если у нас есть ссылка из ячейки, где коробки размера S, на ячейку, которая размера XL, то мы прям прыгаем между спанами, между полками, грубо говоря, и это всё плохо сказывается на кэшировании процессора.

[2:25:00] Саша: Всё так и есть. И идея Green Tea состоит в следующем. А давайте мы будем маркировать не отдельные объекты по отдельности, а маркировать отдельные спаны. То есть мы знаем, что какой-то конкретный спан имеет фиксированный размер, он, не знаю, 8 килобайт. В нём, конечно же, может храниться много объектов. Но теперь, когда мы понимаем, что у нас есть ссылка из одного спана в другой, вместо того, чтобы прям прыгать из одного объекта в другой и маркировать его, мы пометим, что вот этот спан весь надо будет обработать, проверить, какие объекты там достижимые, а какие нет, и отложим эту работу на потом. И таким образом вся работа происходит уже не пообъектно — то есть ты прыгаешь не пообъектно, а по спанам. Ты взял какой-то конкретный спан, посмотрел, в какие спаны он указывает, прошёлся по этим спанам, посмотрел, на какие спаны они уже указывают. То есть у тебя постоянно идёт проход по каким-то линейным кусочкам памяти большого размера. Как ты понимаешь, это, во-первых, хорошо для кэша процессора, потому что идёт последовательная работа уже какими-то определёнными блоками. Во-вторых, для проверки, промаркирован у нас спан и объекты внутри него или нет, поскольку они теперь лежат вот этими кусками непрерывно, используются какие-то хитроумные векторные операции, про что я тоже не до конца сейчас докурил. Тем не менее, основная фишка Green Tea состоит в том, чтобы у вас была лучшая утилизация CPU за счёт меньшего количества кэш-миссов, за счёт того, что ты делаешь работу уже отдельными кусками, которые идут последовательно и строго. Но на самом деле каких-то фундаментальных изменений в самом алгоритме нет — это всё ещё тот же самый concurrent mark-and-sweep, который работает по той же логике, которую мы рассказывали до этого. Он просто чуть подружелюбнее к процессору, но в остальном похож на то, что было до этого.

[2:26:42] Саша: Ну и, резюмируя именно по Green Tea, — почему они вообще занялись так маркировкой? Ну, потому что на sweep у них уходит уже маленькое количество CPU. Они измеряли, на что у них тратится ресурс во время сборки мусора, и поняли, что на маркировку уходит где-то 90% CPU, затраченного вообще на garbage collection. Поэтому со sweep у них проблем нет, и так фрагментацию они победили за счёт этих хитроумных спанов, а была проблема с прыганием по кэшу. Теперь они рассматривают не отдельные объектики, а отдельные спаны, и это стало чуть лучше работать.

[2:27:15] Саша: Мы вроде как всё рассмотрели, да, все эти разнообразные алгоритмы. Конечно же, не в супер-подробных деталях, потому что про каждый garbage collector можно делать отдельный доклад на кучу времени, и особенно желательно, чтобы это делал тот человек, который его разрабатывал. Но если так высокоуровнево на это всё сверху посмотреть — что мы можем сказать, что у нас есть в Java и что у нас есть в Go? Во-первых, в Java все garbage collector’ы так или иначе используют идею, связанную с компактизацией объектов. То есть мы берём объекты, маркируем, какие из них живые, и переносим их в определённую область памяти, где они лежат рядом друг с другом, целёхонькие и живые. Компактизация чего-то стоит, и она не такая уж простая. И за счёт этого, когда мы, например, делаем конкурентную сборку мусора, нам приходится делать целую кучу машинерии, чтобы это работало консистентно вместе с нашим garbage collector’ом. Надо добавлять целую кучу барьеров, делать значительное количество операций. С другой стороны, компактизация улучшает, во-первых, кэш-локальность, потому что мы собираем все объектики вместе. Во-вторых, она позволяет нам делать очень простую схему с аллокацией, а именно через bumping pointer: мы можем просто-напросто этот bumping pointer увеличивать, и таким образом на аллокацию у нас уходит незначительное количество ресурсов.

[2:28:27] Саша: Что ещё у нас есть в Java? У нас есть гипотеза о поколениях. Она у нас выполняется — что большое количество свежесозданных объектов будет мёртвым. Заметь, как это связано с предыдущим пунктом: что дешёвая аллокация, поэтому у нас чертовски много мусора. И чтобы с ним бороться, мы будем пользоваться как раз-таки гипотезой о поколениях. Ну и ещё у нас в Java есть возможность выбора вообще алгоритма сборки мусора. Поскольку Java со временем развивалась, там были разные сценарии, у нас есть возможность выбора. Как я говорил, stop-the-world-коллекторы лучше подходят для ситуации, когда нам важна пропускная способность, но вы можете добавить уменьшение latency в своём коллекторе — за счёт чего? За счёт добавления новых вспомогательных барьеров. Эти барьеры начнут у вас потихоньку отъедать пропускную способность. Таким образом, ваша пропускная способность будет уменьшаться, но вы уменьшите свою latency.

[2:29:20] Саша: Что касается Go — в отличие от Java, у нас нет компактизации. То есть объекты в принципе не перемещаются. И это, с одной стороны, достоинство: не надо инвалидировать наши указатели, не надо париться со всей этой компактизацией. По сути, нам надо сделать конкурентную, хорошо работающую маркировку. Но, с другой стороны, это приводит к тому, что, во-первых, нам надо сделать какой-то хитрый аллокатор, чтобы у нас не было фрагментации, чтобы было вот это разделение по спанам и так далее, и это немножко посложнее технически, чем просто bumping-pointer-аллокатор. И, с другой стороны, гипотеза о поколениях не находит своего места в Go, вы это поняли. Потому что у нас есть структуры, у нас сам язык так спроектирован, что там, где в Java надо было бы сделать один объект или несколько на хипе, в Go это будет, скорее всего, выделение на стеке, которое вообще никак не будет взаимодействовать с хипом. Ну и в Go алгоритм сборки мусора фиксированный. Мы не можем выбирать какой-то конкретный, потому что дизайн был выбран разработчиками рантайма под конкретный сценарий. А именно, они сказали: пускай у нас будет низкий latency, мы сделаем на это фокус, даже путём какого-то снижения пропускной способности. А за счёт чего мы это можем победить? За счёт того, что мы всегда будем писать горизонтально масштабируемые приложения, потому что пропускную способность вы можете горизонтально масштабировать и победить. А вот latency победить горизонтальным масштабированием — да и вертикальным — не очень просто. Поэтому давайте мы прооптимизируем то, что оптимизировать масштабированием тяжело, а вторую метрику оставим на вас, чтобы вы писали системы, которые масштабируются достаточно легко.

[2:30:58] Александр: Навалил ты, конечно, жести. Наверное, это войдёт в топ-5 выпусков по хардкорности подкаста «Тысяча фичей», где прям надо, знаешь, пару раз послушать, если ты действительно хочешь вникнуть. Или ты хочешь уснуть крепко.

[2:31:14] Саша: Ой, это, мне кажется, уже да.

[2:31:16] Александр: Вот кто сейчас спит под мой подкаст — я сейчас вас запрограммирую на большие деньги. Вы пройдёте все интервью, вы молодец, потому что слушаете подкаст «Тысяча фичей». Но вообще мне кажется, что подобные вещи, конечно, надо слушать на прогулке, и это больше развивающая, нежели чилл-история. Поэтому если хочется спать, то лучше поспать.

[2:31:38] Саша: Сто процентов.

[2:31:40] Александр: А для тех, кто ещё не уснул и проснулся, — мы, кстати, ещё не до конца поговорим.

[2:31:44] Саша: Ну, слушай, всё-таки это довольно тяжёлая тема, особенно с учётом того, что рассказывать её голосом. Я думаю, что не всё, что я там сказал, может быть, прям на сто процентов до конца. Кое-где пришлось сгладить углы, чтобы это было не слишком сложно для восприятия. Но тем не менее я думаю, что основную идею — о том, что вообще garbage collector это некоторый компромисс, и вы должны сами определяться с тем, какой компромисс у себя использовать, — донести получилось.

[2:32:11] Александр: Да, и тут у меня вопрос. Вот смотри, компромисс. Окей, мы говорим про это всё. Вот сейчас особенно, мне кажется, это важно в том, как мы сейчас программируем: я, когда реально приложение разрабатываю, мыслю прям в этой парадигме. Вот у меня есть много garbage collector’ов в Java, например, — берём Java, там несколько штук. Как мне, как разработчику, который разрабатывает приложение и хочет запустить в продакшен стартап, выбрать, какой garbage collector нужен именно мне? Вот как я должен рассуждать?

[2:32:43] Саша: Как раз-таки отвечаю на твой вопрос, почему нет того самого золотого garbage collector’а, который используется и всех победил в Java. Как раз-таки потому, что garbage collector’ы предназначены немножко для разных ситуаций, и это зависит уже от вашей ситуации. Это вообще вам, как разработчику, надо выбрать осознанно, желательно над каким-нибудь нагрузочным тестом. Но есть некоторые, скажем так, сценарии, в которых я бы в тех или иных случаях начал рассматривать тот или иной коллектор. Давай я озвучу. Во-первых, если нам супер критично latency — у нас какое-нибудь алготрейдинговое приложение, хотя на Java сейчас, по-моему, особо не пишут уже, но тем не менее, — если нам нужны какие-нибудь субмиллисекундные или миллисекундные паузы, и мы готовы ради этого пожертвовать даже тем, что у нас снизится пропускная способность, надо начать, наверное, с ZGC и Shenandoah, потому что они как раз-таки дают эти гарантии.

[2:33:35] Александр: Ну это вот какие, например, приложения, кроме алготрейдинга? Вот я сейчас так думаю. В целом, если я какой-то, например, кэш разрабатываю, игрушку, где есть рендеринг, где нужно быстро — то есть нужна отзывчивость системы, — то есть десктоп-приложение скорее даже, даже та же самая IntelliJ IDEA, я уж не знаю, кстати, какой они используют, но я бы вот в эту сторону думал при первом подходе. Потому что большие задержки в приложении, которое что-то рендерит, — это вообще говоря, больше 200 миллисекунд уже непозволительно, а если мы говорим про 60 FPS, то это надо, конечно, миллисекунды посчитать, я сейчас не посчитаю уже через 3 часа, но это очень мало миллисекунд, что-то 3–4, где-то такие цифры у тебя есть. И остаётся: если у тебя GC-пауза в 10 миллисекунд, то ты 60 FPS не достигнешь.

[2:34:27] Саша: Ну да, а сейчас ещё у всех и не 60 FPS уже, а 120, короче, как будто это становится критичным. Когда у нас самая важная вещь — это latency, надо смотреть именно в эту сторону. С другой стороны, бывает ситуация, когда нужна пропускная способность. Тут, конечно, вопрос, чем мы готовы пожертвовать. Если мы прям железно знаем, что если у нас приложение зависнет на 10 секунд, у нас не отвалятся никакие таймауты на сокетах, у нас балансировщик не увидит, что сервис почему-то перестал отвечать и умер, — если мы прям на 100% готовы ко всему этому, то, может, стоит посмотреть сначала в Parallel GC, потому что он как раз-таки даёт самый лучший выигрыш пропускной способности по сравнению с остальными. Но если всё-таки вам прям супер-супер это не нужно, вы готовы потерпеть незначительное снижение пропускной способности, может, стоит уже посмотреть G1, потому что это как раз-таки некоторый гибридный подход, промежуточный между latency и пропускной способностью. Единственное, что я тут хочу сказать: как я говорил, у G1, особенно поначалу, были некоторые перформансные проблемы, поэтому я знаю нескольких людей, которые, пользуясь всё время G1, чем-то были недовольны, говорили, что это отстойный GC, и больше им не пользовались. Но G1 сильно поменялся с тех времён, и если у вас был какой-то негативный с ним опыт, на самом деле стоит ему дать ещё один шанс, потому что я думаю, что он вполне может вас сейчас устроить в случае такого гибридного сценария.

[2:36:02] Александр: Ну вот, кстати, про Parallel — я вспомнил, где я это видел. Думаю, блин, GC старый, а что-то где-то я в каких-то конфигах, в SH-файлах видел, где он прям юзается, Parallel GC. Это Spark-джобы. Spark, мне кажется, до сих пор рекомендует использовать Parallel. Почему? Потому что это просто батчевая обработка огромного количества данных, и ему совершенно всё равно на то, какие там сокеты, какие пинги, — просто молотилка данных. Вот молотилка данных — Parallel GC.

[2:36:29] Саша: Ну да, как раз отличный сценарий. Получается, мы поговорили про баланс, про пропускную способность и latency — то есть этим можно играться. Но, с другой стороны, есть ещё несколько характерных сценариев, которые могут сработать. Во-первых, если у нас есть очень большой избыток CPU — то есть мы поняли, что наш сервис на самом деле CPU недоутилизирует, его можно на что-нибудь потратить, — в таком случае, несмотря на то, что у нас есть какие-то ожидания по пропускной способности и latency, стоит посмотреть в конкурентные сборщики мусора. Почему? Что я имею в виду? Может получиться так, что раньше, когда вы пользовались stop-the-world-коллектором, у вас происходили паузы, а приложение в это время не работало. А теперь, несмотря на то, что вы всё это сделаете конкурентно, — за счёт того, что это работает конкурентно с вашим приложением и занимает CPU, которых у вас и так много, — это может привести к тому, что общее время исполнения у вас всё равно уменьшится. В общем, это скорее специфичный сценарий, который надо проверять именно на вашей нагрузке. Но если у вас есть лишний CPU, эту гипотезу можно и не помешает прочекать.

[2:37:32] Саша: Ещё один сценарий. Если у вас гигантские хипы — не знаю, терабайтные или ещё что-то, хотя я в своей жизни уже давно никаких не видел, но наверняка у кого-нибудь есть…

[2:37:41] Александр: А это надо какой-то shared memory RAM, чтобы было. Как реализовать это физически, мне стало интересно. То есть это на каких-то гигантских серверах, там плашки этих оперативок. Ну, я не знаю, сколько сейчас, кстати, можно на одном сервере набить.

[2:37:58] Саша: Да, интересно, сколько максимум. Терабайт уже можно набить, мне кажется.

[2:38:00] Александр: Ну, я бы не удивился. Но если уже на консюмерских лэптопах там больше ста, по-моему — 96 точно гигабайт на Mac есть.

[2:38:08] Саша: Да-да-да. Ну, как будто на серверах в 10 раз больше всего.

[2:38:12] Александр: Ну да, и контроллеры, если они там уже есть по 100 ядер, зачем тебе тогда иметь меньше памяти — как будто тоже бессмысленно. Так что, скорее всего, поддерживает.

[2:38:22] Саша: Ну, короче, если у тебя гигантские хипы, прям огроменные, то тот сборщик мусора, который проектировался именно для такого сценария, — это именно ZGC. ZGC вообще, наверное, самый активно развиваемый сейчас сборщик мусора, по моему субъективному мнению, в Java. Потому что там появляются всякие фишки первыми — не знаю, в своё время там появился первый конкурентный обход стеков, ну и всякие такие фишки. В общем, сейчас мы в это не будем глубоко погружаться. Все самые интересные технологические достижения, которые появляются, как будто появляются именно там. И если посмотреть последние доклады по ZGC, они прям так себя позиционируют, что это будет универсальный сборщик для таких больших хипов, таких больших сценариев. И они стараются прийти к сценарию, когда тебе вообще никак не надо будет подстраивать этот ZGC. То есть, как ты помнишь, у нас была ситуация с CMS, когда тебе нужен был лист А4 опций, которые надо подкрутить. А вот в ZGC наоборот прикручивают целую кучу эвристик, которые сами себя подрегулируют, сами увидят ситуацию, что у тебя гигантский хип, как-то подрегулируются под эти настройки, чтобы это всё хорошо работало.

[2:39:26] Саша: Но тут можно вспомнить про другой сценарий, а именно наоборот: что тебе всё ещё нужна конкурентная сборка мусора, то есть тебе всё ещё нужен низкий latency, но ты точно знаешь, что хип у тебя будет небольшого размера, меньше 30 гигабайт. В таком случае ты можешь использовать сжатые указатели, как ты помнишь. Сжатые указатели благотворно влияют на производительность, потому что у тебя происходит лучшая утилизация кэшей: все объектики, точнее все ссылочки, начинают занимать меньше, больше объектиков влезает в кэши. Но ZGC, как мы помним, за то, что он использует 64-битные указатели, сжатые указатели не поддерживает. А конкурентный сборщик мусора, который поддерживает, — это Shenandoah. Соответственно, для такого сценария я попробовал бы Shenandoah со сжатыми указателями. Но опять же, это всё некоторые, знаешь, высокоуровневые гадания на воде. На самом деле всегда в таких сценариях вам надо измерять перформанс вашего приложения именно в вашем конкретном сценарии, на вашей конкретной реалистичной нагрузке и на вашем конкретном алгоритме, — и тогда уже делать какие-то определённые выводы. В общем, тут, как мы с тобой говорили до этого, не надо заниматься астрологией, надо заниматься исследованиями.

[2:40:32] Александр: Да-да, но большинство, к сожалению, верят в астрологию.

[2:40:39] Саша: Ты думаешь, после выпуска меня назовут астрологом, да?

[2:40:44] Александр: Ну, мы тут тоже, знаешь, типа, не запускаем приложение, не смотрим по факту — это чем-то подобным занимаемся, просто у нас более доказательная база и опыта больше. Это, кстати, educated guess: когда тебе говорят, что ты астролог, ты говоришь — это просто мой educated guess, и всё.

[2:40:58] Саша: Да-да.

[2:41:00] Александр: Я бы ещё такой вопрос задал про Java. А по дефолту-то что там используется? То есть если я ничего не делаю, просто запускаю Java, JAR, — какой там GC? G1?

[2:41:10] Саша: Есть определённый сценарий, когда у тебя совсем нет ядер и очень мало памяти, — там всё ещё может вызываться Serial GC, потому что Serial GC как раз-таки для сценариев, когда у тебя почти ничего нет. А так у тебя сборщик мусора по умолчанию сейчас — это G1.

[2:41:24] Александр: Я вот, по-моему, с какой-то версии Java помню, ZGC начал использоваться по умолчанию, или он ввёлся с недавних пор?

[2:41:31] Саша: Он ввёлся, но по умолчанию он не используется. Ну, потому что, смотри, это логично: ты же не знаешь, какой сценарий у типичного пользователя, поэтому ты должен что-то среднее выбрать. А среднее между latency и пропускной способностью — это G1. Поэтому как будто логично его выбрать.

[2:41:48] Александр: Да-да, окей. Google говорит, что G1. Ребята, всё так. Хотя вот смотри, какая версия: с девятой пишется, что G1, а в восьмой и до неё был Serial.

[2:42:02] Саша: Да, это я про старый весь Java не упоминал. В старых там действительно было Parallel GC и Serial, в зависимости от того, какие у тебя… Там же, помнишь, было это разделение дурацкое на то, что у нас GC для сервера, GC для клиента. По-моему, это всё уже благополучно отмело.

[2:42:17] Александр: Конечно, да. Вот я как раз в седьмом классе был, я это очень хорошо помню.

[2:42:21] Саша: Каким ты GC пользовался — для сервера или для клиента?

[2:42:27] Александр: Minecraft у меня там клиентский был, и серверный. Нет, я на самом деле не знаю.

[2:42:33] Саша: Окей, окей.

[2:42:34] Александр: Слушай, а, кстати, интересно, какой GC в Minecraft сейчас используется? Ну, какая Java там ещё?

[2:42:40] Саша: На какой Java Minecraft? Сейчас в Roblox все играют, Minecraft уже уходит, но всё ещё популярен. Ну, тебе надо в какой-нибудь выпуск позвать, кстати, — сейчас есть довольно большое количество разработчиков, которые стали выступать на конференциях и которые выросли как раз из Minecraft. Они, очевидно, сильно моложе меня. Может быть, будет интересно с ними разговаривать как раз на эту тему: как там в этом Minecraft всё было устроено, жив ли он до сих пор, какой там сборщик памяти, надо ли им там заниматься, были ли оптимизации, профилирование? Это же довольно интересно звучит.

[2:43:10] Александр: Да, это офигенная штука, когда ты пишешь свои вот эти плагины, по-моему, называются, и прям Java изучаешь. Это топчик, топчик.

[2:43:15] Саша: Ну да, я, собственно, и знаю несколько людей с бэкграундом оттуда, и они очень неплохие джависты.

[2:43:21] Александр: Да, слушай, а вопрос тогда по Go. Вот если в Java мы разобрались, то в Go как понять, какой использовать?

[2:43:28] Саша: А у тебя нет выбора, и всё.

[2:43:31] Александр: Ну, то есть у тебя фиксирован…

[2:43:33] Саша: Не, ну смотри, там есть всякие ещё трюки, типа пулов переиспользуемых объектов и так далее, для всяких сценариев, когда тебя GC не устраивает. Но это уже более специфичная тема. По сути, у тебя есть выбор: пользоваться дефолтным garbage collector’ом или не пользоваться им с помощью пулов переиспользуемых объектов, и всё.

[2:43:51] Александр: Ну, может, это и хорошо, кстати. Знаешь, вот что мне ещё нравится в Go — это то, что у него, в отличие от Java (там, особенно раньше, как было), у тебя нет 150 ручек для регулировки GC. У тебя там на самом деле есть управление, как часто ему срабатывать, какой у тебя лимит на память, и, по-моему, это всё. Там две ручки всего лишь.

[2:44:10] Саша: Очень просто и легко управляется.

[2:44:12] Александр: Это офигенно. Мне в этом плане максимально импонирует эта система. И что самое интересное — с одной стороны, можно надуть сеньорские Java-энтерпрайзные щёки и сказать: «ну что это такое-то несерьёзное? Вот в Java мы можем всё подтюнить». Но практика показывает, что, блин, Go работает, на нём пишут базы данных, как RocksDB замечательная база данных, на нём Kubernetes написан, на нём огромное количество серверов написано и работает. Так что а сколько головной боли снимает? Как проще писать на Go?

[2:44:44] Саша: Не, ну я думаю, что они тоже там, скорее всего, в какие-нибудь off-heap, ну не off-heap, а в пулы переиспользуемых объектов уходят.

[2:44:50] Александр: Да, конечно, да.

[2:44:52] Саша: Я, кстати, вспомнил, не так давно в LinkedIn видел пост… Ты же знаешь, что есть база данных на Java, QuestDB?

[2:44:58] Александр: Знаем такое.

[2:44:59] Саша: Да, там очень много всяких интересных потрохов, очень много. Короче, если вам вся эта внутрянка интересна, это прям интересный кладезь всяких решений. Я как раз вспомнил пост, где обсуждалось использование объектов в Java. Короче, люди пытались воспользоваться пулом переиспользуемых объектов в Java — то есть ты не аллоцируешь новый объект через слово new, а у тебя есть какая-то структура данных, в которой хранятся объектики: если объект не используется, ты его туда возвращаешь, получаешь обратно и так далее. Но из-за того, что это нарушает гипотезу о поколениях, это не очень хорошо работало, и в какой-то момент они отказались от этой идеи и ушли на off-heap. То есть в Java у тебя тоже, на самом деле, есть возможность отказаться совсем от GC и идти на off-heap, но, блин, вот тут проблема…

[2:45:45] Александр: Да, так базы данных и делают все на Java.

[2:45:47] Саша: Ну, это больно, потому что, по сути, ты на Java перестаёшь писать. То есть это как будто, знаешь, так несправедливо.

[2:45:55] Александр: Да, но это трейд-офф: либо у тебя продукт не работает, либо он работает, но тебе трудно писать.

[2:46:03] Александр: Окей. А какой там из твоей практики у тебя самый часто встречаемый сборщик мусора был?

[2:46:09] Саша: Я вот недавно, по-моему, ZGC специально включил, только недавно был pull request, я уже забыл. А, нет, я Serial заиспользовал, потому что у меня CLI был как раз, я оптимизировал время старта CLI — почему-то с ним быстрее на бенчмарках показал. Вот. В основном это G1 тоже. И, наверное, в 97% случаев я вообще не думаю о GC последние 5 лет, когда пишу на Java. Просто потому что они как будто, знаешь, дошли до такого уровня, что перестали быть проблемой в подавляющем большинстве случаев.

[2:46:45] Александр: Ну, так, может, это и хорошо. Может, вот мы сейчас ругаем, говорим, что Go клёво, а на самом деле Java-то тоже не такая уж и плоха. Потому что были эти все истории с настройкой GC, а сейчас у нас целая куча garbage collector’ов, все они как-то уже более-менее подстраиваются, и, может, поэтому не надо так много времени тратить.

[2:47:01] Саша: Да, я не трачу время на GC, и вот твой вопрос заставил меня прям вспомнить, подумать, потому что на поверхности это не лежит. И это действительно хорошо. Потому что, честно говоря, об этом думать редко хочется. И когда ты начинаешь думать о GC, скорее всего, это не энтузиазм, а решение какой-то проблемы, которая, скорее всего, ещё и горящая.

[2:47:22] Александр: Ну, я надеюсь, у слушателей теперь после этого подкаста таких проблем станет либо меньше, либо, может быть, если они случатся, они что-нибудь вспомнят из того, что мы с тобой обсуждали, и посмотрят: ага, вот тут мне надо ZGC включить, и всё, и больше не париться. И на этом всё, решение проблем.

[2:47:39] Саша: Да, попробовать стоит.

[2:47:41] Александр: Ну, от меня, конечно, огромный респект слушателям, тем, кто дослушал до конца. Это, ну, вы красавчики, вы крутые инженеры, ребята, знайте про себя это. И, конечно, огромное спасибо тебе, Саша, за то, что ты пришёл, поделился таким плотным материалом. Мне было, по крайней мере, очень интересно. И я вот теперь думаю — можешь сходить на собес, чтобы тебя спросили, а как работает garbage collector, чтобы просто на три часа телегу закатить, и интервьюер просто охренел.

[2:48:07] Саша: Думаешь, это оффер принесёт?

[2:48:09] Александр: Да.

[2:48:10] Саша: Я не уверен. Это, мне кажется, наоборот. Типа, чувак уволился после собеседования с тобой, и всё.

[2:48:17] Александр: Да-да-да. Довести до инсульта во время спора — вот из этой тоже области.

[2:48:23] Саша: Ну да. Спасибо тебе, Саша, что дал возможность рассказать. Конечно, это всё было, не знаю, насколько доступно, без картинок, без всего, голосом. Но я надеюсь всё-таки, что какие-то полезные вещи люди узнали. Если нет — ну, не обессудьте. Надеюсь, у вас хороший сон, и вы выспитесь, по крайней мере, завтра.

[2:48:40] Александр: Да. А тем, кому хочется копнуть поглубже, что можешь посоветовать? Может, какие-то ссылки есть, телеграм-каналы, помимо книги, которую мы посоветовали вначале?

[2:48:49] Саша: Ага. Ну, смотри, вот я сейчас как раз-таки то, что тебе сейчас озвучивал, ещё готовлю доклад на SnowOne и на JPoint, где про всё это буду докладывать. Вообще, у меня есть ТГ-канал, я, правда, его что-то не особо активно развиваю, но вот думаю как раз-таки всякие интересные фишки оттуда выкладывать отдельными постами. Потому что, как я тебе говорил, можно сказать словами, что в G1 вот такой-то барьер, который занимается только инструкцией, а с другой стороны, можно его рассмотреть уже детально в каком-нибудь посте, как-то поинтереснее. Не знаю, насколько у меня это получится.

[2:49:20] Александр: Ссылочки в шоу-нотах будут.

[2:49:24] Саша: Да, окей, я тебе просто дам ссылочки, а там уж как получится.

[2:49:27] Александр: Ну, на этом всё. Спасибо всем большое, и пока.

[2:49:30] Саша: Пока всем.

[2:49:33] Александр: Пока.