Статьи

Java на стероидах: 5 супер полезных методов оптимизации JIT

Java разработчик? Оптимизируйте мониторинг производства. См. Исходный код, стек вызовов и состояние переменных за всеми зарегистрированными ошибками, предупреждениями и исключениями — попробуйте Takipi .

Каковы некоторые из наиболее полезных JIT-оптимизаций JVM и как их использовать?

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

В этом посте мы поговорили с Моше Цуром , руководителем отдела исследований и разработок Takipi , и получили возможность поделиться некоторыми советами о компиляторе JVM Just In Time (JIT).

Посмотрим, что происходит за кулисами.

Пиши один раз, беги куда угодно, оптимизируй как раз вовремя

Большинство людей знают, что их исходный код Java превращается в байт-код компилятором javac, а затем запускается JVM, который затем компилирует его в Assembly и передает его в CPU.

Меньше людей знают, что кроличья нора идет глубже. JVM имеет 2 различных режима работы.

Сгенерированный байт-код является точным представлением исходного исходного кода Java без каких-либо оптимизаций. Когда JVM переводит его на Assembly, все становится сложнее, и волшебство начинает действовать:

  1. Интерпретированный режим — где JVM читает и запускает сам байт-код, как есть.
  2. Режим компиляции (байт-код для сборки) — когда JVM ослабляет захват, и байт-код не используется.

Связь между ними — компилятор JIT. Как вы уже могли догадаться, интерпретируемый режим намного медленнее, чем прямой запуск на процессоре без посредников.

Интерпретированный режим дает платформе Java прекрасную возможность собирать информацию о коде и о том, как он на самом деле ведет себя на практике, что дает ему возможность узнать, как можно оптимизировать полученный код сборки.

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

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

Вот 5 наиболее полезных примеров:

1. Устранение нулевой проверки

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

Тем не менее, в большинстве случаев явные нулевые проверки могут быть исключены из оптимизированного кода сборки, и это справедливо для любых условий, которые случаются редко. В редком случае, когда это условие действительно происходит, JIT использует механизм, называемый «необычная ловушка», который позволяет ему знать, что ему нужно вернуться и исправить оптимизацию, возвращая набор инструкций, которые необходимо выполнить, когда он происходит — без всплытия фактического исключения NullPointerException.

Причина, по которой эти проверки вызывают беспокойство по поводу оптимизированного кода, заключается в том, что они могут вносить скачки в код сборки, замедляя его выполнение.

Давайте посмотрим на простой пример:

1
2
3
4
5
6
7
private static void runSomeAlgorithm(Graph graph) {
    if (graph == null) {
        return;
    }
 
    // do something with graph
}

Если JIT видит, что граф никогда не вызывается с нулем, скомпилированная версия будет выглядеть так, как будто он отражает код, который был написан без проверки нуля:

1
2
3
4
private static void runSomeAlgorithm(Graph graph) {
 
    // do something with graph
}

Итог: нам не нужно делать ничего особенного, чтобы насладиться этой оптимизацией, и когда возникает редкое условие, JVM знает, что нужно «деоптимизировать» и получить желаемый результат.

2. Прогнозирование отрасли

Подобно устранению нулевой проверки, существует другая методика оптимизации JIT, которая помогает определить, являются ли определенные строки кода « более горячими », чем другие, и происходят ли они чаще.

Мы уже знаем, что если определенное условие редко или никогда не выполняется, вполне вероятно, что механизм «необычной ловушки» сработает и удалит его из скомпилированной сборки.

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

Вот как это работает на практике:

01
02
03
04
05
06
07
08
09
10
private static int isOpt(int x, int y) {
    int veryHardCalculation = 0;
 
    if (x >= y) {
        veryHardCalculation = x * 1000 + y;
    } else {
        veryHardCalculation = y * 1000 + x;
    }
    return veryHardCalculation;
}

Теперь, предполагая, что большую часть времени x <y, условие будет изменено, чтобы статистически уменьшить количество скачков сборки:

01
02
03
04
05
06
07
08
09
10
11
12
private static int isOpt(int x, int y) {
    int veryHardCalculation = 0;
 
    if (x < y) {
        // this would not require a jump
        veryHardCalculation = y * 1000 + x;
        return veryHardCalculation;
    } else {
        veryHardCalculation = x * 1000 + y;
        return veryHardCalculation;
    }
}

Если вы не уверены, мы на самом деле провели небольшой тест и извлекли оптимизированный код сборки:

1
2
3
0x00007fd715062d0c: cmp    %edx,%esi
0x00007fd715062d0e: jge    0x00007fd715062d24  ;*if_icmplt
                                               ; - Opt::isOpt@4 (line 117)

Как вы можете видеть, инструкция перехода — jge (переход, если больше или равен), соответствующий ветви else условия.

Итог: еще одна готовая оптимизация JIT, которой мы можем наслаждаться. В недавнем посте мы также рассмотрели прогнозирование ветвлений о некоторых наиболее интересных ответах Stackoverflow Java .

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

3. Развертывание петли

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

Вот почему петли пахнут неприятностями.

Каждая итерация на самом деле является переходом сборки к началу набора команд. При развертывании цикла JIT-компилятор открывает цикл и просто повторяет соответствующие инструкции по сборке одну за другой.

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

Например, давайте посмотрим на метод, который умножает матрицу на вектор:

01
02
03
04
05
06
07
08
09
10
11
private static double[] loopUnrolling(double[][] matrix1, double[] vector1) {
    double[] result = new double[vector1.length];
 
    for (int i = 0; i < matrix1.length; i++) {
    for (int j = 0; j < vector1.length; j++) {
            result[i] += matrix1[i][j] * vector1[j];
        }
    }
 
    return result;
}

Развернутая версия будет выглядеть так:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
private static double[] loopUnrolling2(double[][] matrix1, double[] vector1) {
    double[] result = new double[vector1.length];
 
    for (int i = 0; i < matrix1.length; i++) {
        result[i] += matrix1[i][0] * vector1[0];
        result[i] += matrix1[i][1] * vector1[1];
        result[i] += matrix1[i][2] * vector1[2];
        // and maybe it will expand even further - e.g. 4 iterations, thus
        // adding code to fix the indexing
        // which we would waste more time doing correctly and efficiently
    }
 
    return result;
}

Повторяя одно и то же действие снова и снова без скачка накладных расходов:

01
02
03
04
05
06
07
08
09
10
11
....
0x00007fd715060743: vmovsd 0x10(%r8,%rcx,8),%xmm0  ;*daload
                                                   ; - Opt::loopUnrolling@26 (line 179)
0x00007fd71506074a: vmovsd 0x10(%rbp),%xmm1        ;*daload
                                                   ; - Opt::loopUnrolling@36 (line 179)
0x00007fd71506074f: vmulsd 0x10(%r12,%r9,8),%xmm1,%xmm1
0x00007fd715060756: vaddsd %xmm0,%xmm1,%xmm0       ;*dadd
                                                   ; - Opt::loopUnrolling@38 (line 179)
0x00007fd71506075a: vmovsd %xmm0,0x10(%r8,%rcx,8)  ;*dastore
                                                   ; - Opt::loopUnrolling@39 (line 179)
....

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

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

4. Методы встраивания

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

Также возможно точно настроить способ, которым JIT-компилятор включает методы с двумя аргументами JVM:

  1. -XX: MaxInlineSize — максимальный размер байт-кода метода, который может быть встроен (выполняется для любого метода, даже если он не выполняется часто). По умолчанию около 35 байтов.
  2. -XX: FreqInlineSize — максимальный размер байт-кода метода, который считается горячей точкой (выполняется часто), который должен быть встроен. По умолчанию зависит от платформы.

Например, давайте посмотрим на метод, который вычисляет координаты простой линии:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
private static void calcLine(int a, int b, int from, int to) {
    Line l = new Line(a, b);
    for (int x = from; x <= to; x++) {
        int y = l.getY(x);
        System.err.println("(" + x + ", " + y + ")");
    }
}
 
static class Line {
    public final int a;
    public final int b;
    public Line(int a, int b) {
        this.a = a;
        this.b = b;
    }
 
    // Inlining
    public int getY(int x) {
        return (a * x + b);
    }
}

Оптимизированная встроенная версия исключает переход, отправку аргументов l и x и возврат y:

1
2
3
4
5
6
7
private static void calcLine(int a, int b, int from, int to) {
    Line l = new Line(a, b);
    for (int x = from; x <= to; x++) {
        int y = (l.a * x + l.b);
        System.err.println("(" + x + ", " + y + ")");
    }
}

Итог: встраивание — это очень полезная оптимизация, но она включится только в том случае, если вы будете придерживаться максимально простых методов с минимальным количеством строк. Сложные методы с меньшей вероятностью оказываются встроенными, поэтому это один из моментов, когда вы можете помочь компилятору JIT справиться со своей задачей.

5. Поля потоков и локальное хранилище потоков (TLS)

Как выясняется, поля потоков намного быстрее, чем обычные переменные. Объект потока хранится в реальном регистре процессора, что делает его поля очень эффективным пространством хранения.

С помощью локального хранилища потоков вы можете создавать переменные, хранящиеся в объекте Thread.

Вот как вы можете получить доступ к локальному хранилищу потоков с помощью простого примера счетчика запросов:

1
2
3
4
5
6
private static void handleRequest() {
    if (counter.get() == null) {
        counter.set(0);
    }
 
    counter.set(counter.get() + 1);

В соответствующем ассемблерном коде мы видим, что данные помещаются непосредственно в регистр, что обеспечивает более быстрый доступ к данным, чем, скажем, переменная статического класса:

1
2
3
4
5
6
7
0x00007fd71508b1ec: mov    0x1b0(%r15),%r10   ;*invokestatic currentThread
                                                ; - java.lang.ThreadLocal::get@0 (line 143)
                                                ; - Opt::handleRequest@3 (line 70)
 0x00007fd71508b1f3: mov    0x50(%r10),%r11d   ;*getfield threadLocals
                                                ; - java.lang.ThreadLocal::getMap@1 (line 213)
                                                ; - java.lang.ThreadLocal::get@6 (line 144)
                                                ; - Opt::handleRequest@3 (line 70)

Итог: некоторые конфиденциальные типы данных лучше хранить в локальном хранилище потоков для более быстрого доступа и поиска.

Последние мысли

JIT-компилятор JVM является одним из увлекательных механизмов на платформе Java. Он оптимизирует ваш код для производительности, не жертвуя его читабельностью. Мало того, что помимо «статических» методов оптимизации встраивания, он также принимает решения на основе того, как код работает на практике.

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

Java разработчик? Оптимизируйте мониторинг производства. См. Исходный код, стек вызовов и состояние переменных за всеми зарегистрированными ошибками, предупреждениями и исключениями — попробуйте Takipi .