Виртуальная машина Java HotSpot VM (доставшаяся Oracle после приобретения компании Sun Microsystems) составляет основу как для виртуальной машины Java (JVM), так и для OpenJDK (проект с открытым исходным кодом). Как и все виртуальные машины Java HotSpot VM обеспечивает необходимую среду для выполнения байт-кода. На практике она отвечает за три основные функции:

  • интерпретация байт-кода
  • поиск, загрузка и проверка типов (так называемая загрузка классов)
  • управление памятью

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

JIT-компиляция

Java HotSpot VM помимо непосредственной интерпретации байт-кода может выполнять компиляцию байт-кода (отдельных методов целиком) в машинные инструкции для ускорения процесса выполнения.

Если виртуальной машине передать параметр -XX:+PrintCompilation, то можно увидеть как были скомпилированы методы. Эта компиляция происходит во время исполнения, после того как метод уже был выполнен несколько раз. Ожидание фактического использования метода дает возможность Java HotSpot VM сделать боле точное решение о том, как оптимизировать код путем компиляции.

Если вам интересно какой выигрыш дает JIT, вы можете отключить ее используя параметр -Djava.compiler=none и затем посмотреть как изменились результаты ваших тестов.

Java HotSpot VM способна работать в двух независимых режимах: server или client. Конкретный режим выбирается путем указания одноименного параметра -server или -client в момент запуска JVM (необходимо чтобы это был первый параметр в командной строке). В зависимости от конкретной ситуации предпочтительно использовать тот или иной режим. В этой статье будет использоваться режим server.

Основное различие между этими двумя режимами заключается в том, что в режиме server выполняются более агрессивные оптимизации, основанные на предположениях, которые не всегда могут быть выполнены. Для оптимизации всегда проверяется верно ли соответствующее предположение об оптимизации. Если по каким-то причинам предположение не действительно, Java HotSpot VM откатывает оптимизацию и возвращает метод в режим интерпретации байт-кода. Такое поведение означает, что Java HotSpot VM никогда не сделает неверную оптимизацию.

По-умолчанию в режиме server Java HotSpot VM выполнит метод 10 000 раз в режиме интерпретации прежде чем скомпилирует его. Вы можете регулировать это значение выставив параметр CompileThreshold. Например, использование -XX:CompileThreshold=5000 приведет к тому, что Java HotSpot VM выполнит метод 5 000 раз прежде чем скомпилирует его.

Может показаться заманчивым уменьшить порог компиляции до очень маленького значения. Однако, это может привести к падению производительности, так как будет затрачено время на компиляцию методов не выполняющихся так часто чтобы покрыть накладные расходы на их компиляцию.

Наибольшей эффективности Java HotSpot VM достигает тогда, когда она может собрать достаточно статистики, чтобы принять разумное решение о том, что скомпилировать. Если вы уменьшите порог компиляции, Java HotSpot VM может потратить огромное количество времени компилируя методы, которые выполняются не так часто. Некоторые оптимизации выполняются только тогда, когда достаточно статистики было собрано. Так что код может быть не таким оптимальным каким бы он мог быть.

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

В Java HotSpot VM есть множество параметров, которые увеличивают количество выводимой о JIT информации. Наиболее часто используется PrintCompilation (который мы уже видели), но есть несколько других.

Мы будем использовать PrintCompilation для наблюдения за эффектами компиляции методов в Java HotSpot VM во время исполнения. Но для начала нужно сказать пару слов о методе System.nanoTime() для замера времени.

Таймеры

В Java мы можем получить доступ к двум таймерам: currentTimeMillis() и nanoTime(). Первый достаточно близко соответствует времени, которое мы наблюдаем в физическом мире. Его разрешения достаточно для большинства целей, но не для приложений с низкой задержкой.

Наносекундный таймер является альтернативой с более высоким разрешением. Этот таймер измеряет время в невероятно коротких интервалах. Одна наносекунда - это время, за которое свет пройдет 20 сантиметров в волоконно-оптическом кабеле. В отличии от этого, требуется 27,5 мс для того, чтобы свет прошел расстояние от Лондона до Нью-Йорка по волоконно-оптическому кабелю.

В связи с очень высоким разрешением наносекундного таймера требуется с осторожностью обращаться с ним.

Например, currentTimeMillis() как правило синхронизировано между машинами достаточно хорошо и может использоваться для замера сетевых задержек. Но nanoTime() не обладает таким свойством.

Встраивание методов

Одна из ключевых оптимизаций JIT-компиляции (но не javac) - это встраивание методов: копирование тела метода в метод, который вызвал его и устранение вызова. Эта функциональность очень важна, так как стоимость вызова простого метода может быть больше в сравнении с производимой им работой.

JIT-компилятор может выполнять постепенное встраивание, то есть начать с встраивания простых методов и затем переходить на большие и большие блоки кода пока другие оптимизации не станут возможными.

Рассмотрим следующий код, сравнивающий производительность различных способов доступа к полям:

  • прямой доступ к открытому полю класса (DFACaller),
  • через гетер и сетер (GetSetCaller).
import java.util.concurrent.Callable;
import java.lang.management.ManagementFactory;

public class Main {
    private static double timeTestRun(String desc, int runs,
            Callable<Double> callable) throws Exception {
        long start = System.nanoTime();
        callable.call();
        long time = System.nanoTime() - start;
        return (double) time / runs;
    }

    // время с момента запуска
    private static long uptime() {
        return ManagementFactory.getRuntimeMXBean().getUptime()
            + 15; // выдуманный фактор
    }

    public static void main(String... args) throws Exception {
        int iterations = 0;
        
        for (int i : new int[]{ 100, 1000, 5000, 9000, 10000,
                                11000, 13000, 20000, 100000} ) {
            final int runs = i - iterations;
            iterations += runs;

            // ПРИМЕЧАНИЕ: сумма значений возвращается как double для
            // предотвращения агрессивной JIT-компиляции (устранения цикла)

            Callable<Double> directCall = new DFACaller(runs);
            Callable<Double> viaGetSet = new GetSetCaller(runs);

            double time1 = timeTestRun("public fields", runs, directCall);
            double time2 = timeTestRun("get/set fields", runs, viaGetSet);

            System.out.printf("%7d %,7d\t\tfield access=%.1f ns, get/set=%.1f ns%n",
                uptime(), iterations, time1, time2);

            // добавляем задержку для улучшения вывода программы
            Thread.sleep(100);
        }
    }
}
import java.util.concurrent.Callable;

public class DFACaller implements Callable<Double> {
    private final int runs;

    public DFACaller(int runs) {
        this.runs = runs;
    }

    @Override
    public Double call() {
        DirecFieldAccess direct = new DirecFieldAccess();
        double sum = 0;
        for (int i = 0; i < runs; i++) {
            direct.one++;
            sum += direct.one;
        }
        return sum;
    }
}

class DirecFieldAccess {
    int one;
}
import java.util.concurrent.Callable;

public class GetSetCaller implements Callable<Double> {
    private final int runs;

    public GetSetCaller(int runs) {
        this.runs = runs;
    }

    @Override
    public Double call() {
        ViaGetSet getSet = new ViaGetSet();
        double sum = 0;
        for (int i = 0; i < runs; i++) {
            getSet.setOne(getSet.getOne() + 1);
            sum += getSet.getOne();
        }
        return sum;
    }
}

class ViaGetSet {
    private int one;

    public int getOne() {
        return one;
    }

    public void setOne(int one) {
        this.one = one;
    }
}

Объединение JVM

Инженеры Oracle работают над объединением Java HotSpot VM и Oracle JRockit в одно решение, которое будет наделено лучшими возможностями каждой виртуальной машины. Полученную виртуальную машину Oracle планирует внести в проект с открытым исходным кодом - OpenJDK. Вот ключевые моменты этого объединения:

  • Oracle JRockit и HotSpot будут слиты в одну JVM, включающую лучшие возможности обоих.
  • Полученная JVM будет базироваться на коде HotSpot с импортированными возможностями из Oracle JRockit.
  • Результат будет постепенно внесен в OpenJDK.
  • Некоторые существующие решения (такие как Mission Control в Oracle JRocket) останутся проприетарными.
  • Oracle будет и в дальнейшем распространять бесплатные бинарные пакеты JDK и JRE, которые включают некоторые элементы закрытого кода.
  • Процесс объединения JVM будет многолетним.

Более подробную информацию о слиянии JVM можно прочитать в статье Oracle's JVM Strategy - Henrik Stahl (старший директор по управлению продуктами Java Platform Group в Oracle). Чтобы узнать больше о HotSpot, посетите страницу OpenJDK HotSpot. Полный список улучшений JDK вы можете увидеть в каталоге JEP. Чтобы следить за развитием JVM можно подписаться на email-рассылку hotspot-dev@openjdk.java.net.

Гетеры и сетеры - первые кандидаты на встраивание. Это простые методы, которые будут намного "дороже" если они не встроены, так как вызов метода более дорогая операция, чем прямое обращение к полю.

Скомпилируем эти классы и выполним тестирование:

jamel@mac:~$ java -version
java version "1.7.0_07"
Java(TM) SE Runtime Environment (build 1.7.0_07-b10)
Java HotSpot(TM) 64-Bit Server VM (build 23.3-b01, mixed mode)
jamel@mac:~$
jamel@mac:~$ javac Main.java DFACaller.java GetSetCaller.java
jamel@mac:~$
jamel@mac:~$ java -cp . -XX:+PrintCompilation Main

на моей машине (2.8 GHz Intel Core i7, MacOS X 10.7) вывод был таким:

     57    1             java.lang.String::hashCode (55 bytes)
     62     100     field access=3430.0 ns, get/set=3330.0 ns
    156   1,000      field access=140.0 ns, get/set=568.9 ns
    261   5,000      field access=67.3 ns, get/set=481.3 ns
    284    2             ViaGetSet::getOne (5 bytes)
    364   9,000      field access=47.3 ns, get/set=201.5 ns
    488    3             ViaGetSet::setOne (6 bytes)
    493  10,000      field access=109.0 ns, get/set=403.0 ns
    591    4             DFACaller::call (51 bytes)
    591    5             GetSetCaller::call (51 bytes)
    569  11,000      field access=180.0 ns, get/set=346.0 ns
    671  13,000      field access=30.0 ns, get/set=6.0 ns
    772  20,000      field access=9.7 ns, get/set=7.1 ns
    875 100,000      field access=1.7 ns, get/set=1.7 ns

Что все это значит? Числа в первом столбце показывают время в миллисекундах с момента старта программы. Во втором столбце отображается ID метода (для скомпилированных методов) или количество итераций, выполненых в тесте.

Обратите внимание, что метод hashCode класса String в тесте непосредственно не использовался, но все же был скомпилирован, так как он использовался самой платформой.

Во 2-й строке можно увидеть, что оба способа обращения к полю довольно медленные, так как при первом запуске необходимо было выполнить загрузку соответствующих классов. В следующей строке мы видим, что тест выполнился значительно быстрее хотя никакой компиляции еще не произошло.

Также заметим следующее:

  • В тестах на 1 000 и 5 000 итераций прямой доступ к полю быстрее чем через вызовы методов get/set, так как они еще не были встроены или как-то оптимизированы. Даже не смотря на это оба способа работают достаточно быстро.
  • На 9 000 итераций, гетер был оптимизирован (он вызывается дважды за итерацию), что дает небольшое улучшение производительности.
  • На 10 000 итерациях был оптимизирован сетер. Дополнительное время (затраченное на выполнение оптимизации) привело к тому, что общее время теста увеличилось (403 нс вместо 201.5 нс)
  • И наконец методы call() классов DFACaller и GetSetCaller были оптимизированы:
    • гетер и сетер были не просто оптимизированы, но еще и встроены в GetSetCaller.
    • на следующей итерации можно заметить, что время выполнения тестов все еще не оптимально.
  • После 13 000 итераций, производительность каждого метода практически сравнялась. Мы достигли производительности установившегося состояния.

Важно отметить, что в установившемся состоянии выполнения доступ к полям напрямую или через методы get/set выполняются одинаково, так как методы были встроены в методы класса GetSetCaller. Таким образом код в классе GetSetCaller выполняет те же действия, что и код в классе DFACaller.

JIT-компиляция выполняется в фоне именно тогда, когда определенная оптимизация становится возможной для выполнения (изменяясь от машины к машине и реже от запуска к запуску).

Заключение

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

Тесты, использовавшиеся здесь довольно простые и вряд ли подойдут для реальных замеров. Во второй части статьи будет показано как поставить более реалистичные тесты и детально рассмотрен код, производимый JIT-компилятором.

Это вольный перевод статьи Introduction to JIT Compilation in Java HotSpot VM Бена Эванса и Питера Лоурея, опубликованной в майском номере журнала Java Magazine.