1. Thread Dump і аналіз станів потоків
Thread Dump (дамп потоків) — це знімок стану всіх потоків у застосунку на певний момент часу. Це як групове фото всіх ваших потоків: хто чим зайнятий, хто де застряг, хто на кого чекає. Thread Dump — основний інструмент для пошуку deadlock, livelock та інших загадкових зависань.
Як отримати Thread Dump?
Через термінал (jstack):
Якщо у вас є PID процесу Java, виконайте:
jstack <PID>
Команда виведе у консоль стан усіх потоків із зазначенням, хто в якому стані перебуває та які монітори (блокування) утримує.
Через IDE (IntelliJ IDEA):
У меню «Run» → «Show Running List» → оберіть процес → «Thread Dump».
Через VisualVM або JConsole:
Відкрийте процес, знайдіть вкладку «Threads» і зробіть знімок стану.
Приклад Thread Dump
Фрагмент дампу:
"Thread-1" #12 prio=5 os_prio=0 tid=0x000000001e0c7800 nid=0x1a48 waiting for monitor entry [0x000000001f00f000]
java.lang.Thread.State: BLOCKED (on object monitor)
at com.example.DeadlockDemo.lambda$main$0(DeadlockDemo.java:25)
- waiting to lock <0x00000000d6d6baf8> (a java.lang.Object)
- locked <0x00000000d6d6bb08> (a java.lang.Object)
Тут видно, що потік «Thread-1» заблокований (BLOCKED), тримає один монітор, але очікує на інший. Якщо ви бачите кілька таких потоків, які тримають ресурс A і очікують на B, а інший потік тримає B і очікує на A — це класичний deadlock.
Стани потоків
| Статус | Опис |
|---|---|
| RUNNABLE | Потік виконується або готовий до виконання |
| BLOCKED | Очікує на захоплення монітора (блокування) |
| WAITING | Чекає на notify()/notifyAll() (наприклад, викликано wait()) |
| TIMED_WAITING | Чекає з тайм-аутом (наприклад, sleep, wait(timeout)) |
| TERMINATED | Потік завершений |
Важливо: статус RUNNABLE не завжди означає, що потік саме зараз виконується — він лише готовий до виконання (планувальник JVM може не запускати його негайно).
Як зрозуміти, що у вас deadlock?
У дампі кілька потоків у стані BLOCKED, кожен очікує на монітор, який утримує інший потік із цього ж набору.
Наприкінці дампу jstack, як правило, повідомляє:
Found one Java-level deadlock:
=============================
"Thread-1":
waiting to lock monitor 0x00000000d6d6baf8 (object 0x00000000d6d6baf8, a java.lang.Object),
which is held by "Thread-2"
"Thread-2":
waiting to lock monitor 0x00000000d6d6bb08 (object 0x00000000d6d6bb08, a java.lang.Object),
which is held by "Thread-1"
Якщо потоки тривалий час перебувають у BLOCKED або WAITING — це привід для розслідування.
2. Моніторинг і профілювання потоків
VisualVM
VisualVM — безкоштовний інструмент, що входить до складу більшості JDK. Дозволяє під’єднатися до процесу, переглянути стан потоків, зробити Thread Dump, побачити навантаження на CPU, активні та «завислі» потоки.
Вкладка Threads: показує, скільки потоків створено, їхні стани та історію активності.
Thread Dump: кнопка «Thread Dump» робить знімок, аналогічний до jstack.
Java Mission Control і Flight Recorder
Java Mission Control (JMC): потужний інструмент аналізу роботи JVM у реальному часі. Допомагає досліджувати блокування, час виконання, виділення пам’яті, затримки.
Java Flight Recorder (JFR): вбудований профілювальник JVM, що збирає події про потоки, блокування, паузи тощо.
Приклад: моніторинг блокувань
У VisualVM або JMC можна побачити, що:
- Потік «A» заблокований на об’єкті X.
- Потік «B» тримає об’єкт X, але чекає на об’єкт Y.
- Потік «C» тримає об’єкт Y, але чекає на об’єкт X.
Це класичне кругове блокування (deadlock).
Як використовувати ці інструменти на практиці?
- Запускайте застосунок із ключем -XX:+FlightRecorder (або просто використовуйте JDK 11+).
- Відкрийте JMC, під’єднайтеся до процесу, почніть запис (start recording).
- Аналізуйте «вузькі місця», тривалі блокування та конкуренцію між потоками.
3. Логування і трасування
У багатопотокових програмах налагодження «на око» часто болісне. Логуйте вхід/вихід із критичних секцій (synchronized-блоків), операції зі спільними змінними, очікування та пробудження потоків — так ви зрозумієте, хто і коли захопив або відпустив ресурс.
Як логувати?
- Використовуйте стандартні засоби: java.util.logging, SLF4J, Log4j.
- Логуйте ім’я потоку: Thread.currentThread().getName().
- Логуйте час та ідентифікатори потоків.
- Логуйте події захоплення/звільнення блокувань.
Приклад логування
synchronized(lock) {
System.out.println(Thread.currentThread().getName() + " захопив lock");
// критична секція
System.out.println(Thread.currentThread().getName() + " виходить із lock");
}
Використання імен потоків
Призначайте потокам осмислені імена!
Thread t = new Thread(runnable, "MyWorker-1");
Приклад трасування за допомогою логера
import java.util.logging.Logger;
public class Example {
private static final Logger logger = Logger.getLogger(Example.class.getName());
public void doWork() {
logger.info(Thread.currentThread().getName() + " розпочав роботу");
synchronized (this) {
logger.info(Thread.currentThread().getName() + " увійшов у synchronized");
// ...
}
logger.info(Thread.currentThread().getName() + " завершив роботу");
}
}
4. Найкращі практики діагностики
Мінімізуйте область блокувань
Тримайте блокування якнайменший час.
Поганий приклад:
synchronized(lock) {
// тривале введення-виведення
// складні обчислення
// звернення до БД
// ... і лише потім робота зі спільними даними
}
Хороший приклад:
// поза synchronized: тривале введення-виведення, обчислення
synchronized(lock) {
// лише робота зі спільними даними
}
Використовуйте імена потоків
Осмислені імена потоків заощаджують час на аналізі дампів і логів.
Пишіть тести для багатопоточності
Використовуйте JUnit + CountDownLatch, щоб моделювати конкурентні сценарії.
CountDownLatch latch = new CountDownLatch(2);
Runnable task = () -> {
// ...
latch.countDown();
};
new Thread(task, "Worker-1").start();
new Thread(task, "Worker-2").start();
latch.await(); // чекаємо завершення обох потоків
Використовуйте try-finally для ReentrantLock
Lock lock = new ReentrantLock();
lock.lock();
try {
// критична секція
} finally {
lock.unlock();
}
Так ви не забудете звільнити блокування навіть у разі винятку. Щоб уникнути взаємних блокувань, використовуйте tryLock() із тайм-аутом.
Документуйте, навіщо потрібна синхронізація
Коментарі на кшталт «Тут потрібен synchronized, тому що…» допоможуть з часом зрозуміти задум.
5. Практика: аналіз deadlock у тестовій програмі
Приклад коду з deadlock
public class DeadlockDemo {
private static final Object lockA = new Object();
private static final Object lockB = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (lockA) {
System.out.println("Thread-1: захопив lockA");
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
synchronized (lockB) {
System.out.println("Thread-1: захопив lockB");
}
}
}, "Thread-1");
Thread t2 = new Thread(() -> {
synchronized (lockB) {
System.out.println("Thread-2: захопив lockB");
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
synchronized (lockA) {
System.out.println("Thread-2: захопив lockA");
}
}
}, "Thread-2");
t1.start();
t2.start();
}
}
Як спіймати deadlock
- Запустіть програму — вона зависне.
- Отримайте Thread Dump (jstack або через VisualVM).
- Знайдіть «Thread-1» і «Thread-2» — побачите, що кожен тримає один lock і чекає на інший.
- У кінці дампу буде секція «Found one Java-level deadlock».
Як усунути
- Захоплюйте блокування завжди в одному й тому самому порядку.
- Використовуйте ReentrantLock із tryLock() і тайм-аутом: якщо не вдалося захопити усі блокування — відпустіть і спробуйте знову.
6. Типові помилки під час діагностики багатопотокових програм
Помилка № 1: Невміння читати Thread Dump. Розробники-початківці лякаються дампу: «Що за дивні стек-трейси та статуси?» Насправді достатньо знати основні стани й шукати BLOCKED/WAITING, щоб спростити аналіз.
Помилка № 2: Ігнорування імен потоків. Без осмислених імен розбиратися в дампі — як шукати голку в копиці сіна. Не лінуйтеся призначати імена!
Помилка № 3: Надто великі synchronized-блоки. Якщо ви синхронізуєте великі шматки коду, потоки частіше блокуватимуться один об одного — це видно за частими BLOCKED у дампі.
Помилка № 4: Плутанина між RUNNABLE і реально працюючим потоком. RUNNABLE — не завжди «біжить» на процесорі. Планувальник JVM сам вирішує, кого запускати.
Помилка № 5: Невикористання інструментів моніторингу. Багато хто не знає про VisualVM, JMC, Flight Recorder і мучиться з println. Використовуйте інструменти — вони значно спрощують життя.
Помилка № 6: Відсутність логування критичних операцій. Без логів зрозуміти, хто і коли захопив/відпустив блокування, майже неможливо.
Помилка № 7: Спроби «на око» спіймати гонки даних. Гонки проявляються не завжди й не відразу — використовуйте тести з CountDownLatch, провокуйте конкуренцію через Thread.yield() і аналізуйте стан спільних змінних.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ