1. Вступ до профілювання
Профілювання — це як медичне обстеження для вашого застосунку: ми не просто дивимося на «температуру» (моніторинг), а шукаємо, де у застосунку «болить», що працює повільно, де витрачається надто багато пам’яті або ресурсів.
Профілювання — це процес збирання та аналізу інформації про роботу програми з метою виявлення вузьких місць (bottlenecks) і неефективних ділянок коду. На відміну від моніторингу, який зазвичай відстежує загальні показники (навантаження на процесор, пам’ять, кількість потоків), профілювання дає змогу зазирнути всередину: дізнатися, які методи викликаються найчастіше, скільки часу вони займають, скільки об’єктів створюється і де саме відбувається витік пам’яті.
Коли профілювання справді потрібне?
- Застосунок «гальмує», але незрозуміло, чому.
- Раптово зросло споживання пам’яті.
- Після оновлення коду щось стало працювати довше.
- Потрібно зрозуміти, чому серверу бракує ресурсів.
До речі, майже кожен розробник хоча б раз у житті оптимізував не ту ділянку коду. Чому? Тому що «на око» визначити вузьке місце практично неможливо — для цього і потрібен профайлер.
Основні метрики профілювання
- Час виконання методів (CPU‑профілювання): Які методи займають найбільше часу? Де програма «витрачає» процесор?
- Використання пам’яті (Memory‑профілювання): Які об’єкти створюються найчастіше? Де вони лишаються в пам’яті довше, ніж потрібно?
- Кількість об’єктів: Чи не створюємо ми надто багато однотипних об’єктів?
- Потоки: Чи не надто багато потоків? Чи немає блокувань (deadlock, contention)?
- Виклики методів: Яка глибина стеку? Чи не відбувається рекурсія без виходу?
2. Інструменти профілювання
У світі Java є кілька класичних (і безкоштовних!) інструментів, які дозволяють виконувати профілювання. Розглянемо основні з них.
VisualVM
VisualVM — це безкоштовний інструмент, який входить до складу JDK (починаючи з JDK 6). Він дозволяє:
- Під’єднуватися до локальних і віддалених JVM.
- Переглядати пам’ять, потоки, CPU, збирання сміття.
- Робити heap dump і аналізувати його.
- Профілювати застосунок за CPU і пам’яттю.
Як запустити VisualVM?
Зазвичай він знаходиться в теці JDK: <шлях_до_JDK>/bin/jvisualvm
Запускаєте, обираєте процес Java‑застосунку — і можна спостерігати за його життям, як за рибками в акваріумі (тільки рибки тут — це об’єкти та потоки).
JProfiler, YourKit
Це комерційні, але дуже потужні інструменти. Вони дозволяють:
- Профілювати пам’ять, CPU, потоки.
- Робити аналіз «знімків» пам’яті (heap dump).
- Шукати витоки, тривалі блокування, повільні методи.
- Інтегруватися з IDE та CI/CD.
Для початку вистачить VisualVM, але якщо ви «доростете» до великих проєктів — придивіться до цих інструментів.
Java Flight Recorder (JFR)
JFR — це вбудований у JDK інструмент для збирання подій про роботу JVM. Він дуже легкий, майже не впливає на продуктивність, і дозволяє збирати інформацію про:
- Час роботи методів.
- Збирання сміття.
- Потоки, блокування, помилки.
JFR чудово підходить для роботи в продакшні, коли не можна гальмувати застосунок.
3. Практика: профілюємо простий застосунок
Створімо мінікалькулятор, який уміє виконувати довгі обчислення та зберігати історію операцій (щоб у нас були цикли, колекції та робота з пам’яттю).
Приклад коду: «Повільний калькулятор»
import java.util.ArrayList;
import java.util.List;
public class SlowCalculator {
private final List<String> history = new ArrayList<>();
public int add(int a, int b) {
simulateHeavyOperation();
int result = a + b;
history.add(a + " + " + b + " = " + result);
return result;
}
public int multiply(int a, int b) {
simulateHeavyOperation();
int result = a * b;
history.add(a + " * " + b + " = " + result);
return result;
}
public List<String> getHistory() {
return history;
}
// Симуляція "важкої" операції
private void simulateHeavyOperation() {
for (int i = 0; i < 5_000_000; i++) {
Math.sqrt(i);
}
}
}
А тепер — основний клас:
public class Main {
public static void main(String[] args) {
SlowCalculator calc = new SlowCalculator();
for (int i = 0; i < 10; i++) {
calc.add(i, i * 2);
calc.multiply(i, i + 5);
}
System.out.println("Історія операцій:");
for (String entry : calc.getHistory()) {
System.out.println(entry);
}
}
}
Як профілювати цей застосунок?
- Скомпілюйте та запустіть застосунок.
- Відкрийте VisualVM (jvisualvm).
- Знайдіть свій процес (зазвичай за назвою класу Main).
- Перейдіть на вкладку CPU Profiler і натисніть Start.
- Дайте програмі попрацювати (або запустіть обчислення повторно).
- Подивіться, які методи займають найбільше часу.
Запитання: Який метод, на вашу думку, буде най«важчим»?
Відповідь: Звісно ж, simulateHeavyOperation() — адже він виконує величезний цикл на 5_000_000 ітерацій і викликає Math.sqrt.
4. Типові проблеми продуктивності
Повільні алгоритми
Найчастіша причина: невдалий вибір алгоритму або структури даних. Наприклад, пошук у списку замість використання HashMap, або сортування «бульбашкою» замість швидкого сортування.
Приклад:
// Повільний пошук
for (String s : list) {
if (s.equals("target")) {
// знайшли
}
}
Краще використовувати Set або Map для швидкого пошуку.
Витоки пам’яті
Витік пам’яті — це ситуація, коли об’єкти залишаються «живими» (на них є посилання), хоча вони більше не потрібні. Це призводить до зростання споживання пам’яті і, зрештою, до OutOfMemoryError.
public class MemoryLeakDemo {
private static List<byte[]> leakyList = new ArrayList<>();
public static void main(String[] args) {
while (true) {
leakyList.add(new byte[1_000_000]); // 1 МБ
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
}
}
}
Як знаходити витоки?
Зробіть heap dump у VisualVM і подивіться, які об’єкти займають найбільше пам’яті та чому на них є посилання.
Надмірне створення об’єктів
Якщо ви в циклі створюєте багато однотипних об’єктів, це не лише навантажує збирач сміття, а й може гальмувати застосунок.
for (int i = 0; i < 1_000_000; i++) {
String s = new String("hello"); // погано!
}
Краще використовувати константи або пул рядків (String pool).
Блокування потоків
Якщо кілька потоків змагаються за один і той самий ресурс (наприклад, синхронізований метод), це може призвести до блокувань і падіння продуктивності.
public synchronized void doWork() {
// ...
}
Як виявляти?
На вкладці Threads у VisualVM можна побачити, які потоки блокуються або очікують — і чому.
5. Підходи до оптимізації
Спочатку вимірюй, потім оптимізуй
Головне правило оптимізації: не оптимізуй те, що не гальмує.
Спочатку профілюйте, знайдіть «гарячі точки», а вже потім змінюйте код. Іноді най«очевидніша» ділянка коду займає всього 1 % часу, а справжній «монстр» — десь у бібліотеці або в неочікуваному місці.
Використання профайлера для пошуку гарячих точок
Гаряча точка — це метод або ділянка коду, що займає найбільшу частку часу роботи застосунку.
У VisualVM це видно на вкладці CPU Profiler:
- Сортуйте методи за часом виконання.
- Дивіться на stack trace: хто кого викликає.
- Пам’ятайте, що іноді «винен» не ваш код, а бібліотека або навіть JDK.
Приклади оптимізації
Приклад 1: Заміна алгоритму
Якщо ви з’ясували, що найбільше часу витрачається на пошук у списку, замініть List на HashSet.
Set<String> set = new HashSet<>(list);
if (set.contains("target")) {
// швидко!
}
Приклад 2: Зменшення кількості виділень
Замість створення нових об’єктів у циклі використовуйте повторне використання або StringBuilder.
// Погано:
for (int i = 0; i < 10000; i++) {
String s = "Результат: " + i;
}
// Краще:
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
sb.setLength(0);
sb.append("Результат: ").append(i);
String s = sb.toString();
}
Приклад 3: Кешування
Якщо ви бачите, що важкий метод викликається багато разів з одними й тими самими параметрами, використовуйте кеш.
Map<Integer, Double> sqrtCache = new HashMap<>();
public double cachedSqrt(int x) {
return sqrtCache.computeIfAbsent(x, Math::sqrt);
}
6. Демонстрація: пришвидшуємо наш калькулятор
Проблема: simulateHeavyOperation() витрачає багато часу
Крок 1. Профілюємо
У VisualVM видно, що майже весь час іде на Math.sqrt(i) усередині циклу на 5_000_000 ітерацій.
Крок 2. Оптимізуємо
Якщо це просто симуляція навантаження — приберіть її або зменште кількість ітерацій.
Якщо це реальна бізнес-логіка — подумайте, чи можна:
- Кешувати результат.
- Використати швидший алгоритм.
- Перенести обчислення в окремий потік (якщо це не критично для користувача).
Приклад оптимізації:
private void simulateHeavyOperation() {
// Було 5_000_000, стало 100_000
for (int i = 0; i < 100_000; i++) {
Math.sqrt(i);
}
}
Крок 3. Перевіряємо результат
Знову запускаємо профілювання — програма працює швидше, навантаження на CPU зменшилося.
7. Візуалізація: процес оптимізації
8. Типові помилки під час профілювання й оптимізації
Помилка № 1: Оптимізація «на око». Дуже часто розробники починають змінювати код, не вимірявши, де насправді проблема. У підсумку — багато роботи, а результат мінімальний.
Помилка № 2: Профілювання за «нереальних» умов. Профілювати треба на тих даних і з тим навантаженням, яке наближене до реального. Профілювання «на порожньому місці» може не виявити справжніх проблем.
Помилка № 3: Ігнорування витоків пам’яті. Якщо не дивитися на heap dump і не аналізувати посилання, можна довго не помічати, що програма «пухне» і скоро впаде.
Помилка № 4: Погоня за мікроскопічною оптимізацією. Не варто витрачати дні на прискорення коду, який займає 0.1 % часу роботи застосунку. Спочатку — головні вузькі місця.
Помилка № 5: Неврахування потоків і синхронізації. У багатопотокових застосунках проблеми з продуктивністю часто пов’язані не з алгоритмами, а з блокуваннями й очікуваннями (synchronized, contention).
Помилка № 6: Забули про профілювання після змін. Після оптимізації обов’язково перевірте результат: іноді «оптимізація» може навіть сповільнити роботу!
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ