JMH – Java Microbenchmark Harness

Java Microbenchmark Harness( JMH ) — это инструмент, позволяющий измерять различные характеристики производительности кода и точно определять узкие места — вплоть до уровня метода. Этот учебник Java познакомит вас с JMH, созданием и запуском бенчмарков с примерами.

1. Что такое бенчмаркинг и JMH?

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

Микробенчмаркинг — это отличный способ проверить способность определенного раздела кодирования работать в идеальных условиях выполнения. Это наиболее подходящие сегменты кода, такие как алгоритмы. Обратите внимание, что микробенчмарк не должен взаимодействовать с внешними системами или выполнять какие-либо вызовы ввода/вывода. Он просто гарантирует, что при объединении нескольких таких блоков кода микробенчмарка в приложении мы получим максимальные результаты оптимальным образом.

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

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

2. Основные концепции JMH

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

2.1. Настройка JMH

Хотя существует множество возможных способов интеграции и использования JMH, рекомендуемый способ — создать новое приложение Maven или Gradle только для целей микробенчмаркинга. Проект должен иметь все зависимости, включенные в реальное приложение, чтобы оба проекта были почти идентичными.

После создания нового проекта добавьте зависимости JMH jmh-core и jmh-generator- и process в файл сборки.

<dependency><groupId>org.openjdk.jmh</groupId><artifactId>jmh-core</artifactId><version>1.35</version></dependency><dependency><groupId>org.openjdk.jmh</groupId><artifactId>jmh-generator-annprocess</artifactId><version>1.35</version></dependency>

Добавление зависимости процессора аннотаций ясно указывает на то, что JMH основан на аннотациях для настройки поведения бенчмаркинга. Аннотация @Benchmark является точкой входа для создания любого бенчмарка. Мы можем создать столько бенчмарков в классе, сколько захотим.

@Benchmarkpublic void someMethod() {//code}

Для выполнения всех тестов нам необходимо запустить процесс с помощью метода org.openjdk.jmh.Main.main().

public class ApplicationBenchmarks {public static void main(String[] args) throws Exception {org.openjdk.jmh.Main.main(args);}@Benchmarkpublic void someMethod() {//code}}

После завершения процесса JMH мы можем увидеть в консоли показатели производительности, например такие:

Benchmark Mode Cnt Score Error UnitsApplicationBenchmarks.someMethod thrpt 25 2670644438.470 ± 121534763.138 ops/s

2.2 Тестовые режимы

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

  • Режим пропускной способности: измеряет количество вызовов метода, которые могут быть выполнены за единицу времени. Это больше похоже на нагрузочное тестирование метода и выдает результаты в виде успешных и неудачных операций. Большое число в результате указывает на то, что операции выполняются в быстром темпе.
  • Режимы, основанные на времени: проверьте время, необходимое для одного вызова метода. Они полезны для сравнения производительности разных методов выполнения одного и того же действия. Это помогает сделать вывод о том, какая логика быстрее выполняет задачу. Ниже приведены режимы, основанные на времени, доступные в JMH.
    • Среднее время: измеряет среднее время, необходимое для выполнения одного эталонного метода.
    • Время выборки: измеряет общее время, необходимое для выполнения метода эталонного теста, включая минимальное и максимальное время выполнения для одного вызова.
    • Single Shot Time: запускает одну итерацию сравниваемых методов без разогрева JVM. Полезно для быстрого принятия решений во время разработки.

Используйте аннотацию @BenchmarkMode для указания предпочтительного режима.

@Benchmark@BenchmarkMode(Mode.Throughput)public void someMethod() {//code}

2.3. Итерации разогрева и измерения

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

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

Мы можем настроить количество раз, когда бенчмарк должен быть выполнен для разогрева JVM, используя аннотацию @warmup. Значение итерации разогрева по умолчанию — 5.

@Benchmark@BenchmarkMode(Mode.Throughput)@Warmup(iterations = 5, time = 100, timeUnit = TimeUnit.MILLISECONDS)public void someMethod() {//code}

После прогрева JVM мы можем выполнить метод в режиме измерения производительности, используя аннотацию @Measurement.

@Benchmark@BenchmarkMode(Mode.Throughput)@Warmup(iterations = 5, time = 100, timeUnit = TimeUnit.MILLISECONDS)@Measurement(iterations = 5, time = 100, timeUnit = TimeUnit.MILLISECONDS)public void someMethod() {//code}

2.4.Разветвление

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

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

@Fork(value = 5, warmups = 2)

Он также позволяет нам добавлять параметры Java в командную строку Java, например:

@Fork(value = 5, warmups = 2, jvmArgs = {"-Xms2G", "-Xmx2G"})

Аннотации @Warmup, @Measurement, @BenchmarkMode и @Fork можно применять только к определенному методу или к экземпляру включающего класса, чтобы они оказали влияние на все методы тестирования в классе.

3. Простой пример эталонного теста JMH

Следующий класс ListComparisonBenchmarks сравнивает производительность ArrayList и LinkedList, вставляя 1 миллион строк и затем извлекая все 1 миллион строк из списка.

import java.util.ArrayList;import java.util.LinkedList;import java.util.concurrent.TimeUnit;import org.openjdk.jmh.annotations.Benchmark;import org.openjdk.jmh.annotations.BenchmarkMode;import org.openjdk.jmh.annotations.Measurement;import org.openjdk.jmh.annotations.Mode;import org.openjdk.jmh.annotations.Scope;import org.openjdk.jmh.annotations.State;import org.openjdk.jmh.annotations.Warmup;import org.openjdk.jmh.infra.Blackhole;@BenchmarkMode(Mode.Throughput)@Warmup(iterations = 3, time = 10, timeUnit = TimeUnit.MILLISECONDS)@Measurement(iterations = 3, time = 10, timeUnit = TimeUnit.MILLISECONDS)public class ApplicationBenchmarks {public static void main(String[] args) throws Exception {org.openjdk.jmh.Main.main(args);}@State(Scope.Benchmark)public static class Params {public int listSize = 10_000_000; //1Mpublic double b = 1;}@Benchmarkpublic static void addSelectUsingArrayList(Params param, Blackhole blackhole) {ArrayList<String> arrayList = new ArrayList<>();for(int i = 0; i < param.listSize; i++) {arrayList.add("prefix_" + i);}for(int i = 0; i < param.listSize; i++) {blackhole.consume(arrayList.get(i));}}@Benchmarkpublic static void addSelectUsingLinkedList(Params param, Blackhole blackhole) {LinkedList<String> linkedList = new LinkedList<>();for(int i = 0; i < param.listSize; i++) {linkedList.add("prefix_" + i);}for(int i = 0; i < param.listSize; i++) {blackhole.consume(linkedList.get(i));}}}

4. Оптимизация JVM для сравнительного анализа

Хотя бенчмарки кажутся простыми в настройке и создании, они не в большой степени обусловлены внутренними оптимизациями, выполняемыми JVM. Чтобы по-настоящему измерить производительность метода, мы должны отменить такие оптимизации JVM.

4.1 Избегайте постоянного сворачивания с помощью аннотации @State

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

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

public static double add() {double a = 1;double b = 1;return a + b;}

JVM определит шаблон и изменит операцию сложения на константное значение. Это называется сворачиванием констант.

public static double add() {return 2;}

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

@State(Scope.Benchmark)public static class Params {public double a = 1;public double b = 1;}@Benchmarkpublic static double add(Params params) {return params.a + params.b;}

4.2 Избегайте устранения мертвого кода с помощью объекта Blackhole

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

Например, в следующем методе оператор 'new Object()' избыточен и ничего не делает. JIT-компилятор идентифицирует этот оператор и удаляет его из скомпилированного байт-кода.

@Benchmarkpublic static double add(Params params) {new Object();return params.a + params.b;}

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

Объект Blackhole — отличный способ заставить компилятор скомпилировать это выражение и сгенерировать для него байт-код. Объект Blackhole «потребляет» значения, не передавая JIT никакой информации о том, будет ли значение фактически использовано впоследствии.

@Benchmarkpublic static double add(Params params, Blackhole blackhole) {blackhole.consume(new Object());return params.a + params.b;}

4.3 Включение или исключение вызовов методов с помощью аннотации @CompilerControl

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

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

@Benchmarkpublic static double add(Params params, Blackhole blackhole) {return params.a + params.b + someOtherMethod();}@CompilerControl(CompilerControl.Mode.INLINE)private double someOtherMethod(Params params){return params.a * params.b;}

5. Лучшие практики

Разобравшись с тонкостями JMH, давайте рассмотрим некоторые передовые практики, которые помогут свести ошибки к минимуму:

  • В большинстве случаев лучшим человеком для написания бенчмарка является программист, который пишет фактический метод в приложении. Когда в написании фактического кода и бенчмарков задействовано несколько человек, всегда есть вероятность неправильной интерпретации.
  • Еще одно хорошее предложение — использовать ту же структуру классов в реальном приложении. Иерархия классов и интерфейсов имеет большое значение в случае микрооптимизаций.
  • Помните о среде выполнения, например, о настроенной памяти на машине и памяти, используемой для запуска тестов. Проверьте размер памяти и тип диска(обычный/SSD). Они работают по-разному и дают разные результаты. В идеале тесты должны запускаться с очень строгой и определенной конфигурацией машины, чтобы все время получать согласованные результаты.

6. Заключение

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

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

Прокрутить вверх