JIT перекомпилирует для выполнения быстрого Throw после нескольких итераций, если stacktrace имеет четную длину - PullRequest
5 голосов
/ 25 марта 2020

Следующий код,

public class TestFastThrow {

    public static void main(String[] args) {
        int count = 0;
        int exceptionStackTraceSize = 0;
        Exception exception = null;
        do {
            try {
                throwsNPE(1);
            }
            catch (Exception e) {
                exception = e;
                if (exception.getStackTrace().length != 0) {
                    exceptionStackTraceSize = exception.getStackTrace().length;
                    count++;
                }
            }
        }
        while (exception.getStackTrace().length != 0);
        System.out.println("Iterations to fastThrow :" + count + ", StackTraceSize :" + exceptionStackTraceSize);
    }

    static void throwsNPE(int callStackLength) {
        throwsNPE(callStackLength, 0);
    }

    static void throwsNPE(int callStackLength, int count) {
        if (count == callStackLength) {
            ((Object) null).getClass();
        }
        else {
            throwsNPE(callStackLength, count + 1);
        }
    }

}

дает следующий вывод после многократного запуска,

Iterations to fastThrow :5517, StackTraceSize :4
Iterations to fastThrow :2825, StackTraceSize :5
Iterations to fastThrow :471033, StackTraceSize :6
Iterations to fastThrow :1731, StackTraceSize :7
Iterations to fastThrow :157094, StackTraceSize :10
.
.
.
Iterations to fastThrow :64587, StackTraceSize :20
Iterations to fastThrow :578, StackTraceSize :29

Подробности VM

Java HotSpot(TM) 64-Bit Server VM (11.0.5+10-LTS) for bsd-amd64 JRE (11.0.5+10-LTS)
-XX:+UnlockDiagnosticVMOptions -XX:+TraceClassLoading -XX:+LogCompilation -XX:+PrintAssembly

Удивительно, почему JIT требует гораздо больше итераций для оптимизации, если трассировка стека имеет четную длину?

Я включил журналы JIT и проанализировал с помощью jitwatch, но не смог найти ничего полезного, только временную шкалу, когда C1 и Компиляция C2, кажется, происходит позже для стековых трасс четного размера.

Временная шкала примерно такая (глядя на то, когда java.lang.Throwable.getStackTrace() компилируется)

| StackSize     | 10    | 11    |
|---------------|-------|-------|
| Queued for C1 | 1.099 | 1.012 |
| C1            | 1.318 | 1.162 |
| Queued for C2 | 1.446 | 1.192 |
| C2            | 1.495 | 1.325 |

Почему именно это происходит ? А какую эвристику использует JIT для быстрого броска?

1 Ответ

3 голосов
/ 27 марта 2020

Этот эффект является результатом сложной многоуровневой политики компиляции и встраивания .

Позвольте мне объяснить на упрощенном примере:

public class TestFastThrow {

    public static void main(String[] args) {
        for (int iteration = 0; ; iteration++) {
            try {
                throwsNPE(2);
            } catch (Exception e) {
                if (e.getStackTrace().length == 0) {
                    System.out.println("Iterations to fastThrow: " + iteration);
                    break;
                }
            }
        }
    }

    static void throwsNPE(int depth) {
        if (depth <= 1) {
            ((Object) null).getClass();
        }
        throwsNPE(depth - 1);
    }
}

Для простоты я исключу из компиляции все методы, кроме throwsNPE.

-XX:CompileCommand=compileonly,TestFastThrow::throwsNPE -XX:+PrintCompilation
  1. HotSpot по умолчанию использует многоуровневую компиляцию. Здесь throwsNPE сначала компилируется на уровне 3 (C1 с профилированием). Профилирование в C1 позволяет позже перекомпилировать метод с помощью C2.

  2. OmitStackTraceInFastThrow оптимизация работает только в скомпилированном коде C2. Таким образом, чем раньше код скомпилирован C2, тем меньше итераций пройдет до завершения l oop.

  3. Как работает профилирование в коде, скомпилированном в C1: счетчик увеличивается на при каждом вызове метода и в каждой обратной ветви (однако в методе throwsNPE обратной ветви нет). Когда счетчик достигает определенного настраиваемого порога, политика компиляции JVM решает, нужно ли перекомпилировать текущий метод.

  4. throwsNPE - рекурсивный метод. HotSpot может встраивать рекурсивные вызовы до -XX:MaxRecursiveInlineLevel (значение по умолчанию - 1).

  5. Частота, с которой код, скомпилированный в С1, вызывает политику компиляции JVM, отличается для обычных вызовов и для встроенных вызовы. Обычный метод уведомляет JVM каждые 2 10 вызовов (-XX:Tier3InvokeNotifyFreqLog=10), в то время как встроенный метод уведомляет JVM гораздо реже: каждые 2 20 вызовов (-XX:Tier23InlineeNotifyFreqLog=20).

  6. Для четного числа рекурсивных вызовов все вызовы следуют параметру Tier23InlineeNotifyFreqLog. Если количество вызовов нечетное, вставка не работает для последнего оставшегося вызова, и этот последний вызов следует параметру Tier3InvokeNotifyFreqLog.

  7. Это означает, что когда глубина вызова четная, throwsNPE будет перекомпилирован только после 2 20 вызовов, т.е. после 2 19 l oop итераций. Это именно то, что вы увидите, когда запустите приведенный выше код с throwNPE(2):

    Iterations to fastThrow: 524536
    

    524536 очень близко к 2 19 = 524288

    Теперь, если вы запустите одно и то же приложение с -XX:Tier23InlineeNotifyFreqLog=15, число итераций будет близко к 2 14 = 16384.

    Iterations to fastThrow: 16612
    
  8. Теперь давайте изменим код позвонить throwsNPE(1). Программа завершит работу sh очень быстро, независимо от значения Tier23InlineeNotifyFreqLog. Это потому, что теперь разные правила выбора. Но если я перезапущу программу с -XX:Tier3InvokeNotifyFreqLog=20, l oop завершится sh не раньше, чем после 2 20 итераций:

    Iterations to fastThrow: 1048994
    

Сводка

Быстрая оптимизация броска применяется только к скомпилированному C2-коду. Из-за одного уровня встраивания (-XX:MaxRecursiveInlineLevel) компиляция C2 запускается раньше (после 2 вызовов Tier3InvokeNotifyFreqLog , если число рекурсивных вызовов нечетное), или позже (после 2 Tier23InlineeNotifyFreqLog вызовы, если все рекурсивные вызовы покрыты встраиванием).

...