1. Введение
Сегодня приложения нередко обслуживают тысячи или даже миллионы пользователей одновременно. Такие приложения требуют огромных объемов памяти. Однако управление всей этой памятью может легко повлиять на производительность приложения.
Чтобы решить эту проблему, в Java 11 был представлен сборщик мусора Z (ZGC) в качестве экспериментальной реализации сборщика мусора (GC).
В этом руководстве мы увидим, как ZGC удается поддерживать малое время паузы даже на кучах размером в несколько терабайт .
2. Основные понятия
Чтобы понять, как работает ZGC, нам нужно понять основные понятия и терминологию, лежащие в основе управления памятью и сборщиков мусора .
2.1. Управление памятью
Физическая память — это оперативная память, которую предоставляет наше оборудование.
Операционная система (ОС) выделяет пространство виртуальной памяти для каждого приложения.
Конечно, мы храним виртуальную память в физической памяти, и ОС отвечает за поддержание соответствия между ними. Это сопоставление обычно включает аппаратное ускорение.
2.2. Мульти-отображение
Множественное отображение означает, что в виртуальной памяти есть определенные адреса, которые указывают на один и тот же адрес в физической памяти. Поскольку приложения получают доступ к данным через виртуальную память, они ничего не знают об этом механизме (да им и не нужно).
По сути, мы сопоставляем несколько диапазонов виртуальной памяти с одним и тем же диапазоном в физической памяти:
На первый взгляд варианты его использования не очевидны, но позже мы увидим, что ZGC нуждается в нем, чтобы творить чудеса. Кроме того, он обеспечивает некоторую безопасность, поскольку разделяет области памяти приложений.
2.3. Переезд
Поскольку мы используем динамическое выделение памяти, память среднего приложения со временем становится фрагментированной. Это потому, что когда мы освобождаем объект в середине памяти, там остается пробел свободного места. Со временем эти пробелы накапливаются, и наша память будет выглядеть как шахматная доска, состоящая из чередующихся областей свободного и используемого пространства.
Конечно, мы могли бы попытаться заполнить эти пробелы новыми объектами. Для этого мы должны просканировать память на наличие свободного места, достаточного для размещения нашего объекта. Это дорогостоящая операция, особенно если нам приходится делать это каждый раз, когда мы хотим выделить память. Кроме того, память все равно будет фрагментирована, так как, вероятно, мы не сможем найти свободное место нужного нам размера. Поэтому между объектами будут промежутки. Конечно, эти зазоры меньше. Кроме того, мы можем попытаться свести к минимуму эти пробелы, но это требует еще большей вычислительной мощности.
Другая стратегия заключается в частом перемещении объектов из фрагментированных областей памяти в свободные области в более компактном формате . Чтобы быть более эффективным, мы разделили пространство памяти на блоки. Мы перемещаем все объекты в блоке или ни один из них. Таким образом, выделение памяти будет происходить быстрее, поскольку мы знаем, что в памяти есть целые пустые блоки.
2.4. Вывоз мусора
Когда мы создаем Java-приложение, нам не нужно освобождать выделенную нами память, потому что за нас это делают сборщики мусора. Таким образом, GC отслеживает, к каким объектам мы можем получить доступ из нашего приложения через цепочку ссылок, и освобождает те, к которым мы не можем получить доступ .
GC должен отслеживать состояние объектов в пространстве кучи, чтобы выполнять свою работу. Например, возможное состояние достижимо. Это означает, что приложение содержит ссылку на объект. Эта ссылка может быть транзитивной. Важно только то, что приложение может обращаться к этим объектам через ссылки. Другой пример финализируем: объекты, к которым мы не можем получить доступ. Это объекты, которые мы считаем мусором.
Для этого сборщики мусора имеют несколько фаз.
2.5. Свойства фазы ГХ
Фазы GC могут иметь разные свойства:
- параллельная фаза может выполняться на нескольких потоках GC
- последовательная фаза выполняется в одном потоке
- фаза остановки мира не может выполняться одновременно с кодом приложения
- параллельная фаза может работать в фоновом режиме, пока наше приложение выполняет свою работу
- инкрементная фаза может завершиться до завершения всей своей работы и продолжиться позже
Обратите внимание, что все вышеперечисленные методы имеют свои сильные и слабые стороны. Например, предположим, что у нас есть фаза, которая может выполняться одновременно с нашим приложением. Последовательная реализация этой фазы требует 1% от общей производительности ЦП и выполняется в течение 1000 мс. Напротив, параллельная реализация использует 30% ЦП и завершает свою работу за 50 мс.
В этом примере параллельное решение в целом использует больше ЦП, поскольку оно может быть более сложным и требует синхронизации потоков . Для приложений с интенсивным использованием ЦП (например, пакетных заданий) это проблема, поскольку у нас меньше вычислительной мощности для выполнения полезной работы.
Конечно, в этом примере цифры вымышлены. Однако ясно, что все приложения имеют свои характеристики, поэтому у них разные требования к сборке мусора.
Более подробное описание можно найти в нашей статье об управлении памятью в Java .
3. Концепции ZGC
ZGC намерена обеспечить как можно более короткие этапы Stop-the-World. Это достигается таким образом, что продолжительность этих пауз не увеличивается с размером кучи. Благодаря этим характеристикам ZGC хорошо подходит для серверных приложений, где распространены большие кучи и требуется быстрое время отклика приложений.
В дополнение к проверенным методам GC, ZGC представляет новые концепции, которые мы рассмотрим в следующих разделах.
А пока давайте взглянем на общую картину того, как работает ZGC.
3.1. Большая фотография
ZGC имеет этап, называемый маркировкой, когда мы находим достижимые объекты. Сборщик мусора может хранить информацию о состоянии объекта несколькими способами. Например, мы могли бы создать карту,
где ключи — это адреса памяти, а значение — это состояние объекта по этому адресу. Это просто, но требует дополнительной памяти для хранения этой информации. Кроме того, поддержание такой карты может быть сложной задачей.
ZGC использует другой подход: он хранит эталонное состояние в виде битов эталона. Это называется эталонной окраской. Но таким образом у нас есть новый вызов. Установка битов ссылки для хранения метаданных об объекте означает, что несколько ссылок могут указывать на один и тот же объект, поскольку биты состояния не содержат никакой информации о местоположении объекта. Мультимаппинг в помощь!
Мы также хотим уменьшить фрагментацию памяти. Для этого ZGC использует перемещение. Но при большой куче перемещение — медленный процесс. Поскольку ZGC не требует длительных пауз, большую часть перемещений он выполняет параллельно с приложением. Но это вводит новую проблему.
Допустим, у нас есть ссылка на объект. ZGC перемещает его, и происходит переключение контекста, где запускается поток приложения и пытается получить доступ к этому объекту через его старый адрес. ZGC использует грузовые барьеры, чтобы решить эту проблему. Барьер нагрузки — это фрагмент кода, который запускается, когда поток загружает ссылку из кучи — например, когда мы обращаемся к не примитивному полю объекта.
В ZGC барьеры нагрузки проверяют биты метаданных ссылки. В зависимости от этих битов ZGC может выполнить некоторую обработку ссылки, прежде чем мы ее получим. Следовательно, это может привести к совершенно другой ссылке. Мы называем это переназначением.
3.2. Маркировка
ZGC разбивает маркировку на три этапа.
Первая фаза — это фаза остановки мира. На этом этапе мы ищем корневые ссылки и помечаем их. Корневые ссылки — это отправные точки для доступа к объектам в куче , например локальным переменным или статическим полям. Поскольку количество корневых ссылок обычно невелико, эта фаза коротка.
Следующий этап является параллельным. На этом этапе мы проходим по графу объектов, начиная с корневых ссылок. Мы отмечаем каждый объект, которого достигаем. Кроме того, когда грузовой барьер обнаруживает немаркированную ссылку, он также помечает ее.
Последняя фаза также является фазой остановки мира для обработки некоторых крайних случаев, таких как слабые ссылки.
На данный момент мы знаем, до каких объектов мы можем добраться.
ZGC использует для маркировки биты
метаданных маркировки 0
и маркировки 1. ``
3.3. Эталонная раскраска
Ссылка представляет позицию байта в виртуальной памяти. Однако для этого необязательно использовать все биты ссылки — некоторые биты могут представлять свойства ссылки . Это то, что мы называем эталонной окраской.
С 32 битами мы можем адресовать 4 гигабайта. Поскольку в настоящее время компьютеры обычно имеют больше памяти, чем это, мы, очевидно, не можем использовать ни один из этих 32 битов для раскрашивания. Поэтому ZGC использует 64-битные ссылки. Это означает , что ZGC доступен только на 64-битных платформах:
Ссылки ZGC используют 42 бита для представления самого адреса. В результате ссылки ZGC могут адресовать 4 терабайта памяти.
Кроме того, у нас есть 4 бита для хранения эталонных состояний:
- `` финализируемый бит - объект доступен только через
финализатор
бит переназначения
— ссылка актуальна и указывает на текущее местоположение объекта (см. перемещение)биты marker0
иmarker1
— они используются для обозначения доступных объектов
Мы также назвали эти биты битами метаданных. В ZGC ровно один из этих битов метаданных равен 1.
3.4. Переезд
В ZGC переезд состоит из следующих этапов:
- Параллельная фаза, которая ищет блоки, которые мы хотим переместить, и помещает их в набор перемещений.
- Фаза остановки мира перемещает все корневые ссылки в наборе перемещений и обновляет их ссылки.
- Параллельная фаза перемещает все оставшиеся объекты в наборе перемещений и сохраняет сопоставление между старым и новым адресами в таблице переадресации.
- Перезапись оставшихся ссылок происходит на следующем этапе маркировки. Таким образом, нам не нужно дважды проходить дерево объектов. В качестве альтернативы это могут сделать и грузовые барьеры.
3.5. Переназначение и барьеры нагрузки
Обратите внимание, что на этапе перемещения мы не переписывали большинство ссылок на перемещенные адреса. Следовательно, используя эти ссылки, мы не сможем получить доступ к нужным нам объектам. Хуже того, мы могли получить доступ к мусору.
ZGC использует грузовые барьеры для решения этой проблемы. Барьеры нагрузки фиксируют ссылки, указывающие на перемещенные объекты, с помощью метода, называемого переназначением.
Когда приложение загружает ссылку, оно запускает барьер загрузки, который выполняет следующие шаги, чтобы вернуть правильную ссылку:
- Проверяет, установлен ли бит
переназначения
в 1. Если это так, это означает, что ссылка актуальна, поэтому мы можем безопасно ее вернуть. - Затем мы проверяем, был ли указанный объект в наборе перемещений или нет. Если это не так, значит, мы не хотели его перемещать. Чтобы избежать этой проверки в следующий раз, когда мы загружаем эту ссылку, мы устанавливаем бит
переназначения
в 1 и возвращаем обновленную ссылку. - Теперь мы знаем, что объект, к которому мы хотим получить доступ, был целью перемещения. Вопрос только в том, произошло ли переселение или нет? Если объект был перемещен, мы переходим к следующему шагу. В противном случае мы перемещаем его сейчас и создаем запись в таблице переадресации, в которой хранится новый адрес для каждого перемещенного объекта. После этого переходим к следующему шагу.
- Теперь мы знаем, что объект был перемещен. Либо ZGC, нами на предыдущем шаге, либо нагрузочный барьер при более раннем попадании этого объекта. Мы обновляем эту ссылку на новое местоположение объекта (либо адресом из предыдущего шага, либо ищем его в таблице переадресации), устанавливаем бит
переназначения
и возвращаем ссылку.
Вот и все, с помощью описанных выше шагов мы добились того, что каждый раз, когда мы пытаемся получить доступ к объекту, мы получаем самую последнюю ссылку на него. Поскольку каждый раз, когда мы загружаем ссылку, она запускает барьер загрузки. Следовательно, это снижает производительность приложения. Особенно при первом доступе к перемещенному объекту. Но это цена, которую мы должны заплатить, если нам нужны короткие паузы. И поскольку эти шаги выполняются относительно быстро, это не оказывает существенного влияния на производительность приложения.
4. Как включить ZGC?
Мы можем включить ZGC со следующими параметрами командной строки при запуске нашего приложения:
-XX:+UnlockExperimentalVMOptions -XX:+UseZGC
Обратите внимание, что, поскольку ZGC является экспериментальным GC, для его официальной поддержки потребуется некоторое время.
5. Вывод
В этой статье мы увидели, что ZGC намеревается поддерживать большие размеры кучи с малым временем паузы приложений.
Для достижения этой цели он использует методы, в том числе цветные 64-битные ссылки, барьеры нагрузки, перемещение и переназначение.