Статьи

JVM Performance Magic Tricks

HotSpot, JVM, которую мы все знаем и любим, — это мозг, в котором текут наши соки Java и Scala. За прошедшие годы он был улучшен и настроен более чем несколькими инженерами, и с каждой итерацией скорость и эффективность выполнения его кода приближается к производительности собственного скомпилированного кода. duke01-e1369743413155

В его основе лежит JIT (Just-In-Time) компилятор. Единственная цель этого компонента — заставить ваш код работать быстро , и это одна из причин популярности и успеха HotSpot.

Что на самом деле делает JIT-компилятор?

Во время выполнения вашего кода JVM собирает информацию о его поведении. Как только собрана достаточная статистика о горячем методе (10K-вызовы являются пороговым значением по умолчанию), компилятор включается и преобразует независимый от платформы «медленный» байт-код в оптимизированную, компактную, средне скомпилированную версию самого себя.

Некоторые оптимизации очевидны: простое встраивание методов, удаление мертвого кода, замена библиотечных вызовов собственными математическими операциями и т. Д. Обратите внимание, компилятор JIT на этом не останавливается. Вот некоторые из наиболее интересных оптимизаций, выполненных им:

Разделяй и властвуй

Сколько раз вы использовали следующую схему:

01
02
03
04
05
06
07
08
09
10
StringBuilder sb = new StringBuilder("Ingredients: ");
 
for (int i = 0; i < ingredients.length; i++) {
    if (i > 0) {
        sb.append(", ");
    }
    sb.append(ingredients[i]);
}
 
return sb.toString();

Или, возможно, этот:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
boolean nemoFound = false;
  
for (int i = 0; i < fish.length; i++) {
    String curFish = fish[i];
      
    if (!nemoFound) {
        if (curFish.equals("Nemo")) {
            System.out.println("Nemo! There you are!");
            nemoFound = true;
            continue;
        }
    }
      
    if (nemoFound) {
        System.out.println("We already found Nemo!");
    } else {
        System.out.println("We still haven't found Nemo : (");
    }
}

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

Давайте возьмем первый цикл для примера. Строка if (i > 0) начинается как false для одной итерации, и с этого момента она всегда принимает значение true . Зачем проверять состояние каждый раз? Компилятор скомпилирует этот код так, как если бы он был написан так:

01
02
03
04
05
06
07
08
09
10
11
StringBuilder sb = new StringBuilder("Ingredients: ");
 
if (ingredients.length > 0) {
    sb.append(ingredients[0]);
    for (int i = 1; i < ingredients.length; i++) {
        sb.append(", ");
        sb.append(ingredients[i]);
    }
}
 
return sb.toString();

Таким образом, избыточный, if (i > 0) удаляется, даже если некоторый код может дублироваться в процессе, так как скорость — это то, что это все.

Живут на грани

Нулевые чеки — это хлеб с маслом. Иногда null является допустимым значением для наших ссылок (например, указывает на отсутствующее значение или ошибку), но иногда мы добавляем нулевые проверки просто для безопасности.

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

1
2
3
4
5
6
public static String l33tify(String phrase) {
    if (phrase == null) {
        throw new IllegalArgumentException("phrase must not be null");
    }
    return phrase.replace('e', '3');
}

Если ваш код ведет себя хорошо и никогда не передает null в качестве аргумента для l33tify , утверждение никогда не потерпит неудачу.

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

1
2
3
public static String l33tify(String phrase) {
    return phrase.replace('e', '3');
}

Это может привести к значительному повышению производительности, что в большинстве случаев может быть чистой победой.

Но что, если это предположение о счастливом пути в конечном итоге окажется неверным?

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

Виртуальное безумие

Одно из основных различий между JIT-компилятором JVM и другими статическими, такими как компиляторы C ++, заключается в том, что JIT-компилятор имеет динамические данные времени выполнения, на которые он может опираться при принятии решений.

Встраивание метода — это общая оптимизация, при которой компилятор берет полный метод и вставляет свой код в чужой, чтобы избежать вызова метода. Это становится немного сложнее при работе с вызовами виртуальных методов (или динамической диспетчеризацией ).

Возьмите следующий код для примера:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Main {
    public static void perform(Song s) {
        s.sing();
    }
}
 
public interface Song { void sing(); }
 
public class GangnamStyle implements Song {
    @Override
    public void sing() {
        System.out.println("Oppan gangnam style!");
    }
}
 
public class Baby implements Song {
    @Override
    public void sing() {
        System.out.println("And I was like baby, baby, baby, oh");
    }
}
 
// More implementations here

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

Не обязательно! После выполнения perform несколько тысяч раз компилятор может решить, согласно собранной статистике, что 95% вызовов нацелены на экземпляр GangnamStyle . В этих случаях HotSpot JIT может выполнить оптимистическую оптимизацию с целью устранения виртуального вызова sing . Другими словами, компилятор будет генерировать собственный код для чего-то такого:

1
2
3
4
5
6
7
public static void perform(Song s) {
    if (s fastnativeinstanceof GangnamStyle) {
        System.out.println("Oppan gangnam style!");
    } else {
        s.sing();
    }
}

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

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

Могу ли я помочь?

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

Ссылка: JVM Performance Magic Tricks от нашего партнера JCG Нива Штейнгартена в блоге Takipi .