Профилирование Java кода: находим узкие места на практике

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: зачем профилировать Java-код?

Разработка на Java — это не только написание корректного кода, но и создание быстрых, масштабируемых приложений. Даже идеально спроектированная система может тормозить из-за одной неоптимальной строки. Как найти эту «бутылочное горлышко»? Ответ — профилирование.

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

Мы рассмотрим два популярных инструмента: VisualVM (бесплатный, встроенный в JDK) и JProfiler (коммерческий, но мощный). Все примеры будут с реальным кодом.

Основная часть: практика профилирования

1. Настройка окружения и запуск профилировщика

Для начала убедитесь, что у вас установлен JDK (8 или выше). VisualVM поставляется вместе с JDK 8 и 9, для более новых версий его нужно скачать отдельно с официального сайта.

Напишем простой «тормозной» код для теста. Создайте класс SlowApp.java:

import java.util.ArrayList;
import java.util.List;
import java.util.Random;

public class SlowApp {
    public static void main(String[] args) throws InterruptedException {
        List<String> data = new ArrayList<>();
        Random random = new Random();

        // Симуляция долгой загрузки данных
        for (int i = 0; i < 100_000; i++) {
            data.add(generateString(random));
        }

        // Обработка данных
        long sum = 0;
        for (String s : data) {
            sum += processString(s);
        }

        System.out.println("Result: " + sum);
        Thread.sleep(10000); // Чтобы успеть подключиться профайлером
    }

    private static String generateString(Random random) {
        StringBuilder sb = new StringBuilder(100);
        for (int i = 0; i < 100; i++) {
            sb.append((char) ('a' + random.nextInt(26)));
        }
        return sb.toString();
    }

    private static long processString(String s) {
        long result = 0;
        for (char c : s.toCharArray()) {
            result += c;
            // Искусственная задержка
            for (int j = 0; j < 100; j++) {
                result += Math.sqrt(j);
            }
        }
        return result;
    }
}

Запустите приложение:

javac SlowApp.java
java SlowApp

Пока программа работает (она заснет на 10 секунд), откройте VisualVM. В левой панели найдите ваш процесс (SlowApp) и дважды кликните. Перейдите на вкладку Sampler или Profiler — вы увидите статистику по CPU и памяти.

2. Анализ CPU: находим самый тяжелый метод

В VisualVM нажмите кнопку CPU в разделе Sampler. Через несколько секунд вы получите таблицу с методами, отсортированными по времени выполнения (Self Time).

В нашем примере вы увидите, что метод processString занимает более 80% времени. Внутри него — вложенный цикл Math.sqrt(j). Это и есть узкое место.

Как это интерпретировать?

  • Self Time — время, потраченное непосредственно на выполнение метода (без учета вызовов дочерних методов).
  • Total Time — общее время, включая все вложенные вызовы.
  • Invocations — количество вызовов метода.

Пример вывода (упрощенно):

Method                          Self Time (ms)   Invocations
SlowApp.processString           8500              100000
SlowApp.generateString          1200              100000
java.lang.Math.sqrt             6000              10000000

Очевидно, что Math.sqrt вызывается 10 миллионов раз (100000 строк * 100 итераций). Это и есть кандидат на оптимизацию. В реальном проекте таким «тяжелым» методом может быть работа с БД, парсинг JSON или сложные вычисления.

Практический совет: Если метод вызывается миллионы раз, даже микрооптимизация даст прирост. Попробуйте заменить Math.sqrt на предвычисленные значения или кэширование.

3. Профилирование памяти: утечки и сборка мусора

Вторая по важности метрика — память. Переключитесь на вкладку Monitor в VisualVM. Вы увидите графики Heap, Metaspace и GC activity.

Добавим в наш пример классическую утечку памяти (закомментируйте предыдущий код и используйте этот):

import java.util.HashMap;
import java.util.Map;
import java.util.Random;

public class MemoryLeak {
    public static Map<String, byte[]> cache = new HashMap<>();

    public static void main(String[] args) throws InterruptedException {
        Random random = new Random();
        while (true) {
            String key = "key" + random.nextInt(1000);
            // Утечка: каждый раз создаем новый массив, но не удаляем старые
            cache.put(key, new byte[1024 * 100]); // 100 KB
            Thread.sleep(10);
            if (cache.size() > 10000) {
                System.out.println("Cache size: " + cache.size());
            }
        }
    }
}

Запустите и откройте в VisualVM. График Heap будет неуклонно расти, пока не упрется в максимум. После этого начнутся частые Full GC (сборки мусора), что приведет к паузам (Stop-The-World).

Чтобы найти источник утечки:

  1. Перейдите на вкладку Sampler → Memory.
  2. Нажмите Heap Dump (снимок кучи).
  3. Откроется окно с объектами. Отсортируйте по размеру (Retained Size).
  4. Вы увидите, что byte[] и HashMap$Node занимают больше всего памяти.

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

4. Продвинутые техники: JProfiler и Flame Graph

VisualVM хорош для быстрого

Блоги

Рекомендаций по книгам