Управление памятью в Java

Добрый день. Меня зовут Глеб Чернявский, и я являюсь Java разработчиком компании Software Cats.

Компания Software Cats уже более пяти лет занимается аутстафом и аутсорсом по направлениям

Если у вас есть ИТ-проблема, оставьте ваши контакты, и мы поможем составить план ее решения.

Я расскажу достаточно просто и понятно, как под капотом джава управляет памятью, на какие зоны поделена память и как они друг с другом взаимодействует. Уверен, что для многих информация из этой статьи будет полезна. Сегодня мы обсудим такую тему, как управление памяти в Java, как это работает. Проблемы с памятью – основная причина замедления работы приложения. Понимание устройства памяти для разработчика является важным знанием, поскольку позволяет улучшать производительность приложения, избегать утечек.
Немного пробежимся по плану статьи. Сперва мы обсудим каким образом осуществлялась работа в памяти на языках до Java, обсудим плюсы и минусы данного подхода. Затем перейдём к реализации работы с памятью в Java, поговорим о выделяемых пространствах, обсудим их специфику. Далее мы затронем Garbage Collector, поговорим об его устройстве, разберем типы GC, которые есть в Java, обсудим их плюсы и минусы.

Аллокация памяти до Java

В языках до Java: в C и C++ весь процесс так называемой “аллокации” (выделения памяти под объект) и “деллокации” (очищение используемой памяти) ложился на плечи разработчиков. На картинке представлен пример кода на C, здесь мы видим, что для переменной X, которая является указателем, мы задаем определенный размер в памяти. После того, как переменная больше нам не нужна, мы вызываем метод free(), который очищает значение, а затем присваиваем указателю нулевую ссылку.
Какие же проблемы влечёт за собой такое ручное управление памятью:
Висячие указатели: это происходит, если вы не устанавливаете переменную, содержащую указатель, в значение NULL после освобождения адреса памяти.
Утечки памяти: это происходит, если вы не освобождаете память, которая больше не нужна. Она не становится доступной снова и остается заблокированной без необходимости. В конце концов, вы можете исчерпать память, удерживая все значения, которые вам не нужны.

Шаблонный код: у вас в кодовой базе много кода, который имеет дело с выделением и деаллокацией, но не так много с вашей бизнес-логикой. Весь этот код необходимо поддерживать.

Склонность к ошибкам: Даже если разработчики (обычно) знают, что должно происходить, легко допустить небольшую ошибку и забыть освободить память или, например, установить указатель на NULL.

JVM - Java Virtual Machine

Каким же образом в Java решили подойти к решению этой проблемы? Встречайте, её величество JVM. Виртуальная машина джава – то, благодаря чему Java завоевало свое положение в мире программирования. Принцип “пиши один раз, используй везде” осуществлён, благодаря виртуальной машине джава, которая транслировала байт код компилятора на разные типы машин, благодаря чему снимала головную боль с разработчиков. Благодаря JVM управление памятью перестало лежать на плечах разработчиков, а стало одной из фишек JVM.
Чтобы иметь возможность выполнять приложения, JVM состоит из трех компонентов. Один из них используется для загрузки всех классов – загрузчик классов. На самом деле это сложный процесс: классы загружаются и проверяется байткод. Загрузка классов и выполнение байткода требует память. Эта память необходима для хранения данных классов, распределения памяти и инструкций, которые выполняются. Для этого и предназначен компонент runtime data areas.

Это та часть, о которой мы будем пристально говорить в этой статье. Когда классы загружены, файлы должны быть выполнены.

Компонент, выполняющий байт код после его загрузки в основную память с помощью первых двух компонентов, называется компонентом исполнения.

Компонентом принято называть механизмом выполнения. Механизм выполнения взаимодействует с Java Native Interface (JNI) для использования родных библиотек, необходимых для выполнения байт кода.

Из чего же состоит это самое RDA? Давайте рассмотрим их подробнее.

Stack, Heap, Metaspace – мы разберём подробнее в следующих слайдах, про PC register и Native Stack коротко скажу сейчас.

Регистр Program Counter (PC) знает, какой код выполняется, храня адрес инструкции, которая выполняется в его потоке. Каждый поток имеет свой собственный регистр PC, иногда также называемый стеком вызовов. Он знает последовательность операторов, которые должны быть выполнены, и какой из них выполняется в данный момент. Вот почему нам необходим отдельный регистр для каждого потока – с одним регистром PC мы не смогли бы выполнять несколько потоков одновременно!

Native method stack – стек родных методов, также известный как стек C. Он предназначен для нативного кода, который выполняется. Родной код – это часть реализации, которая написана не на Java, а, например, на C. Это стеки, которые хранят значения для нативного кода, точно так же, как стек JVM хранит значения для кода Java. И снова каждый поток имеет свой собственный.

Области памяти в Java

Stack – область памяти джава, где хранятся примитивы и ссылочные типы на heap. Стэк работает по принципу Last in – first out, наглядно этот пример демонстрирует стопка тарелок. Для того, чтобы (безопасно) добраться до нижней тарелки, нам необходимо сначала снять все верхние тарелки. Для каждого потока в Java существует свой отдельный stack.
На картинке представлена визуализация работы стэка, как вы видите, для каждого треда, свой набор фреймов стэка. Фреймы стека создаются под каждый метод и существует в рамках работы метода, как только метод прекращает свою работу, он уничтожается, и продолжает работу фрейм, который был вызван до этого.
Heap или куча, область памяти в джава, где хранятся объекты, на которые ссылается stack. Здесь же могут храниться примитивы, как часть полей объекта. Также в хипе размещён пул строк. Куча доступна для всех потоков. Куча содержит в себе две области памяти: молодое и старое поколение.
На картинке представлены эти две области памяти. Область молодого поколения (слева) также поделена на пространство эдема (где создаются новые объекты) и пространство выживших. После того, как память эдема заполнена, происходит вызов минорного ГК (о котором мы поговорим позже), и все живые объекты переносятся в пространство выживших. Пространство выживших также поделено на to и from. Объекты из эдема и из from перемещаются в to, попутно получая +1 к своему возрасту.

Если возраст объекта достигнет порогового значения – он переместится в пространство старшего поколения.

Есть две гипотезы насчёт поколений в heap:
  • Слабая гипотеза – большинство объектов умирают молодыми (die young)
  • Сильная гипотеза – старые объекты живут до конца.
Можно посмотреть на то, как взаимодействуют между собой stack и heap. В методе мэйн мы создаём массив строк, который хранится в хипе, а стек на него ссылается. Также мы создали переменную x = 0, которая хранится только в стэке и также объект класса person, который хранится в heap. У этого объекта есть поле имя – которое хранится в String pool. Также примитивный инт – возраст.

Garbage Collector

Пришло время познакомится с главным трудягой, всей сутью работы с памятью в Java, благодаря кому происходит деллокация (очищение) памяти в Java – Garbage Collector.
Не о ней, хотя судя по всему она тоже является GC. И она тоже собирает мусор на острове Ява в Индонезии.

Но что есть мусор в джава приложении? Мусор – это объекты, которые нам больше не нужны, которые бесполезно занимают выделенное под них вместо памяти, но не имеют связи со Stack.
Все объекты в stack имеют связь с кучей, поэтому ни один из этих объектов не является мусором и не доступен для сборщика мусора.
Мы обнулили объект в stack, и он больше НАПРЯМУЮ не связан с кучей, но при этом он по-прежнему не доступен для GC, так как имеет обособленную связь, через список, который имеет связь со стэком.
После того, как мы обнулили наш список, в дело уже может вступить GC и удалить все объекты, которые больше не имеют связи со stack, то есть наш лист и объект person.

Способы очистки мусора при помощи GC

Поговорим про маркировку объектов, каким же образом GC определяет, что объект является мусором и его нужно уничтожить? Все "достижимые" объекты из heap помечаются. Достижимые = имеют связь со stack. Каждый такой объект получает бит информации, изначально он имеет отметку 0. После завершения разметки все объекты со значением 0 удаляются.
Для того, чтобы пометить объекты, но при этом не удалить случайно только что созданные объекты (так как их бит будет равнятся 0), в Java реализована стратегия под названием Stop-the-world, которая останавливает все потоки на время маркировки. Это необходимо для того, чтобы гарантировать, что ни один новый объект не будет создан, а, следовательно, случайно не удалён, так как не был помечен. Минусами такого подхода, безусловно, является пауза, которая влияет на производительность приложения.
Есть другая стратегия, называемая подсчетом ссылок. GC добавляет к счётчику объекта +1 каждый раз, когда кто-то ссылается на этот объект. Если у объекта счётчик 0, значит он является мусором. Плюсом такого подхода в том, что не нужно останавливать приложение. Однако могут возникнуть ситуации, когда два мусорных объекта будут ссылаться друг на друга.
GC считает эти объекты живыми. Это проблема называется островком изоляции.
Давайте обсудим, как же GC производит "подметание" или делокацию объектов. Это осуществляется тремя способами:

  • Обычное подметание. В данном случае GC просто удаляет объекты из памяти. Такой подход практически не используется, так как в данном случае память остается фрагментированной, и если мы попытаемся записать в данный участок объекты, память которых будет превышать размер предыдущих объектов, мы можем получить ошибку, хотя фактически памяти хватало. Плюсом такой очистки является её скорость.
  • Подметание с компоновкой. В данном случае, после того как мы удалили объекты, мы производим компоновку объектов в памяти, чтобы неиспользуемые участки были в полном доступе для следующих объектов. Это полностью решает проблему первого типа подметания, но для процессора требуется больше мощности для таких операций и, естественно, медленнее, так как компоновка происходит по очереди.
  • Подметание с копированием. В таком сценарии мы полностью копируем все живые объекты и переносим в чистый участок памяти, а предыдущий раздел полностью удаляем. Этот процесс намного быстрее компоновки, но требует больше памяти для того, чтобы копировать объекты.
Производительность GC:

  • Throughput: пропускная способность приложения
  • Предсказуемость: на какое время прерывается работа приложения
  • Footprint: объем использованной памяти

Виды GC

Поговорим про типы GC. На данный момент в Java представлены 5 видов GС.
Serial GC (он же – последовательный сборщик). Работает на одном потоке, использует стратегию SWT. Маркировка и уборка с копированием для молодого поколения. И уборка со сжатием для старого поколения. Основное достоинство данного сборщика очевидно – это непритязательность по части ресурсов компьютера. Так как всю работу он выполняет последовательно в одном потоке, никаких заметных оверхедов и негативных побочных эффектов у него нет.
Parallel GC. Стандартный GC, начиная с Java 8. Использует сразу несколько потоков. В целом, это его главное отличие от Serial GC.

Бесспорным плюсом данного сборщика на фоне Serial GC является возможность автоматической подстройки под требуемые параметры производительности и меньшие паузы на время cборок. При наличии нескольких процессорных ядер выигрыш в скорости будет практически во всех приложениях.
CMS GC. Имеет продвинутый алгоритм маркировки-подметания. Использует несколько потоков. Маркировка-копирование для молодого поколения, две короткие паузы в старом поколении, многопоточная маркировки и уборка для старого поколения.

При этом CMS GC использует ту же самую организацию памяти, что и уже рассмотренные Serial / Parallel GC: регионы Eden + Survivor 0 + Survivor 1 + Tenured и такие же принципы малой сборки мусора. Отличия начинаются только когда дело доходит до полной сборки. В случае CMS ее называют старшей (major) сборкой, а не полной, так как она не затрагивает объекты младшего поколения. В результате малая и старшая сборки здесь всегда разделены. Одним из побочных эффектов такого разделения является то, что все объекты младшего поколения (даже потенциально мертвые) могут играть роль корней при определении статуса объектов в старшем поколении.

Начинается она с остановки основных потоков приложения и пометки всех объектов, напрямую доступных из корней. После этого приложение возобновляет свою работу, а сборщик параллельно с ним производит поиск всех живых объектов, доступных по ссылкам из тех самых помеченных корневых объектов (эту часть он делает в одном или в нескольких потоках).

Естественно, за время такого поиска ситуация в куче может поменяться и не вся информация, собранная во время поиска живых объектов, оказывается актуальной. Поэтому сборщик еще раз приостанавливает работу приложения и просматривает кучу для поиска живых объектов, ускользнувших от него за время первого прохода. При этом допускается, что в живые будут записаны объекты, которые на время окончания составления списка таковыми уже не являются. Эти объекты называются плавающим мусором (floating garbage), они будут удалены в процессе следующей сборки.

Важным отличием сборщика CMS от рассмотренных ранее является также то, что он не дожидается заполнения Tenured для того, чтобы начать старшую сборку. Вместо этого он трудится в фоновом режиме постоянно, пытаясь поддерживать Tenured в компактном состоянии.

Достоинством данного сборщика по сравнению с рассмотренными ранее Serial / Parallel GC является его ориентированность на минимизацию времен простоя, что является критическим фактором для многих приложений. Но для выполнения этой задачи приходится жертвовать ресурсами процессора и зачастую общей пропускной способностью.
G1 GC. Делит кучу на маленькие регионы. Маркирует и убирает эти регионы. Отслеживает количество достижимых и недостижимых объектов для регионов. Регионы, где больше всего мусора, собираются первыми (поэтому G1).

Малые сборки выполняются периодически для очистки младшего поколения и переноса объектов в регионы Survivor, либо их повышения до старшего поколения с переносом в Tenured. Над переносом объектов трудятся несколько потоков, и на время этого процесса работа основного приложения останавливается. Это уже знакомый нам подход из рассмотренных ранее сборщиков, но отличие состоит в том, что очистка выполняется не на всем поколении, а только на части регионов, которые сборщик сможет очистить, не превышая желаемого времени. При этом он выбирает для очистки те регионы, в которых, по его мнению, скопилось наибольшее количество мусора, и очистка которых принесет наибольший результат. Отсюда как раз название Garbage First – мусор в первую очередь.

В целом считается, что сборщик G1 более аккуратно предсказывает размеры пауз, чем CMS, и лучше распределяет сборки во времени, чтобы не допустить длительных остановок приложения, особенно при больших размерах кучи. При этом он лишен и некоторых других недостатков CMS, например, он не фрагментирует память.
Z GC. Зачем?

Официальное описание говорит нам о том, что при его проектировании ставились следующие цели:

Поддерживать паузы STW на уровне меньше одной миллисекунды.
Сделать так, чтобы паузы не увеличивались с ростом размера кучи, количества живых объектов или количества корневых ссылок.
Поддерживать кучи размером до 16 ТБ.

Для достижения своих целей ZGC использует подход, называемый раскрашиванием указателей. На практике это означает, что каждый 64-битный указатель (а ZGC поддерживает только 64-битные системы) содержит не только адрес памяти, но и дополнительные метаданные, определяющие текущий статус указателя. Использует окраску ссылок. Работает только в 64 битных системах, так как для окраски требуется доп бит. Избегает фрагментации используя релокацию. Использует загрузочные барьеры для избежания утечек.

Еще одной особенностью ZGC является использование т. н. барьеров (barriers) во время конкурентных фаз сборки мусора (когда сборщик работает одновременно с приложением, не останавливая его выполнение).

Барьер – это просто функция, которая принимает указатель на объект в памяти, анализирует цвет этого указателя, в зависимости от цвета выполняет какие-либо действия с этим указателем или даже с самим объектом, на который он ссылается, после чего возвращает актуальное значение указателя, которое необходимо использовать для доступа к объекту.

Важной особенностью функции-барьера является то, что она выполняется в том числе в рамках основных потоков приложения. То есть сборкой мусора занимаются не только выделенные потоки GC, но и само приложение, чего мы в предыдущих сборщиках не встречали.

На практике ZGC, действительно, позволяет добиться субмиллисекундных пауз, в том числе на очень больших кучах, что очень хорошо для приложений, чувствительных даже к коротким задержкам в обработке запросов.
Во-первых, из-за наличия длительных конкурентных фаз сборки может страдать пропускная способность приложения. Насколько сильно – зависит от конкретного приложения.

Во-вторых, используемые для доступа к объектам барьеры совсем не бесплатные. В разных источниках фигурируют оценки замедления перехода по указателю на 4-5%, но это в оптимистичном сценарии, когда цвет указателя "хороший" и барьеру не требуется менять сам указатель или переносить соответствующий объект на новое место. В случае обнаружения "нехорошего" цвета указателя барьеру придется уйти на длинный путь и потратить значительно больше времени на приведение данных в порядок.

На картинке представлены сравнения коллекторов между собой. Желтым покрашены процессы, требующие STW.
При выборе GC мы должны руководствоваться следующими показателями.

  • Скорость выделения места для новых объектов.
  • Популяция хипа – количество объектов и их размер, живущих в хипе.
  • Частота мутаций – как часто ссылки обновляются в памяти.
  • Среднее время жизни объекта.

На этом я завершаю свой рассказ об устройстве и управлении памятью в Java, надеюсь теперь Вам стало более понятен процесс аллокации и делокации памяти, работы GC и плюсы, минусы их использования.

Наша команда уже более пяти лет занимается реализацией проектов на Java и усилением команд по направлениям

За время существования компании мы принимали участие в работе над более чем 100 проектами различного объема и длительности.

Если перед вами стоят вызовы, при которых вам может пригодится наша экспертиза, просто напишите нам,

Мы договоримся с вами об онлайн-встрече, чтобы подробнее обсудить ваш проект и вашу проблему.
Глеб Чернявский
Java developer

Еще почитать по теме:

    Обсудить проект_
    Если у вас есть ИТ-проблема, оставьте ваши контакты, и мы поможем составить план ее решения. Обещаем не слать спам.
    Нажимая, я говорю «Да»
    политике конфиденциальности
    hello@swcats.kz


    Контакты_