Рубрики
Без рубрики

🚀 Визуализация управления памятью в JVM (Java, Kotlin, Scala, Groovy, Clojure)

Давайте взглянем на то, как JVM управляет памятью. Помеченный java, kotlin, scala, clojure.

Первоначально опубликовано по адресу deepu.tech .

В этой серии из нескольких частей я стремлюсь развеять мистику концепций, лежащих в основе управления памятью, и глубже взглянуть на управление памятью в некоторых современных языках программирования. Я надеюсь, что эта серия даст вам некоторое представление о том, что происходит под капотом этих языков с точки зрения управления памятью. В этой главе мы рассмотрим управление памятью виртуальной машины Java(JVM), используемой такими языками, как Java, Kotlin, Scala, Clojure, Groovy и так далее.

Если вы не читали первую часть этой серии, пожалуйста, прочтите ее сначала, поскольку я объяснил там разницу между стековой и кучной памятью, что было бы полезно для понимания этой главы.

Во-первых, давайте посмотрим, какова структура памяти JVM. Это основано на JDK 11 и далее. Ниже приведена память, доступная для процесса JVM и выделяемая операционной системой (ОС).

Это собственная память, выделяемая операционной системой, и ее объем зависит от операционной системы, процессора и JRE. Давайте посмотрим, для чего предназначены различные области:

Куча памяти

Это место, где JVM хранит объектные или динамические данные. Это самый большой блок области памяти, и именно здесь происходит Сборка мусора (GC) . Размером кучи памяти можно управлять с помощью Xms (начальный) и Xmx (Максимум) флагов. Вся память кучи не привязана к виртуальной машине(VM) поскольку часть его зарезервирована как виртуальное пространство, и куча может вырасти, чтобы использовать это. Куча далее делится на “Молодое” и “Старое”//пространство поколений.

  • Молодое поколение : Молодое поколение или “Новое пространство” – это место, где живут новые объекты, и далее оно делится на “Пространство Эдема” и “Пространство выживших”. Это пространство управляется “Младшим GC” также иногда называемым “Молодым GC”

    • Eden Space : Здесь создаются новые объекты. Когда мы создаем новый объект, здесь выделяется память.
    • Когда мы создаем новый объект, здесь выделяется память. Когда мы создаем новый объект, здесь выделяется память. S0 S0 S0 Старое поколение : Старое поколение или
  • “Арендуемое пространство” – это место, где живут объекты, достигшие максимального порога владения во время незначительного GC. Старое поколение или “Арендуемое пространство” – это место, где живут объекты, достигшие максимального порога владения во время незначительного GC.

Пакеты потоков

Пакеты потоков Здесь хранятся статические данные, относящиеся к конкретному потоку, включая фреймы методов/функций и указатели на объекты. Ограничение памяти стека может быть установлено с помощью флага Xss .

Метапространство

Это часть встроенной памяти и по умолчанию не имеет верхнего предела. Это то, что раньше было Постоянное поколение(PermGen) Пробел в более ранних версиях JVM. Это пространство используется загрузчиками классов для хранения определений классов. Если это пространство будет продолжать расти, ОС может переместить хранящиеся здесь данные из оперативной памяти в виртуальную память, что может замедлить работу приложения. Чтобы избежать этого, можно установить ограничение на метапространство, используемое с флагами XX:MetaspaceSize и -XX:MaxMetaspaceSize , в этом случае приложение может просто выдавать ошибки нехватки памяти.

Кэш кода

Это то место, где Just In Time(JIT) компилятор хранит скомпилированные блоки кода, к которым часто обращаются. Как правило, JVM должна интерпретировать байт-код в машинный машинный код, тогда как JIT-скомпилированный код не нужно интерпретировать, поскольку он уже находится в собственном формате и кэшируется здесь.

Общие библиотеки

Здесь хранится собственный код для любых используемых совместно используемых библиотек. Это загружается операционной системой только один раз для каждого процесса.

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

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

class Employee {
    String name;
    Integer salary;
    Integer sales;
    Integer bonus;

    public Employee(String name, Integer salary, Integer sales) {
        this.name = name;
        this.salary = salary;
        this.sales = sales;
    }
}

public class Test {
    static int BONUS_PERCENTAGE = 10;

    static int getBonusPercentage(int salary) {
        int percentage = salary * BONUS_PERCENTAGE / 100;
        return percentage;
    }

    static int findEmployeeBonus(int salary, int noOfSales) {
        int bonusPercentage = getBonusPercentage(salary);
        int bonus = bonusPercentage * noOfSales;
        return bonus;
    }

    public static void main(String[] args) {
        Employee john = new Employee("John", 5000, 5);
        john.bonus = findEmployeeBonus(john.salary, john.sales);
        System.out.println(john.bonus);
    }
}

Нажмите на слайды и перемещайтесь вперед/назад с помощью клавиш со стрелками, чтобы увидеть, как выполняется вышеупомянутая программа и как используется память стека и кучи:

Примечание: Если слайды выглядят обрезанными по краям, нажмите на заголовок слайда или здесь чтобы открыть его непосредственно в SpeakerDeck.

Как вы можете видеть:

  • Каждый вызов функции добавляется в память стека потока в виде фрейм-блока
  • Все локальные переменные, включая аргументы и возвращаемое значение, сохраняются в блоке фрейма функции в стеке
  • Все примитивные типы, такие как int , хранятся непосредственно в стеке
  • Все типы объектов, такие как Employee , Integer , Строка создается в куче, и на нее ссылаются из стека с помощью указателей стека. Это относится и к статическим полям
  • Функции, вызываемые из текущей функции, помещаются поверх стека
  • Когда функция возвращает, ее фрейм удаляется из стека
  • Как только основной процесс завершен, объекты в куче больше не имеют указателей из стека и становятся бесхозными
  • Если вы не создаете копию явно, все ссылки на объекты внутри других объектов выполняются с помощью указателей

Стек, как вы можете видеть, управляется автоматически и выполняется операционной системой, а не самой JVM. Следовательно, нам не нужно сильно беспокоиться о стеке. Куча, с другой стороны, не управляется ОС автоматически, и поскольку это самое большое пространство памяти и содержит динамические данные, оно может расти экспоненциально, что со временем приведет к нехватке памяти в нашей программе. Он также становится фрагментированным с течением времени, замедляя работу приложений. Вот тут-то и помогает JVM. Он автоматически управляет кучей, используя процесс сборки мусора.

Теперь, когда мы знаем, как JVM выделяет память, давайте посмотрим, как она автоматически управляет памятью кучи, что очень важно для производительности приложения. Когда программа пытается выделить в куче больше памяти, чем доступно в свободном доступе (в зависимости от Xmx config) мы сталкиваемся с ошибками нехватки памяти .

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

Сборщик мусора в JVM отвечает за:

  • Выделение памяти из ОС и обратно в ОС.
  • Передача выделенной памяти приложению по мере его запроса.
  • Определение того, какие части выделенной памяти все еще используются приложением.
  • Восстановление неиспользуемой памяти для повторного использования приложением.

Сборщики мусора JVM являются поколенческими (объекты в куче группируются по их возрасту и очищаются на разных этапах). Существует много различных алгоритмов, доступных для сбора мусора, но Mark & Sweep является наиболее часто используемым.

Разметка и очистка сбора мусора

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

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

Этот тип GC также называют GC с остановкой мира, поскольку они вводят паузы в приложении во время выполнения GC.

JVM предлагает несколько различных алгоритмов на выбор, когда дело доходит до GC, и может быть доступно еще несколько вариантов в зависимости от используемого вами поставщика JDK (например, Shenandoah GC , доступный в OpenJDK). Различные реализации сосредоточены на разных целях, таких как:

  • Пропускная способность : Время, затраченное на сбор мусора вместо время применения влияет на пропускную способность. Пропускная способность в идеале должна быть высокой (т.Е. при низком времени GC).
  • Время паузы : Продолжительность, на которую GC останавливает выполнение приложения. Время паузы в идеале должно быть очень низким.
  • Footprint : Размер используемой кучи. В идеале это должно быть на низком уровне.

Коллекторы доступны начиная с JDK 11

Начиная с JDK 11, который является текущей версией LTE, доступны следующие сборщики мусора, а используемые по умолчанию выбираются JVM на основе используемого оборудования и операционной системы. Мы всегда можем указать GC, который также будет использоваться с переключателем -XX .

  • Последовательный коллектор : Он использует один поток для GC и эффективен для приложений с небольшими наборами данных и наиболее подходит для однопроцессорных машин. Это можно включить с помощью переключателя -XX:+UseSerialGC .
  • Параллельный сборщик : Этот сборщик ориентирован на высокую пропускную способность и использует несколько потоков для ускорения процесса GC. Это предназначено для приложений со средними и большими наборами данных, работающих на многопоточном/многопроцессорном оборудовании. Это можно включить с помощью переключателя -XX:+UseParallelGC .
  • Мусор-Первый(G1) Коллектор : Сборщик G1 в основном работает параллельно (это означает, что одновременно выполняется только дорогостоящая работа). Это предназначено для многопроцессорных компьютеров с большим объемом памяти и включено по умолчанию на большинстве современных компьютеров и ОС. Он ориентирован на низкое время паузы и высокую пропускную способность. Это можно включить с помощью переключателя -XX:+UseG1GC .
  • Z Сборщик мусора : Это новый экспериментальный GC, представленный в JDK11. Это масштабируемый сборщик с низкой задержкой. Он параллелен и не останавливает выполнение потоков приложения, следовательно, не останавливает мир. Он предназначен для приложений, которые требуют низкой задержки и/или используют очень большую кучу (несколько терабайт). Это можно включить с помощью переключателя -XX:+UseZGC |/.

Процесс GC

Независимо от используемого сборщика, JVM имеет два типа процесса GC в зависимости от того, когда и где он выполняется: второстепенный GC и основной GC.

Незначительный GC

Этот тип GC сохраняет пространство молодого поколения компактным и чистым. Это срабатывает при выполнении следующих условий:

  • JVM не может получить необходимую память из пространства Eden для выделения нового объекта

Изначально все области пространства кучи пусты. Память Эдема заполняется первой, за ней следует пространство для выживших и, наконец, постоянное пространство.

Давайте посмотрим на второстепенный процесс GC:

Нажимайте на слайды и перемещайтесь вперед/назад с помощью клавиш со стрелками, чтобы увидеть процесс:

Примечание: Если слайды выглядят обрезанными по краям, нажмите на заголовок слайда или здесь чтобы открыть его непосредственно в SpeakerDeck.

  1. Давайте предположим, что на момент запуска в пространстве Eden уже есть объекты (блоки с 01 по 06 помечены как используемая память).
  2. Приложение создает новый объект(07)
  3. JVM пытается получить необходимую память из Eden space, но в Eden нет свободного места для размещения нашего объекта, и, следовательно, JVM запускает незначительный GC
  4. GC рекурсивно пересекает граф объектов, начиная с указателей стека, чтобы пометить объекты, которые используются как живые (используемая память), а оставшиеся объекты как мусор (сироты)
  5. JVM выбирает один случайный блок из S0 и S1 в качестве “В пробел”, давайте предположим, что это был S0. GC теперь перемещает все живые объекты в “В пространство”, S0, которое было пустым, когда мы начинали, и увеличивает их возраст на единицу.
  6. GC теперь очищает пространство Eden, и новому объекту выделяется память в пространстве Eden
  7. Давайте предположим, что прошло некоторое время, и теперь в пространстве Eden есть больше объектов (блоки с 07 по 13 помечены как используемая память).
  8. Приложение создает новый объект(14)
  9. JVM пытается получить требуемую память из Eden space, но в Eden нет свободного места для размещения нашего объекта, и, следовательно, JVM запускает второй второстепенный GC
  10. Фаза пометки повторяется, и живые/бесхозные объекты помечаются, включая те, которые находятся в пространстве выживших “В космос”.
  11. JVM теперь выбирает свободный S1 в качестве “В космос”, а S0 становится “Из космоса”. GC теперь перемещает все живые объекты из Eden space и “Из космоса”, S0, в “В космос”, S1, который был пуст, когда мы начинали, и увеличивает их возраст на единицу. Поскольку некоторые объекты здесь не помещаются, они перемещаются в “Постоянное пространство”, поскольку оставшееся пространство не может расти, и этот процесс называется преждевременным продвижением. Это может произойти, даже если одно из оставшихся в живых мест свободно
  12. GC теперь очищает пространство Eden и “Из пространства”, S0, и новому объекту выделяется память в пространстве Eden
  13. Это продолжает повторяться для каждого второстепенного GC, и выжившие перемещаются между S0 и S1, а их возраст увеличивается. Как только возраст достигает “максимального возрастного порога”, по умолчанию 15, объект перемещается в “Арендуемое пространство”.

Итак, мы увидели, как minor GC отвоевывает пространство у молодого поколения. Это процесс остановки мира но это происходит так быстро, что большую часть времени это незначительно.

Крупный ГК

Этот тип GC сохраняет пространство старого поколения (арендованное) компактным и чистым. Это срабатывает при выполнении следующих условий:

  • Разработчик вызывает System.gc() или Runtime.getRuntime().gc() из программы.
  • JVM решает, что выделенного места недостаточно, поскольку оно заполняется из-за незначительных циклов GC.
  • Во время незначительного GC, если JVM не в состоянии восстановить достаточное количество памяти из пространств Eden или survivor.
  • Если мы установим параметр MaxMetaspaceSize для JVM, и не будет достаточно места для загрузки новых классов.

Давайте посмотрим на основной процесс GC, он не так сложен, как второстепенный GC:

  1. Давайте предположим, что прошло много второстепенных циклов GC, а выделенное пространство почти заполнено, и JVM решает запустить “Основной GC”.
  2. GC рекурсивно пересекает граф объектов, начиная с указателей стека, чтобы пометить объекты, которые используются как живые (используемая память), а оставшиеся объекты как мусор (сироты) в арендованном пространстве. Если основной GC был запущен во время второстепенного GC, процесс включает в себя молодое (Eden & Survivor) и арендованное пространство
  3. Теперь GC удалил все бесхозные объекты и освободил память
  4. Во время крупного события GC, если в куче больше нет объектов, JVM также освобождает память из метапространства, удаляя из него загруженные классы. Это также называется полным GC

Этот пост должен дать вам обзор структуры памяти JVM и управления памятью. Это не является исчерпывающим, существует гораздо больше продвинутых концепций и вариантов настройки, доступных для конкретных случаев использования, и вы можете узнать о них из https://docs.oracle.com . Но для большинства разработчиков JVM (Java, Kotlin, Scala, Clojure, JRuby, Jython) этого уровня информации было бы достаточно, и я надеюсь, что это поможет вам написать лучший код, учитывая это, для более производительных приложений, и учет этого поможет вам избежать следующей проблемы с утечкой памяти, которую вы может столкнуться с иным.

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

Если вам понравилась эта статья, пожалуйста, оставьте лайк или комментарий.

Вы можете следить за мной на Twitter и LinkedIn .

Оригинал: “https://dev.to/deepu105/visualizing-memory-management-in-jvm-java-kotlin-scala-groovy-clojure-19le”