Java Virtual Machine, или JVM, является важной частью Java-платформы и представляет собой виртуальное окружение, в котором запускаются Java-приложения. В этой статье мы разберемся, как устроена и работает JVM простым и понятным языком.
Что такое JVM?
JVM (Java Virtual Machine) — это виртуальная машина, обеспечивающая выполнение Java-приложений. Она является независимой от операционной системы, что позволяет Java-приложениям работать на любой платформе, имеющей JVM. Основная задача JVM — конвертировать байт-код Java в машинный код, который затем исполняется процессором компьютера.
Как устроена JVM?
JVM состоит из нескольких основных компонентов:
- Класс-лоадер (Class Loader) — загружает и инициализирует классы Java, преобразуя их из байт-кода в машинный код для выполнения.
- Память JVM (JVM Memory) — разделяется на несколько областей, таких как куча (Heap), стек (Stack), область методов (Method Area) и статическая область данных (Static Data Area).
- Сборщик мусора (Garbage Collector) — автоматически удаляет ненужные объекты из памяти, предотвращая утечки памяти.
- Исполнительный движок (Execution Engine) — интерпретирует и выполняет байт-код, преобразуя его в машинный код.
Как работает JVM (Java Virtual Machine)
Java Virtual Machine (JVM) — это виртуальная машина, которая обеспечивает исполнение Java-приложений. JVM выполняет байт-код, который является промежуточным представлением Java-кода после компиляции исходного кода. Рассмотрим детально процесс работы JVM:
- Загрузка классов (Class Loading): JVM загружает классы приложения в свою память в процессе, который называется загрузкой классов. Загрузчик классов (ClassLoader) считывает байт-код из файлов .class и загружает их в область методов (Method Area) JVM. Загрузка классов может происходить динамически, то есть по мере необходимости в процессе выполнения приложения.
- Проверка байт-кода (Bytecode Verification): после загрузки классов, JVM проверяет корректность и безопасность байт-кода, чтобы убедиться, что он соответствует спецификации Java и не содержит вредоносного кода. Это делается с помощью компонента, называемого верификатором байт-кода (Bytecode Verifier).
- Инициализация классов (Class Initialization): JVM выполняет инициализацию статических полей и блоков инициализации классов. Этот процесс включает присвоение начальных значений статическим переменным и выполнение статических блоков инициализации в порядке их объявления в исходном коде.
- Исполнение байт-кода (Bytecode Execution): после инициализации классов, JVM начинает исполнение байт-кода. Байт-код интерпретируется и выполняется с помощью компонента, называемого исполнителем байт-кода (Bytecode Interpreter). В некоторых случаях, JVM может использовать технологию Just-In-Time (JIT) компиляции для трансляции байт-кода в машинный код, который затем выполняется непосредственно процессором. Это может существенно улучшить производительность приложения.
- Сборка мусора (Garbage Collection): в процессе выполнения приложения, JVM автоматически освобождает память, занимаемую неиспользуемыми объектами, с помощью процесса, называемого сборкой мусора (Garbage Collection). Сборка мусора помогает предотвратить утечки памяти и обеспечивает эффективное использование памяти.
Память JVM
Память в JVM состоит из 5 основных участков:
Рассмотрим каждый из них более детально.
Куча (Heap)
Куча — это область памяти, где хранятся объекты и их данные. Она состоит из двух областей: молодого поколения (Young Generation) и старого поколения (Old Generation).
- Молодое поколение состоит из трех частей: одной области Eden и двух областей Survivor Space (S0 и S1). Все новые объекты сначала создаются в области Eden. После процесса сборки мусора (Garbage Collection), выжившие объекты перемещаются между областями Survivor Space.
- Старое поколение предназначено для хранения объектов, которые прожили достаточно долго и не были удалены во время предыдущих процессов сборки мусора. Объекты из молодого поколения могут быть перемещены в старое поколение, если они продолжают существовать после нескольких сборок мусора.
Стек (Stack)
Стек — это область памяти, где хранятся локальные переменные и ссылки на объекты, а также информация о вызовах методов. Для каждого потока (Thread) выделяется свой стек. Стек организован в виде последовательности стековых фреймов (Stack Frames), каждый из которых соответствует вызову метода.
Stack и Stack Frame имеют разные значения и используются в контексте управления памятью и выполнения программы в Java:
- Stack: Стек – это область памяти, выделенная для каждого потока в Java-приложении. Стек используется для хранения стековых фреймов, соответствующих вызовам методов, выполняемых в потоке. Стек работает по принципу LIFO (Last In, First Out), то есть последний добавленный элемент будет первым извлеченным. Когда поток вызывает метод, на стек добавляется новый стековый фрейм, а когда метод завершает выполнение, соответствующий стековый фрейм удаляется из стека.
- Stack Frame: Стековый фрейм – это структура данных, представляющая вызов одного метода в потоке выполнения. Каждый стековый фрейм содержит информацию о вызываемом методе, такую как локальные переменные, ссылки на операнды и ссылку на вызываемый метод. Когда метод вызывается, создается новый стековый фрейм, который размещается на вершине стека, а когда метод завершает выполнение, соответствующий стековый фрейм удаляется из стека.
Вкратце, стек – это область памяти, которая хранит стековые фреймы для каждого вызова метода в потоке, а стековый фрейм – это структура данных, содержащая информацию о вызове метода.
Область методов (Method Area) и Meta Space
Область методов хранит структуру классов, такую как метаданные классов, пул констант, статические переменные и код методов. Она разделяется между всеми потоками, работающими в JVM.
Meta Space является частью области методов и хранится в нативной памяти, а не в куче. Meta Space заменило PermGen (Permanent Generation) начиная с Java 8 и было введено для улучшения управления памятью и предотвращения переполнения PermGen, которое возникало в более ранних версиях Java.
Meta Space хранит следующие данные:
- Метаданные классов: это информация о структуре классов, такая как имена классов, имена полей, имена методов, модификаторы доступа и т. д.
- Пул констант: это набор значений, используемых в байт-коде, включая числовые константы, ссылки на классы и методы, строковые литералы и т. д.
- Статические переменные: это переменные, которые связаны с классом, а не с его экземплярами. Они имеют одно значение для всех экземпляров класса и сохраняют свое значение между вызовами методов.
- Код методов: это скомпилированный байт-код для методов класса.
Сборщик мусора (Garbage Collector) также работает в области Meta Space, удаляя метаданные классов, которые больше не используются приложением. Это помогает предотвратить утечки памяти и обеспечивает эффективное использование памяти JVM.
Регистры нативных методов (Native Method Stacks)
Native Method Stack — это область памяти, выделенная для каждого потока в приложении Java, в которой хранятся стековые фреймы для вызовов нативных методов, то есть методов, написанных на других языках программирования, таких как C или C++. Эти методы обычно используются для выполнения низкоуровневых операций или взаимодействия с системными ресурсами, которые недоступны или сложны для реализации средствами самого языка Java.
Нативные методы объявляются в Java с помощью ключевого слова native
, и их реализации предоставляются в виде библиотек совместимых с платформой (например, DLL-файлы в Windows или .so-файлы в Unix-подобных системах). Обычно нативные методы используются в стандартной библиотеке Java (например, в пакете java.lang
или java.io
) для обеспечения доступа к операционной системе и аппаратному обеспечению.
Когда Java-приложение вызывает нативный метод, для этого вызова создается стековый фрейм на Native Method Stack. Этот стековый фрейм аналогичен стековому фрейму на обычном стеке (Java Stack), но хранит информацию, специфичную для вызова нативного метода. Native Method Stack управляется JVM и используется для разделения памяти между потоками при вызове нативных методов.
Важно отметить, что Native Method Stack и Java Stack — это две разные области памяти, и они хранят разные типы стековых фреймов: стековые фреймы для вызовов методов, написанных на языке Java, хранятся на Java Stack, в то время как стековые фреймы для вызовов нативных методов хранятся на Native Method Stack.
Пул строк (String Pool)
Это механизм, используемый в JVM для оптимизации использования памяти при работе со строками. Это специальный кэш строк, который позволяет нескольким объектам String с одинаковым содержимым ссылаться на один и тот же объект String в куче памяти.
При создании объекта String в Java, JVM сначала проверяет String Pool на наличие уже существующего объекта с таким же содержимым. Если объект уже существует в String Pool, то новый объект String не создается, а ссылается на уже существующий объект. Это позволяет сократить использование памяти и повысить производительность, особенно при работе со строками с повторяющимся содержимым.
Строки, которые могут быть добавлены в String Pool, включают строки, созданные с помощью литералов (например, “hello”), а также строки, созданные с помощью метода String.intern(). Метод intern() возвращает ссылку на строку в String Pool, если такая строка уже существует, иначе он добавляет новую строку в String Pool.
До Java 7 (включительно)String Pool хранился в области памяти, называемой PermGen (Permanent Generation), который позже был заменен на Metaspace. Однако, начиная с Java 8 String Pool был перемещен в кучу.
Важно понимать, что хранение строк в String Pool может привести к утечкам памяти, если не управлять строковыми объектами правильно. Например, создание множества строк, которые необходимы только во время выполнения, может привести к переполнению String Pool и, как следствие, к утечке памяти. Чтобы избежать этого, следует быть внимательным при использовании метода intern() и не добавлять в String Pool строки, которые не нужны во время выполнения.
Также важно отметить, что в некоторых случаях использование String Pool может привести к неожиданным результатам при сравнении строк. Например, если две строки создаются с помощью конструктора String, они не будут добавлены в String Pool и не будут считаться одинаковыми, даже если содержат одинаковое значение. В этом случае необходимо использовать метод equals() для сравнения строк.
Для управления размером String Pool можно использовать параметры JVM, такие как -XX:StringTableSize и -XX:StringTableSizePerBucket. Они позволяют задать максимальный размер String Pool и количество корзин, в которые разбивается String Pool для улучшения производительности.
В целом, использование String Pool может существенно улучшить производительность и уменьшить использование памяти при работе со строками. Однако, необходимо быть внимательным при использовании этого механизма и управлять строковыми объектами правильно, чтобы избежать утечек памяти и неожиданных результатов.
Эти области памяти вместе обеспечивают эффективное выполнение Java-приложений, оптимизацию использования памяти и гибкость при работе с различными типами данных и структурами.
Сборка мусора (Garbage Collection, GC)
Это процесс автоматического освобождения памяти, занимаемой неиспользуемыми объектами в куче (Heap) JVM. Важной целью GC является эффективное использование памяти и предотвращение утечек памяти. Сборка мусора в JVM работает по следующему алгоритму:
- Определение мусора: сборщик мусора ищет объекты, на которые не существует ссылок из активных частей программы. Это означает, что объекты, которые больше не могут быть достигнуты и использованы, считаются мусором.
- Пометка (Marking): сборщик мусора начинает процесс пометки с корневых объектов (root objects). Корневые объекты — это объекты, на которые существуют прямые ссылки из стека (Stack) или статических переменных. Затем сборщик мусора продолжает процесс пометки, исследуя все связанные объекты рекурсивно. В результате, все достижимые объекты помечаются.
- Удаление (Sweeping): после процесса пометки, сборщик мусора удаляет все непомеченные объекты, освобождая память, которую они занимают. В зависимости от алгоритма сборки мусора, этот процесс может включать компактизацию памяти, то есть перемещение выживших объектов в куче, чтобы уменьшить фрагментацию памяти и улучшить производительность.
Существует несколько алгоритмов сборки мусора, которые используются в разных ситуациях и вариантах JVM. Некоторые из наиболее распространенных алгоритмов включают:
Serial Garbage Collector (GC)
Это алгоритм сборки мусора, который использует один поток для выполнения сборки мусора. Этот алгоритм подходит для небольших приложений с ограниченными требованиями к производительности и системами с ограниченными ресурсами, такими как маломощные серверы или встроенные системы. Serial GC идеально подходит для однопроцессорных систем или систем, где другие ресурсы процессора могут быть лучше использованы приложением.
Serial GC работает в двух основных фазах:
- Минорная сборка мусора (Minor GC): Минорная сборка мусора происходит в молодом поколении (Young Generation), которое состоит из областей Eden и Survivor. Новые объекты создаются в области Eden. Когда область Eden заполняется, Serial GC выполняет “Stop-The-World” операцию, останавливая все потоки приложения, и перемещает выжившие объекты из области Eden в одну из областей Survivor (называемую “to-space”). Объекты, которые не могут быть достигнуты, считаются мусором и удаляются. Затем область Eden очищается, и выполнение приложения возобновляется.
- Мажорная сборка мусора (Major GC) или полная сборка мусора (Full GC): Мажорная сборка мусора происходит в старшем поколении (Old Generation) и молодом поколении. Этот тип сборки мусора менее частый, но более затратный по времени. Мажорная сборка мусора также является “Stop-The-World” операцией, останавливая все потоки приложения. Во время мажорной сборки мусора Serial GC удаляет недостижимые объекты и перемещает выжившие объекты в старшее поколение.
Несмотря на свою простоту и эффективность в ситуациях с ограниченными ресурсами, Serial GC имеет недостатки. Основной из них — остановка работы приложения во время сборки мусора, что может привести к существенным задержкам, особенно при больших объемах памяти или долгоживущих объектах. В многопроцессорных системах Serial GC также может быть менее эффективным, чем многопоточные алгоритм
Parallel Garbage Collector (GC)
Он также известен как Throughput Collector, — это алгоритм сборки мусора, который использует несколько потоков для повышения производительности сборки мусора. Он подходит для многопроцессорных систем и приложений, которым требуется высокая пропускная способность (throughput) и быстрое освобождение памяти. Parallel GC обеспечивает более быстрое выполнение сборки мусора за счет использования нескольких ядер процессора, но при этом все еще использует “Stop-The-World” паузы.
Parallel GC работает в двух основных фазах:
- Минорная сборка мусора (Minor GC): Минорная сборка мусора происходит в молодом поколении (Young Generation), состоящем из областей Eden и Survivor. Когда область Eden заполняется, Parallel GC выполняет “Stop-The-World” операцию, останавливая все потоки приложения. В отличие от Serial GC, Parallel GC использует несколько потоков для сборки мусора. Выжившие объекты перемещаются из области Eden в одну из областей Survivor (называемую “to-space”), а недостижимые объекты удаляются. Затем область Eden очищается, и выполнение приложения возобновляется.
- Мажорная сборка мусора (Major GC) или полная сборка мусора (Full GC): Мажорная сборка мусора происходит в старшем поколении (Old Generation) и молодом поколении. Этот тип сборки мусора менее частый, но более затратный по времени. Мажорная сборка мусора также является “Stop-The-World” операцией, останавливая все потоки приложения. Во время мажорной сборки мусора Parallel GC использует несколько потоков для удаления недостижимых объектов и перемещения выживших объектов в старшее поколение.
Parallel GC позволяет улучшить производительность сборки мусора на многопроцессорных системах, однако его использование может повысить нагрузку на процессор из-за параллельной работы. Важно отметить, что, хотя Parallel GC ускоряет сборку мусора, он все равно использует “Stop-The-World” паузы, что может вызывать задержки в приложении.
Concurrent Mark and Sweep (CMS)
Concurrent Mark and Sweep (CMS) Garbage Collector (GC) — это алгоритм сборки мусора, разработанный для уменьшения пауз на сборку мусора в приложениях с низкой задержкой. Он предназначен для систем с большим объемом памяти и большим количеством ядер процессора. Основная идея CMS GC заключается в выполнении большей части работы по сборке мусора параллельно с работой приложения, чтобы уменьшить влияние на его производительность.
CMS GC состоит из следующих этапов:
- Инициализация (Initial Mark): на этом этапе сборщик мусора помечает корневые объекты (объекты, на которые есть прямые ссылки из стека или статических переменных). Этот этап является “Stop-The-World”, так как приложение приостанавливается на время его выполнения.
- Параллельная пометка (Concurrent Mark): сборщик мусора продолжает процесс пометки объектов, исследуя все связанные объекты, начиная с корневых объектов, и помечая их как достижимые. Этот этап выполняется параллельно с работой приложения, не останавливая его.
- Вторичная пометка (Remark): сборщик мусора выполняет еще одну короткую фазу “Stop-The-World”, чтобы обработать объекты, которые были изменены во время параллельной пометки. На этом этапе сборщик мусора обновляет информацию о достижимых объектах, чтобы учесть изменения, произошедшие во время параллельной пометки.
- Параллельное удаление (Concurrent Sweep): после завершения процесса пометки сборщик мусора удаляет все непомеченные объекты из памяти, освобождая занимаемый ими ресурс. Этот этап также выполняется параллельно с работой приложения, не останавливая его.
- Сброс (Reset): на этом этапе сборщик мусора очищает информацию о помеченных объектах и возвращает себя в исходное состояние, чтобы быть готовым к следующему циклу сборки мусора.
G1 (Garbage-First)
Это алгоритм сборки мусора, разработанный для замены CMS GC и решения некоторых его недостатков. G1 GC предназначен для обработки больших объемов памяти с минимальными паузами и предсказуемым временем сборки мусора. Вот некоторые ключевые аспекты и детали работы G1 GC:
- Разделение кучи на регионы: G1 GC разбивает область кучи (heap) на регионы фиксированного размера. Регионы могут быть молодыми (young), состоящими из областей эдема (Eden) и выживания (Survivor), или старыми (old).
- Сборка мусора на основе регионов: G1 GC сосредотачивает усилия на тех регионах, где больше всего мусора. Он идентифицирует регионы с наибольшим количеством мусора и выбирает их для очистки в первую очередь. Это позволяет оптимизировать процесс сборки мусора и уменьшить паузы.
- Параллельная и конкурентная сборка мусора: G1 GC использует множество потоков для выполнения задач сборки мусора. Большая часть работы выполняется параллельно с приложением, что уменьшает паузы, связанные с сборкой мусора. Однако, некоторые фазы G1 GC могут приостанавливать выполнение приложения (stop-the-world паузы), хотя эти паузы обычно короче, чем в других алгоритмах сборки мусора.
- Предсказуемость времени сборки мусора: G1 GC позволяет задать целевое время паузы для сборки мусора с помощью параметра
-XX:MaxGCPauseMillis
. Это позволяет гарантировать, что паузы, вызванные сборкой мусора, не превысят заданный порог, что полезно для приложений с требованиями к производительности и низким временем ожидания. - Сжатие кучи: G1 GC также может выполнять сжатие кучи (heap compaction) для освобождения пространства и уменьшения фрагментации памяти. Он перемещает живые объекты, освобождая пространство для новых объектов и уплотняя области памяти. Это обеспечивает более эффективное использование памяти и сокращает время, необходимое для поиска свободного пространства при выделении новых объектов.
- Адаптивная настройка: G1 GC адаптируется к характеристикам приложения и системы, настраивая параметры сборки мусора во время выполнения. Он анализирует информацию о предыдущих циклах сборки мусора, такие как время сборки мусора, объем очищенной памяти и скорость выделения памяти, чтобы определить оптимальные параметры для последующих циклов сборки мусора.
- Журналирование и мониторинг: G1 GC предоставляет подробные сведения о своей работе через журнал событий сборки мусора и различные средства мониторинга, такие как Java VisualVM и JMX (Java Management Extensions). Это позволяет разработчикам и администраторам отслеживать процесс сборки мусора и оптимизировать параметры JVM для лучшей производительности приложения.
В целом, G1 GC является современным и эффективным алгоритмом сборки мусора, подходящим для больших приложений с требованиями к низкому времени ожидания и предсказуемым временем сборки мусора. Он обеспечивает улучшенную производительность и управление памятью по сравнению с другими алгоритмами сборки мусора, такими как Serial, Parallel и CMS GC.
JNI (Java Native Interface)
JNI (Java Native Interface) – это механизм, который позволяет взаимодействовать между Java-кодом и кодом, написанным на других языках программирования, таких как C, C++ или Assembly. JNI предоставляет набор функций и интерфейсов для вызова функций, написанных на других языках, из Java-кода и наоборот.
В JVM, JNI обеспечивает следующие возможности:
- Вызов функций из C/C++ из Java-кода: С помощью JNI, можно написать функции на C/C++, скомпилировать их в библиотеку и загрузить ее в JVM. Затем, можно вызывать функции из этой библиотеки из Java-кода, используя JNI-интерфейсы.
- Вызов функций Java из C/C++: JNI также позволяет вызывать функции, написанные на Java, из кода на C/C++. Для этого нужно получить доступ к JVM из кода на C/C++, создать объекты Java и вызывать методы, используя JNI-интерфейсы.
- Управление объектами Java из C/C++: JNI также позволяет создавать, изменять и управлять объектами Java из кода на C/C++. Для этого нужно получить доступ к JVM из кода на C/C++, создать объекты Java и использовать JNI-интерфейсы для их манипулирования.
- Управление памятью: При использовании JNI, нужно быть особенно внимательным при управлении памятью. Когда объекты создаются в коде на C/C++, они не управляются сборщиком мусора JVM. Поэтому, необходимо явно освобождать память, выделенную под объекты Java в коде на C/C++, используя JNI-интерфейсы.
- Безопасность: Использование JNI может быть опасным, так как это открывает доступ к низкоуровневому коду на C/C++. Для обеспечения безопасности, JVM имеет механизмы проверки безопасности, которые позволяют ограничивать доступ к ресурсам системы.
- Переносимость: JNI обеспечивает переносимость кода между различными платформами, так как код на C/C++ может быть скомпилирован для разных архитектур и операционных систем. Однако, необходимо учитывать различия в библиотеках и интерфейсах на разных платформах.
Хотя JNI не является одним из основных компонентов JVM, он играет важную роль в расширении возможностей JVM и интеграции Java с другими языками программирования. JNI позволяет использовать функции и библиотеки на C/C++ в Java-приложениях и вызывать функции Java из кода на C/C++.
Использование JNI может быть полезным при работе с низкоуровневым кодом, оборудованием и библиотеками, написанными на других языках программирования, что может улучшить производительность и расширить возможности Java-приложений.
На этом мы завершаем обзор основных компонентов JVM и принципов их работы.