Углубленный анализ JIT-компилятора Java (включено)

JVM
Углубленный анализ JIT-компилятора Java (включено)

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

Содержание этой статьи основано на виртуальной машине HotSpot, и место, где разработана версия Java, будет объяснено в тексте.

0 Процесс выполнения Java-программы

На собеседовании по Java один из вопросов задавался так: Java-программа интерпретируется и выполняется или компилируется и выполняется?

Когда мы впервые изучали Java, мы, вероятно, думали, что Java компилируется и выполняется. На самом деле в Java есть как интерпретируемое, так и компилируемое выполнение.

Обычный процесс выполнения Java-программы выглядит следующим образом:

Java程序执行过程

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

Следует отметить, что по принципу компиляции компиляцию принято делить на front-end и back-end. Внешний интерфейс выполнит лексический анализ, синтаксический анализ и семантический анализ программы, а затем сгенерирует промежуточную форму выражения (называемую IR: Intermediate Representation). Серверная часть расскажет об этом промежуточном выражении для оптимизации и, наконец, сгенерирует целевой машинный код.

В Java то, что генерируется после javac, является промежуточным выражением (.class), например

public class JITDemo2 {
    public static void main(String[] args) {
        System.out.println("Hello World");
    }
}

Приведенный выше код декомпилируется javap следующим образом:

// javap -c JITDemo2.class

Compiled from "JITDemo2.java"
public class com.example.demo.jitdemo.JITDemo2 {
  public com.example.demo.jitdemo.JITDemo2();
    Code:
       0: aload_0       
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return        


  public static void main(java.lang.String[]);
    Code:
       0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: ldc           #3                  // String Hello World
       5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
       8: return        
}

Когда JVM выполняется, она сначала считывает IR-инструкции одну за другой для выполнения, и этот процесс представляет собой процесс интерпретации и выполнения. Когда количество вызовов метода достигает порога, определяемого JIT-компиляцией, он инициирует JIT-компиляцию, в это время JIT-компилятор оптимизирует IR и генерирует машинный код. этого метода. Если этот метод вызывается позже, машина будет вызвана напрямую. Выполнение кода, это процесс компиляции и выполнения.

Итак, от файла .java до финального исполнения процесс примерно такой:

Java程序执行过程pro

(CodeCache будет представлен ниже)

Итак, когда начинать своевременную компиляцию? Что представляет собой процесс своевременной компиляции? Продолжаем изучение ниже.

1 Предварительное изучение JIT-компилятора Java

Виртуальная машина HotSpot имеет два компилятора, называемые компиляторами C1 и C2 (после Java 10 был добавлен новый компилятор Graal).

Компилятору С1 соответствует параметр -client.Для программ с малым временем выполнения и требованиями к производительности запуска можно выбрать С1.

Компилятору C2 соответствует параметр -server.Для программ, требующих максимальной производительности, можно выбрать C2.

Но будь то -клиент или -сервер, C1 и C2 участвуют в работе по компиляции. Этот метод становится смешанным режимом (mixed), который также является методом по умолчанию, что видно по java -версии:

C:\Users\Lord_X_>java -version
java version "1.8.0_121"
Java(TM) SE Runtime Environment (build 1.8.0_121-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.121-b13, mixed mode)

Смешанный режим в последней строке иллюстрирует это.

Также мы можем принудительно использовать интерпретируемый режим через параметр -Xint, в это время компилятор реального времени вообще не участвует в работе, а последняя строка java -version будет отображать интерпретируемый режим.

Можно с помощью параметра -Xcomp принудительно указать, что используется только режим компиляции.В это время все коды будут скомпилированы сразу после старта программы.Этот метод замедлит время запуска, но после запуска выполнение интерпретации и компиляция C1 и C2 опущены.Время, эффективность выполнения кода будет значительно улучшена. На этом этапе последняя строка java -version будет отображать скомпилированный режим.

Давайте сравним эффективность выполнения трех режимов с помощью фрагмента кода (простая производительность):

public class JITDemo2 {

    private static Random random = new Random();

    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        int count = 0;
        int i = 0;
        while (i++ < 99999999){
            count += plus();
        }
        System.out.println("time cost : " + (System.currentTimeMillis() - start));
    }

    private static int plus() {
        return random.nextInt(10);
    }
}
  • Первый - это режим выполнения чистой интерпретации.

Добавьте параметры виртуальной машины: -Xint -XX:+PrintCompilation (распечатать информацию о компиляции)

Результаты:

执行结果1

Информация о компиляции не выводится, что свидетельствует о том, что JIT-компилятор в работе не участвует.

  • Тогда это чистый режим выполнения компиляции

Добавьте параметры виртуальной машины: -Xcomp -XX:+PrintCompilation

Результаты:

执行结果2

будет генерировать много информации о компиляции

  • И, наконец, режим наложения

Добавьте параметры виртуальной машины: -XX:+PrintCompilation

Результаты:

执行结果3

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

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

2 Когда запускать своевременную компиляцию

Триггеры компилятора «точно в срок» основаны на двух аспектах:

  • вызовы методов
  • Количество выполнений петли изнаночной кромки

Когда JVM вызывает метод, он добавит к счетчику 1. Если метод имеет тело цикла, счетчик также будет увеличиваться на 1 при каждом цикле.

Когда многоуровневая компиляция не включена (описано в следующей статье), когда счетчик метода достигает значения, заданного параметром -XX:CompileThreshold (C1 — 1500, C2 — 10000), будет запущена JIT-компиляция .

Давайте проведем эксперимент, запускаемый JIT-компиляцией, когда многоуровневая компиляция отключена:

  • Первый запускается на основе вызовов методов (без циклов)
// 参数:-XX:+PrintCompilation -XX:-TieredCompilation(关闭分层编译)
public class JITDemo2 {
    private static Random random = new Random();

    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        int count = 0;
        int i = 0;
        while (i++ < 15000){
            System.out.println(i);
            count += plus();
        }
        System.out.println("time cost : " + (System.currentTimeMillis() - start));
    }

    // 调用时,编译器计数器+1
    private static int plus() {
        return random.nextInt(10);
    }
}

Результат выполнения следующий:

执行结果4

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

  • По заднему краю петли
public class JITDemo2 {
    private static Random random = new Random();

    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        plus();
        System.out.println("time cost : " + (System.currentTimeMillis() - start));
    }

    // 调用时,编译器计数器+1
    private static int plus() {
        int count = 0;
        // 每次循环,编译器计数器+1
        for (int i = 0; i < 15000; i++) {
            System.out.println(i);
            count += random.nextInt(10);
        }
        return random.nextInt(10);
    }
}

Результаты:

执行结果5

  • На основе вызовов методов и обратных краев цикла

PS: в каждом вызове метода есть 10 циклов, поэтому каждый счетчик вызова метода должен +11, поэтому он должен запускать компиляцию точно в срок, когда количество вызовов превышает 10000/11=909.

public class JITDemo2 {
    private static Random random = new Random();

    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        int count = 0;
        int i = 0;
        while (i++ < 15000) {
            System.out.println(i);
            count += plus();
        }
        System.out.println("time cost : " + (System.currentTimeMillis() - start));
    }

    // 调用时,编译器计数器+1
    private static int plus() {
        int count = 0;
        // 每次循环,编译器计数器+1
        for (int i = 0; i < 10; i++) {
            count += random.nextInt(10);
        }
        return random.nextInt(10);
    }
}

Результаты:

执行结果6

3 CodeCache

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

Параметры -XX:InitialCodeCacheSize и -XX:ReservedCodeCacheSize указывают размер памяти CodeCache.

  • -XX:InitialCodeCacheSize: исходный размер памяти CodeCache, по умолчанию 2496 КБ.
  • -XX:ReservedCodeCacheSize: размер зарезервированной памяти CodeCache, по умолчанию 48M

PS: Вы можете распечатать значения всех параметров по умолчанию через -XX:+PrintFlagsFinal.

3.1 Мониторинг CodeCache через jconsole

Вы можете увидеть расположение CodeCache в памяти с помощью инструмента jconsole, который поставляется с JDK, например

CodeCache内存

Из графика на рисунке видно, что CodeCache использовал более 4M.

3.2 Что происходит, когда CodeCache заполнен

Обычно, когда мы выделяем память под приложение, CodeCache часто игнорируется, хотя CodeCache занимает не так много места в памяти, и у него тоже есть GC, но он часто не заполняется. Но как только CodeCache заполнен, это может иметь катастрофические последствия для приложения с высоким QPS и высокими требованиями к производительности.

Из вышеприведенного введения мы знаем, что JVM сначала попытается интерпретировать и выполнить байт-код Java.Когда вызов метода или повторный цикл достигает определенного количества раз, он запускает компиляцию точно в срок для компиляции байт-кода Java. в локальный машинный код для улучшения выполнения. Скомпилированный собственный машинный код кэшируется в CodeCache.Если есть большой объем кода, который вызывает компиляцию точно в срок, и нет своевременной сборки мусора, CodeCache заполнится.

После заполнения CodeCache скомпилированный код по-прежнему будет выполняться как собственный код, но код, который не будет скомпилирован позже, может выполняться только как интерпретируемое выполнение.

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

Когда CodeCache заполняется, JVM печатает журнал:

CodeCache日志

JVM предоставляет метод GC для CodeCache: -XX:+UseCodeCacheFlushing. Этот параметр включен по умолчанию после JDK1.7.0_4 и будет пытаться перезапуститься, когда CodeCache вот-вот заполнится. JDK7 много перерабатывает в этой области, доход от сборщика мусора низкий, и он был значительно улучшен в JDK8, поэтому вы можете напрямую улучшить производительность в этой области, обновившись до JDK8.

3.3 Переработка CodeCache

Итак, когда можно переработать скомпилированный код в CodeCache?

Он начинается с того, как компилирует компилятор. Например, следующий код:

public int method(boolean flag) {
    if (flag) {
        return 1;
    } else {
        return 0;
    }
}

С точки зрения интерпретации-исполнения процесс его исполнения выглядит следующим образом:

CodeCache执行

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

public int method(boolean flag) {
    return 1;
}

То есть, как показано на рисунке ниже

CodeCache执行

Но это не всегда может быть flag=true позже. После того, как флаг передан false, это неправильно. В это время компилятор «деоптимизирует» его и превратит в метод выполнения компиляции. Производительность в журнале сделано не вступившим:

made not entrant

В это время метод больше не может быть введен. Когда JVM обнаружит, что все потоки вышли из скомпилированного, сделанного не входным, он пометит метод как: сделанный зомби, В это время память, занимаемая этим кодом, может быть переработана. Это видно из журнала компиляции:

made zombie

3.4 Настройка CodeCache

Параметр запуска JVM предоставляется в Java8: -XX:+PrintCodeCache, который может печатать использование CodeCache, когда JVM останавливается.Вы можете наблюдать это значение каждый раз, когда останавливаете приложение, и медленно подгонять его до наиболее подходящего размера.

Давайте проиллюстрируем демонстрацию SpringBoot:

// 启动参数:-XX:ReservedCodeCacheSize=256M -XX:+PrintCodeCache
@RestController
@SpringBootApplication
public class DemoApplication {
   // ... other code ...

   public static void main(String[] args) {
      SpringApplication.run(DemoApplication.class, args);
      System.out.println("start....");
      System.exit(1);
   }
}

Здесь я определяю CodeCache как 256M и распечатываю использование CodeCache при выходе из JVM, журнал выглядит следующим образом:

CodeCache out

Используется максимум 6721K (max_used), что тратит много памяти, в это время можно попробовать уменьшить -XX:ReservedCodeCacheSize=256M и выделить лишнюю память в другие места.

4 Справочные документы

[1] https://blog.csdn.net/yandaonan/article/details/50844806

[2] Глубокое понимание виртуальной машины Java Чжоу Чжимин Глава 11

[3] Geek Time «Глубокий разбор виртуальной машины Java» Чжэн Юди


Добро пожаловать в мой публичный аккаунт WeChat

公众号