Java-приложения работают на JVM, но знаете ли вы о технологии JVM? В этой статье (первая часть этой серии) рассказывается, как работает классическая виртуальная машина Java, например: плюсы и минусы Java с однократной записью, кроссплатформенные механизмы, основы сборки мусора, классические алгоритмы GC и оптимизация компиляции. В последующих статьях речь пойдет об оптимизации производительности JVM, включая новейшую разработку JVM, поддерживающую производительность и масштабируемость современных Java-приложений с высокой степенью параллелизма.
Если вы разработчик, вы наверняка испытывали это особое чувство: к вам внезапно приходит озарение, все ваши идеи связаны между собой, и вы можете вспомнить свои предыдущие идеи с новой точки зрения. Лично мне нравится ощущение получения новых знаний. У меня был такой опыт много раз, когда я работал с технологией JVM, особенно со сборкой мусора и оптимизацией производительности JVM. В этом новом мире Java я надеюсь поделиться с вами этим вдохновением. Надеюсь, вам так же интересно узнать о производительности JVM, как и мне, когда я пишу эту статью.
Эта серия статей написана для всех разработчиков Java, которые хотят узнать больше об основных знаниях о JVM и о том, что на самом деле делает JVM. На высоком уровне я расскажу о сборке мусора и бесконечном стремлении к безопасности и скорости свободной памяти, не влияя на работу приложений. Вы изучите ключевые части JVM: алгоритмы сборки мусора и GC, оптимизацию компиляции и некоторые часто используемые оптимизации. Я также расскажу, почему разметка Java так сложна, и дам советы, когда вам следует рассмотреть возможность тестирования производительности. Наконец, я расскажу о некоторых новых инновациях в JVM и GC, в том числе о Zing JVM от Azul, IBM JVM и о сборке мусора Oracle Garbage First (G1).
Я надеюсь, что вы закончите чтение этой серии с более глубоким пониманием природы ограничений масштабируемости Java и того, как эти ограничения заставляют нас создавать оптимальное развертывание Java. Надеюсь, у вас появится чувство просветления и хорошее вдохновение для Java: перестаньте принимать эти ограничения и измените их! Если вы еще не занимаетесь открытым исходным кодом, эта серия может побудить вас развиваться в этой области.
Производительность JVM и задача «компилировать один раз — запускать где угодно»
У меня есть новые новости для тех, кто упрямо верит в то, что платформа Java по своей сути медленна. Когда Java впервые стала приложением корпоративного уровня, проблемы с производительностью Java, за которые JVM критиковали, возникли уже более десяти лет назад, но сейчас этот вывод устарел. Это правда, что если вы сегодня запускаете простые статические и детерминированные задачи на разных платформах разработки, вы, скорее всего, обнаружите, что использование оптимизированного для машины кода будет работать лучше, чем использование любой виртуальной среды под той же JVM. Однако производительность Java значительно улучшилась за последние 10 лет. Рыночный спрос и рост Java-индустрии привели к появлению нескольких алгоритмов сборки мусора, новых инноваций в компиляции, а также множества эвристик и оптимизаций, использующих передовую технологию JVM. Я расскажу о некоторых из них в будущих главах.
Техническая красота JVM также является ее самой большой проблемой: ничто не может считаться приложением «один раз компилировать, запускать где угодно». Вместо оптимизации для одного варианта использования, одного приложения или одной конкретной пользовательской нагрузки JVM постоянно отслеживает, что в данный момент делает приложение Java, и соответствующим образом оптимизирует. Эта динамическая операция приводит к ряду динамических проблем. Разработчики, работающие над JVM, не полагаются на статическую компиляцию и предсказуемую скорость выделения ресурсов при разработке инноваций (по крайней мере, когда нам требуется производительность в производственных средах).
Причина производительности JVM
В начале своей работы я понял, что сборку мусора очень сложно «решить», и меня всегда восхищали JVM и технологии промежуточного программного обеспечения. Моя страсть к JVM началась, когда я работал в команде JRockit, разрабатывая новый способ самостоятельного обучения и отладки алгоритмов сборки мусора (см. Ресурсы). Этот проект (который превратился в экспериментальную функцию JRockit и стал основой алгоритма детерминированной сборки мусора) положил начало моему пути в технологию JVM. Я работал в BEA Systems, Intel, Sun и Oracle (поскольку Oracle приобрела BEA Systems, я некоторое время работал в Oracle). Затем я присоединился к команде Azul Systems, чтобы управлять JVM Zing, а сейчас работаю в Cloudera.
Машинно-оптимизированный код может обеспечить лучшую производительность (но за счет гибкости), но это не повод взвешивать его для корпоративных приложений с динамической загрузкой и быстро меняющимся функционалом. Ради преимуществ Java большинство компаний охотнее жертвуют едва ли идеальной производительностью, обеспечиваемой машинно-оптимизированным кодом.
1. Простота кодирования и разработки функций (что означает более короткое время реагирования на запросы рынка)
2. Найдите знающих программистов
3. Используйте Java API и стандартные библиотеки для более быстрой разработки.
4. Портативность – нет необходимости переписывать Java-приложения под новые платформы.
От Java-кода к байт-коду
Как Java-программист, вы, вероятно, знакомы с кодированием, компиляцией и выполнением приложений Java. Пример: предположим, что у вас есть программа (MyApp.java) и вы хотите, чтобы она работала. Чтобы выполнить эту программу, вам необходимо сначала скомпилировать ее с помощью javac (статического компилятора языка Java в байт-код, встроенного в JDK). На основе кода Java javac генерирует соответствующий исполняемый байт-код и сохраняет его в файле класса с тем же именем: MyApp.class. После компиляции кода Java в байт-код вы можете запустить исполняемый файл класса с помощью команды Java (через командную строку или сценарий запуска, без использования параметра запуска) для запуска вашего приложения. Таким образом, ваш класс загружается в среду выполнения (то есть работает виртуальная машина Java), и программа начинает выполняться.
Это то, что на первый взгляд выполняет каждое приложение, но теперь давайте рассмотрим, что именно происходит, когда вы выполняете команду Java. Что такое виртуальная машина Java? Большинство разработчиков взаимодействуют с JVM посредством непрерывной отладки — то есть выбора и назначения значений параметрам запуска, чтобы ваши Java-программы работали быстрее, избегая при этом печально известных ошибок «нехватки памяти». Но задумывались ли вы когда-нибудь, зачем вообще нам нужна JVM для запуска Java-приложений?
Что такое виртуальная машина Java?
Проще говоря, JVM — это программный модуль, который выполняет байт-код приложения Java и преобразует байт-код в инструкции, специфичные для оборудования и операционной системы. Благодаря этому JVM позволяет выполнять программу Java в другой среде после ее первой написания, не требуя внесения изменений в исходный код. Переносимость Java является ключом к языку корпоративных приложений: разработчикам не нужно переписывать код приложения для разных платформ, поскольку JVM берет на себя перевод и оптимизацию платформы.
JVM — это, по сути, виртуальная среда выполнения, которая действует как машина инструкций байт-кода и используется для распределения задач выполнения и выполнения операций с памятью путем взаимодействия с базовым уровнем.
JVM также обеспечивает динамическое управление ресурсами для запуска приложений Java. Это означает, что он управляет распределением и освобождением памяти, поддерживает согласованную модель потоков на каждой платформе и организует исполняемые инструкции, в которых приложение выполняется способом, подходящим для архитектуры ЦП. JVM освобождает разработчиков от необходимости отслеживать ссылки на объекты и время их существования в системе. Аналогично, от нас не требуется управлять моментом освобождения памяти — болевая точка в нединамических языках, таких как C.
Вы можете думать о JVM как об операционной системе, специально предназначенной для запуска Java; ее задача — управлять средой выполнения Java-приложений. JVM — это, по сути, виртуальная среда выполнения, которая взаимодействует с базовой средой как машина с инструкциями байт-кода для распределения задач выполнения и выполнения операций с памятью.
Обзор компонентов JVM
О внутреннем устройстве JVM и оптимизации производительности написано много статей. В основу этой серии я подведу итог и обзор компонентов JVM. Этот краткий обзор особенно полезен для разработчиков, которые плохо знакомы с JVM, и у вас возникнет желание узнать больше о последующих более глубоких обсуждениях.
С одного языка на другой — о компиляторах Java
Компилятор принимает на вход один язык, а затем выводит другой исполняемый оператор. Компилятор Java имеет две основные задачи:
1. Сделать язык Java более портативным и больше не нужно привязывать его к конкретной платформе при первом написании;
2. Убедитесь, что для конкретной платформы создан действительный исполняемый код.
Компиляторы могут быть статическими или динамическими. Пример статической компиляции — javac. Он принимает код Java в качестве входных данных и преобразует его в байт-код (язык, выполняемый на виртуальной машине Java). Статический компилятор один раз интерпретирует входной код и выводит исполняемую форму, которая будет использоваться при выполнении программы. Поскольку ввод статический, вы всегда будете видеть один и тот же результат. Только если вы измените исходный код и перекомпилируете, вы увидите другой результат.
Динамические компиляторы , такие как JIT-компиляторы, преобразуют один язык в другой динамически, то есть они делают это во время выполнения кода. JIT-компилятор позволяет собирать или создавать аналитику времени выполнения (путем вставки показателей производительности), используя решения компилятора и имеющиеся данные среды. Динамический компилятор может реализовать лучшие последовательности инструкций в процессе компиляции в язык, заменить ряд инструкций более эффективными и даже исключить избыточные операции. Со временем вы соберете больше данных о конфигурации кода и примете больше и более эффективных решений о компиляции. Весь процесс мы обычно называем оптимизацией и перекомпиляцией кода.
Динамическая компиляция дает вам возможность адаптироваться к динамическим изменениям на основе поведения или новым оптимизациям по мере увеличения количества загрузок приложения. Вот почему динамические компиляторы идеально подходят для операций Java. Стоит отметить, что динамический компилятор запрашивает внешние структуры данных, ресурсы потоков, анализ и оптимизацию цикла ЦП. Чем глубже оптимизация, тем больше ресурсов вам понадобится. Однако в большинстве сред верхний уровень очень мало увеличивает производительность — производительность в 5–10 раз выше, чем ваша чистая интерпретация.
Распределение вызывает сбор мусора
Выделяется в каждом потоке на основе каждого «адресного пространства памяти, выделенного процессом Java», или называется кучей Java, или напрямую называется кучей. В мире Java однопоточное распределение распространено в клиентских приложениях. Однако однопоточное распределение бесполезно для корпоративных приложений и серверов рабочих нагрузок, поскольку оно не использует преимуществ параллелизма современных многоядерных сред.
Проектирование параллельных приложений также заставляет JVM следить за тем, чтобы несколько потоков не выделяли одно и то же адресное пространство одновременно. Вы можете контролировать это, установив блокировку на все выделенное пространство. Но этот метод (часто называемый блокировкой кучи) очень ресурсоемок, а удержание или постановка в очередь потоков может повлиять на использование ресурсов и производительность оптимизации приложений. Плюс многоядерных систем в том, что они создают потребность во множестве новых методов для предотвращения однопоточных узких мест при распределении ресурсов и сериализации.
Распространенным подходом является разделение кучи на части, где каждый раздел имеет разумный размер для приложения — очевидно, их необходимо настроить, скорость выделения памяти и размеры объектов значительно различаются между приложениями, и количество потоков для одного и того же также разное. Буфер локального распределения потока (TLAB), или иногда локальная область потока (TLA), представляет собой специализированный раздел, в котором потоки могут свободно выделять ресурсы без объявления полной блокировки кучи. Когда область заполнена, куча заполнена, а это значит, что в куче недостаточно свободного места для размещения объектов, и пространство необходимо выделить. Когда куча заполнится, начнется сбор мусора.
фрагменты
Использование TLAB для перехвата исключений фрагментирует кучу, что снижает эффективность использования памяти. Если приложение не может увеличить или полностью выделить пространство TLAB при выделении объектов, существует риск того, что пространство окажется слишком маленьким для создания новых объектов. Такое свободное пространство считается «фрагментацией». Если приложение сохраняет ссылку на объект, а затем выделяет оставшееся пространство, в конечном итоге пространство будет свободным в течение длительного времени.
Фрагментация — это когда фрагменты разбросаны по куче, тратя пространство кучи на небольшие участки неиспользуемого пространства памяти. Выделение «неправильного» пространства TLAB для вашего приложения (в отношении размера объекта, размера смешанного объекта и коэффициента хранения ссылок) является причиной повышенной фрагментации кучи. По мере работы приложения количество фрагментов увеличивается и занимает место в куче. Фрагментация приводит к снижению производительности, и система не может выделить достаточно потоков и объектов для новых приложений. В этом случае сборщику мусора будет трудно предотвратить исключения, связанные с нехваткой памяти.
Отходы TLAB образуются на работе. Один из способов полностью или временно избежать фрагментации — оптимизировать пространство TLAB для каждой базовой операции. Типичный подход к этому подходу заключается в том, что пока приложение имеет поведение распределения, его необходимо перенастроить. Этого можно достичь с помощью сложных алгоритмов JVM. Другой метод — организовать разделы кучи для более эффективного распределения памяти. Например, JVM может реализовать списки свободной памяти, которые связаны друг с другом как список свободных блоков памяти определенного размера. Непрерывный блок свободной памяти соединяется с другим смежным блоком памяти того же размера, создавая таким образом небольшое количество связанных списков, каждый со своими границами. В некоторых случаях списки свободных приводят к лучшему распределению памяти. Потоки могут размещать объекты в блоках одинакового размера, потенциально создавая меньшую фрагментацию, чем если бы вы просто полагались на TLAB фиксированного размера.
GC мелочи
Некоторые ранние сборщики мусора имели несколько старых поколений, но наличие более двух старых поколений приводило к тому, что накладные расходы перевешивали ценность. Другой способ оптимизировать распределение и уменьшить фрагментацию — создать так называемое молодое поколение, которое представляет собой выделенное пространство в куче, предназначенное для размещения новых объектов. Оставшаяся куча становится так называемым старым поколением. Старое поколение используется для выделения долгоживущих объектов. Предполагается, что объекты, существующие в течение длительного времени, включают объекты, не подлежащие сборке мусора, или большие объекты. Чтобы лучше понять этот метод распределения, нам нужно поговорить о некоторых знаниях о сборке мусора.
Сбор мусора и производительность приложений
Сбор мусора — это сборщик мусора JVM, который освобождает занятую память кучи, на которую нет ссылок. Когда сборка мусора запускается в первый раз, все ссылки на объекты по-прежнему сохраняются, а пространство, занятое предыдущими ссылками, освобождается или перераспределяется. После того, как вся подлежащая освобождению память собрана, пространство ожидает захвата и повторного выделения новым объектам.
Сборщик мусора никогда не сможет повторно объявить ссылочный объект, поскольку это нарушит стандартную спецификацию JVM. Исключением из этого правила является мягкая или слабая ссылка, которую можно перехватить, если сборщику мусора вот-вот не хватит памяти. Однако я настоятельно рекомендую вам стараться избегать слабых ссылок, поскольку двусмысленность спецификации Java приводит к неверным интерпретациям и ошибкам использования. Более того, Java предназначена для динамического управления памятью, поскольку вам не нужно думать о том, когда и где освободить память.
Одной из задач сборщика мусора является выделение памяти таким образом, чтобы это не влияло на работу приложений. Если вы не будете собирать мусор как можно больше, ваше приложение будет потреблять память; если вы будете собирать мусор слишком часто, вы потеряете пропускную способность и время отклика, что плохо скажется на работающем приложении.
Алгоритм GC
Существует множество различных алгоритмов сборки мусора. Некоторые моменты будут подробно обсуждены позже в этой серии. На самом высоком уровне двумя основными методами сборки мусора являются подсчет ссылок и отслеживание сборщиков мусора.
Коллектор подсчета ссылок отслеживает, на сколько ссылок указывает объект. Когда ссылка на объект достигает 0, память будет немедленно освобождена, что является одним из преимуществ этого подхода. Сложность подхода подсчета ссылок заключается в циклической структуре данных и обновлении всех ссылок в реальном времени.
Сборщик отслеживания отмечает объекты, на которые все еще ссылаются, и использует отмеченные объекты для многократного отслеживания и маркировки всех объектов, на которые имеются ссылки. Когда все объекты, на которые все еще есть ссылки, помечены как «активные», все неотмеченное пространство будет освобождено. Этот подход управляет кольцевыми структурами данных, но во многих случаях сборщик должен дождаться завершения всей маркировки, прежде чем освобождать неиспользуемую память.
Существуют различные способы реализации описанного выше метода. Наиболее известными алгоритмами являются алгоритмы маркировки или копирования, параллельные или параллельные алгоритмы. Я расскажу об этом в следующей статье.
Вообще говоря, смысл сборки мусора заключается в выделении адресного пространства новым и старым объектам в куче. «Старые объекты» — это объекты, пережившие множество сборок мусора. Используйте новое поколение для выделения новых объектов и старое поколение для старых объектов. Это может уменьшить фрагментацию за счет быстрого повторного использования недолговечных объектов, занимающих память. Он также объединяет долгоживущие объекты и помещает их в пространство по адресам старого поколения. Все это уменьшает фрагментацию между долгоживущими объектами и экономит память кучи от фрагментации. Положительным эффектом нового поколения является то, что оно задерживает более дорогостоящий сбор объектов старого поколения, и вы можете повторно использовать одно и то же пространство для эфемерных объектов. (Сбор старого пространства будет стоить дороже, поскольку долгоживущие объекты будут содержать больше ссылок и требовать большего количества обходов.)
Последний алгоритм, о котором стоит упомянуть, — это уплотнение, метод управления фрагментацией памяти. Сжатие в основном перемещает объекты вместе, чтобы освободить большее непрерывное пространство памяти. Если вы знакомы с фрагментацией диска и инструментами, которые с ней справляются, вы обнаружите, что сжатие очень похоже на нее, за исключением того, что оно выполняется в куче памяти Java. Я буду обсуждать уплотнение подробно позже в этой серии.
Резюме: обзор и основные моменты
JVM обеспечивает переносимость (программирование один раз, запуск где угодно) и динамическое управление памятью — все ключевые функции платформы Java, которые способствуют ее популярности и повышению производительности.
В первой статье о системах оптимизации производительности JVM я объяснил, как компилятор преобразует байт-код в язык инструкций целевой платформы и помогает динамически оптимизировать выполнение Java-программ. Разные приложения требуют разных компиляторов.
Я также кратко рассказал о распределении памяти и сборке мусора, а также о том, как они связаны с производительностью приложений Java. По сути, чем быстрее вы заполняете кучу и чаще запускаете сборку мусора, тем выше коэффициент использования вашего Java-приложения. Одной из задач сборщика мусора является выделение памяти таким образом, чтобы это не влияло на работающее приложение, но до того, как приложению исчерпается память. В будущих статьях мы более подробно обсудим традиционную и новую сборку мусора, а также оптимизацию производительности JVM.