Статья

Java компилируемый или интерпретируемый язык?

Языки программирования классифицируются в зависимости от их уровней абстракции. Мы различаем языки высокого уровня (Java, Python, JavaScript, C++, Go), низкого уровня (ассемблер) и, наконец, машинный код.
Код любого языка высокого уровня, такой как Java, должен быть переведен в машинный код для выполнения. Этот процесс перевода может быть как компиляцией, так и интерпретацией. Однако есть и третий вариант – комбинация, которая стремится использовать преимущества обоих подходов.
В этой статье мы рассмотрим, как Java-код компилируется и выполняется на нескольких платформах. Мы рассмотрим некоторые особенности проектирования Java и JVM. Они помогут нам определить, является ли Java компилируемым, интерпретируемым или гибридом того и другого.
Давайте начнем с рассмотрения основных различий между компилируемыми и интерпретируемыми языками программирования.

Компилируемые языки программирования

Компилируемые языки (к примеру, C++, Go) преобразуются непосредственно в машинный код с помощью программы-компилятора.
Они требуют явного шага сборки перед выполнением. Вот почему нам нужно перекомпилировать программу каждый раз, когда мы вносим изменения в код.
Компилируемые языки, как правило, быстрее и эффективнее интерпретируемых языков. Однако, сгенерированный ими машинный код зависит от платформы.

Интерпретируемые языки программирования

С другой стороны, в интерпретируемых языках (Python, JavaScript) нет шагов сборки. Вместо этого интерпретаторы работают с исходным кодом программы во время ее выполнения.
Интерпретируемые языки когда-то считались значительно более медленными, чем скомпилированные языки. Однако с развитием JIT-компиляции разрыв в производительности сокращается. JIT-компиляторы превращают код с интерпретируемого языка в машинный код по мере выполнения программы.
Кроме того, мы можем выполнять интерпретируемый код на нескольких платформах, таких как Windows, Linux или Mac. Интерпретируемый код не имеет никакого отношения к определенному типу архитектуры процессора.

Напишите код один раз и запустите в любом месте

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

Компилятор Java

Инструмент командной строки javac компилирует исходный код Java в файлы классов Java, содержащие байт-код, не зависящий от платформы:
javac HelloWorld.java
Файлы исходного кода имеют суффиксы .java, в то время как файлы классов, содержащие байт-код, генерируются с суффиксами .class.

Виртуальная машина Java

Скомпилированные файлы классов (байт-код) могут быть выполнены виртуальной машиной Java (JVM):
$ java HelloWorld
Hello World!
Давайте теперь глубже рассмотрим архитектуру JVM. Наша цель – определить, как байт-код преобразуется в машинный код во время выполнения.

Обзор архитектуры

JVM состоит из пяти подсистем:
  • ClassLoader
  • JVM memory
  • Execution engine
  • Native method interface
  • Native method library

ClassLoader

JVM использует подсистемы ClassLoader для переноса скомпилированных файлов классов в память JVM.
Помимо загрузки, ClassLoader также выполняет связывание и инициализацию. Основные функции ClassLoader:
  • Проверка байт-кода на наличие любых нарушений безопасности
  • Выделение памяти для статических переменных
  • Замена символических ссылок на память исходными ссылками
  • Присвоение исходных значений статическим переменным
  • Выполнение всех статических блоков кода

Execution engine

Execution engine отвечает за чтение байт-кода, преобразование его в машинный код и его выполнение.
За выполнение отвечают три основных компонента, включая как интерпретатор, так и компилятор:
  • Поскольку JVM не зависит от платформы, она использует интерпретатор для выполнения байт-кода
  • JIT-компилятор повышает производительность за счет компиляции байт-кода в машинный код для повторяющихся вызовов методов
  • Сборщик мусора собирает и удаляет все объекты, на которые нет ссылок
Execution engine использует интерфейс нативных методов (JNI) для вызова нативных библиотек и приложений.

Just in Time компилятор

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

Сравнение производительности

Давайте посмотрим, как JIT-компиляция улучшает производительность Java во время выполнения.

Тест производительности Фибоначчи

Мы будем использовать простой рекурсивный метод для вычисления n-го числа Фибоначчи:
private static int fibo(int index) {
    if (index <= 1) {
        return index;
    }
    return fibo(index-1) + fibo(index-2);
}
Чтобы измерить преимущества производительности при повторных вызовах методов, мы запустим метод fibo 100 раз:
for (int i = 0; i < 100; i++) {
    long start = System.nanoTime();
    int result = fibo(14);
    long totalTime = System.nanoTime() - start;
    System.out.println(totalTime);
}
Сначала мы скомпилируем и выполним Java-код в обычном режиме:
$ java Fibo.java
Затем мы выполним тот же код с отключенным JIT-компилятором:
$ java -Djava.compiler=NONE Fibo.java
Наконец, мы реализуем и запустим один и тот же алгоритм на C++ и JavaScript для сравнения.

Результаты тестирования производительности

Давайте взглянем на измеренные средние показатели в наносекундах после выполнения рекурсивного теста Фибоначчи:
  • Java с включенным JIT компилятором – 2727 ns – самый быстрый
  • Java с выключенным JIT компилятором – 17965 ns – на 560% медленнее
  • C++ без O2 оптимизации – 9431 ns – на 245% медленнее
  • C++ с O2 оптимизацией – 3648 ns – на 35% медленнее
  • JavaScript – 22791 ns – на 740% медленнее
В этом примере производительность Java более чем на 500% выше при использовании JIT-компилятора. Однако для прогрева JIT-компилятора требуется несколько запусков.
Интересно, что Java работает на 35% лучше, чем код C++, даже когда C++ компилируется с включенным флагом оптимизации O2. Как и ожидалось, C++ работал намного лучше в первых нескольких запусках, когда Java все еще интерпретировалась.
Java также превзошла аналогичный код JavaScript, выполняемый с помощью NodeJS, который также использует JIT-компилятор. Результаты показывают более чем на 700% лучшую производительность. Основная причина заключается в том, что JIT-компилятор Java запускается намного быстрее.

Вещи, которые следует учитывать

Технически возможно скомпилировать любой статический код языка программирования непосредственно в машинный код. Также возможно пошагово интерпретировать любой программный код.
Подобно многим другим современным языкам программирования, Java использует комбинацию компилятора и интерпретатора. Цель состоит в том, чтобы использовать лучшее из обоих миров, обеспечивая высокую производительность и независимое от платформы исполнение.
В этой статье мы сосредоточились на объяснении того, как все работает в HotSpot. HotSpot – это реализация JVM с открытым исходным кодом по умолчанию от Oracle. Graal VM также основана на HotSpot, поэтому применяются те же принципы.
Большинство популярных реализаций JVM в настоящее время используют комбинацию интерпретатора и JIT-компилятора. Однако вполне возможно, что некоторые из них используют другой подход.

Заключение

В этой статье мы рассмотрели Java и внутренние компоненты JVM. Наша цель состояла в том, чтобы определить, является ли Java компилируемым или интерпретируемым языком. Мы изучили компилятор Java и внутренние компоненты механизма выполнения JVM.
Исходя из этого, мы пришли к выводу, что Java использует комбинацию обоих подходов.
Исходный код, который мы пишем на Java, сначала компилируется в байт-код в процессе сборки. Затем JVM интерпретирует сгенерированный байт-код для выполнения. Однако JVM также использует JIT-компилятор во время выполнения для повышения производительности.
java