Avatar ·

Как правильно написать микро-бенчмарк на Java? Ловушки JIT и оптимизаций

Всем привет. Я начинающий Java-разработчик, и мне нужно замерить производительность двух реализаций алгоритма: одна использует `ArrayList`, другая — обычный массив. Я написал простой тест: ```java public class MyBenchmark { public static void main(String[] args) { long start = System.nanoTime(); for (int i = 0; i < 100000; i++) { // Код с ArrayList List list = new ArrayList<>(); for (int j = 0; j < 1000; j++) { list.add(j); } int sum = 0; for (int val : list) { sum += val; } } long end = System.nanoTime(); System.out.println("ArrayList time: " + (end - start)); start = System.nanoTime(); for (int i = 0; i < 100000; i++) { // Код с массивом int[] arr = new int[1000]; for (int j = 0; j < 1000; j++) { arr[j] = j; } int sum = 0; for (int val : arr) { sum += val; } } end = System.nanoTime(); System.out.println("Array time: " + (end - start)); } } ``` Результаты каждый раз разные, иногда ArrayList быстрее, иногда массив. Я слышал про JIT-компиляцию, сборку мусора и «прогрев» JVM, но не понимаю, как это учитывать. Почему мои замеры ненадёжны? Как правильно написать микро-бенчмарк на Java, чтобы получить честные цифры? Может, есть готовые инструменты? Заранее спасибо! <<>>

Почему ваш бенчмарк не работает

Ваш код страдает от классических проблем наивных микро-бенчмарков:

  • JIT-компиляция не прогрета — JVM сначала интерпретирует код, потом компилирует. Замеры включают время компиляции.
  • Dead Code Elimination (DCE) — переменная sum нигде не используется, JIT может выкинуть весь цикл.
  • Сборка мусора (GC) — создание 100 000 списков и массивов вызывает GC, что искажает время.
  • Начальная инициализация — Class loading, allocation overhead.

Правильный подход: JMH (Java Microbenchmark Harness)

Это стандартный инструмент от OpenJDK. Он автоматически решает все проблемы. Вот как исправить ваш бенчмарк:

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

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Thread)
public class MyCorrectBenchmark {

    @Benchmark
    public int arrayListSum() {
        List<Integer> list = new ArrayList<>();
        for (int j = 0; j < 1000; j++) {
            list.add(j);
        }
        int sum = 0;
        for (int val : list) {
            sum += val;
        }
        return sum; // Возвращаем результат, чтобы избежать DCE
    }

    @Benchmark
    public int arraySum() {
        int[] arr = new int[1000];
        for (int j = 0; j < 1000; j++) {
            arr[j] = j;
        }
        int sum = 0;
        for (int val : arr) {
            sum += val;
        }
        return sum;
    }
}

Как запустить JMH

Добавьте зависимость в Maven/Gradle:

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

Запустите через main:

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

Что делает JMH под капотом

  • Прогрев (Warmup) — выполняет код несколько итераций без замера, чтобы JIT скомпилировал и оптимизировал.
  • Fork — запускает тесты в отдельных JVM, чтобы избежать влияния предыдущих тестов.
  • Blackhole — потребляет результат, чтобы JIT не выкинул код.
  • Учёт GC — можно настроить паузы для сборки мусора.

Ручные правила (если JMH недоступен)

  1. Прогрев: выполните тест 10-20 раз до замера.
  2. Используйте результат: сохраняйте в поле или печатайте.
  3. System.gc() перед замером — не гарантирует, но помогает.
  4. Много итераций: не менее 10-20 замеров, усредняйте.

Итог

Ваш исходный код ненадёжен. Используйте JMH — это сэкономит часы отладки. Помните: микро-бенчмаркинг — это искусство, и даже JMH не даёт абсолютной истины, но он максимально приближает к реальности.

<<>>

Альтернативный подход: тестирование на макроуровне

Если вам не нужна микроскопическая точность, а важно сравнить два подхода в реальном приложении, лучше написать интеграционный тест или нагрузочный тест с реальными данными. Микро-бенчмарки часто вводят в заблуждение, потому что изолируют код от контекста (кэши, GC, конкуренция).

Вот простой способ без JMH, который хотя бы учитывает прогрев:

public class SimpleBenchmark {
    public static void main(String[] args) {
        // Прогрев
        for (int i = 0; i < 10000; i++) {
            testArrayList();
            testArray();
        }
        // Замер
        long total = 0;
        for (int i = 0; i < 100; i++) {
            total += testArrayList();
        }
        System.out.println("Avg ArrayList: " + (total / 100));
        // Аналогично для массива
    }

    static long testArrayList() {
        long start = System.nanoTime();
        List<Integer> list = new ArrayList<>();
        for (int j = 0; j < 1000; j++) list.add(j);
        int sum = 0;
        for (int v : list) sum += v;
        return System.nanoTime() - start;
    }
}

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