Эта статья будет второй статьей в серии по оптимизации производительности JVM (первая статья: Портал), а компилятор Java будет основным предметом обсуждения в этой статье.
В этой статье автор (Ева Андреассон) впервые знакомит с различными типами компиляторов и сравнивает производительность компиляции на стороне клиента, компилятора на стороне сервера и многоуровневой компиляции. Затем, в конце статьи, представлены несколько распространенных методов оптимизации JVM, таких как устранение мертвого кода, встраивание кода и оптимизация тела цикла.
Самая гордая особенность Java — «независимость от платформы» — берет начало в компиляторе Java. Разработчики программного обеспечения делают все возможное, чтобы написать лучшие приложения Java, а компилятор работает за кулисами, создавая эффективный исполняемый код на основе целевой платформы. Разные компиляторы подходят для разных требований приложений, что дает разные результаты оптимизации. Следовательно, если вы сможете лучше понять, как работают компиляторы, и знать больше типов компиляторов, вы сможете лучше оптимизировать свою программу на Java.
В этой статье освещаются и объясняются различия между различными компиляторами виртуальных машин Java. В то же время я также расскажу о некоторых решениях по оптимизации, обычно используемых JIT-компиляторами.
Что такое компилятор?
Проще говоря, компилятор принимает программу на языке программирования в качестве входных данных и другую исполняемую программу на языке в качестве выходных данных. Javac — самый распространенный компилятор. Он существует во всех JDK. Javac принимает код Java в качестве вывода и преобразует его в исполняемый код JVM — байт-код. Эти байт-коды хранятся в файлах, заканчивающихся на .class, и загружаются в среду выполнения Java при запуске Java-программы.
Байт-код не может быть прочитан непосредственно процессором. Его также необходимо перевести на язык машинных инструкций, понятный текущей платформе. В JVM есть еще один компилятор, который отвечает за перевод байт-кода в инструкции, исполняемые целевой платформой. Некоторым компиляторам JVM требуется несколько уровней этапов байт-кода. Например, компилятору может потребоваться пройти несколько различных промежуточных этапов, прежде чем транслировать байт-код в машинные инструкции.
С точки зрения независимости платформы мы хотим, чтобы наш код был как можно более независимым от платформы.
Чтобы добиться этого, мы работаем на последнем уровне трансляции — от самого низкого представления байт-кода до реального машинного кода — который действительно связывает исполняемый код с архитектурой конкретной платформы. На самом высоком уровне мы можем разделить компиляторы на статические и динамические компиляторы. Мы можем выбрать подходящий компилятор на основе нашей целевой среды выполнения, желаемых результатов оптимизации и ограничений ресурсов, которые нам необходимо удовлетворить. В предыдущей статье мы кратко обсудили статические и динамические компиляторы, а в следующих разделах мы объясним их более подробно.
Статическая компиляция против динамической компиляции
Javac, о котором мы упоминали ранее, является примером статической компиляции. В статическом компиляторе входной код интерпретируется один раз, а на выходе получается форма, в которой программа будет выполняться в дальнейшем. Если вы не обновите исходный код и не перекомпилируете (через компилятор), результат выполнения программы никогда не изменится: это потому, что входные данные являются статическим вводом, а компилятор является статическим компилятором.
При статической компиляции следующая программа:
Скопируйте код кода следующим образом:
staticint add7(int x ){ return x+7;}
будет преобразован в байт-код, подобный следующему:
Скопируйте код кода следующим образом:
iload0 bipush 7 iadd возврат
Динамический компилятор динамически компилирует один язык в другой язык. Так называемый динамический компилятор относится к компиляции во время работы программы – компиляции во время работы! Преимущество динамической компиляции и оптимизации заключается в том, что они могут обрабатывать некоторые изменения при загрузке приложения. Среда выполнения Java часто работает в непредсказуемых или даже изменяющихся средах, поэтому динамическая компиляция очень подходит для среды выполнения Java. Большинство JVM используют динамические компиляторы, например JIT-компиляторы. Стоит отметить, что динамическая компиляция и оптимизация кода требуют использования некоторых дополнительных структур данных, потоков и ресурсов ЦП. Чем более продвинут оптимизатор или анализатор контекста байт-кода, тем больше ресурсов он потребляет. Но эти затраты незначительны по сравнению со значительным улучшением производительности.
Типы JVM и независимость Java от платформы
Общей особенностью всех реализаций JVM является компиляция байт-кода в машинные инструкции. Некоторые JVM интерпретируют код при загрузке приложения и используют счетчики производительности для поиска «горячего» кода; другие делают это посредством компиляции; Основная проблема компиляции заключается в том, что централизация требует много ресурсов, но она также приводит к лучшей оптимизации производительности.
Если вы новичок в Java, тонкости JVM определенно вас запутают. Но хорошая новость в том, что вам не нужно в этом разбираться! JVM будет управлять компиляцией и оптимизацией кода, и вам не нужно беспокоиться о машинных инструкциях и о том, как написать код, чтобы он наилучшим образом соответствовал архитектуре платформы, на которой работает программа.
От байт-кода Java к исполняемому файлу
После того как ваш Java-код скомпилирован в байт-код, следующим шагом будет преобразование инструкций байт-кода в машинный код. Этот шаг можно реализовать через интерпретатор или через компилятор.
объяснять
Интерпретация — это самый простой способ компиляции байт-кода. Интерпретатор находит аппаратную инструкцию, соответствующую каждой инструкции байт-кода, в форме таблицы поиска, а затем отправляет ее в ЦП для выполнения.
Вы можете думать об интерпретаторе как о словаре: для каждого конкретного слова (инструкции байт-кода) существует соответствующий ему конкретный перевод (инструкция машинного кода). Поскольку интерпретатор немедленно выполняет инструкцию каждый раз, когда читает ее, этот метод не может оптимизировать набор инструкций. В то же время каждый раз, когда вызывается байт-код, его необходимо немедленно интерпретировать, поэтому интерпретатор работает очень медленно. Интерпретатор выполняет код очень точно, но поскольку набор выходных команд не оптимизирован, он может не дать оптимальных результатов для процессора целевой платформы.
компилировать
Компилятор загружает весь исполняемый код в среду выполнения. Таким образом, он может ссылаться на весь или на часть контекста времени выполнения при трансляции байт-кода. Решения, которые он принимает, основаны на результатах анализа графа кода. Например, сравнение различных ветвей выполнения и обращение к данным контекста времени выполнения.
После того как последовательность байт-кода преобразуется в набор команд машинного кода, на основе этого набора команд машинного кода может быть выполнена оптимизация. Оптимизированный набор команд хранится в структуре, называемой буфером кода. Когда эти байт-коды выполняются снова, оптимизированный код может быть получен непосредственно из этого буфера кода и выполнен. В некоторых случаях компилятор не использует оптимизатор для оптимизации кода, а использует новую последовательность оптимизации — «подсчет производительности».
Преимущество использования кэша кода заключается в том, что инструкции результирующего набора могут быть выполнены немедленно без необходимости повторной интерпретации или компиляции!
Это может значительно сократить время выполнения, особенно для приложений Java, в которых метод вызывается несколько раз.
оптимизация
С появлением динамической компиляции у нас появилась возможность вставлять счетчики производительности. Например, компилятор вставляет счетчик производительности, который увеличивается каждый раз, когда вызывается блок байт-кода (соответствующий определенному методу). Компилятор использует эти счетчики для поиска «горячих блоков», чтобы определить, какие блоки кода можно оптимизировать, чтобы добиться максимального повышения производительности приложения. Данные анализа производительности во время выполнения могут помочь компилятору принимать больше решений по оптимизации в оперативном режиме, тем самым еще больше повышая эффективность выполнения кода. Поскольку мы получаем все более точные данные анализа производительности кода, мы можем найти больше точек оптимизации и принять более эффективные решения по оптимизации, например: как лучше упорядочить инструкции и следует ли использовать более эффективный набор команд, заменить исходный набор команд и стоит ли исключить лишние операции и т.д.
Например
Рассмотрим следующий код Java. Скопируйте код. Код выглядит следующим образом:
staticint add7(int x ){ return x+7;}
Javac статически преобразует его в следующий байт-код:
Скопируйте код кода следующим образом:
iload0
бипуш 7
ядобавить
возвращение
При вызове этого метода байт-код будет динамически скомпилирован в машинные инструкции. Метод может быть оптимизирован, когда счетчик производительности (если он существует) достигает заданного порога. Оптимизированные результаты могут выглядеть следующим образом:
Скопируйте код кода следующим образом:
Леа Ракс,[rdx+7] ret
Разные компиляторы подходят для разных приложений.
Разные приложения имеют разные потребности. Корпоративным серверным приложениям обычно требуется работать в течение длительного времени, поэтому им обычно требуется более высокая оптимизация производительности, тогда как клиентским апплетам может потребоваться более быстрое время отклика и меньшее потребление ресурсов. Давайте обсудим три разных компилятора, их плюсы и минусы.
Клиентские компиляторы
C1 — известный оптимизирующий компилятор. При запуске JVM добавьте параметр -client, чтобы запустить компилятор. По его названию мы можем узнать, что C1 — это клиентский компилятор. Он идеально подходит для клиентских приложений, которые имеют мало доступных системных ресурсов или требуют быстрого запуска. C1 выполняет оптимизацию кода с помощью счетчиков производительности. Это простой метод оптимизации с меньшим вмешательством в исходный код.
Серверные компиляторы
Для долго выполняющихся приложений (таких как серверные корпоративные приложения) использование клиентского компилятора может оказаться недостаточным. На данный момент нам следует выбрать серверный компилятор, например C2. Оптимизатор можно запустить, добавив сервер в строку запуска JVM. Поскольку большинство серверных приложений, как правило, работают долго, с помощью компилятора C2 вы сможете собрать больше данных по оптимизации производительности, чем с короткими, облегченными клиентскими приложениями. Таким образом, вы также сможете применять более совершенные методы и алгоритмы оптимизации.
Совет: прогрейте серверный компилятор
При развертывании на стороне сервера компилятору может потребоваться некоторое время для оптимизации этих «горячих» кодов. Поэтому развертывание на стороне сервера часто требует фазы «разогрева». Поэтому при измерении производительности при развертывании на стороне сервера всегда проверяйте, что ваше приложение достигло устойчивого состояния! Предоставление компилятору достаточно времени для компиляции принесет много преимуществ вашему приложению.
Компилятор на стороне сервера может получить больше данных о настройке производительности, чем компилятор на стороне клиента, поэтому он может выполнять более сложный анализ ветвей и находить пути оптимизации с более высокой производительностью. Чем больше у вас данных анализа производительности, тем лучше будут результаты анализа вашего приложения. Конечно, выполнение обширного анализа производительности требует больше ресурсов компилятора. Например, если JVM использует компилятор C2, ей потребуется использовать больше циклов ЦП, больший кэш кода и т. д.
Многоуровневая компиляция
Многоуровневая компиляция сочетает в себе компиляцию на стороне клиента и компиляцию на стороне сервера. Азул был первым, кто реализовал многоуровневую компиляцию в своей JVM Zing. Недавно эта технология была принята JVM Oracle Java Hotspot (после Java SE7). Многоуровневая компиляция сочетает в себе преимущества клиентских и серверных компиляторов. Клиентский компилятор активен в двух ситуациях: при запуске приложения и когда счетчики производительности достигают пороговых значений нижнего уровня для выполнения оптимизации производительности. Клиентский компилятор также вставляет счетчики производительности и подготавливает набор инструкций для последующего использования серверным компилятором для расширенной оптимизации. Многоуровневая компиляция — это метод анализа производительности с высоким использованием ресурсов. Поскольку он собирает данные во время работы компилятора с минимальным воздействием, эти данные можно использовать позже в более сложных оптимизациях. Этот подход предоставляет больше информации, чем анализ счетчиков с использованием интерпретируемого кода.
На рисунке 1 показано сравнение производительности интерпретаторов, компиляции на стороне клиента, компиляции на стороне сервера и многоуровневой компиляции. По оси X — время выполнения (единица времени), а по оси Y — производительность (количество операций в единицу времени).
Рисунок 1. Сравнение производительности компилятора
По сравнению с чисто интерпретируемым кодом использование компилятора на стороне клиента может повысить производительность примерно в 5–10 раз. Прирост производительности, который вы получите, зависит от эффективности компилятора, типов доступных оптимизаторов и того, насколько хорошо дизайн приложения соответствует целевой платформе. Но разработчикам программ последнее часто можно игнорировать.
По сравнению с компиляторами на стороне клиента, компиляторы на стороне сервера часто могут повысить производительность на 30–50 %. В большинстве случаев повышение производительности часто достигается за счет потребления ресурсов.
Многоуровневая компиляция сочетает в себе преимущества обоих компиляторов. Компиляция на стороне клиента имеет более короткое время запуска и может выполнять быструю оптимизацию; компиляция на стороне сервера может выполнять более сложные операции оптимизации во время последующего процесса выполнения.
Некоторые распространенные оптимизации компилятора
До сих пор мы обсуждали, что значит оптимизировать код, а также как и когда JVM выполняет оптимизацию кода. Далее я закончу эту статью, представив некоторые методы оптимизации, которые фактически используются компиляторами. Оптимизация JVM фактически происходит на этапе байт-кода (или этапе представления языка нижнего уровня), но для иллюстрации этих методов оптимизации здесь будет использоваться язык Java. Конечно, в этом разделе невозможно охватить все методы оптимизации JVM; я надеюсь, что эти введения вдохновят вас на изучение сотен более продвинутых методов оптимизации и инноваций в технологии компиляторов.
Удаление мертвого кода
Устранение мертвого кода, как следует из названия, заключается в удалении кода, который никогда не будет выполнен, то есть «мертвого» кода.
Если во время работы компилятор обнаружит какие-то избыточные инструкции, он удалит эти инструкции из набора команд выполнения. Например, в листинге 1 одна из переменных никогда не будет использоваться после присвоения ей, поэтому оператор присваивания можно полностью игнорировать во время выполнения. В соответствии с операцией на уровне байт-кода значение переменной никогда не нужно загружать в регистр. Отсутствие необходимости загрузки означает, что процессор потребляет меньше времени, что ускоряет выполнение кода, что в конечном итоге приводит к более быстрому приложению - если код загрузки вызывается много раз в секунду, эффект оптимизации будет более очевидным.
В листинге 1 используется код Java для иллюстрации примера присвоения значения переменной, которая никогда не будет использоваться.
Листинг 1. Код копирования мертвого кода выглядит следующим образом:
int timeToScaleMyApp(логическое значение бесконечногоOfResources){
INT реАрхитектор = 24;
int patchByClustering = 15;
ИНТ useZing = 2;
если (конечные ресурсы)
вернуть reArchitect + useZing;
еще
вернуть использованиеZing;
}
Если на этапе байт-кода переменная загружена, но никогда не используется, компилятор может обнаружить и устранить мертвый код, как показано в листинге 2. Если вы никогда не выполняете эту операцию загрузки, вы можете сэкономить время процессора и повысить скорость выполнения программы.
Листинг 2. Оптимизированный код копирования кода выглядит следующим образом:
int timeToScaleMyApp(логическое значение бесконечногоOfResources){
int reArchitect =24; //здесь удалена ненужная операция…
ИНТ useZing = 2;
если (конечные ресурсы)
вернуть reArchitect + useZing;
еще
вернуть использованиеZing;
}
Устранение избыточности — это метод оптимизации, который повышает производительность приложения за счет удаления повторяющихся инструкций.
Многие оптимизации пытаются исключить инструкции перехода на уровне машинных команд (например, JMP в архитектуре x86 изменяют регистр указателя инструкций, тем самым отвлекая поток выполнения программы). Эта команда перехода требует очень много ресурсов по сравнению с другими инструкциями ASSEMBLY. Вот почему мы хотим сократить или исключить такого рода инструкции. Встраивание кода — очень практичный и хорошо известный метод оптимизации, позволяющий исключить инструкции передачи. Поскольку выполнение инструкций перехода требует больших затрат, встраивание некоторых часто вызываемых небольших методов в тело функции принесет много преимуществ. Листинг 3-5 демонстрирует преимущества встраивания.
Листинг 3. Код копирования метода вызова. Код выглядит следующим образом:
int WhenToEvaluateZing(int y){ return DayLeft(y)+ DayLeft(0)+ DayLeft(y+1);}
Листинг 4. Код копирования вызываемого метода выглядит следующим образом:
int daysLeft(int x){ if(x ==0) return0; else return x -1;}
Листинг 5. Код копирования встроенного метода выглядит следующим образом:
интервал когдаToEvaluateZing(int y){
интервал температуры = 0;
если (у == 0)
температура +=0;
еще
температура += у -1;
если (0==0)
температура +=0;
еще
температура +=0-1;
если (у+1==0)
температура +=0;
еще
температура +=(у +1)-1;
температура возврата;
}
В листинге 3-5 мы видим, что небольшой метод вызывается три раза в теле другого метода, и мы хотим проиллюстрировать следующее: стоимость внедрения вызываемого метода непосредственно в код будет меньше, чем выполнение трех переходов. передача инструкций.
Внедрение метода, который вызывается нечасто, возможно, не будет иметь большого значения, но внедрение так называемого «горячего» метода (метода, который вызывается часто) может значительно улучшить производительность. Встроенный код часто можно дополнительно оптимизировать, как показано в листинге 6.
Листинг 6. После внедрения кода дальнейшей оптимизации можно добиться, скопировав код следующим образом:
int WhenToEvaluateZing (int y) { if (y == 0) return y; elseif (y ==-1) return y -1; elsereturn y + y -1;}
Оптимизация цикла
Оптимизация цикла играет важную роль в снижении дополнительных затрат на выполнение тела цикла. Дополнительные затраты здесь относятся к дорогостоящим переходам, множеству проверок условий и неоптимизированным конвейерам (то есть серии наборов инструкций, которые не выполняют реальных операций и потребляют дополнительные циклы ЦП). Существует множество типов оптимизации цикла. Вот некоторые из наиболее популярных оптимизаций цикла:
Объединение тела цикла: когда два соседних тела цикла выполняют одинаковое количество циклов, компилятор попытается объединить два тела цикла. Если два тела цикла полностью независимы друг от друга, они также могут выполняться одновременно (параллельно).
Инверсионный цикл. По сути, вы заменяете цикл while циклом do- while. Этот цикл do- while размещается внутри оператора if. Эта замена уменьшит количество двух операций перехода, но увеличит условное суждение, тем самым увеличивая объем кода; Этот вид оптимизации является отличным примером обмена большего количества ресурсов на более эффективный код: компилятор взвешивает затраты и выгоды и динамически принимает решения во время выполнения.
Реорганизация тела цикла. Реорганизуйте тело цикла так, чтобы все тело цикла можно было сохранить в кеше.
Расширьте тело цикла: уменьшите количество проверок условий цикла и переходов. Вы можете думать об этом как о выполнении нескольких «встроенных» итераций без необходимости выполнения условной проверки. Развертывание тела цикла также несет определенные риски, поскольку может снизить производительность из-за воздействия на конвейер и большого количества избыточных выборок инструкций. Опять же, компилятор должен решить, следует ли разворачивать тело цикла во время выполнения, и стоит ли его разворачивать, если это приведет к большему повышению производительности.
Выше представлен обзор того, как компиляторы на уровне байт-кода (или более низком уровне) могут повысить производительность приложений на целевой платформе. Мы обсудили некоторые распространенные и популярные методы оптимизации. Из-за ограниченности места мы приведем лишь несколько простых примеров. Наша цель — пробудить у вас интерес к углубленному изучению оптимизации посредством приведенного выше простого обсуждения.
Заключение: моменты для размышления и ключевые моменты
Выбирайте разные компиляторы для разных целей.
1. Интерпретатор — это простейшая форма перевода байт-кода в машинные инструкции. Его реализация основана на таблице поиска инструкций.
2. Компилятор может оптимизировать на основе счетчиков производительности, но это требует потребления некоторых дополнительных ресурсов (кэш кода, поток оптимизации и т. д.).
3. Клиентский компилятор может повысить производительность в 5–10 раз по сравнению с интерпретатором.
4. Компилятор на стороне сервера может повысить производительность на 30–50 % по сравнению с компилятором на стороне клиента, но для этого требуется больше ресурсов.
5. Многоуровневая компиляция сочетает в себе преимущества обоих. Используйте компиляцию на стороне клиента для сокращения времени отклика, а затем используйте компилятор на стороне сервера для оптимизации часто вызываемого кода.
Здесь существует множество возможных способов оптимизации кода. Важная задача компилятора — проанализировать все возможные методы оптимизации, а затем сопоставить затраты различных методов оптимизации с улучшением производительности, обеспечиваемым окончательными машинными инструкциями.