Привет, Хабр! Большинство команд разработки так или иначе вовлечены в гонку за производительностью. Если понимать этот показатель как скорость работы системы, то вся деятельность по прокачке производительности — это, по сути, анализ метрик и поиск инструментов, которые эту скорость могут повысить.

Меня зовут Александр Певненко, я работаю в СберТехе, компании, которая разрабатывает ПО. Для большинства наших продуктов производительность — критичный фактор, поэтому анализ метрик и оптимизация кода — важная часть цикла разработки. Хочу поделиться личным опытом и мнением обо всём, что касается оптимизации и повышения скорости работы кода, а также нагрузочного тестирования как части этой работы. Тут представлены моя собственная практика использования набора библиотек JMH для нагрузочного тестирования и замеров производительности Java-кода. Всем, кому интересно, добро пожаловать под кат!

Нагрузочное тестирование: кому и для чего необходимо

Начну с моего мнения по следующим теоретическим вопросам. Все, кого интересует руководство по установке и тестированию производительности с помощью JMH, могут пропустить — тут будет по большей части теория.

Для чего нужно НТ?

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

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

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

Нужно ли НТ в MVP?

Вопрос «тестировать или нет» в больших проектах отпадает сразу: есть проект, в нем куча легаси, которое писалась в 19 веке, и единственный способ пощупать код — это написать тест. А единственный способ пощупать систему — сделать НТ.

А если проект небольшой? В таких код часто вообще не обкладывают тестами, а единственный способ проверки качества — «на глаз» главного разработчика.

Лично мне кажется, все индивидуально. Но «подстелить соломку» на будущее и проверить код объективно, доверяя цифрам, а не человеку, никогда не будет лишним. Но вопрос дискуссионный, и если вы считаете иначе, буду рад обсудить в комментариях.

Кто должен заниматься НТ?

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

Теперь переходим к сути статьи: как реализовать тестирование и работать с результатами. Фактически, для НТ нам нужно направить на сервис много запросов. Чтобы подготовить столько, нужны инструменты, способные генерировать их в режиме реального времени. В нашем случае, поскольку речь идёт о коде на Java, это будет JMH.

Руководство по использованию JMH в своём проекте

Шпаргалка по аннотациям JMH:

  • @Benchmark — помечаем метод, который будет подвергаться НТ.

  • @OutputTimeUnit(param) — устанавливаем единицу времени для отображения результата.

  • @BenchmarkMode — устанавливаем режим отображения результата.

Настройка приложения для использования JMH:

Шаг 1: добавление зависимости JMH

Maven:

<dependencies>
    <dependency>
        <groupId>org.openjdk.jmh</groupId>
        <artifactId>jmh-core</artifactId>
        <version>1.33</version> 
    </dependency>
</dependencies>

Gradle:

dependencies {
    implementation 'org.openjdk.jmh:jmh-core:1.33'
}

Шаг 2: создание бенчмарка

Создайте класс, в котором будут содержаться методы, которые вы хотите измерить. Отметьте их аннотацией @Benchmark.

Например, измерим производительность операции сложения двух чисел:

Код класса:

package runner;

import org.openjdk.jmh.annotations.*;
@State(Scope.Thread)
public class BenchmarkRunner {
   private int a;
   private int b;

   @Setup
   public void setup() {
       a = 3;
       b = 7;
   }

   @Benchmark
   public int benchmarkAddition() {
       return a + b;
   }

   public static void main(String[] args) throws Exception {
       org.openjdk.jmh.Main.main(args);
   }
}

После запуска завариваем кофе и ждём, пока завершится замер производительности. Сам процесс будет выглядеть примерно так:

Шаг 3: настройка параметров запуска

Запуск тестов производительности можно и нужно настраивать. Например, через аннотации и запуск метода run экземпляра класса Runner с настройками в виде объекта Options. В этой статье я рассмотрю настройку посредством аннотаций (их список можно найти в конце, в шпаргалке). Они позволяют тонко настроить параметры запуска бенчмарка, чтобы получить более точные и надёжные результаты производительности. Можно подобрать нужную конфигурацию под машину и вообще всячески экспериментировать.

Вот пример — немного переделываем код выше и смотрим, что получилось:

package runner;

import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;

@State(Scope.Thread)
public class BenchmarkRunner {

   private int a;
   private int b;

   @Setup
   public void setup() {
       a = 3;
       b = 7;
   }
   @Benchmark
   @BenchmarkMode(Mode.Throughput)
   @OutputTimeUnit(TimeUnit.MILLISECONDS)
   @Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
   @Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
   @Fork(value = 2, warmups = 1)
   @Threads(4)
   public int benchmarkAddition() {
       return a + b;
   }
   public static void main(String[] args) throws Exception {
       org.openjdk.jmh.Main.main(args);
   }
}

@BenchmarkMode — определяем режим измерения производительности. Например, Mode.Throughput измеряет количество операций в единицу времени, а Mode.AverageTime — средняя продолжительность выполнения метода.

@OutputTimeUnit — задаём единицу измерения времени для результатов бенчмарка.

@Warmup — указываем количество прогревочных итераций и их продолжительность.

@Measurement — определяем количество измерительных итераций и их продолжительность.

@Fork — указываем нужное количестве разветвлений (повторных выполнений) для каждого бенчмарка.

@Threads — определяем количество потоков, которые будут использоваться для выполнения бенчмарка.

@Timeout — максимальная продолжительность выполнения для каждой итерации бенчмарка.

Шаг 4: запуск тестирования

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

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

Шаг 6: анализ и оптимизация кода для улучшения производительности

Давайте представим следующий сценарий на основе примера cо сложением двух чисел. Оба числа поступают к нам как Long. Посмотрим на результаты тестирования:

Результат:

А теперь давайте немного оптимизируем код, заменив экземпляры классов-обёрток их близнецами из мира примитивов, и посмотрим на результат:

Банальная оптимизация дала прирост производительности почти в 2,5 раза!

Важные заметки вместо заключения

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

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

Чтобы компилятор и виртуальная машина Java (JVM) случайно не оптимизировали «бесполезный» код, в JMH используется класс Blackhole. В контексте бенчмарков это помогает предотвратить удаление кода, который воспринимается как «мёртвый» из-за неиспользуемых результатов.

Результат, который получаем после прогона

В виде кода:

package runner;

import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;

import java.util.concurrent.TimeUnit;

@State(Scope.Thread)
public class BenchmarkRunner {
   private int a;
   private int b;
   @Setup
   public void setup() {
       a = 3;
       b = 7;
   }
   @Benchmark
   @BenchmarkMode(Mode.AverageTime)
   @OutputTimeUnit(TimeUnit.NANOSECONDS)
   @Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
   @Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
   @Fork(value = 2, warmups = 1)
   @Threads(4)
   public void benchmarkAddition(Blackhole blackhole) {
       int result = a + b;
       // Использование Blackhole для предотвращения оптимизации кода
       blackhole.consume(result);
   }
   public static void main(String[] args) throws Exception {
       org.openjdk.jmh.Main.main(args);
   }
}

Шпаргалка по аннотациям JMH

Тут приведены часто используемые аннотации, полный список ищите на сайте с официальной документацией JMH.

  1. @Benchmark помечает метод как бенчмарк для измерения производительности.

  2. @State устанавливает состояние, используемое в бенчмарке. Служит для предоставления отдельного экземпляра каждому потоку.

  3. @BenchmarkMode устанавливает режим измерения производительности (например: Mode.Throughput, Mode.AverageTime).

  4. @OutputTimeUnit задаёт единицу измерения времени для результатов бенчмарка.

  5. @Warmup задаёт количество итераций и продолжительность для прогревочных итераций (влияет на качество замеров).

  6. @Measurement задаёт количество итераций и продолжительность для измерительных итераций (влияет на качество замеров).

  7. @Fork задаёт количество повторных выполнений для каждого бенчмарка.

  8. @Threads устанавливает количество потоков, используемых для выполнения бенчмарка.

  9. @Group группирует бенчмарки вместе для их выполнения единым блоком.

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

  11. @TearDown используется для отметки метода, выполняющего завершающие действия после окончания бенчмарков.

  12. @Param внедряет параметры в методы бенчмарка.

  13. @AuxCounters объявляет вспомогательные счётчики для измерения дополнительных метрик.

  14. @CompilerControl позволяет контролировать оптимизации JIT.

  15. @Timeout определяет максимальное время выполнения для каждой итерации бенчмарка.

Комментарии (1)


  1. PqDn
    15.05.2024 13:40
    +2

    Кажется jmh надо запускать в разрезе сравнения как минимум двух стратегий new / old. И только тогда когда в одном прогоне есть разница хотя бы процентов в 20% можно говорить, что новый код лучше.

    Если быстро нужно по проекту пробежаться, чтобы найти узкие места лучше профилировщиком посмотреть в той же idea