Основы многопоточности и параллелизма
Процесс vs Поток
Процесс — это независимая программа, выполняющаяся в собственном адресном пространстве. Каждый процесс имеет собственную память, файловые дескрипторы, стек и heap.
Поток (Thread) — это легковесная единица выполнения внутри процесса. Все потоки одного процесса разделяют общую память (heap), но имеют собственный стек вызовов.
Процесс 1 Процесс 2
├── Memory (изолирована) ├── Memory (изолирована)
├── Thread 1 ├── Thread 1
│ └── Stack │ └── Stack
├── Thread 2 └── Thread 2
│ └── Stack └── Stack
└── Shared Heap
Потоки внутри процесса делят heap, но имеют отдельные стеки
Процессы полностью изолированы друг от друга
Ключевые отличия:
Создание:
- Процесс: Тяжеловесное — требует выделения памяти, дескрипторов, инициализации ОС
- Поток: Легковесное — требует только stack (~1 МБ)
Адресное пространство:
- Процесс: Полностью изолированное
- Поток: Разделяют heap, но имеют свой stack
Обмен данными:
- Процесс: Inter-Process Communication (IPC), sockets, pipes
- Поток: Прямой доступ к shared памяти
Переключение контекста:
- Процесс: Дорогое — ОС должна сохранить/загрузить все состояние (регистры, страницы памяти)
- Поток: Дешевле — сохраняются только регистры и stack pointer
Восстановление после сбоя:
- Процесс: Один сбой не влияет на другие процессы
- Поток: Один крах потока может убить весь процесс
// Процесс в Java
ProcessBuilder pb = new ProcessBuilder("java", "-jar", "app.jar");
Process process = pb.start(); // Создает новый изолированный процесс в ОС
// Поток в Java
Thread thread = new Thread(() -> System.out.println("Hello from thread"));
thread.start(); // Создает легковесный поток внутри текущего процесса
Многопоточность vs Многозадачность
Многозадачность (Multitasking) — способность операционной системы выполнять несколько задач (процессов/потоков) одновременно путем быстрого переключения между ними.
Многопоточность (Multithreading) — частный случай многозадачности, когда один процесс выполняет несколько потоков параллельно, используя общие ресурсы процесса.
Виды многозадачности
1. Process-based Multitasking
- ОС переключается между процессами через механизм Context Switching
- Каждый процесс полностью изолирован и имеет собственное адресное пространство
- Переключение контекста дорогое (сохранение/загрузка всех регистров, страниц памяти)
- Пример: Chrome с несколькими независимыми процессами для каждой вкладки
2. Thread-based Multitasking
- ОС переключается между потоками одного процесса
- Потоки разделяют память процесса (heap, static переменные)
- Переключение контекста дешевле, так как одна JVM и heap остаются те же
- Пример: Web-сервер обрабатывает множество HTTP запросов в разных потоках
// Thread-based multitasking: простой Web-сервер
class SimpleWebServer {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(8080);
System.out.println("Server listening on port 8080");
while (true) {
Socket clientSocket = serverSocket.accept();
// Каждый запрос обрабатывается в отдельном потоке
// Все потоки разделяют одну JVM и heap (доступ к общей конфигурации, кешу)
new Thread(() -> handleRequest(clientSocket)).start();
}
}
private static void handleRequest(Socket socket) {
try (InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream()) {
// Обработка HTTP запроса
out.write("HTTP/1.1 200 OK\r\n\r\nHello World".getBytes());
} catch (IOException e) {
e.printStackTrace();
}
}
}
Зачем нужна многопоточность?
1. Параллелизм на многоядерных системах
- На многоядерном процессоре потоки выполняются действительно параллельно, а не чередуются
- 4-ядерный CPU может выполнять до 4 потоков одновременно без переключения контекста
2. Отзывчивость пользовательского интерфейса
- Длительные операции (загрузка, вычисления) не блокируют UI поток
- UI остается отзывчивым к действиям пользователя (клики, движение мыши)
3. Эффективное использование I/O операций
- Пока один поток ждет ответа от сети/диска, другой выполняет вычисления на CPU
- I/O операции асинхронны — потока может спать, освобождая CPU для других потоков
4. Throughput и масштабируемость
- Сервер может одновременно обрабатывать множество запросов от разных клиентов
- Количество одновременных соединений не ограничено одним потоком
Практический пример: последовательный vs параллельный I/O
// Последовательная обработка (медленно)
void processUserRequestsSequentially(List<User> users) {
for (User user : users) {
sendEmail(user); // Блокирующая I/O операция, ~1 сек на пользователя
// Поток ждет ответа от почтового сервера
}
// 1000 пользователей → 1000 секунд (~17 минут)
}
// Параллельная обработка (быстро)
void processUserRequestsConcurrently(List<User> users) {
ExecutorService executor = Executors.newFixedThreadPool(10);
for (User user : users) {
executor.submit(() -> sendEmail(user));
}
executor.shutdown();
executor.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);
// 1000 пользователей → ~100 секунд (10 потоков обрабатывают параллельно)
// 9 потоков ждут I/O, пока 1 выполняет вычисления
}
// Отправка электронного письма (блокирует на ~1 сек)
private void sendEmail(User user) {
try {
// Подключение к SMTP, отправка, получение подтверждения
Thread.sleep(1000);
System.out.println("Email sent to " + user.getEmail());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
Жизненный цикл потока
Поток в Java проходит через несколько состояний в течение своей жизни. Понимание этих состояний критично для отладки и оптимизации:
start()
↓
NEW → RUNNABLE ──────────→ RUNNING
↑ ↓ ↓
│ └─ BLOCKED ←───────┘
│ (ждет монитора)
│
│ WAITING (Object.wait(), join(), LockSupport.park())
│ TIMED_WAITING (sleep(), wait(timeout), join(timeout))
│
└─────────────────────────────→ TERMINATED
(завершен или исключение)
Описание состояний
NEW (Новый)
- Поток создан конструктором
new Thread(), но еще не запущен start()не был вызван- Поток не имеет статуса в ОС, это просто объект Java
Thread thread = new Thread(() -> System.out.println("Hello"));
System.out.println(thread.getState()); // NEW
RUNNABLE (Готов к выполнению)
- Поток вызвал
start()и был добавлен в очередь готовых потоков - Ждет выделения CPU от планировщика потоков
- Может быть готов либо находиться в процессе выполнения (технически оба сценария — это RUNNABLE в Java API)
Thread thread = new Thread(() -> System.out.println("Hello"));
thread.start();
System.out.println(thread.getState()); // RUNNABLE (может быть или выполняется, или ждет в очереди)
RUNNING (Выполняется)
- Поток получил CPU и активно выполняет свой код
- В Java API это подсостояние RUNNABLE (нет отдельного RUNNING)
- Переключение контекста может прервать поток в любой момент
BLOCKED (Заблокирован на мониторе)
- Поток попытался войти в
synchronizedблок или вызвать синхронизированный метод - Другой поток держит монитор (lock) этого объекта
- Поток ждет, пока lock будет освобожден
Object lock = new Object();
Thread thread1 = new Thread(() -> {
synchronized (lock) {
try { Thread.sleep(5000); } catch (InterruptedException e) {}
}
});
Thread thread2 = new Thread(() -> {
synchronized (lock) { // BLOCKED — ждет, пока thread1 освободит lock
System.out.println("Got lock");
}
});
thread1.start();
Thread.sleep(100);
thread2.start();
Thread.sleep(200);
System.out.println(thread2.getState()); // BLOCKED
WAITING (Ожидание без таймаута)
- Поток ждет сигнала от другого потока (уведомления)
Object.wait(),Thread.join(),LockSupport.park()и аналогичные- Ждет бесконечно долго, пока не получит сигнал
Object signal = new Object();
Thread waiter = new Thread(() -> {
synchronized (signal) {
try {
signal.wait(); // WAITING — ждет notify() или notifyAll()
System.out.println("Был разбужен");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});
Thread notifier = new Thread(() -> {
synchronized (signal) {
try { Thread.sleep(2000); } catch (InterruptedException e) {}
signal.notifyAll(); // Пробуждает waiter
}
});
waiter.start();
notifier.start();
TIMED_WAITING (Ожидание с таймаутом)
- Поток ждит с ограничением по времени
Thread.sleep(ms),Object.wait(timeout),Thread.join(timeout)- Автоматически пробуждается через указанное время или раньше, если получит сигнал
Thread sleeper = new Thread(() -> {
try {
Thread.sleep(2000); // TIMED_WAITING на 2000 мс
System.out.println("Проснулся");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
sleeper.start();
Thread.sleep(500);
System.out.println(sleeper.getState()); // TIMED_WAITING
Thread.sleep(2000);
System.out.println(sleeper.getState()); // RUNNABLE или TERMINATED
TERMINATED (Завершен)
- Поток завершил выполнение метода
run() - Или в методе
run()было выброшено необработанное исключение - Поток нельзя перезапустить — нужно создать новый экземпляр
Thread thread = new Thread(() -> {
System.out.println("Работаю");
// run() завершился
});
thread.start();
thread.join(); // Ждем завершения
System.out.println(thread.getState()); // TERMINATED
// Попытка перезапустить
thread.start(); // IllegalThreadStateException!
Полный пример жизненного цикла
class ThreadLifecycleDemo {
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
System.out.println("Thread: начал работу");
try {
// Читаем состояние (внутри потока)
System.out.println("Thread: перехожу в TIMED_WAITING");
Thread.sleep(2000);
} catch (InterruptedException e) {
System.out.println("Thread: был прерван");
Thread.currentThread().interrupt();
}
System.out.println("Thread: завершаю работу");
});
// NEW — поток создан, но не запущен
System.out.println("Main: " + thread.getState()); // NEW
// Запускаем поток
thread.start();
// RUNNABLE — поток запущен и готов выполняться
Thread.sleep(50);
System.out.println("Main: " + thread.getState()); // RUNNABLE
// Даем время потоку войти в sleep
Thread.sleep(100);
System.out.println("Main: " + thread.getState()); // TIMED_WAITING (спит)
// Прерываем поток
thread.interrupt();
// Ждем завершения потока
thread.join();
// TERMINATED — поток завершился
System.out.println("Main: " + thread.getState()); // TERMINATED
}
}
Создание потоков в Java
Способ 1: Наследование от Thread
class MyThread extends Thread {
@Override
public void run() {
System.out.println("Выполняю: " + Thread.currentThread().getName());
for (int i = 0; i < 5; i++) {
System.out.println("Итерация " + i);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
System.out.println("Поток был прерван");
Thread.currentThread().interrupt();
}
}
}
}
// Использование
MyThread thread = new MyThread();
thread.setName("MyWorkerThread");
thread.start(); // Важно: вызываем start(), не run()!
Критическое различие: start() vs run()
Thread thread = new Thread(() -> System.out.println("Hello"));
// НЕПРАВИЛЬНО: run() выполняется в текущем потоке
thread.run(); // "Hello" печатается в main потоке, без многопоточности
// ПРАВИЛЬНО: start() создает новый поток
thread.start(); // "Hello" печатается в новом потоке
Проблемы подхода наследования:
- Java не поддерживает множественное наследование — если класс уже наследуется от другого, нельзя наследоваться от Thread
- Нарушает принцип композиции над наследованием
- Смешивает логику задачи (что выполнить) с логикой многопоточности (как выполнить)
Способ 2: Реализация Runnable (предпочтительно)
class MyTask implements Runnable {
private String taskName;
public MyTask(String taskName) {
this.taskName = taskName;
}
@Override
public void run() {
System.out.println("Выполняю задачу: " + taskName);
System.out.println("В потоке: " + Thread.currentThread().getName());
}
}
// Использование
Runnable task = new MyTask("DataProcessing");
Thread thread = new Thread(task);
thread.setName("DataProcessor-1");
thread.start();
// Lambda выражение (Java 8+) — еще короче
Thread thread2 = new Thread(() -> {
System.out.println("Работаю в потоке: " + Thread.currentThread().getName());
});
thread2.start();
Преимущества Runnable:
- Разделение ответственности: класс содержит логику задачи, Thread отвечает за механизм выполнения
- Можно реализовать другие интерфейсы и наследоваться от других классов
- Задачу можно передать в ExecutorService, ForkJoinPool, Virtual Threads (Project Loom)
- Более гибко и расширяемо
// Класс может реализовать и Runnable, и другой интерфейс
class DataProcessor implements Runnable, Serializable {
@Override
public void run() {
// Задача
}
// Другие методы
}
// Можно использовать везде, где ожидается Runnable
ExecutorService executor = Executors.newFixedThreadPool(10);
executor.submit(new DataProcessor());
Способ 3: Callable (возвращает результат)
import java.util.concurrent.*;
class CalculationTask implements Callable<Integer> {
private int a, b;
public CalculationTask(int a, int b) {
this.a = a;
this.b = b;
}
@Override
public Integer call() throws Exception {
System.out.println("Вычисляю в потоке: " + Thread.currentThread().getName());
// Имитируем долгое вычисление
Thread.sleep(1000);
return a + b; // Возвращаем результат
}
}
// Использование
ExecutorService executor = Executors.newSingleThreadExecutor();
// submit() возвращает Future<Integer>
Future<Integer> future = executor.submit(new CalculationTask(5, 3));
System.out.println("Поток запущен, можем что-то еще делать...");
// get() блокирует до получения результата
try {
Integer result = future.get(); // Ждем результат
System.out.println("Результат: " + result); // 8
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
// Или с таймаутом
try {
Integer result = future.get(2, TimeUnit.SECONDS); // Ждем макс 2 сек
} catch (TimeoutException e) {
System.out.println("Поток не завершился за 2 сек");
}
executor.shutdown();
Различия Runnable vs Callable:
Возвращаемое значение:
- Runnable: void (ничего не возвращает)
- Callable: Generics тип (T)
Исключения:
- Runnable: Только unchecked
- Callable: Может выбросить checked exception
Интерфейс:
- Runnable:
void run() - Callable:
T call() throws Exception
Использование:
- Runnable: Thread, ExecutorService
- Callable: ExecutorService с Future
Thread Scheduler и планирование потоков
Thread Scheduler — компонент JVM, который решает, какой поток выполнять на CPU в данный момент времени. Работает совместно с планировщиком ОС.
Приоритеты потоков
Java позволяет задавать приоритет потока от 1 (минимальный) до 10 (максимальный):
Thread thread = new Thread(() -> {
System.out.println("Поток с приоритетом " + Thread.currentThread().getPriority());
});
thread.setPriority(Thread.MIN_PRIORITY); // 1
thread.setPriority(Thread.NORM_PRIORITY); // 5 (по умолчанию)
thread.setPriority(Thread.MAX_PRIORITY); // 10
thread.start();
Как работает планировщик
Многоядерный процессор (4 ядра):
RUNNABLE очередь:
[Thread-1: pri=10] ──→ выполняется на Core 1
[Thread-2: pri=8] ──→ выполняется на Core 2
[Thread-3: pri=5] ──→ выполняется на Core 3
[Thread-4: pri=5] ──→ выполняется на Core 4
[Thread-5: pri=3] ──→ ждет в очереди
[Thread-6: pri=1] ──→ ждет в очереди
Когда Core 1 освобождается, планировщик выбирает Thread-5 (приоритет 3 выше, чем 1)
Алгоритмы планирования
Preemptive Scheduling (вытесняющая многозадачность)
- Планировщик может прервать выполнение потока и переключиться на другой, даже если текущий поток не завершился
- Потоки с высшим приоритетом могут вытеснить потоки с низким приоритетом
- Используется в современных JVM (HotSpot, OpenJDK)
Поток A (pri=10) выполняется
↓
[Context Switch]
↓
Поток B (pri=5) был добавлен в очередь с высшим приоритетом
↓
Планировщик ВЫТЕСНЯЕТ Поток A и переключается на Поток B
Time-Slicing (разделение времени)
- Каждому потоку выделяется квант времени CPU (time slice, обычно 10-100 мс)
- После истечения кванта планировщик переключается на следующий поток в очереди
- Обеспечивает справедливое распределение CPU между потоками одного приоритета
Time Slice = 10 ms:
[0-10 ms] Thread-A выполняется
[10-20 ms] Thread-B выполняется
[20-30 ms] Thread-C выполняется
[30-40 ms] Thread-A выполняется (снова)
Пример приоритизации
class PriorityDemo {
public static void main(String[] args) throws InterruptedException {
Thread highPriority = new Thread(() -> {
for (int i = 0; i < 5; i++) {
System.out.println("HIGH: " + i);
}
}, "HighPriorityThread");
Thread lowPriority = new Thread(() -> {
for (int i = 0; i < 5; i++) {
System.out.println("LOW: " + i);
}
}, "LowPriorityThread");
highPriority.setPriority(Thread.MAX_PRIORITY); // 10
lowPriority.setPriority(Thread.MIN_PRIORITY); // 1
// Запускаем оба потока
lowPriority.start();
highPriority.start();
// Вывод (обычно высокий приоритет печатается больше):
// HIGH: 0
// HIGH: 1
// HIGH: 2
// LOW: 0 (обычно попадает между выполнениями HIGH)
// HIGH: 3
// HIGH: 4
// LOW: 1
// LOW: 2
// LOW: 3
// LOW: 4
}
}
Важные замечания о приоритетах:
- Приоритеты — это hint для планировщика, а не строгая гарантия
- ОС может игнорировать приоритеты Java или использовать разные алгоритмы
- На разных платформах (Linux, Windows, macOS) поведение различается
- Не полагайтесь на приоритеты для корректности программы — используйте синхронизацию
Thread.yield() — добровольное уступание CPU
Thread thread = new Thread(() -> {
for (int i = 0; i < 10; i++) {
System.out.println(i);
Thread.yield(); // Подсказка планировщику: можно переключиться на другой поток
}
});
yield() не гарантирует переключение контекста. Планировщик может проигнорировать вызов и оставить текущий поток на CPU. Это мягкая рекомендация, а не команда.
Daemon-потоки
Daemon-поток — это фоновый поток, который не препятствует завершению JVM. JVM выходит, когда остаются только daemon-потоки.
User-поток (обычный) — обычный поток. JVM ждет завершения всех user-потоков перед выходом.
Как это работает
Thread userThread = new Thread(() -> {
System.out.println("User thread начал");
sleep(3000);
System.out.println("User thread закончил");
}, "UserThread");
Thread daemonThread = new Thread(() -> {
System.out.println("Daemon thread начал");
sleep(3000);
System.out.println("Daemon thread закончил"); // Может не выполниться!
}, "DaemonThread");
daemonThread.setDaemon(true); // ВАЖНО: вызвать ДО start()
userThread.start();
daemonThread.start();
// JVM дождется завершения userThread (user-поток)
// Но daemon-поток будет насильно остановлен при выходе из JVM
// Выход из main() не гарантирует завершение daemon-потока
Жизненный цикл:
JVM старт
↓
Main поток (user) запущен
↓
userThread.start() — user-поток, JVM будет ждать
↓
daemonThread.start() — daemon-поток, JVM его игнорирует при выходе
↓
userThread завершился (3 сек)
↓
main() завершился, user-потоков больше нет
↓
JVM выходит, даже если daemon еще работает
↓
daemonThread убивается ОС (не успел напечатать "закончил")
Практические применения Daemon-потоков
1. Garbage Collector — встроенный daemon для очистки памяти
// JVM автоматически создает GC в качестве daemon-потока
// Когда приложение завершается, GC прерывается
// Проверка
ThreadMXBean bean = ManagementFactory.getThreadMXBean();
for (ThreadInfo info : bean.getThreadInfo(bean.getAllThreadIds())) {
System.out.println(info.getThreadName() + " - Daemon: " + info.isDaemon());
// ...
// GC task thread - Daemon: true
}
2. Мониторинг и логирование
class LogMonitor {
public static void main(String[] args) throws InterruptedException {
Thread monitor = new Thread(() -> {
while (true) {
System.out.println("[MONITOR] Проверка здоровья приложения...");
sleep(5000);
}
}, "LogMonitorDaemon");
monitor.setDaemon(true); // Фоновый поток
monitor.start();
// Основная работа приложения
System.out.println("Приложение работает");
Thread.sleep(10000);
System.out.println("Приложение завершается");
// daemon автоматически остановится
}
private static void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException e) {}
}
}
3. Обновление кеша и фоновые задачи
class CacheRefresher {
private static final Map<String, String> cache = new ConcurrentHashMap<>();
public static void main(String[] args) throws InterruptedException {
Thread refresher = new Thread(() -> {
while (true) {
// Обновляем кеш каждую минуту
cache.put("timestamp", String.valueOf(System.currentTimeMillis()));
cache.put("status", "OK");
System.out.println("Кеш обновлен");
sleep(60000);
}
}, "CacheRefresherDaemon");
refresher.setDaemon(true);
refresher.start();
// Приложение использует кеш
// При выходе daemon автоматически остановится
}
private static void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException e) {}
}
}
Подводные камни Daemon-потоков
// ❌ ОШИБКА: setDaemon() вызван ПОСЛЕ start()
Thread thread = new Thread(() -> {});
thread.start();
thread.setDaemon(true); // IllegalThreadStateException!
// ✅ ПРАВИЛЬНО: setDaemon() вызван ДО start()
Thread thread2 = new Thread(() -> {});
thread2.setDaemon(true);
thread2.start();
// ❌ ПРОБЛЕМА: критичная работа в daemon
Thread critical = new Thread(() -> {
try {
saveDataToDB(); // Может быть прервана!
commitTransaction(); // Может не выполниться!
} catch (Exception e) {}
}, "CriticalDaemon");
critical.setDaemon(true);
critical.start();
// Лучше использовать обычный user-поток с graceful shutdown
Thread important = new Thread(() -> {
try {
saveDataToDB(); // Гарантирует выполнение
commitTransaction();
} catch (Exception e) {}
});
// Не устанавливаем setDaemon(), это user-поток
important.start();
important.join(); // Ждем завершения перед выходом
Контекст потока и переключение контекста
Контекст потока — это полное состояние потока, необходимое для сохранения и восстановления при переключении:
- Регистры CPU: EAX, EBX, ECX и т.д. (где хранятся текущие значения переменных)
- Program Counter (PC): адрес следующей инструкции для выполнения
- Stack Pointer (SP): указатель на вершину стека потока
- Состояние флагов: результаты последних операций (overflow, zero, carry и т.д.)
- Информация о памяти: таблица страниц, регистры MMU
Context Switch (переключение контекста) — процесс сохранения состояния одного потока и загрузки состояния другого:
Шаг 1: Поток A выполняется на CPU
CPU выполняет инструкции из Code Segment Потока A
Регистры содержат локальные переменные Потока A
Шаг 2: Планировщик решает переключиться на Поток B
(Через системный вызов, IRQ, или истечение time-slice)
Шаг 3: Сохранение контекста Потока A
CPU сохраняет регистры в стек ядра ОС
Сохраняется Program Counter (адрес следующей инструкции)
Сохраняется состояние флагов
Шаг 4: Загрузка контекста Потока B
ОС загружает регистры Потока B из памяти
Восстанавливается Program Counter
Восстанавливается состояние TLB (Translation Lookaside Buffer)
Шаг 5: Поток B продолжает выполнение
CPU выполняет инструкции с того же места, где Поток B остановился
Стоимость переключения контекста
Переключение контекста дорого по времени и ресурсам:
Сохранение/загрузка регистров: ~1-10 микросекунд
Сброс кеша L1/L2/L3: ~100-1000 нс на строку
TLB flush (переполнение буфера): 50-300 тактов CPU
Потеря локальности кода: Новый поток может работать с другой памятью
Итого: 1 Context Switch = 1000-10000 тактов CPU
На современном CPU (3 ГГц) это ~0.3-3 микросекунды
Пример: слишком много потоков
class TooManyThreads {
public static void main(String[] args) throws InterruptedException {
long startTime = System.currentTimeMillis();
// ❌ Создаем 10,000 потоков (плохая идея!)
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 10000; i++) {
Thread t = new Thread(() -> {
// Простая работа: вычисление суммы
int sum = 0;
for (int j = 0; j < 1000; j++) {
sum += j;
}
});
threads.add(t);
t.start();
}
// Ждем завершения всех потоков
for (Thread t : threads) {
t.join();
}
long endTime = System.currentTimeMillis();
System.out.println("10K threads: " + (endTime - startTime) + "ms");
// Результат: ~5000-10000 ms (очень медленно!)
// Причина: огромное количество context switches
}
}
Решение: Thread Pool
class OptimizedThreadPool {
public static void main(String[] args) throws InterruptedException {
long startTime = System.currentTimeMillis();
// ✅ Используем пул потоков (число = число ядер CPU)
int cores = Runtime.getRuntime().availableProcessors();
ExecutorService executor = Executors.newFixedThreadPool(cores);
// Отправляем 10,000 задач, но обрабатываем только cores одновременно
for (int i = 0; i < 10000; i++) {
executor.submit(() -> {
int sum = 0;
for (int j = 0; j < 1000; j++) {
sum += j;
}
});
}
executor.shutdown();
executor.awaitTermination(1, TimeUnit.MINUTES);
long endTime = System.currentTimeMillis();
System.out.println("Thread Pool: " + (endTime - startTime) + "ms");
// Результат: ~500-1000 ms (в 5-10 раз быстрее!)
// Причина: контролируемое количество потоков, минимум context switches
}
}
CPU-bound vs I/O-bound задачи
CPU-bound задачи (вычисления, обработка данных):
- Потоки активно используют CPU
- Переключение контекста — это потеря времени
- Оптимальное число потоков = число CPU ядер
- Больше потоков = больше переключений = медленнее
// CPU-bound: тяжелые вычисления
ExecutorService cpuBound = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors() // 4, 8, 16...
);
I/O-bound задачи (сеть, диск, базы данных):
- Потоки часто ждут I/O операций (не используют CPU)
- Пока один поток ждет диск, другой выполняет вычисления
- Оптимальное число потоков > число CPU ядер
- Типично: cores * 2 или даже cores * 10
// I/O-bound: ожидание сети, БД
ExecutorService ioBound = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors() * 2 // 8, 16, 32...
);
// Более гибко: с использованием BlockingQueue
ExecutorService adaptive = Executors.newFixedThreadPool(
Math.max(2, Runtime.getRuntime().availableProcessors() / 2)
);
Практические примеры
Параллельная загрузка данных
class DataFetcher {
public static void main(String[] args) throws InterruptedException {
// Загружаем данные из нескольких источников параллельно
Thread userThread = new Thread(() -> {
System.out.println("[Users] Загрузка из БД...");
sleep(2000);
System.out.println("[Users] Загружено 1000 пользователей");
}, "UserLoader");
Thread ordersThread = new Thread(() -> {
System.out.println("[Orders] Загрузка из API...");
sleep(3000);
System.out.println("[Orders] Загружено 500 заказов");
}, "OrderLoader");
Thread productsThread = new Thread(() -> {
System.out.println("[Products] Загрузка из кеша...");
sleep(1000);
System.out.println("[Products] Загружено 10000 товаров");
}, "ProductLoader");
long start = System.currentTimeMillis();
// Запускаем все параллельно
userThread.start();
ordersThread.start();
productsThread.start();
// Ждем завершения всех
userThread.join();
ordersThread.join();
productsThread.join();
long elapsed = System.currentTimeMillis() - start;
System.out.println("Всего времени: " + elapsed + "ms");
// Результат: ~3000 ms (время самого медленного потока)
// Вместо 2000+3000+1000=6000 ms при последовательном выполнении
// Экономия: 50% времени благодаря параллелизму
}
private static void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
Web Scraper с контролируемым пулом потоков
class WebScraper {
private static final List<String> urls = List.of(
"http://example.com/page1",
"http://example.com/page2",
"http://example.com/page3",
"http://example.com/page4",
"http://example.com/page5"
);
public static void main(String[] args) throws InterruptedException {
// I/O-bound задача: используем больше потоков, чем ядер
int threadCount = Runtime.getRuntime().availableProcessors() * 2;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
System.out.println("Начинаю скрейпинг с " + threadCount + " потоками");
for (String url : urls) {
executor.submit(() -> scrapeUrl(url));
}
executor.shutdown(); // Не принимаем новые задачи
boolean completed = executor.awaitTermination(30, TimeUnit.SECONDS);
if (completed) {
System.out.println("Все URL'ы обработаны");
} else {
System.out.println("Таймаут: некоторые задачи не завершились");
executor.shutdownNow(); // Прерываем оставшиеся
}
}
private static void scrapeUrl(String url) {
try {
System.out.println("[" + Thread.currentThread().getName() +
"] Скрейпинг " + url);
// Имитируем HTTP запрос
Thread.sleep((long) (1000 + Math.random() * 2000));
System.out.println("[" + Thread.currentThread().getName() +
"] Завершен " + url);
} catch (InterruptedException e) {
System.out.println("[" + Thread.currentThread().getName() +
"] Прерван: " + url);
Thread.currentThread().interrupt();
}
}
}
Обработчик запросов (Producer-Consumer)
class RequestProcessor {
private static final BlockingQueue<String> requestQueue =
new LinkedBlockingQueue<>(100);
public static void main(String[] args) throws InterruptedException {
// Producer: генерирует запросы
Thread producer = new Thread(() -> {
try {
for (int i = 1; i <= 10; i++) {
String request = "Request-" + i;
requestQueue.put(request); // Блокирует, если очередь полна
System.out.println("Произведен: " + request);
Thread.sleep(500);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Producer");
// Consumers: обрабатывают запросы
ExecutorService consumers = Executors.newFixedThreadPool(3);
for (int i = 0; i < 3; i++) {
consumers.submit(() -> {
try {
while (true) {
String request = requestQueue.take(); // Блокирует, если очередь пуста
System.out.println("[" + Thread.currentThread().getName() +
"] Обработка: " + request);
Thread.sleep(1000);
System.out.println("[" + Thread.currentThread().getName() +
"] Завершен: " + request);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
producer.start();
producer.join(); // Ждем завершения producer
// Даем consumers время закончить
Thread.sleep(5000);
consumers.shutdownNow();
}
}
Подводные камни и Best Practices
❌ Типичные ошибки
1. Вызов run() вместо start()
Thread thread = new Thread(() -> System.out.println("Hello"));
thread.run(); // ❌ ПЛОХО: выполняется в текущем потоке
thread.start(); // ✅ ХОРОШО: создает новый поток
2. Повторный запуск потока
Thread thread = new Thread(() -> System.out.println("Task"));
thread.start();
thread.start(); // ❌ IllegalThreadStateException!
// Решение: создайте новый поток
Thread newThread = new Thread(() -> System.out.println("Task"));
newThread.start();
3. Неправильная обработка InterruptedException
// ❌ ПЛОХО: проглатываем исключение
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// Ничего не делаем — потеряем информацию о прерывании
}
// ✅ ХОРОШО: восстанавливаем флаг прерывания
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // Восстанавливаем флаг
// Или пробрасываем дальше
throw new RuntimeException("Thread interrupted", e);
}
4. setDaemon() после start()
Thread thread = new Thread(() -> {});
thread.start();
thread.setDaemon(true); // ❌ IllegalThreadStateException!
// Правильно
Thread thread2 = new Thread(() -> {});
thread2.setDaemon(true);
thread2.start();
5. Активное ожидание (busy-waiting)
// ❌ ПЛОХО: циклическая проверка, тратит CPU
Thread worker = new Thread(() -> {});
worker.start();
while (worker.isAlive()) {
Thread.sleep(100); // Все равно проверяет каждые 100 мс
// Пока поток жив, занимаем CPU
}
// ✅ ХОРОШО: блокирующее ожидание, не тратит CPU
worker.join(); // Поток засыпает до завершения worker
6. Daemon-потоки для критичной работы
// ❌ ПЛОХО: критичные операции в daemon
Thread critical = new Thread(() -> {
saveToDatabaseCriticalData(); // Может быть убит в любой момент!
});
critical.setDaemon(true);
critical.start();
// ✅ ХОРОШО: используйте user-поток с graceful shutdown
Thread important = new Thread(() -> {
saveToDatabaseCriticalData();
});
// не устанавливаем setDaemon
important.start();
important.join(); // Ждем завершения перед выходом
✅ Best Practices
1. Используйте ExecutorService, а не ручное управление потоками
// ❌ Сложно и подвержено ошибкам
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 100; i++) {
Thread t = new Thread(() -> doWork());
threads.add(t);
t.start();
}
for (Thread t : threads) {
t.join();
}
// ✅ Просто и правильно
ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < 100; i++) {
executor.submit(() -> doWork());
}
executor.shutdown();
executor.awaitTermination(1, TimeUnit.MINUTES);
2. Выбирайте правильный размер пула потоков
// CPU-bound: равно числу ядер
int cores = Runtime.getRuntime().availableProcessors();
ExecutorService cpuBound = Executors.newFixedThreadPool(cores);
// I/O-bound: больше чем ядра
ExecutorService ioBound = Executors.newFixedThreadPool(cores * 2);
// Или используйте ForkJoinPool для рекурсивных задач
ForkJoinPool pool = new ForkJoinPool(cores);
3. Правильная обработка исключений
ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < 100; i++) {
executor.submit(() -> {
try {
doWork();
} catch (Exception e) {
// Логируем ошибку
System.err.println("Ошибка в потоке: " + e.getMessage());
e.printStackTrace();
}
});
}
executor.shutdown();
executor.awaitTermination(1, TimeUnit.MINUTES);
4. Используйте volatile и synchronized для shared state
class Worker {
private volatile boolean running = true; // volatile — видна между потоками
public void run() {
while (running) {
// Работа
}
}
public void stop() {
running = false; // Безопасно прерывает работу
}
}
5. Именуйте потоки для отладки
// ✅ Хорошее имя помогает при отладке
Thread thread = new Thread(() -> processData(), "DataProcessor-1");
thread.start();
// Или через ThreadFactory в ExecutorService
ExecutorService executor = Executors.newFixedThreadPool(
10,
runnable -> {
Thread t = new Thread(runnable);
t.setName("Worker-" + t.getId());
return t;
}
);
6. Graceful shutdown: даем потокам время завершиться
ExecutorService executor = Executors.newFixedThreadPool(10);
// ... отправляем задачи ...
// Graceful shutdown
executor.shutdown(); // Останавливаем прием новых задач
if (!executor.awaitTermination(30, TimeUnit.SECONDS)) {
// Если не завершились за 30 сек, принудительно останавливаем
List<Runnable> remaining = executor.shutdownNow();
System.out.println("Прервано " + remaining.size() + " задач");
}
7. Не создавайте потоки в loop без контроля
// ❌ ПЛОХО: создание потока на каждый элемент списка
for (String item : hugeList) {
new Thread(() -> processItem(item)).start(); // Может быть 1000000 потоков!
}
// ✅ ХОРОШО: батчинг или пул
ExecutorService executor = Executors.newFixedThreadPool(10);
for (String item : hugeList) {
executor.submit(() -> processItem(item));
}
executor.shutdown();
Java Memory Model и видимость памяти
Архитектура Java Memory Model
Java Memory Model (JMM) — это спецификация, определяющая, как потоки взаимодействуют через память и какие гарантии видимости изменений предоставляет JVM. Это не физическая архитектура, а логический договор между разработчиком и JVM.
Проблема, которую решает JMM: Современные процессоры и компиляторы агрессивно оптимизируют код для повышения производительности. Без правил JMM многопоточные программы вели бы себя непредсказуемо — изменения в одном потоке могли бы не быть видны другому, операции выполнялись бы в непредсказуемом порядке, а отладка становилась бы невозможной.
Структура памяти в многопоточной JVM
Многопоточное приложение в JVM:
┌─────────────────────────────────────────────┐
│ Main Memory (Heap) │
│ Общая память для всех потоков │
│ ┌─────────────────────────────────────┐ │
│ │ Object fields, static variables │ │
│ │ Arrays, class metadata │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
↑ ↑
│ │
read/write read/write
│ │
┌─────────┴─────────┐ ┌───────┴──────────┐
│ Thread 1 │ │ Thread 2 │
│ ┌───────────────┐ │ │ ┌──────────────┐ │
│ │ Working Memory│ │ │ │Working Memory│ │
│ │ (CPU Cache) │ │ │ │(CPU Cache) │ │
│ │ - Локальные │ │ │ │- Локальные │ │
│ │ копии │ │ │ │ копии │ │
│ │ - Регистры │ │ │ │- Регистры │ │
│ └───────────────┘ │ │ └──────────────┘ │
│ ┌───────────────┐ │ │ ┌──────────────┐ │
│ │ Stack │ │ │ │ Stack │ │
│ │ (приватный) │ │ │ │ (приватный) │ │
│ └───────────────┘ │ │ └──────────────┘ │
└───────────────────┘ └──────────────────┘
Ключевые компоненты:
Main Memory (Heap) — единственный источник истины для всех потоков:
- Все объекты и их поля хранятся здесь
- Static переменные класса
- Arrays и их элементы
- Это то, на что физически указывают все потоки
Working Memory (CPU Cache + Регистры) — локальная копия данных для каждого потока:
- L1, L2, L3 кэши процессора (очень быстрые, но маленькие)
- Регистры CPU (самые быстрые, но их всего 8-32)
- Локальные копии переменных, которые поток считал из main memory
- Каждый поток имеет свою рабочую память, изолированную от других
Stack — абсолютно приватная память потока:
- Локальные переменные методов
- Параметры методов
- Адреса возврата вызовов
- Каждому потоку выделен собственный stack (обычно 1 МБ)
Ключевая идея: Когда поток читает переменную, он может работать с локальной копией, а не с оригиналом в main memory. Если другой поток изменит оригинал, первый поток может не узнать об этом!
Почему происходит кеширование?
CPU кешируют данные для оптимизации производительности:
class CachingReason {
private static int counter = 0;
public static void main(String[] args) throws InterruptedException {
// Thread 1: читает counter и выполняет интенсивные вычисления
Thread reader = new Thread(() -> {
int sum = 0;
for (int i = 0; i < 1_000_000_000; i++) {
sum += counter; // Если каждый раз читать из main memory - медленно!
sum += complexCalculation(); // Интенсивные вычисления
}
System.out.println("Reader done: " + sum);
});
// Thread 2: пишет в counter
Thread writer = new Thread(() -> {
for (int i = 0; i < 100; i++) {
counter = i; // Пишет в своем cache
try { Thread.sleep(10); } catch (InterruptedException e) {}
}
});
reader.start();
Thread.sleep(100); // Дать reader время на кеширование
writer.start();
reader.join();
writer.join();
}
static int complexCalculation() {
int result = 0;
for (int i = 0; i < 1000; i++) {
result += Math.sin(i) * Math.cos(i);
}
return result;
}
}
Что происходит:
- Thread 1 читает
counterодин раз и закеширует значение в L1 cache (1-4 наносекунды доступ) - Далее миллиард итераций используют это закешированное значение (не идут в main memory)
- Если каждый раз идти в main memory (~40-100 наносекунд) — программа будет в 10+ раз медленнее
- Thread 2 пишет в
counterв своем cache - Без синхронизации Thread 1 может никогда не увидеть новые значения!
Это не баг CPU, это важная оптимизация. Но она создает проблемы видимости.
Проблема видимости без синхронизации
class VisibilityProblem {
private static boolean flag = false; // В main memory
public static void main(String[] args) throws InterruptedException {
// Thread 1: читает flag
Thread reader = new Thread(() -> {
// Поток может закешировать flag в своей working memory
while (!flag) {
// Может крутиться БЕСКОНЕЧНО, даже после flag = true!
// Компилятор может оптимизировать цикл до:
// if (!flag) while (true) { }
}
System.out.println("Flag is true!");
});
// Thread 2: меняет flag
Thread writer = new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {}
flag = true; // Меняет в своей working memory
System.out.println("Flag set to true");
// Изменение может НЕ быть сбрасывает в main memory сразу!
});
reader.start();
writer.start();
reader.join(5000); // Ждем 5 секунд
if (reader.isAlive()) {
System.out.println("ПРОБЛЕМА: Reader завис! Visibility issue!");
System.exit(0);
}
}
}
Анализ проблемы:
-
Инициализация:
flag = falseзаписывается в main memory и cache Thread 1 -
Кеширование: JIT компилятор видит цикл
while (!flag)и кешируетflagв регистр Thread 1. Регистр быстрее памяти в 50-100 раз -
Первое изменение: Thread 2 устанавливает
flag = trueв своем cache -
Проблема: Изменение может:
- Остаться в cache Thread 2
- Или быть записано в main memory, но Thread 1 уже не проверяет main memory
-
Результат: Thread 1 видит старое значение
flag = falseвечно!
Решение: использовать volatile, synchronized, ReentrantLock или другие механизмы синхронизации.
Happens-Before отношения
Happens-Before — это фундаментальное правило JMM, которое определяет, когда изменения памяти одного потока гарантированно видны другому потоку.
Определение: Если операция A happens-before операция B, то:
- Все изменения памяти, сделанные операцией A, видны операции B
- A гарантированно выполнится до B с точки зрения видимости памяти (физически может выполниться позже, но эффект будет как будто раньше)
Это не гарантирует временной порядок выполнения! Это гарантирует видимость и логический порядок с точки зрения памяти.
Правила Happens-Before
1. Program Order Rule
Внутри одного потока операции выполняются в порядке программы:
int x = 1; // A
int y = 2; // B
int z = x + y; // C
// Гарантии:
// A happens-before B
// B happens-before C
// => A happens-before C (транзитивность)
// Компилятор может переставить A и B? НЕТ (нет зависимости от порядка),
// но логически они выполнятся в порядке кода
Практический пример:
class ProgramOrder {
private static int result = 0;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
int x = getValue(); // A
int y = 2; // B
result = x + y; // C
// Гарантия: x будет содержать корректное значение perед C
});
t.start();
t.join();
System.out.println(result); // Содержит корректное значение
}
static int getValue() { return 42; }
}
2. Monitor Lock Rule (Synchronized)
Unlock монитора happens-before последующего lock того же монитора:
class MonitorExample {
private int counter = 0;
private final Object lock = new Object();
void increment() {
synchronized (lock) { // Lock acquire (A)
counter++; // B (все изменения памяти)
System.out.println("Incremented"); // C
} // Unlock release (D)
// Все изменения между A и D будут видны next lock
}
void read() {
synchronized (lock) { // Lock acquire (E)
// Lock E happens-after Unlock D
// => все изменения из increment видны здесь
int value = counter; // F видит counter = 1
System.out.println("Counter: " + value);
}
}
}
// Использование:
Thread t1 = new Thread(() -> {
MonitorExample obj = new MonitorExample();
obj.increment(); // Unlock happens-before
});
Thread t2 = new Thread(() -> {
MonitorExample obj = new MonitorExample();
obj.read(); // Lock happens-after increment's unlock
// Гарантированно видит counter = 1
});
Важный момент: Это работает только для одного и того же lock объекта. Разные lock'и дают разные гарантии!
// ПЛОХО: разные locks
synchronized (lock1) {
x = 42;
}
synchronized (lock2) { // Другой объект!
int y = x; // НЕ гарантирует видимость x!
}
// ХОРОШО: один lock
synchronized (lock1) {
x = 42;
}
synchronized (lock1) { // Тот же объект
int y = x; // Гарантирует видимость x
}
3. Volatile Variable Rule
Запись в volatile переменную happens-before последующего чтения той же переменной:
class VolatileExample {
private volatile boolean ready = false; // volatile!
private int data = 0;
// Thread 1
void writer() {
data = 42; // A
System.out.println("Data written"); // B
ready = true; // C (volatile write)
System.out.println("Ready flag set"); // D
}
// Thread 2
void reader() {
// Ждем флага
while (!ready) { // E (volatile read) - проверяет часто
// Спинлок
}
// E (volatile read) happens-after C (volatile write)
// => все предыдущие операции writer видны
int x = data; // F видит data = 42!
System.out.println("Read data: " + x);
}
}
// Timeline:
// [Thread 1] A: data = 42
// [Thread 1] C: ready = true (volatile write - memory barrier)
// [Thread 2] E: if (!ready) - видит true
// [Thread 2] F: x = data - видит 42
Как это работает:
volatile writeвыполняет StoreLoad barrier — гарантирует, что все store операции до этого завершены и видныvolatile readвыполняет LoadLoad и LoadStore барьеры — гарантирует правильный порядок load операций- Результат: гарантия видимости
Ограничение volatile:
// volatile гарантирует видимость ЗНАЧЕНИЯ, но не атомарность операции
private volatile int counter = 0;
void increment() {
counter++; // НЕ атомарно!
// 1. Read counter (видит 0)
// 2. Increment (0 + 1 = 1)
// 3. Write counter (пишет 1)
// Между шагами 1-3 другой поток может сделать то же!
}
// Результат двух параллельных increment() может быть 1 вместо 2!
4. Thread Start Rule
Вызов thread.start() happens-before любая операция в запущенном потоке:
class ThreadStartRule {
private static int x = 0;
private static String name = null;
public static void main(String[] args) throws InterruptedException {
x = 42; // A
name = "Test"; // B
Thread thread = new Thread(() -> {
int y = x; // C видит x = 42
String n = name; // D видит name = "Test"
System.out.println("x=" + y + ", name=" + n);
});
thread.start(); // happens-before C и D
thread.join();
}
}
Важно: happens-before только для операций до start(), а не для операций после:
Thread thread = new Thread(() -> {
int y = x; // Может не видеть x = 100, если он присвоен ПОСЛЕ start()
});
thread.start();
x = 100; // ПОСЛЕ start() — нет гарантии видимости в потоке
5. Thread Join Rule
Все операции в потоке happens-before thread.join() в вызывающем потоке:
class ThreadJoinRule {
private static int result = 0;
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
result = 42; // A
System.out.println("Worker done"); // B
});
worker.start();
worker.join(); // happens-after всех операций в worker
// После join(), гарантирует видимость всех изменений worker
int value = result; // видит result = 42
System.out.println("Result: " + value);
}
}
// Гарантии:
// A (result = 42) happens-before join()
// join() happens-before C (int value = result)
// => C видит result = 42
Практическое применение:
class WorkerPool {
private List<Integer> results = new ArrayList<>();
void processInParallel(List<Integer> data) throws InterruptedException {
List<Thread> threads = new ArrayList<>();
for (Integer item : data) {
Thread t = new Thread(() -> {
int processed = expensiveComputation(item);
results.add(processed); // A
});
threads.add(t);
t.start();
}
// Ждем все потоки
for (Thread t : threads) {
t.join(); // happens-after всех A
}
// Все результаты гарантированно добавлены
processResults(results);
}
int expensiveComputation(Integer item) { return item * 2; }
void processResults(List<Integer> results) {}
}
6. Thread Interruption Rule
Вызов thread.interrupt() happens-before обнаружение прерывания в потоке:
class InterruptionRule {
private static boolean workDone = false;
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
try {
while (!Thread.currentThread().isInterrupted()) {
workDone = true; // A
doWork();
}
} catch (InterruptedException e) {
System.out.println("Interrupted!");
}
});
worker.start();
Thread.sleep(100);
worker.interrupt(); // happens-before проверки isInterrupted()
worker.join();
// workDone гарантированно = true
}
static void doWork() {}
}
7. Finalizer Rule
Завершение конструктора happens-before начала finalize():
class FinalizerRule {
private final int id;
private final String name;
FinalizerRule() {
this.id = 42; // A
this.name = "Test"; // B
// Конструктор завершился
}
@Override
protected void finalize() {
// Конструктор завершился happens-before finalize()
// => видит id = 42, name = "Test"
System.out.println(id + ": " + name);
}
}
Примечание: Finalize() deprecated в Java 9+, используйте try-with-resources или Cleaner вместо этого.
8. Transitivity (Транзитивность)
Если A happens-before B и B happens-before C, то A happens-before C:
class TransitivityExample {
private static int x = 0;
private static volatile boolean flag = false;
public static void main(String[] args) throws InterruptedException {
// Thread 1
Thread t1 = new Thread(() -> {
x = 42; // A (program order happens-before B)
flag = true; // B (volatile write)
});
// Thread 2
Thread t2 = new Thread(() -> {
while (!flag) {} // C (volatile read) happens-after B
// A happens-before B (program order)
// B happens-before C (volatile rule)
// => A happens-before C (transitivity)
int y = x; // D видит x = 42!
});
t1.start();
t2.start();
t1.join();
t2.join();
}
}
Практический пример: Double-Checked Locking
Один из самых важных примеров для понимания happens-before:
// НЕПРАВИЛЬНО ДО JAVA 5 (без volatile)
class BrokenSingleton {
private static Singleton instance; // НЕ volatile!
public static Singleton getInstance() {
if (instance == null) { // Чтение 1 - может быть старое значение
synchronized (BrokenSingleton.class) {
if (instance == null) { // Чтение 2
instance = new Singleton(); // Запись - конструктор выполняется
// Проблема: конструктор может переставиться!
// instance может быть ВИДЕН до инициализации полей!
}
}
}
return instance; // Может вернуть частично инициализированный объект!
}
}
// Что может произойти:
// Thread 1:
// 1. allocate memory for Singleton
// 2. instance = ref to memory <- OTHER THREADS SEE THIS!
// 3. initialize fields in constructor
//
// Thread 2:
// 1. if (instance == null) { } <- видит NOT null
// 2. return instance <- НЕПРАВИЛЬНО! Поля еще не инициализированы!
// ПРАВИЛЬНО (Java 5+) - используем volatile
class WorkingSingleton {
private static volatile Singleton instance; // volatile!
public static Singleton getInstance() {
if (instance == null) { // Чтение 1
synchronized (WorkingSingleton.class) {
if (instance == null) { // Чтение 2
instance = new Singleton(); // Запись
// volatile write гарантирует:
// 1. Все предыдущие операции (конструктор) видны
// 2. instance не видим до завершения конструктора
}
}
}
return instance; // Гарантированно правильно инициализирован
}
}
// Гарантии:
// 1. synchronized блок (first check) happens-after synchronized блок (previous holder)
// 2. Конструктор Singleton() happens-before volatile write
// 3. volatile write happens-before next volatile read
// => Новые потоки видят полностью инициализированный объект
Почему volatile необходим:
// БЕЗ volatile - может быть reordering:
new Singleton()
// 1. allocate memory (A)
// 2. instance = ref (B) - может быть ЗДЕСЬ!
// 3. call constructor (C) - может быть ПОСЛЕ (B)
// 4. initialize fields (D) - может быть ПОСЛЕ (B)
// WITH volatile - гарантирует порядок:
new Singleton()
// 1. allocate memory (A)
// 2. call constructor (C)
// 3. initialize fields (D)
// [volatile write barrier - все предыдущие ops видны]
// 4. instance = ref (B) - ПОСЛЕ инициализации!
Проблемы видимости памяти в деталях
Проблема 1: Кеширование переменных в CPU регистрах
class CachingProblem {
private boolean stopped = false; // НЕ volatile!
void backgroundTask() {
new Thread(() -> {
int count = 0;
// JIT компилятор может оптимизировать так:
// 1. Прочитать stopped один раз
// 2. Положить в регистр
// 3. Переделать цикл:
// while (true) { count++; } // Бесконечный цикл!
while (!stopped) {
count++;
if (count % 1_000_000 == 0) {
System.out.println("Still running...");
}
}
System.out.println("Stopped after " + count + " iterations");
}).start();
}
void stop() {
stopped = true; // Может не быть видно потоку!
}
}
// Тест:
BackgroundTask task = new CachingProblem();
task.backgroundTask();
Thread.sleep(1000);
task.stop(); // Сообщение может никогда не появиться!
Thread.sleep(5000);
Почему это происходит:
- CPU читает
stoppedи кеширует в L1 cache (4 наносекунды для следующих чтений) - JIT видит цикл
while (!stopped)с локальным значением - JIT понимает: "stopped не меняется в этом потоке" → оптимизирует цикл
- Компилятор превращает цикл в
while (true) stopped = trueв другом потоке не видно
Решение:
private volatile boolean stopped = false; // volatile!
// volatile заставляет:
// 1. Каждый раз перечитывать из памяти (запрещает регистр кеширование)
// 2. Запрещает JIT оптимизацию цикла
// 3. Вставляет memory barrier
Проблема 2: Reordering (Переупорядочивание)
Компилятор и процессор могут менять порядок операций для оптимизации:
class ReorderingProblem {
private int x = 0;
private int y = 0;
private boolean ready = false; // НЕ volatile!
// Thread 1
void writer() {
x = 42; // Store 1
y = 100; // Store 2
ready = true; // Store 3
// CPU может переупорядочить! Возможно:
// Store 3: ready = true <- ПЕРВЫМ!
// Store 1: x = 42
// Store 2: y = 100
}
// Thread 2
void reader() {
if (ready) { // Load 1 - видит true!
int a = x; // Load 2 - может видеть x = 0 (старое значение!)
int b = y; // Load 3 - может видеть y = 0 (старое значение!)
System.out.println("x=" + a + ", y=" + b); // x=0, y=0 !!!
}
}
}
// Выполнение:
Thread t1 = new ReorderingProblem();
Thread reader = new Thread(() -> {
while (true) {
t1.reader(); // Может вывести "x=0, y=0"!
}
});
Thread writer = new Thread(() -> {
Thread.sleep(100);
t1.writer();
});
reader.start();
writer.start();
Thread.sleep(2000);
Почему CPU переупорядочивает:
- Latency hiding: Store операции медленнее, чем Load. CPU может выполнить Load раньше, чтобы начать ждать данные раньше
- Cache line optimization:
readyможет быть в другой cache line, чемxиy. Писать в соседнюю cache line быстрее - Out-of-order execution: Современные CPU могут выполнять инструкции не по порядку, если нет зависимостей
Решение:
private int x = 0;
private int y = 0;
private volatile boolean ready = false; // volatile!
// volatile write запрещает переупорядочивание:
// - Никакие store операции НЕ могут быть после volatile write
// - Никакие load операции НЕ могут быть до volatile write
// Результат: x = 42, y = 100 ГАРАНТИРОВАННО выполнятся ДО ready = true
Проблема 3: Partially Constructed Objects (Частично созданные объекты)
class PublishingProblem {
private int value;
private String name;
private byte[] data;
public PublishingProblem(int v, String n) {
value = v; // Write 1
name = n; // Write 2
data = new byte[1024]; // Write 3
}
private static PublishingProblem instance; // НЕ volatile!
public static void publish(int v, String n) {
instance = new PublishingProblem(v, n); // Write 4
// CPU может переупорядочить:
// Write 4: instance = ref <- ПЕРВЫМ!
// Write 1: value = v
// Write 2: name = n
// Write 3: data = ...
}
public static PublishingProblem getInstance() {
PublishingProblem obj = instance; // Load 1
if (obj != null) { // Load 2
// Если другой поток увидел instance != null (Load 1),
// но writes 1-3 еще не видны:
int v = obj.value; // Load 3 - может быть 0 (default value)!
String n = obj.name; // Load 4 - может быть null!
System.out.println("value=" + v + ", name=" + n);
// Частично инициализированный объект!
}
return obj;
}
}
Тест проблемы:
// Thread 1: создает и публикует
PublishingProblem.publish(42, "Test");
// Thread 2: читает
PublishingProblem obj = PublishingProblem.getInstance();
if (obj != null) {
System.out.println(obj.value); // Может быть 0 вместо 42!
}
Решение 1: volatile переменная
private static volatile PublishingProblem instance;
// volatile write гарантирует, что все поля инициализированы ДО записи ref
Решение 2: final поля (предпочтительно)
public class PublishingFixed {
private final int value; // final!
private final String name; // final!
private final byte[] data; // final!
public PublishingFixed(int v, String n) {
value = v; // Даже если переупорядочено,
name = n; // final гарантирует видимость
data = new byte[1024];
}
private static PublishingFixed instance; // НЕ нужен volatile!
public static void publish(int v, String n) {
instance = new PublishingFixed(v, n);
// final поля гарантируют видимость после конструктора
}
public static PublishingFixed getInstance() {
return instance; // Безопасно!
}
}
Почему final работает:
- Final Field Rule (implicit happening before): JMM гарантирует, что все writes в final поля видны после конструктора
- Это более безопасно, чем volatile (нет runtime overhead)
Проблема 4: Несогласованные состояния (Inconsistent State)
class InconsistentState {
private int x;
private int y;
// Инвариант: x == y (всегда)
synchronized void updateBoth(int value) {
x = value;
y = value;
// ВНУТРИ synchronized - гарантирует атомарность
}
int readX() {
return x; // НЕ synchronized!
}
int readY() {
return y; // НЕ synchronized!
}
}
// Использование:
InconsistentState state = new InconsistentState();
Thread writer = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
state.updateBoth(i);
}
});
Thread reader = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
int x = state.readX(); // Read 1
int y = state.readY(); // Read 2
if (x != y) {
System.out.println("INCONSISTENCY: x=" + x + ", y=" + y);
// Инвариант нарушен!
}
}
});
writer.start();
reader.start();
writer.join();
reader.join();
Почему это происходит:
-
Thread 1 выполнил synchronized updateBoth(5)
- x = 5
- y = 5
- Unlock выполнен
-
Thread 2 начинает читать:
- Видит x = 5 (из memory)
- Прежде чем прочитать y, Thread 1 начинает updateBoth(6)
- Thread 1 обновляет y = 6
- Thread 2 читает y = 6
- Результат: x = 5, y = 6 (несогласованное состояние!)
Решение:
// Вариант 1: synchronized на обе операции
synchronized int[] readBoth() {
return new int[] { x, y };
}
// Вариант 2: сделать переменные volatile
private volatile int x;
private volatile int y;
// Но это НЕ гарантирует консистентность при чтении
// Вариант 3: использовать AtomicReference для объекта
private final AtomicReference<State> state = new AtomicReference<>(new State(0, 0));
class State {
final int x, y;
State(int x, int y) { this.x = x; this.y = y; }
}
void updateBoth(int value) {
state.set(new State(value, value)); // атомарная публикация
}
State readBoth() {
return state.get(); // видит консистентное состояние
}
Ключевое слово volatile
volatile — это модификатор в Java, который гарантирует видимость изменений переменной между потоками и запрещает определенные оптимизации компилятора.
Гарантии volatile
1. Видимость (Visibility)
class VolatileVisibility {
private volatile int counter = 0;
void increment() {
counter++; // Это НЕ атомарно, но запись видна
}
void read() {
int value = counter; // Всегда читает свежее значение
}
}
// Механизм:
// Write counter++:
// 1. Прочитать counter из памяти
// 2. Прибавить 1
// 3. Написать обратно
// [StoreLoad barrier - сбросить cache, убедиться видимость]
//
// Read counter:
// 1. Посылается запрос в main memory (не из cache)
// 2. Получает свежее значение
2. Happens-Before гарантия
class VolatileHappensBefore {
private int data = 0;
private volatile boolean flag = false;
// Thread 1
void writer() {
data = 42; // A (happens-before B)
flag = true; // B (volatile write)
}
// Thread 2
void reader() {
if (flag) { // C (volatile read) happens-after B
int x = data; // D (happens-after A due to transitivity)
assert x == 42; // ГАРАНТИРОВАНО!
}
}
}
// Гарантии:
// A (data = 42) happens-before B (flag = true) - program order
// B (volatile write) happens-before C (volatile read) - volatile rule
// => A happens-before D (transitivity)
// => D видит data = 42
3. Запрет Reordering
class VolatileNoReordering {
private int x;
private volatile boolean flag;
private int y;
void example() {
x = 1; // A - может быть переупорядочено относительно других
flag = true; // B - volatile write
y = 2; // C - НЕ может быть ДО B (запрещено!)
// Гарантированный порядок:
// A может быть ПОСЛЕ B? НЕТ!
// C может быть ДО B? НЕТ!
// => A, B, [barrier], C
}
}
// Правила reordering запрета:
// 1. Load не может быть ДО volatile Load
// 2. Store не может быть ПОСЛЕ volatile Store
// 3. Store не может быть ПОСЛЕ volatile Load
// 4. Load не может быть ПОСЛЕ volatile Store
Когда использовать volatile
✅ Подходит: Флаги состояния
class ServiceController {
private volatile boolean running = true;
private volatile boolean shutdown = false;
void backgroundService() {
while (running) {
if (shutdown) break;
try {
doWork();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
cleanup();
}
void stop() {
running = false; // видно сразу
}
void gracefulShutdown() {
shutdown = true; // видно сразу
}
}
// Использование:
ServiceController service = new ServiceController();
Thread worker = new Thread(service::backgroundService);
worker.start();
Thread.sleep(5000);
service.stop(); // Поток прекратит работу
worker.join();
✅ Подходит: Publish-Subscribe паттерн
class EventPublisher {
private String eventData;
private volatile boolean eventReady = false;
void publishEvent(String data) {
eventData = data; // Write 1
eventReady = true; // Write 2 (volatile)
// volatile write гарантирует видимость eventData
}
void waitForEvent() {
while (!eventReady) { // Volatile read
Thread.sleep(10);
}
String data = eventData; // видит data перед ready=true
System.out.println("Event: " + data);
}
}
✅ Подходит: Статус переменные
class ProgressTracker {
private volatile int progress = 0;
private volatile String currentTask = "";
private volatile long startTime = 0;
void updateProgress(int p, String task) {
progress = p;
currentTask = task;
startTime = System.currentTimeMillis();
}
void printStatus() {
System.out.println("Progress: " + progress + "%");
System.out.println("Task: " + currentTask);
System.out.println("Time: " + (System.currentTimeMillis() - startTime) + "ms");
}
}
// Использование:
ProgressTracker tracker = new ProgressTracker();
Thread worker = new Thread(() -> {
for (int i = 0; i <= 100; i += 10) {
tracker.updateProgress(i, "Processing batch " + i);
Thread.sleep(500);
}
});
Thread monitor = new Thread(() -> {
while (true) {
tracker.printStatus();
Thread.sleep(100);
}
});
worker.start();
monitor.start();
❌ НЕ подходит: Составные операции (read-modify-write)
// ПЛОХО: volatile не защищает составную операцию
class BadCounter {
private volatile int counter = 0;
void increment() {
counter++; // НЕ АТОМАРНО!
// Расшифровка:
// 1. volatile read: int temp = counter; (~10ns)
// 2. math: temp++;
// 3. volatile write: counter = temp; (~50ns)
// RACE CONDITION между шагами!
}
void test() throws InterruptedException {
// 10 потоков, каждый делает 1000 increment'ов
// Ожидаемо: 10000
// На самом деле: ~3000-8000 (теряем обновления)
}
}
// ХОРОШО: используйте атомарные классы
class GoodCounter {
private final AtomicInteger counter = new AtomicInteger(0);
void increment() {
counter.incrementAndGet(); // Атомарно!
}
}
// ИЛИ используйте synchronized
class AlsoGood {
private int counter = 0;
synchronized void increment() {
counter++; // Атомарно
}
}
❌ НЕ подходит: Операции с зависимостями
// ПЛОХО: volatile не защищает условную логику
class BadValidation {
private volatile int value = 0;
void updateIfValid(int newValue) {
if (value < 100) { // Read
value = newValue; // Write
// RACE CONDITION между чтением и записью!
}
}
void test() throws InterruptedException {
// Два потока могут оба пройти проверку value < 100
// и оба обновить value
// Инвариант нарушен!
}
}
// ХОРОШО: используйте synchronized
class GoodValidation {
private int value = 0;
synchronized void updateIfValid(int newValue) {
if (value < 100) {
value = newValue; // Атомарно с проверкой
}
}
}
// ИЛИ используйте AtomicInteger.compareAndSet()
class BetterValidation {
private final AtomicInteger value = new AtomicInteger(0);
void updateIfValid(int newValue) {
while (true) {
int current = value.get();
if (current < 100) {
if (value.compareAndSet(current, newValue)) {
break;
}
// retry
} else {
break;
}
}
}
}
❌ НЕ подходит: Множественные связанные переменные
// ПЛОХО: несогласованное состояние
class BadPoint {
private volatile int x;
private volatile int y;
void move(int dx, int dy) {
x += dx; // Write 1
y += dy; // Write 2
// Другой поток может увидеть x = новое, y = старое
}
void printCoordinates() {
int px = x; // Read 1
int py = y; // Read 2
// Между Read 1 и Read 2 может произойти move()
System.out.println("(" + px + ", " + py + ")"); // Несогласованно!
}
}
// ХОРОШО: synchronized блок
class GoodPoint {
private int x, y;
synchronized void move(int dx, int dy) {
x += dx;
y += dy;
}
synchronized void printCoordinates() {
System.out.println("(" + x + ", " + y + ")"); // Согласованно!
}
}
// ИЛИ используйте immutable объект + AtomicReference
class BetterPoint {
static class Point {
final int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
}
private final AtomicReference<Point> point = new AtomicReference<>(new Point(0, 0));
void move(int dx, int dy) {
Point current;
do {
current = point.get();
} while (!point.compareAndSet(current, new Point(current.x + dx, current.y + dy)));
}
void printCoordinates() {
Point p = point.get();
System.out.println("(" + p.x + ", " + p.y + ")"); // Согласованно!
}
}
Производительность volatile
Операция Время (наносекунды)
────────────────────────────────────────────
обычная переменная (read) ~1 нс (из cache)
обычная переменная (write) ~1 нс (write-back)
volatile read ~10-20 нс (memory barrier)
volatile write ~50-200 нс (StoreLoad barrier - самый дорогой)
synchronized enter/exit ~100-500 нс (lock + all barriers)
AtomicInteger.get() ~10-20 нс (volatile read)
AtomicInteger.set() ~50-200 нс (volatile write)
AtomicInteger.incrementAndGet() ~50-200 нс (CAS loop)
Правило: volatile write дороже, чем volatile read. StoreLoad barrier требует сброса всего CPU cache.
Сравнение механизмов синхронизации
volatile:
- Видимость: ✅
- Атомарность: ❌
- Производительность: Высокая (~50ns write)
- Применение: Флаги, status переменные
synchronized:
- Видимость: ✅
- Атомарность: ✅
- Производительность: Средняя (~300ns)
- Применение: Критические секции, составные операции
Atomic (AtomicInteger, AtomicLong, ...):*
- Видимость: ✅
- Атомарность: ✅
- Производительность: Высокая (~100ns)
- Применение: Счетчики, cas-based операции
ReentrantLock:
- Видимость: ✅
- Атомарность: ✅
- Производительность: Средняя (~200ns)
- Применение: Гибкие критические секции, условия
final:
- Видимость: ✅
- Атомарность: ✅ (в конструкторе)
- Производительность: Максимальная (0ns)
- Применение: Immutable объекты
Memory Barriers (Барьеры памяти)
Memory Barrier — это CPU инструкция, которая гарантирует порядок операций с памятью и видимость изменений между ядрами процессора.
Архитектура памяти на уровне CPU
┌─────────────────────────────────────────────────────┐
│ Core 0 (CPU ядро) Core 1 (CPU ядро) │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Registers │ │ Registers │ │
│ └──────────────┘ └──────────────┘ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ L1 Cache │ │ L1 Cache │ │
│ │ (private) │ │ (private) │ │
│ └──────────────┘ └──────────────┘ │
│ │ │ │
│ └──────────────┬───────────┘ │
│ │ │
│ ┌─────────────────────┐ │
│ │ L2/L3 Cache │ │
│ │ (shared) │ │
│ └─────────────────────┘ │
│ │ │
├───────────────────┼───────────────────────────────┤
│ │ │
│ [Store Buffer] │ (важно!)
│ (очередь для записи в память) │
│ │ │
│ ┌────────┴────────┐ │
│ │ Main Memory │ │
│ │ (DRAM Heap) │ │
│ └─────────────────┘ │
└─────────────────────────────────────────────────────┘
Store Buffer: Это буфер между CPU ядром и main memory. CPU пишет данные в Store Buffer, а потом асинхронно они идут в memory. Это создает видимость задержек!
Типы Memory Barriers
1. LoadLoad Barrier (acquire semantics)
Операция1 (Load);
LoadLoad barrier;
Операция2 (Load);
Гарантия: Load1 завершится ДО Load2
class LoadLoadExample {
private static int x = 0;
private static int y = 0;
// Thread 1: Writer
static void write() {
x = 1;
y = 1;
}
// Thread 2: Reader без barriers (может быть проблема на слабых CPU)
static void readBad() {
int a = y; // Load y
int b = x; // Load x
// На слабых архитектурах (ARM, PowerPC) может быть:
// b = 0 (старое значение), a = 1 (новое значение)
}
// Thread 2: Reader с барьером (правильно)
static void readGood() {
int a = y; // Load y
// LoadLoad barrier (явно или через volatile)
int b = x; // Load x гарантированно видит новое значение
// Порядок вычисления a и b сохранен
}
}
2. StoreStore Barrier (release semantics)
Операция1 (Store);
StoreStore barrier;
Операция2 (Store);
Гарантия: Store1 попадет в memory ДО Store2
class StoreStoreExample {
private static int x = 0;
private static int y = 0;
// Writer БЕЗ barrier (может быть проблема)
static void writeBad() {
x = 1; // Store x
y = 1; // Store y
// На слабых архитектурах: y может быть сохранено в memory,
// а x еще в store buffer другого ядра!
}
// Writer С barrier (правильно)
static void writeGood() {
x = 1; // Store x
// StoreStore barrier
y = 1; // Store y гарантировано после x
}
}
3. LoadStore Barrier (full barrier на чтение)
Load;
LoadStore barrier;
Store;
Гарантия: Load завершится ДО Store
4. StoreLoad Barrier (самый дорогой - full barrier)
Store;
StoreLoad barrier;
Load;
Гарантия: Store попадет в main memory и будет видна ДО Load
class StoreLoadExample {
private static int x = 0;
private static boolean flag = false;
// Thread 1
static void writer() {
x = 42; // Store x
// StoreLoad barrier - дорогая операция!
flag = true; // Store flag
}
// Thread 2
static void reader() {
if (flag) { // Load flag
int value = x; // Load x - ГАРАНТИРОВАНО видит 42
}
}
}
Почему StoreLoad дорогой:
- Store Buffer Flush: Все pending stores должны быть сброшены в main memory
- Cache Invalidation: Cache других ядер должны быть инвалидированы
- Full Serialization: На некоторых архитектурах (x86) требует MFENCE инструкция
- Стоимость: ~20-50 наносекунд (vs ~1-5 для других barriers)
Barriers в volatile операциях
class VolatileBarriers {
private int x = 0;
private int y = 0;
private volatile boolean ready = false;
void writer() {
x = 42; // A
y = 100; // B
// [StoreStore barrier]
ready = true; // C (volatile write)
// [StoreLoad barrier] <- ДОРОГО!
}
void reader() {
// [LoadLoad barrier]
if (ready) { // D (volatile read)
// [LoadStore barrier]
int a = x; // E видит 42
int b = y; // F видит 100
}
}
}
// Timeline:
// Writer: A, B, [SSB], C, [SLB]
// Reader: [LLB], D, [LSB], E, F
//
// Гарантии:
// A happens-before E (переходимые)
// B happens-before F (транзитивные)
Barriers в synchronized
class SynchronizedBarriers {
private int x = 0;
private int y = 0;
synchronized void writer() {
// [MonitorEnter barrier] - LoadStore + StoreLoad
x = 42; // A
y = 100; // B
// [MonitorExit barrier] - LoadStore + StoreLoad <- ОЧЕНЬ дорого!
}
synchronized void reader() {
// [MonitorEnter barrier] - LoadStore + StoreLoad <- ОЧЕНЬ дорого!
int a = x; // E видит 42
int b = y; // F видит 100
// [MonitorExit barrier] - LoadStore + StoreLoad
}
}
// Timeline:
// Writer: [enter full barrier], A, B, [exit full barrier]
// Reader: [enter full barrier], E, F, [exit full barrier]
//
// Стоимость: ~300-500 ns за операцию (vs ~50 ns для volatile)
Практический пример: Dekker's Algorithm
Классический алгоритм для взаимного исключения без locks:
class DekkerAlgorithm {
private boolean flag1 = false;
private boolean flag2 = false;
private int turn = 0; // 1 или 2
// Thread 1
void criticalSection1() {
flag1 = true; // 1. Я хочу войти
while (flag2) { // 2. Проверь flag2
if (turn != 1) { // 3. Если очередь не моя
flag1 = false; // 4. Отступи
while (turn != 1) {} // 5. Жди очередь
flag1 = true; // 6. Попробуй снова
}
}
// CRITICAL SECTION
turn = 2;
flag1 = false;
}
// Thread 2
void criticalSection2() {
flag2 = true;
while (flag1) {
if (turn != 2) {
flag2 = false;
while (turn != 2) {}
flag2 = true;
}
}
// CRITICAL SECTION
turn = 1;
flag2 = false;
}
}
Проблема без barriers:
На слабых CPU (ARM, PowerPC) может быть reordering:
Thread 1: Thread 2:
while (flag2) { ... } while (flag1) { ... }
Check flag2 = false Check flag1 = false
flag2 = true
flag1 = true // слишком поздно!
Оба потока могут войти в critical section!
Решение: volatile barriers
class FixedDekker {
private volatile boolean flag1 = false; // volatile!
private volatile boolean flag2 = false; // volatile!
private volatile int turn = 0;
// Теперь barriers гарантируют правильный порядок
}
Reordering и оптимизации компилятора
Reordering — это переупорядочивание операций компилятором или CPU для повышения производительности.
Почему CPU переупорядочивает?
1. Out-of-Order Execution
Современные CPU могут выполнять инструкции не по порядку, если нет зависимостей:
int a = 1; // A
int b = 2; // B
int c = a + b; // C (зависит от A и B)
// Выполнение:
// A: int a = 1
// B: int b = 2
// [ждем оба результата]
// C: int c = 3
// CPU может выполнить:
// A и B параллельно (нет зависимостей)
// Потом C
2. Store Buffer Optimization
CPU может задержать Store для оптимизации:
int x = 0;
int y = 0;
void writer() {
x = 42; // Store 1 - CPU может отложить в store buffer
y = 100; // Store 2 - выполнится быстрее, если идет в другую cache line
}
// Возможно выполнение:
// Store 2: y = 100 (быстрее, потому что соседняя cache line)
// Store 1: x = 42 (задержано в store buffer)
3. Cache Line Optimization
CPU предпочитает писать в соседние cache lines:
Cache Line 1: [x = 0]
Cache Line 2: [y = 0, ready = false]
x = 42; // Cache Line 1 - требует invalidation соседних
y = 100; // Cache Line 2 - может быть быстрее
ready = true; // Cache Line 2 - соседняя, быстро
JIT Compiler Reordering
class JITReordering {
private static int x = 0;
private static int y = 0;
static void example() {
int a = 1;
int b = 2;
x = a; // A
y = b; // B
int c = x + y; // C
// JIT может переставить:
// 1. Constant folding: x = 1, y = 2, c = 3
// 2. Dead code elimination: если c не используется дальше
// 3. Loop unrolling, inlining и т.д.
}
static void anotherExample() {
for (int i = 0; i < 10; i++) {
x = i; // Store 1
y = i; // Store 2
}
// JIT может оптимизировать:
// int last = 9;
// x = last;
// y = last;
// Все итерации слиты в одну операцию!
}
}
As-If-Serial Semantics (AISO)
Важное правило: Компилятор и CPU могут делать reordering, но должны сохранять видимость последовательного выполнения внутри одного потока!
// Компилятор может переставить операции:
int x = getValue(); // A (медленно, идет в функцию)
int y = 2; // B (быстро, const)
int z = x + y; // C
// Переставленный порядок:
int y = 2; // B (выполнено первым)
int x = getValue(); // A (во время ждания результата getValue)
int z = x + y; // C
// НО: From the perspective of the thread, it looks like AISO:
// 1. getValue() completes
// 2. y gets assigned
// 3. z gets computed
// Результат z остается одинаковым!
Правила что МОЖНО переставить
// ✅ МОЖНО переставить - независимые операции
int x = 1; // A
int y = 2; // B
// B может быть ДО A? ДА (нет зависимостей)
// ❌ НЕЛЬЗЯ переставить - зависимые операции
int x = 1; // A
int y = x + 1; // B (зависит от A)
// B не может быть ДО A
// ✅ МОЖНО переставить - разные переменные
a = 1; // A
b = 2; // B
// B может быть ДО A (разные переменные)
// ❌ НЕЛЬЗЯ переставить - тот же объект
obj.field = 1; // A
int x = obj.field; // B
// B не может быть ДО A (один объект)
Volatile запрещает Reordering
class VolatileNoReordering {
private int x;
private int y;
private volatile boolean flag;
void example() {
x = 1; // A
y = 2; // B
// [StoreStore barrier - запрещает переупорядочивание]
flag = true; // C (volatile write)
// [StoreLoad barrier]
int z = 3; // D
// [LoadStore barrier запретит следующий load]
// Гарантированный порядок:
// A и B могут быть переставлены (нет зависимостей)
// НО! НИ A, НИ B не могут быть ПОСЛЕ C
// D может быть ПОСЛЕ C
// => Эффективный порядок: (A,B), [barrier], C, [barrier], D
}
}
// Практический пример
class ProducerConsumer {
private String data;
private volatile boolean ready = false;
// Producer
void produce() {
data = "Hello"; // A
// [barrier запрещает:]
// 1. data = "Hello" быть ПОСЛЕ ready = true
ready = true; // B
}
// Consumer
void consume() {
while (!ready) {} // C - спинлок на флаг
// [barrier гарантирует:]
// 1. data перечитать из memory (не из cache)
String msg = data; // D видит "Hello"
}
}
Практические сценарии и Best Practices
Сценарий 1: Ленивая инициализация (Double-Checked Locking)
// ВЕРСИЯ 1: НЕПРАВИЛЬНАЯ (до Java 5)
class BrokenSingleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // Чтение 1
synchronized (BrokenSingleton.class) {
if (instance == null) { // Чтение 2
instance = new Singleton(); // Создание + присвоение
}
}
}
return instance; // Может вернуть частично инициализированный объект!
}
}
Проблема:
Singleton constructor:
1. allocate memory (A)
2. initialize fields (B) - instance.value = 42
3. instance = reference (C) - может быть ПЕРЕСТАВЛЕНО!
Возможный порядок выполнения:
1. allocate memory (A)
2. instance = reference (C) <- ПЕРВЫМ!
3. initialize fields (B) - слишком поздно
Результат:
Thread 1: видит instance != null
Thread 1: возвращает instance
Thread 1: обращается к value
=> NullPointerException или default value (0, null)
// ВЕРСИЯ 2: ПРАВИЛЬНАЯ (Java 5+)
class WorkingSingleton {
private static volatile Singleton instance; // volatile!
public static Singleton getInstance() {
if (instance == null) { // Чтение 1
synchronized (WorkingSingleton.class) {
if (instance == null) { // Чтение 2
instance = new Singleton(); // volatile write
// volatile write запрещает перестановку!
}
}
}
return instance; // Гарантировано правильно инициализирован
}
}
// volatile гарантирует:
// 1. Конструктор завершится ДО volatile write
// 2. volatile write будет видна другим потокам
// 3. StoreLoad barrier гарантирует сброс в main memory
// ВЕРСИЯ 3: ЛУЧШЕ ВСЕГО (Holder pattern)
class BestSingleton {
private BestSingleton() {}
private static class Holder {
static final Singleton INSTANCE = new Singleton();
// final + class initialization = thread-safe без synchronized!
}
public static Singleton getInstance() {
return Holder.INSTANCE; // Class initialization happens-before access
}
}
// Почему работает:
// 1. Java гарантирует thread-safe инициализацию класса
// 2. Holder.class загружается только при первом обращении к getInstance()
// 3. final поле гарантирует видимость
// 4. Нет runtime synchronization!
Сценарий 2: Event-Driven Notification
class EventNotifier {
private String eventData;
private volatile boolean eventReady = false;
private volatile long timestamp = 0;
// Producer (может быть в многопоточной среде)
void publishEvent(String data) {
eventData = data; // Write 1
timestamp = System.currentTimeMillis(); // Write 2
// [volatile write barrier]
eventReady = true; // Write 3 - гарантирует видимость 1 и 2
}
// Consumer (может быть в многопоточной среде)
void onEventReceived() {
while (!eventReady) { // Volatile read 1
Thread.sleep(10); // Спинлок с задержкой
}
// После volatile read - все предыдущие writeы видны
String data = eventData; // Read 2 - видит данные
long ts = timestamp; // Read 3 - видит время
long elapsed = System.currentTimeMillis() - ts;
System.out.println("Event: " + data);
System.out.println("Latency: " + elapsed + "ms");
// Для следующего события
eventReady = false; // Reset для next event
}
}
// Использование:
EventNotifier notifier = new EventNotifier();
Thread producer = new Thread(() -> {
for (int i = 0; i < 10; i++) {
notifier.publishEvent("Event " + i);
Thread.sleep(500);
}
});
Thread consumer = new Thread(() -> {
for (int i = 0; i < 10; i++) {
notifier.onEventReceived();
}
});
producer.start();
consumer.start();
producer.join();
consumer.join();
Сценарий 3: Background Task с Cancellation
class CancellableBackgroundTask {
private volatile boolean cancelled = false;
private volatile boolean completed = false;
private volatile Exception lastException = null;
private final Thread workerThread;
public CancellableBackgroundTask(Runnable task) {
workerThread = new Thread(() -> {
try {
while (!cancelled) {
task.run(); // Выполняем работу
}
completed = true;
} catch (Exception e) {
lastException = e;
completed = true;
}
});
workerThread.setName("BackgroundTask");
workerThread.start();
}
public void cancel() {
cancelled = true; // volatile write - видно потоку сразу
}
public void await() throws InterruptedException {
workerThread.join(); // happens-after всего в потоке
}
public boolean isCancelled() {
return cancelled; // volatile read
}
public boolean isCompleted() {
return completed; // volatile read
}
public Exception getException() {
return lastException; // volatile read
}
}
// Использование:
Runnable heavyWork = () -> {
try {
doExpensiveComputation();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
};
CancellableBackgroundTask task = new CancellableBackgroundTask(heavyWork);
// Дать ему время на работу
Thread.sleep(5000);
// Отменить
if (!task.isCompleted()) {
task.cancel();
}
// Ждать завершения
task.await();
if (task.getException() != null) {
System.err.println("Task failed: " + task.getException());
}
Сценарий 4: Progress Tracking
class ProgressMonitor {
private volatile int percentage = 0;
private volatile String currentOperation = "";
private volatile long processedItems = 0;
private volatile long totalItems = 0;
private volatile long startTime = 0;
class Progress {
int percentage;
String operation;
long processed;
long total;
long elapsed;
Progress(int p, String op, long proc, long tot, long elapsed) {
this.percentage = p;
this.operation = op;
this.processed = proc;
this.total = tot;
this.elapsed = elapsed;
}
}
void updateProgress(int percent, String operation, long processed, long total) {
percentage = percent;
currentOperation = operation;
processedItems = processed;
totalItems = total;
}
void startTracking() {
startTime = System.currentTimeMillis();
}
Progress getProgress() {
long elapsed = System.currentTimeMillis() - startTime;
return new Progress(
percentage,
currentOperation,
processedItems,
totalItems,
elapsed
);
}
void printStatus() {
Progress p = getProgress();
System.out.printf(
"[%d%%] %s: %d/%d (%.1f items/sec) (%.1fs)%n",
p.percentage,
p.operation,
p.processed,
p.total,
(p.processed * 1000.0) / Math.max(p.elapsed, 1),
p.elapsed / 1000.0
);
}
}
// Использование:
ProgressMonitor monitor = new ProgressMonitor();
Thread worker = new Thread(() -> {
monitor.startTracking();
long totalItems = 1_000_000;
for (long i = 0; i < totalItems; i++) {
processItem(i);
if (i % 100_000 == 0) {
monitor.updateProgress(
(int)(i * 100 / totalItems),
"Processing",
i,
totalItems
);
}
}
monitor.updateProgress(100, "Done", totalItems, totalItems);
});
Thread reporter = new Thread(() -> {
while (monitor.getProgress().percentage < 100) {
monitor.printStatus();
Thread.sleep(1000);
}
monitor.printStatus();
});
worker.start();
reporter.start();
worker.join();
reporter.join();
Подводные камни и Best Practices
❌ Ошибка 1: volatile НЕ защищает составные операции
// НЕПРАВИЛЬНО
class BadCounter {
private volatile int counter = 0;
void increment() {
counter++; // RACE CONDITION!
// Расшифровка:
// 1. temp = counter; (volatile read)
// 2. temp++;
// 3. counter = temp; (volatile write)
// Race condition между 1 и 3!
}
void test() throws InterruptedException {
// 10 потоков, каждый делает 100 000 increment
// Ожидаемо: 1_000_000
// На самом деле: ~300_000-800_000 (теряем обновления!)
counter = 0;
List<Thread> threads = new ArrayList<>();
for (int t = 0; t < 10; t++) {
threads.add(new Thread(() -> {
for (int i = 0; i < 100_000; i++) {
increment();
}
}));
}
threads.forEach(Thread::start);
threads.forEach(t -> {
try { t.join(); } catch (InterruptedException e) {}
});
System.out.println("Counter: " + counter); // < 1_000_000!
}
}
// ПРАВИЛЬНО: используйте AtomicInteger
class GoodCounter {
private final AtomicInteger counter = new AtomicInteger(0);
void increment() {
counter.incrementAndGet(); // Атомарно!
}
void test() throws InterruptedException {
counter.set(0);
List<Thread> threads = new ArrayList<>();
for (int t = 0; t < 10; t++) {
threads.add(new Thread(() -> {
for (int i = 0; i < 100_000; i++) {
increment();
}
}));
}
threads.forEach(Thread::start);
threads.forEach(t -> {
try { t.join(); } catch (InterruptedException e) {}
});
System.out.println("Counter: " + counter.get()); // 1_000_000 ✅
}
}
❌ Ошибка 2: volatile НЕ защищает условную логику
// НЕПРАВИЛЬНО
class BadValidation {
private volatile int value = 0;
void updateIfValid(int newValue) {
if (value < 100) { // Read 1
// RACE CONDITION: другой поток может обновить value между Read 1 и Write
value = newValue; // Write 1
}
}
void test() throws InterruptedException {
// Два потока:
// Thread 1: updateIfValid(200) - проверяет value < 100? ДА
// Thread 2: updateIfValid(50) - проверяет value < 100? ДА
// Оба потока обновляют value!
// Инвариант нарушен (оба могли пройти проверку)
}
}
// ПРАВИЛЬНО: используйте synchronized
class GoodValidation {
private int value = 0;
synchronized void updateIfValid(int newValue) {
if (value < 100) { // Read + Write атомарно
value = newValue;
}
}
void test() throws InterruptedException {
// Гарантия: только один поток может быть внутри synchronized блока
// Инвариант сохранен
}
}
// ИЛИ используйте AtomicInteger.compareAndSet()
class BetterValidation {
private final AtomicInteger value = new AtomicInteger(0);
void updateIfValid(int newValue) {
while (true) {
int current = value.get();
if (current < 100) {
// compareAndSet атомарна: проверка + запись в одной операции
if (value.compareAndSet(current, newValue)) {
break; // Успешно
}
// retry если другой поток обновил value
} else {
break; // Не валидно
}
}
}
}
❌ Ошибка 3: volatile НЕ гарантирует консистентность многих переменных
// НЕПРАВИЛЬНО
class BadPoint {
private volatile int x;
private volatile int y;
// Инвариант: (x, y) - согласованная точка
void move(int dx, int dy) {
x += dx; // Write 1
y += dy; // Write 2
// RACE CONDITION: другой поток может прочитать x и старый y
}
void print() {
int px = x; // Read 1
int py = y; // Read 2
// Может быть: px из одного update(), py из другого update()
System.out.println("(" + px + ", " + py + ")"); // Несогласованно!
}
void test() throws InterruptedException {
// Thread 1: move(1, 0)
// Thread 2: x=1, y=0; print() -> (1, 0)
// Thread 1: y=0
// Но может быть другой порядок!
// print() может вывести (1, 0-prev) - несогласованное состояние
}
}
// ПРАВИЛЬНО: synchronized на обе операции
class GoodPoint {
private int x, y;
synchronized void move(int dx, int dy) {
x += dx;
y += dy; // Атомарно с x
}
synchronized void print() {
System.out.println("(" + x + ", " + y + ")"); // Согласованно!
}
}
// ИЛИ используйте immutable Point + AtomicReference
class BetterPoint {
static class Point {
final int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
}
private final AtomicReference<Point> point = new AtomicReference<>(new Point(0, 0));
void move(int dx, int dy) {
Point current;
do {
current = point.get();
Point next = new Point(current.x + dx, current.y + dy);
// compareAndSet атомарна
} while (!point.compareAndSet(current, next));
}
void print() {
Point p = point.get(); // Согласованная точка
System.out.println("(" + p.x + ", " + p.y + ")");
}
}
✅ Best Practice 1: используйте final для immutable объектов
// ХОРОШО: final гарантирует видимость без runtime overhead
class ImmutableConfiguration {
private final String host;
private final int port;
private final long timeout;
private final boolean secure;
public ImmutableConfiguration(String host, int port, long timeout, boolean secure) {
this.host = host;
this.port = port;
this.timeout = timeout;
this.secure = secure;
// final fields гарантируют видимость после конструктора
}
// Нет setters!
public String getHost() { return host; }
public int getPort() { return port; }
public long getTimeout() { return timeout; }
public boolean isSecure() { return secure; }
}
// Использование:
private final ImmutableConfiguration config = new ImmutableConfiguration(...);
// Все поля видны всем потокам! Нет synchronized, нет volatile!
✅ Best Practice 2: минимизируйте synchronized блоки
// ПЛОХО: большой synchronized блок - много операций заблокировано
class BadService {
private List<Item> items = new ArrayList<>();
synchronized void processItems() {
for (Item item : items) {
Item processed = expensiveOperation(item); // Долгая операция!
item.setResult(processed);
}
}
private Item expensiveOperation(Item item) {
// Может быть медленной (IO, CPU-intensive, etc.)
return item;
}
}
// ХОРОШО: small synchronized блоки - минимум блокировок
class GoodService {
private final List<Item> items = new ArrayList<>();
void processItems() {
// Получить копию без блокировок долгой операции
List<Item> itemsToProcess;
synchronized (items) {
itemsToProcess = new ArrayList<>(items);
}
// Долгая операция БЕЗ блокировок
for (Item item : itemsToProcess) {
Item processed = expensiveOperation(item);
item.setResult(processed);
}
// Обновить результаты
synchronized (items) {
// Обновить в основном списке
}
}
private Item expensiveOperation(Item item) {
return item;
}
}
✅ Best Practice 3: используйте правильный инструмент для разной задачи
Задача Инструмент Производительность
─────────────────────────────────────────────────────────────────────────────
Простой флаг (read-mostly) volatile boolean ⭐⭐⭐⭐⭐ (~10ns)
Счетчик AtomicInteger ⭐⭐⭐⭐ (~100ns)
Простой lock synchronized ⭐⭐⭐ (~300ns)
Сложный lock (ReadWriteLock) ReentrantReadWriteLock ⭐⭐⭐ (~200ns read)
Immutable объект final fields ⭐⭐⭐⭐⭐ (0ns!)
Thread-safe коллекция CopyOnWriteArrayList ⭐⭐⭐ (write ~300ns)
✅ Best Practice 4: document thread safety
/**
* This class is NOT thread-safe!
* External synchronization required for safe concurrent access.
*/
class NonThreadSafeCounter {
private int count = 0;
public void increment() {
count++; // race condition
}
public int getCount() {
return count; // may see stale value
}
}
/**
* This class is thread-safe.
* - Uses synchronized for atomic updates
* - All reads see latest values
* - Safe for concurrent access
*/
class ThreadSafeCounter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
/**
* This class is thread-safe for initialization only.
* - Safe to publish after construction
* - All fields are final
* - Use AtomicReference if updates needed
*/
class ThreadSafeImmutable {
private final int value;
private final String name;
public ThreadSafeImmutable(int value, String name) {
this.value = value;
this.name = name;
}
public int getValue() { return value; }
public String getName() { return name; }
}
Заключение
Java Memory Model решает сложную задачу: обеспечить безопасность многопоточных программ при сохранении высокой производительности оптимизаций компилятора и CPU.
Ключевые концепции:
- Структура памяти: Main Memory (heap) + Working Memory (cache) каждого потока
- Видимость: Без синхронизации изменения в одном потоке могут не быть видны другому
- Happens-Before: Правила, гарантирующие видимость между потоками
- volatile: Гарантирует видимость отдельной переменной без full lock
- Memory Barriers: CPU инструкции, обеспечивающие порядок и видимость
- Reordering: Компилятор и CPU переупорядочивают операции для оптимизации
Правила использования:
volatileдля флагов и статус-переменныхsynchronizedдля составных операций и критических секцийAtomicInteger/Longдля счетчиковfinalдля immutable объектовReentrantLockдля сложной синхронизации- Thread-safe коллекции для многопоточного доступа
Помните: неправильная синхронизация может быть очень трудна для отладки, так как проблемы видимости проявляются непредсказуемо и трудно воспроизводятся.
Синхронизация и блокировки
Введение: Проблема гонки данных
Когда несколько потоков обращаются к одним и тем же данным, возникает проблема гонки данных (race condition). Рассмотрим простой пример:
class Counter {
private int count = 0;
public void increment() {
count++; // НЕБЕЗОПАСНО при многопоточности
}
}
Хотя count++ выглядит как одна операция, на уровне JVM это три отдельных шага:
1. Прочитать значение count (например, 5)
2. Прибавить 1 (получить 6)
3. Записать результат (6)
Проблема: Если два потока выполняют эту операцию одновременно:
Начальное значение count = 0
Thread 1:
1. Прочитать count = 0
2. Прибавить 1 → 1
3. Записать count = 1
Thread 2 (одновременно):
1. Прочитать count = 0 ← Прочитал ДО того, как Thread 1 записал
2. Прибавить 1 → 1
3. Записать count = 1
Результат: count = 1 (должно быть 2!)
Синхронизация решает эту проблему, гарантируя, что только один поток может выполнять критическую секцию одновременно.
Ключевое слово synchronized
synchronized — это основной механизм синхронизации в Java, который обеспечивает:
- Взаимное исключение (Mutual Exclusion) — только один поток в критической секции
- Видимость памяти (Memory Visibility) — изменения видны другим потокам
- Упорядочение операций (Happens-Before) — гарантирует порядок операций
Как работает synchronized под капотом
Каждый объект в Java имеет встроенный монитор (intrinsic lock):
┌─────────────────────────────────────────┐
│ Java Object Layout │
├─────────────────────────────────────────┤
│ Mark Word (8 bytes) │
│ ├─ Lock state: UNLOCKED/LOCKED/... │
│ ├─ Biased thread ID │
│ └─ Age for GC │
├─────────────────────────────────────────┤
│ Klass pointer (8 bytes) │
│ └─ Указатель на класс │
├─────────────────────────────────────────┤
│ Field data │
└─────────────────────────────────────────┘
Монитор хранится в Mark Word объекта
Когда поток выполняет synchronized блок:
┌─────────────────────────────┐
│ Enter synchronized │
│ ↓ │
│ Попытка захватить монитор │
│ ↓ │
│ ┌─────────────────────┐ │
│ │ Монитор свободен? │ │
│ └────────┬────────────┘ │
│ ДА ↓ ↓ НЕТ │
│ Захват Очередь │
│ ↓ BLOCKED │
│ Вход в ↓ │
│ Критическую (ждет) │
│ секцию ↓ │
│ Когда монитор │
│ освобождается │
│ → вход в │
│ критическую │
│ секцию │
│ ↓ │
│ Выполнение кода │
│ ↓ │
│ Освобождение монитора │
│ ↓ │
│ Exit synchronized │
└─────────────────────────────┘
Synchronized методы
Synchronized метод экземпляра — блокировка на this:
class Counter {
private int count = 0;
public synchronized void increment() {
count++; // Блокировка на объекте Counter
}
public synchronized int getCount() {
return count;
}
}
// Эквивалентно:
class Counter {
private int count = 0;
public void increment() {
synchronized (this) { // Явная блокировка на this
count++;
}
}
public int getCount() {
synchronized (this) {
return count;
}
}
}
Важно: Все synchronized методы экземпляра используют один и тот же монитор (this). Это означает:
Counter c = new Counter();
// В Thread-1
c.increment(); // Захватывает this
// В Thread-2 (одновременно)
c.getCount(); // Ждет! Используется тот же монитор (this)
// Это хорошо, так как видимость count гарантирована
Synchronized статический метод — блокировка на классе:
class Counter {
private static int globalCount = 0;
public static synchronized void incrementGlobal() {
globalCount++; // Блокировка на Counter.class
}
}
// Эквивалентно:
class Counter {
private static int globalCount = 0;
public static void incrementGlobal() {
synchronized (Counter.class) { // Явная блокировка на классе
globalCount++;
}
}
}
Ключевая разница: Статический synchronized метод блокирует класс, а не экземпляр:
Counter c1 = new Counter();
Counter c2 = new Counter();
// Thread-1
c1.increment(); // Блокирует c1 (this)
// Thread-2 (одновременно)
c2.increment(); // ОК! Это другой объект, другой монитор
// Потоки могут выполняться одновременно
// Thread-3
Counter.incrementGlobal(); // Блокирует Counter.class
// Ждет, если другой поток уже захватил
// Thread-4 (одновременно)
c1.increment(); // ОК! Это экземплярный метод
// incrementGlobal() использует другой монитор (class)
Synchronized блоки: гранулярная синхронизация
Synchronized блоки позволяют синхронизировать только критическую часть кода:
class BankAccount {
private double balance = 0;
private final Object lockObject = new Object(); // Монитор
private List<String> transactions = new ArrayList<>();
public void deposit(double amount) {
// Несинхронизированная часть (может быть долго)
double fee = calculateFee(amount);
// Критическая секция (минимальная)
synchronized (lockObject) {
balance += (amount - fee);
transactions.add("Deposit: " + amount);
}
// Несинхронизированная часть
sendConfirmation();
}
private double calculateFee(double amount) {
// Долгая операция
Thread.sleep(100);
return amount * 0.01;
}
private void sendConfirmation() {
// Долгая операция
Thread.sleep(100);
}
}
Почему это лучше:
Полный synchronized метод:
┌────────────────────────────────────┐
│ synchronized void deposit() │
│ ├─ Блокировка захвачена (0%) │
│ ├─ calculateFee() (50% времени) │ ← Другие потоки ждут!
│ ├─ balance += amount (1%) │
│ ├─ transactions.add() (1%) │
│ └─ sendConfirmation() (48%) │ ← Другие потоки ждут!
└────────────────────────────────────┘
Средняя задержка для других потоков: ~100 единиц времени
Synchronized блок:
┌────────────────────────────────────┐
│ void deposit() │
│ ├─ calculateFee() БЕЗ блокировки │ ← Параллельно!
│ ├─ synchronized { │
│ │ ├─ balance += amount (1%) │ ← Только это блокирует
│ │ └─ transactions.add() (1%) │
│ │ Блокировка = 2% │
│ ├─ } │
│ └─ sendConfirmation() БЕЗ │ ← Параллельно!
└────────────────────────────────────┘
Средняя задержка для других потоков: ~2 единиц времени
Проблема: задержка одного объекта блокирует все методы
class BadDesign {
private Object lock = new Object();
private int readCount = 0;
private int writeCount = 0;
// Методы с чтением
public void read() {
synchronized (lock) { // Блокирует весь lock
readCount++;
Thread.sleep(1000); // Долго читаем
}
}
public void anotherRead() {
synchronized (lock) { // ЖДЕТ! Другой поток читает
readCount++;
Thread.sleep(1000);
}
}
// Методы с записью
public void write() {
synchronized (lock) { // Блокирует весь lock
writeCount++;
}
}
}
// Проблема:
// Thread-1: read() → захватил lock → спит 1 сек
// Thread-2: anotherRead() → ждет lock (нужно только читать!)
// Thread-3: write() → ждет lock (нужно писать!)
Решение: Разные блокировки для разных операций
class GoodDesign {
private final Object readLock = new Object();
private final Object writeLock = new Object();
private int readCount = 0;
private int writeCount = 0;
public void read() {
synchronized (readLock) {
readCount++;
Thread.sleep(1000);
}
}
public void anotherRead() {
synchronized (readLock) {
readCount++;
Thread.sleep(1000);
}
}
public void write() {
synchronized (writeLock) { // ОК! Другая блокировка
writeCount++;
}
}
}
// Теперь:
// Thread-1: read() → захватил readLock
// Thread-2: anotherRead() → захватил другой readLock → ЖДЕТ (правильно)
// Thread-3: write() → захватил writeLock → ОК! (выполняется параллельно)
Реентерабельность synchronized
Реентерабельность — это способность одного потока повторно захватить блокировку, которую он уже держит:
class ReentrantExample {
private int count = 0;
public synchronized void methodA() {
System.out.println("Entering methodA");
count++;
// Поток уже держит монитор this
// Может ли он захватить его снова?
methodB(); // ДА, это безопасно (synchronized на this)
System.out.println("Exiting methodA");
}
public synchronized void methodB() {
System.out.println("Entering methodB");
count++;
System.out.println("Exiting methodB");
}
}
Внутри JVM реентерабельность работает через счетчик вложенности:
Начало: монитор свободен
Thread-1 вызывает methodA():
├─ synchronized (this):
│ ├─ Захватить монитор this
│ ├─ Lock state: LOCKED
│ ├─ Owner: Thread-1
│ ├─ Reentrant count: 1
│ └─
│ ├─ count++
│ ├─ Вызов methodB():
│ │ ├─ synchronized (this): ← Повторный вход
│ │ ├─ Монитор уже захвачен Thread-1
│ │ ├─ Просто инкрементируем счетчик
│ │ ├─ Reentrant count: 2 ← Увеличили счетчик
│ │ │
│ │ └─ count++
│ │
│ ├─ Выход из methodB():
│ ├─ Reentrant count: 2 → 1 ← Уменьшили счетчик
│ ├─ Монитор все еще захвачен (count > 0)
│ │
│ └─ Выход из methodA():
│ ├─ Reentrant count: 1 → 0 ← Уменьшили на 1
│ ├─ Реентрант count == 0
│ └─ Освобождаем монитор
│ ├─ Lock state: UNLOCKED
│ └─ Другие потоки теперь могут захватить
Результат: count = 2 (оба инкремента выполнились)
Без реентерабельности был бы deadlock:
// Если бы synchronized НЕ был реентерабельным:
Thread-1 вызывает methodA():
├─ synchronized (this):
│ ├─ Захватить монитор this → OK
│ ├─ count++
│ ├─ Вызов methodB():
│ │ ├─ synchronized (this): ← Попытка захватить ТО ЖЕ, что уже захвачено
│ │ ├─ Монитор уже захвачен Thread-1
│ │ ├─ Поток ждет → DEADLOCK! (сам себя заблокировал)
│ │ └─ Никогда не выйдет
│ └─
Программа зависает!
Пример: многопоточный счетчик
class SharedCounter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int get() {
return count;
}
public synchronized void reset() {
count = 0;
}
}
public class Main {
public static void main(String[] args) throws InterruptedException {
SharedCounter counter = new SharedCounter();
// Thread 1
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
}, "Thread-1");
// Thread 2
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
}, "Thread-2");
// Thread 3: мониторим значение
Thread t3 = new Thread(() -> {
for (int i = 0; i < 20; i++) {
System.out.println("Count: " + counter.get());
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}, "Monitor");
t1.start();
t2.start();
t3.start();
t1.join();
t2.join();
t3.join();
System.out.println("Final count: " + counter.get());
// Вывод всегда: Final count: 2000 (благодаря synchronized)
}
}
Без synchronized результат был бы непредсказуемым (< 2000)
Проблемы synchronized
1. Нет tryLock (попытка захватить)
// С synchronized нельзя проверить, захвачена ли блокировка
class Account {
private double balance;
// Нельзя написать:
// if (synchronized.tryLock()) { ... }
}
// Проблема: если монитор занят, мы ОБЯЗАНЫ ждать
2. Нет таймаута
// С synchronized нельзя задать максимальное время ожидания
// Можем ждать бесконечно
3. Нет справедливости (fairness)
// synchronized может быть несправедлив
// Один и тот же поток может повторно захватить lock,
// хотя другие потоки давно ждут
class UnfairLock {
public synchronized void method() {
// Thread-A может захватить снова, пока Thread-B ждет
}
}
4. Сложно отладить
// Сложно увидеть, где конкретно захвачена блокировка
// Сложно анализировать deadlock
Интерфейс Lock
Lock из пакета java.util.concurrent.locks решает проблемы synchronized:
public interface Lock {
void lock(); // Блокирующее получение
void lockInterruptibly() throws InterruptedException; // С прерыванием
boolean tryLock(); // Попытка (non-blocking)
boolean tryLock(long time, TimeUnit unit) throws InterruptedException; // С таймаутом
void unlock(); // Освобождение
Condition newCondition(); // Condition (wait/notify)
}
Базовое использование ReentrantLock
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
class Counter {
private int count = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock(); // Захватить lock
try {
count++;
} finally {
lock.unlock(); // ВСЕГДА в finally!
}
}
public int getCount() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
}
КРИТИЧНО: Если не вызвать unlock(), другие потоки будут ждать вечно!
// ПЛОХО:
lock.lock();
doSomething();
lock.unlock(); // Если doSomething() выбросит exception, это не выполнится
// ХОРОШО:
lock.lock();
try {
doSomething();
} finally {
lock.unlock(); // Выполнится всегда, даже если exception
}
tryLock() — попытка без блокировки
class AtmMachine {
private double balance = 1000;
private final Lock lock = new ReentrantLock();
public boolean withdraw(double amount) {
// Пытаемся захватить lock БЕЗ ожидания
if (lock.tryLock()) {
try {
if (balance >= amount) {
balance -= amount;
System.out.println("Withdrawal successful: " + amount);
return true;
} else {
System.out.println("Insufficient funds");
return false;
}
} finally {
lock.unlock();
}
} else {
// Lock занят, не ждем
System.out.println("ATM busy, try again later");
return false;
}
}
}
// Использование:
AtmMachine atm = new AtmMachine();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5; i++) {
atm.withdraw(100);
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5; i++) {
atm.withdraw(100);
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t1.start();
t2.start();
// Вывод (примерно):
// Withdrawal successful: 100
// Withdrawal successful: 100
// ATM busy, try again later
// ATM busy, try again later
tryLock(timeout) — попытка с таймаутом
import java.util.concurrent.TimeUnit;
class TimeoutLockExample {
private final Lock lock = new ReentrantLock();
private int sharedData = 0;
public void performOperation() throws InterruptedException {
System.out.println(Thread.currentThread().getName() +
": Trying to acquire lock...");
// Пытаемся захватить lock в течение 3 секунд
if (lock.tryLock(3, TimeUnit.SECONDS)) {
try {
System.out.println(Thread.currentThread().getName() +
": Lock acquired! Working...");
sharedData++;
// Имитируем работу
Thread.sleep(1000);
System.out.println(Thread.currentThread().getName() +
": Finished. Data = " + sharedData);
} finally {
lock.unlock();
System.out.println(Thread.currentThread().getName() +
": Lock released");
}
} else {
System.out.println(Thread.currentThread().getName() +
": Could NOT acquire lock within 3 seconds. Giving up!");
}
}
}
// Использование:
TimeoutLockExample example = new TimeoutLockExample();
Thread t1 = new Thread(() -> {
try {
example.performOperation();
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "Thread-1");
Thread t2 = new Thread(() -> {
try {
// Даем Thread-1 немного времени на запуск
Thread.sleep(500);
example.performOperation();
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "Thread-2");
t1.start();
t2.start();
// Вывод (примерно):
// Thread-1: Trying to acquire lock...
// Thread-1: Lock acquired! Working...
// Thread-2: Trying to acquire lock...
// Thread-2: Could NOT acquire lock within 3 seconds. Giving up!
// Thread-1: Finished. Data = 1
// Thread-1: Lock released
lockInterruptibly() — прерываемая блокировка
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
class InterruptibleLockDemo {
private final Lock lock = new ReentrantLock();
private int counter = 0;
public void performLongTask() throws InterruptedException {
System.out.println(Thread.currentThread().getName() +
": Waiting for lock (interruptible)...");
// Можно прервать ожидание через interrupt()
lock.lockInterruptibly();
try {
System.out.println(Thread.currentThread().getName() +
": Got lock! Working...");
counter++;
// Долгая работа
Thread.sleep(5000);
System.out.println(Thread.currentThread().getName() +
": Finished. Counter = " + counter);
} finally {
lock.unlock();
System.out.println(Thread.currentThread().getName() +
": Lock released");
}
}
}
// Использование:
InterruptibleLockDemo demo = new InterruptibleLockDemo();
Thread t1 = new Thread(() -> {
try {
demo.performLongTask();
} catch (InterruptedException e) {
System.out.println(Thread.currentThread().getName() +
": Interrupted! (был прерван во время работы)");
Thread.currentThread().interrupt();
}
}, "Thread-1");
Thread t2 = new Thread(() -> {
try {
Thread.sleep(1000); // Даем Thread-1 время на запуск
demo.performLongTask();
} catch (InterruptedException e) {
System.out.println(Thread.currentThread().getName() +
": Interrupted! (был прерван во время ожидания lock)");
Thread.currentThread().interrupt();
}
}, "Thread-2");
t1.start();
t2.start();
Thread.sleep(2500); // Даем потокам время
System.out.println("\n--- Прерываем Thread-2 ---");
t2.interrupt(); // ПРЕРЫВАЕМ Thread-2
t1.join();
t2.join();
// Вывод (примерно):
// Thread-1: Waiting for lock (interruptible)...
// Thread-1: Got lock! Working...
// Thread-2: Waiting for lock (interruptible)...
//
// --- Прерываем Thread-2 ---
// Thread-2: Interrupted! (был прерван во время ожидания lock)
// Thread-1: Finished. Counter = 1
// Thread-1: Lock released
ReentrantLock: справедливость (Fairness)
По умолчанию ReentrantLock несправедлив (unfair):
// Unfair lock (по умолчанию)
Lock unfairLock = new ReentrantLock();
// Fair lock
Lock fairLock = new ReentrantLock(true);
Разница:
UNFAIR (по умолчанию):
┌─────────────────────┐
│ Lock свободен │
└─────────────────────┘
↓
Новый поток может "проскочить"
очередь ждущих потоков
Thread-1 ждет
Thread-2 ждет
Thread-3 ждет
Thread-4 (новый!) может захватить lock ПЕРВЫМ
↑ Несправедливо!
Преимущество: высокий throughput
FAIR (справедливый):
┌─────────────────────┐
│ Lock свободен │
└─────────────────────┘
↓
Потоки получают lock в порядке запроса (FIFO)
Thread-1 ждет → получит первый
Thread-2 ждет → получит второй
Thread-3 ждет → получит третий
Thread-4 (новый) → встанет в конец очереди
Преимущество: справедливость (нет starvation)
Недостаток: ниже throughput, выше latency
Пример, где fairness имеет значение:
class DocumentProcessor {
private final ReentrantLock unfairLock = new ReentrantLock(false);
private final ReentrantLock fairLock = new ReentrantLock(true);
// С unfair lock: интерактивные пользователи могут быть голодны
public void processDocument(String docId, boolean isFair)
throws InterruptedException {
Lock lock = isFair ? fairLock : unfairLock;
lock.lock();
try {
// Обработка документа
System.out.println(Thread.currentThread().getName() +
" processing document: " + docId);
Thread.sleep(100);
} finally {
lock.unlock();
}
}
}
// Сценарий:
// 1. Быстрые пользователи (новые запросы) часто "проскакивают" очередь
// 2. Медленные пользователи (дождались захвата lock) ждут еще дольше
// 3. С unfair: "голодание" медленных пользователей
// С fair: все получают справедливый шанс
ReadWriteLock и ReentrantReadWriteLock
Проблема: synchronized блокирует все
class CacheWithSynchronized {
private Map<String, String> cache = new HashMap<>();
// Получение значения
public synchronized String get(String key) {
// Даже если много потоков только ЧИТАЮТ (не конкурируют)
// Они блокируют друг друга!
return cache.get(key);
}
// Сохранение значения
public synchronized void put(String key, String value) {
cache.put(key, value);
}
}
// Проблема: много читателей блокируют друг друга
// Никто не хочет писать одновременно, но читатели конкурируют
Решение: ReadWriteLock
import java.util.concurrent.locks.*;
class CacheWithReadWriteLock {
private final Map<String, String> cache = new HashMap<>();
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
// Получение значения (может быть много одновременно)
public String get(String key) {
rwLock.readLock().lock();
try {
System.out.println(Thread.currentThread().getName() +
" reading (readers: " +
((ReentrantReadWriteLock)rwLock)
.getReadLockCount() + ")");
return cache.get(key);
} finally {
rwLock.readLock().unlock();
}
}
// Сохранение значения (только один одновременно)
public void put(String key, String value) {
rwLock.writeLock().lock();
try {
System.out.println(Thread.currentThread().getName() +
" writing");
cache.put(key, value);
} finally {
rwLock.writeLock().unlock();
}
}
}
// Использование:
CacheWithReadWriteLock cache = new CacheWithReadWriteLock();
// Заполнили кэш
cache.put("user:1", "Alice");
cache.put("user:2", "Bob");
// Много читателей (параллельно)
for (int i = 0; i < 5; i++) {
new Thread(() -> {
for (int j = 0; j < 3; j++) {
cache.get("user:1");
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}, "Reader-" + i).start();
}
// Одиночные писатели (блокируют всех)
for (int i = 0; i < 2; i++) {
new Thread(() -> {
cache.put("user:" + (i + 100), "NewUser");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Writer-" + i).start();
}
// Вывод показывает, что читатели параллельны:
// Reader-0 reading (readers: 1)
// Reader-1 reading (readers: 2)
// Reader-2 reading (readers: 3)
// Writer-0 writing (ждал, пока читатели закончат)
Правила ReadWriteLock
Состояния ReadWriteLock:
✅ ALLOWED:
├─ Много читателей + 0 писателей
├─ 0 читателей + 1 писатель
└─ 0 читателей + 0 писателей (свободно)
❌ NOT ALLOWED:
├─ Писатель + читатели
├─ Писатель + писатель
└─ Помните: писатель → монопольный доступ
Когда НЕ использовать ReadWriteLock
class BadReadWriteLockUsage {
private int counter = 0;
private final ReadWriteLock lock = new ReentrantReadWriteLock();
// ПЛОХО: много писателей
public void increment() {
lock.writeLock().lock(); // Блокирует всех
try {
counter++; // Только один поток одновременно
} finally {
lock.writeLock().unlock();
}
}
public int get() {
lock.readLock().lock();
try {
return counter;
} finally {
lock.readLock().unlock();
}
}
}
// Проблема: если increment() вызывается много
// → readWriteLock дает мало выигрыша
// → обычный Lock был бы проще и быстрее
// Метрика: используйте ReadWriteLock если:
// (reads / writes) > 3 или даже > 1
StampedLock: оптимистическое чтение
StampedLock (Java 8+) — это расширенная версия ReadWriteLock с оптимистическим чтением:
import java.util.concurrent.locks.StampedLock;
class Point {
private double x, y;
private final StampedLock lock = new StampedLock();
// Трёхуровневая схема доступа:
// 1. ОПТИМИСТИЧЕСКОЕ ЧТЕНИЕ (самое быстрое)
// Не захватывает lock вообще!
public double distanceFromOrigin() {
long stamp = lock.tryOptimisticRead(); // Получить "печать" времени
double currentX = x; // Читаем БЕЗ блокировки
double currentY = y;
if (!lock.validate(stamp)) { // Проверяем: была ли запись?
// Была запись → переходим к пессимистическому чтению
stamp = lock.readLock();
try {
currentX = x;
currentY = y;
} finally {
lock.unlockRead(stamp);
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
// 2. ПЕССИМИСТИЧЕСКОЕ ЧТЕНИЕ (среднее)
// Блокирует писателей
public synchronized double getX() {
long stamp = lock.readLock();
try {
return x;
} finally {
lock.unlockRead(stamp);
}
}
// 3. ЗАПИСЬ (самое медленное)
// Эксклюзивный доступ
public void move(double deltaX, double deltaY) {
long stamp = lock.writeLock();
try {
x += deltaX;
y += deltaY;
} finally {
lock.unlockWrite(stamp);
}
}
}
Как работает оптимистическое чтение
tryOptimisticRead():
├─ Версия до чтения: v1 = 42
├─ Вернуть stamp = v1
└─ БЕЗ захвата lock!
Пока мы читаем x, y:
├─ Другие потоки могут писать!
└─ (это может быть проблемой)
После чтения: validate(stamp)
├─ Версия после чтения: v2
├─ Если v1 == v2: данные не менялись
│ ├─ ✅ Данные корректны
│ └─ Нашу работу можно использовать
└─ Если v1 != v2: данные менялись
├─ ❌ Данные могут быть невалидными
└─ Нужно перечитать с lock
Пример: что может пойти не так
class ProblematicOptimisticRead {
private int x = 0, y = 0;
private final StampedLock lock = new StampedLock();
// НЕПРАВИЛЬНО: не проверяем validity
public int getSum_WRONG() {
long stamp = lock.tryOptimisticRead();
int sumX = x;
int sumY = y; // ← Если здесь поток записи обновит x и y?
// validate() НЕ вызывается → используем невалидные данные!
return sumX + sumY;
}
// ПРАВИЛЬНО: проверяем validity
public int getSum_CORRECT() {
long stamp = lock.tryOptimisticRead();
int sumX = x;
int sumY = y;
if (!lock.validate(stamp)) { // Проверяем
// Данные менялись → переходим к пессимистическому чтению
stamp = lock.readLock();
try {
sumX = x;
sumY = y; // Теперь это безопасно
} finally {
lock.unlockRead(stamp);
}
}
return sumX + sumY;
}
public void update(int newX, int newY) {
long stamp = lock.writeLock();
try {
x = newX;
y = newY;
} finally {
lock.unlockWrite(stamp);
}
}
}
Производительность StampedLock
Бенчмарк: чтение vs запись
Сценарий: 95% чтение, 5% запись
synchronized:
└─ throughput: 100 (baseline)
ReentrantLock:
└─ throughput: 150
ReentrantReadWriteLock:
└─ throughput: 300
StampedLock (оптимистическое):
└─ throughput: 600+
StampedLock выигрывает благодаря оптимистическому чтению
(большинство чтений не требует lock вообще)
ОГРАНИЧЕНИЕ: StampedLock НЕ реентерабельный
StampedLock lock = new StampedLock();
void methodA() {
long stamp = lock.writeLock();
try {
methodB(); // DEADLOCK!
} finally {
lock.unlockWrite(stamp);
}
}
void methodB() {
long stamp = lock.writeLock(); // Блокируется → deadlock
try {
// ...
} finally {
lock.unlockWrite(stamp);
}
}
// Поток уже держит write lock
// Пытается захватить его еще раз
// → Deadlock!
Condition: wait/notify с Lock
Condition — это аналог wait()/notify(), но для Lock API:
// С synchronized (старый способ):
public synchronized void waitForSignal() throws InterruptedException {
while (condition) {
wait(); // Связана с монитором this
}
}
public synchronized void sendSignal() {
notifyAll(); // Будит всех, кто ждет на this
}
// С Lock (новый способ):
public void waitForSignal() throws InterruptedException {
lock.lock();
try {
while (condition) {
condition.await(); // Связана с этой Condition
}
} finally {
lock.unlock();
}
}
public void sendSignal() {
lock.lock();
try {
condition.signalAll(); // Будит всех, кто ждет на этой Condition
} finally {
lock.unlock();
}
}
Пример: Producer-Consumer с Condition
import java.util.concurrent.locks.*;
import java.util.*;
class BoundedBuffer<T> {
private final Queue<T> buffer;
private final int capacity;
private final Lock lock = new ReentrantLock();
private final Condition notEmpty = lock.newCondition(); // Буфер не пустой
private final Condition notFull = lock.newCondition(); // Буфер не полный
public BoundedBuffer(int capacity) {
this.capacity = capacity;
this.buffer = new LinkedList<>();
}
// Producer: добавить элемент
public void put(T item) throws InterruptedException {
lock.lock();
try {
// Ждем, пока буфер не будет полным
while (buffer.size() == capacity) {
System.out.println("Buffer FULL, producer waiting...");
notFull.await(); // Ждем, пока освободится место
}
buffer.add(item);
System.out.println("Produced: " + item +
" (size: " + buffer.size() + ")");
// Будим потребителя: буфер не пустой!
notEmpty.signal();
} finally {
lock.unlock();
}
}
// Consumer: получить элемент
public T take() throws InterruptedException {
lock.lock();
try {
// Ждем, пока буфер не будет пуст
while (buffer.isEmpty()) {
System.out.println("Buffer EMPTY, consumer waiting...");
notEmpty.await(); // Ждем, пока появятся данные
}
T item = buffer.remove();
System.out.println("Consumed: " + item +
" (size: " + buffer.size() + ")");
// Будим производителя: есть место в буфере!
notFull.signal();
return item;
} finally {
lock.unlock();
}
}
}
// Использование:
class Main {
public static void main(String[] args) throws InterruptedException {
BoundedBuffer<Integer> buffer = new BoundedBuffer<>(3);
// Producer threads
for (int i = 0; i < 2; i++) {
final int id = i;
new Thread(() -> {
try {
for (int j = 0; j < 5; j++) {
buffer.put(id * 10 + j);
Thread.sleep(200);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Producer-" + i).start();
}
// Consumer threads
for (int i = 0; i < 2; i++) {
final int id = i;
new Thread(() -> {
try {
for (int j = 0; j < 5; j++) {
buffer.take();
Thread.sleep(300);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Consumer-" + i).start();
}
Thread.sleep(10000);
}
}
// Вывод показывает synchronized ожидание:
// Produced: 0 (size: 1)
// Produced: 10 (size: 2)
// Produced: 1 (size: 3)
// Buffer FULL, producer waiting...
// Consumed: 0 (size: 2)
// Produced: 11 (size: 3)
// ...
Преимущества Condition над wait/notify
1. Несколько Condition на одну блокировку:
class WorkQueue {
private final Queue<Task> queue = new LinkedList<>();
private final Lock lock = new ReentrantLock();
// Отдельные Condition для разных ситуаций
private final Condition hasTask = lock.newCondition(); // Задачи есть
private final Condition hasCapacity = lock.newCondition(); // Место в очереди
private final Condition allTasksDone = lock.newCondition(); // Все задачи выполнены
public void enqueueTask(Task task) throws InterruptedException {
lock.lock();
try {
while (queue.size() >= MAX_SIZE) {
hasCapacity.await(); // Ждем, пока место освободится
}
queue.add(task);
hasTask.signal(); // Будим только WORKERS, не других producers
} finally {
lock.unlock();
}
}
public Task dequeueTask() throws InterruptedException {
lock.lock();
try {
while (queue.isEmpty()) {
hasTask.await(); // Ждем, пока появятся задачи
}
Task task = queue.remove();
hasCapacity.signal(); // Будим только PRODUCERS, не других workers
return task;
} finally {
lock.unlock();
}
}
public void waitAllTasksDone() throws InterruptedException {
lock.lock();
try {
while (!queue.isEmpty()) {
allTasksDone.await();
}
} finally {
lock.unlock();
}
}
}
Преимущество: С wait()/notify() было бы больше ложных пробуждений
2. Таймаут ожидания:
lock.lock();
try {
while (!condition) {
// Ждем максимум 5 секунд
if (!condition.await(5, TimeUnit.SECONDS)) {
System.out.println("Timeout waiting for condition");
break;
}
}
} finally {
lock.unlock();
}
3. awaitUninterruptibly() — без прерывания:
lock.lock();
try {
while (!condition) {
condition.awaitUninterruptibly(); // Не выбросит InterruptedException
}
} finally {
lock.unlock();
}
Практические рекомендации
Выбор механизма синхронизации
| Сценарий | Рекомендация |
|---|---|
| Простой счетчик, коротко синхронизация | synchronized |
| Нужен tryLock или таймаут | ReentrantLock |
| Много читателей, мало писателей | ReadWriteLock |
| Максимальная производительность чтения | StampedLock |
| Много нитей ждут сложных условий | Condition + Lock |
Типичные ошибки
1. Забыть unlock() в finally:
// ПЛОХО
lock.lock();
doWork();
lock.unlock(); // Если исключение, это не выполнится
// ХОРОШО
lock.lock();
try {
doWork();
} finally {
lock.unlock();
}
2. Deadlock: не упорядочивать захват нескольких блокировок:
// ПЛОХО: разный порядок захвата
// Thread-1: lock1 → lock2
// Thread-2: lock2 → lock1 → DEADLOCK
// ХОРОШО: всегда один порядок
// Оба потока: lock1 → lock2
3. Держать lock слишком долго:
// ПЛОХО
synchronized void processLargeData() {
loadFromDisk(); // Медленная операция
updateCache(); // Быстрая, критическая
sendToServer(); // Медленная операция
}
// ХОРОШО
void processLargeData() {
loadFromDisk();
synchronized (this) {
updateCache(); // Только это критическое
}
sendToServer();
}
4. Смешивать synchronized и Lock:
// ПЛОХО: разные механизмы синхронизации
class BadDesign {
private final Lock lock = new ReentrantLock();
public synchronized void methodA() {
// Использует монитор this
}
public void methodB() {
lock.lock(); // Использует Lock
// Нет синхронизации между methodA и methodB!
}
}
Взаимодействие потоков
Методы wait(), notify(), notifyAll()
wait(), notify(), notifyAll() — это механизмы координации потоков через монитор объекта. Они позволяют потокам согласованно менять состояние на основе общих условий без активного полирования.
Основные правила
1. Вызываются ТОЛЬКО внутри synchronized блока:
Это требование жёсткое. При вызове вне synchronized блока будет выброшено IllegalMonitorStateException.
Object lock = new Object();
// ПРАВИЛЬНО
synchronized (lock) {
lock.wait();
}
// ОШИБКА: IllegalMonitorStateException
lock.wait(); // Без synchronized
Причина: Java требует, чтобы поток владел мониторным блокировкой объекта перед вызовом этих методов. Это гарантирует атомарность операции и предотвращает race condition между проверкой условия и началом ожидания.
2. wait() освобождает монитор:
Когда поток вызывает wait(), он автоматически освобождает монитор объекта и переходит в состояние ожидания. Это критично — если монитор не освобождался, другие потоки не смогли бы захватить его, и система зависла бы.
synchronized (lock) {
// Поток держит монитор lock
lock.wait();
// Монитор освобожден, поток ждет
// После notify() монитор захватывается снова
}
Состояния потока:
- Перед
wait(): состояние RUNNABLE (владеет монитором) - Во время
wait(): состояние WAITING (монитор освобожден) - После
notify(): состояние RUNNABLE (вновь владеет монитором)
3. notify() НЕ освобождает монитор сразу:
Критическое различие: notify() пробуждает ожидающий поток, но текущий поток ещё продолжает держать монитор. Пробуженный поток не сможет войти в синхронизированный блок, пока текущий поток не выйдет из него.
synchronized (lock) {
lock.notify(); // Будит ждущий поток
// Но монитор еще держится текущим потоком
System.out.println("Current thread still holds monitor");
doSomeWork(); // Работа продолжается здесь
// Монитор остается в нашем владении
}
// Только здесь монитор освобождается
// Разбуженный поток ТЕПЕРЬ может захватить монитор
Это объясняет необходимость использования wait() в цикле — между пробуждением и получением монитора другой поток может изменить состояние.
Методы wait()
// Ждет бесконечно, пока не разбудят
// Бросает InterruptedException если поток прерван
lock.wait();
// Ждет максимум 1000 миллисекунд (1 секунда)
// Вернется даже если не вызван notify(), если истекло время
lock.wait(1000);
// Ждет 1000 миллисекунд + 500 наносекунд
// Повышенная точность для специфических сценариев
lock.wait(1000, 500);
Возвращаемые состояния:
- После notify()/notifyAll(): поток пробужден сигналом
- По таймауту: истекло время ожидания
- InterruptedException: другой поток прервал наш поток через
interrupt()
Пример: Producer-Consumer (основные механики)
class ProducerConsumer {
private final Queue<Integer> queue = new LinkedList<>();
private final int capacity = 5;
private final Object lock = new Object();
// Producer
public void produce(int value) throws InterruptedException {
synchronized (lock) {
// Ждем, пока освободится место в буфере
while (queue.size() == capacity) {
System.out.println("Queue is full, producer waiting...");
lock.wait(); // Освобождаем монитор и ждем
// После пробуждения повторно проверяем условие
}
queue.add(value);
System.out.println("Produced: " + value + " | Queue size: " + queue.size());
// Уведомляем consumer, что появилась еда
lock.notify(); // Или lock.notifyAll()
}
}
// Consumer
public int consume() throws InterruptedException {
synchronized (lock) {
// Ждем, пока появятся данные
while (queue.isEmpty()) {
System.out.println("Queue is empty, consumer waiting...");
lock.wait(); // Освобождаем монитор и ждем
}
int value = queue.poll();
System.out.println("Consumed: " + value + " | Queue size: " + queue.size());
// Уведомляем producer, что освободилось место
lock.notify();
return value;
}
}
}
Поток выполнения при 2 consumer и 1 producer:
- Producer добавляет элемент → вызывает
notify() - Пробуждается случайный consumer (если несколько ждали)
- Другой consumer продолжает ждать (его не разбудили!)
- Consumer берет элемент → вызывает
notify() - Пробуждается... может быть второй consumer или producer?
Это же показывает проблему с notify() — мы не контролируем, кто проснется.
notify() vs notifyAll()
notify():
- Будит ОДИН случайный ожидающий поток из множества
- Быстрее (меньше потоков просыпается)
- Может привести к deadlock в сложных сценариях
notifyAll():
- Будит ВСЕ ожидающие потоки сразу
- Медленнее (все потоки проходят проверку условия)
- Безопаснее (гарантирует пробуждение нужного потока)
class NotifyExample {
private final Object lock = new Object();
private boolean condition = false;
// Использование notify() - опасно
public void signalOne() {
synchronized (lock) {
condition = true;
lock.notify(); // Один из ждущих проснется
// Что если это был неправильный поток?
}
}
// Использование notifyAll() - безопасно
public void signalAll() {
synchronized (lock) {
condition = true;
lock.notifyAll(); // Все ждущие проснутся и проверят условие
// Каждый решит сам, нужно ли ему продолжать работу
}
}
public void waitForCondition() throws InterruptedException {
synchronized (lock) {
while (!condition) { // ВАЖНО: while, не if
lock.wait();
}
// Здесь condition гарантированно == true
}
}
}
Проблема notify() в реальном коде:
class Deadlock_Example {
private final Object lock = new Object();
private int buffer = 0;
private boolean ready = false;
public void producer() throws InterruptedException {
synchronized (lock) {
for (int i = 0; i < 3; i++) {
while (ready) {
lock.wait(); // Ждет consumer
}
buffer = i;
ready = true;
lock.notify(); // Пробуждает одного ждущего
}
}
}
public void consumer() throws InterruptedException {
synchronized (lock) {
while (true) {
while (!ready) {
lock.wait(); // Несколько consumer могут ждать
}
System.out.println("Consumed: " + buffer);
ready = false;
lock.notify(); // Пробуждает... кого?
// Если пробудился другой consumer, то deadlock!
}
}
}
}
Решение: использовать notifyAll()
// В обоих методах
lock.notifyAll(); // Все проснулись, каждый проверит свое условие
Когда можно использовать notify():
- Только 1 поток ждет (гарантированно)
- Все ждущие потоки имеют ОДИНАКОВОЕ условие ожидания
Правила использования wait/notify
Правило 1: Всегда проверяйте условие в цикле while
ПЛОХО:
synchronized (lock) {
if (!condition) {
lock.wait(); // Опасно!
}
// Может выполниться даже если condition все еще false
}
Почему опасно?
-
Spurious Wakeups (ложные пробуждения)
- ОС может разбудить поток без вызова notify()
- Поток вернется из wait() без причины
- Если была проверка
if, код выполнится с неверным состоянием
-
Множественные потоки
- Несколько потоков ждали одного условия
- notify() разбудил одного (нужного ему)
- Он входит, изменяет состояние
- Второй просыпается, но условие уже не выполнено
-
Ошибки программирования
- Другой thread мог изменить состояние после пробуждения
- Гарантирует повторную проверку
ХОРОШО:
synchronized (lock) {
while (!condition) { // Проверка в цикле
lock.wait();
}
// Гарантия: condition == true
// Даже при spurious wakeup цикл повторится
}
Практический пример с spurious wakeup:
class SpuriousWakeupDemo {
private final Object lock = new Object();
private boolean ready = false;
public void waiter() throws InterruptedException {
synchronized (lock) {
// ПРАВИЛЬНО: while
while (!ready) {
System.out.println("Waiting for ready...");
lock.wait();
System.out.println("Woke up, checking condition again...");
}
System.out.println("Proceeding, ready = " + ready);
}
}
public static void main(String[] args) throws InterruptedException {
SpuriousWakeupDemo demo = new SpuriousWakeupDemo();
Thread t1 = new Thread(() -> {
try {
demo.waiter();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t1.start();
Thread.sleep(1000);
// Имитируем spurious wakeup вызовом notifyAll без изменения ready
synchronized (demo.lock) {
demo.lock.notifyAll(); // Просыпаемся БЕЗ готовности
}
Thread.sleep(1000);
// Теперь реально устанавливаем ready
synchronized (demo.lock) {
demo.ready = true;
demo.lock.notifyAll();
}
t1.join();
}
}
Правило 2: Держать монитор минимально
Пока вы держите монитор, другие потоки не могут войти в синхронизированный блок. Длительные операции внутри synchronized замораживают параллелизм.
ПЛОХО:
synchronized (lock) {
while (!ready) {
lock.wait();
}
// Долгая операция ИЗ-под монитора
List<Data> results = expensiveQuery(); // 5 секунд
results.forEach(this::process); // еще 10 секунд
lock.notify();
}
// Другие потоки ждали целых 15 секунд!
ХОРОШО:
List<Data> results;
synchronized (lock) {
while (!ready) {
lock.wait();
}
// Только минимально необходимая работа с общим состоянием
ready = false;
}
// Освобождаем монитор перед долгой работой
results = expensiveQuery(); // 5 секунд (без блокировки)
results.forEach(this::process); // еще 10 секунд (без блокировки)
synchronized (lock) {
lock.notify();
}
Правило 3: Используйте notifyAll() для безопасности
ПЛОХО (может привести к зависанию):
// 3 potreb thread ждут сигнала
// notify() разбудит только одного
synchronized (lock) {
condition = true;
lock.notify(); // Один поток проснулся
}
// Два других остались спать навсегда!
ХОРОШО:
synchronized (lock) {
condition = true;
lock.notifyAll(); // Все потоки проснулись
// Каждый проверит свое условие и решит, нужно ли ему продолжать
}
Правило 4: Один объект для wait/notify
ПЛОХО:
Object lock1 = new Object();
Object lock2 = new Object();
synchronized (lock1) {
lock2.wait(); // IllegalMonitorStateException!
}
Java проверяет: владеете ли вы мониторой того объекта, чей метод вызываете? Нет? Исключение.
ХОРОШО:
synchronized (lock) {
lock.wait(); // Тот же объект - OK
}
Missed Signals и Spurious Wakeups
Два наиболее коварных багов при работе с wait/notify:
Missed Signal (Потерянный сигнал)
Проблема: notify() вызван ДО wait() → сигнал потеряется навсегда
class MissedSignalProblem {
private boolean ready = false;
private final Object lock = new Object();
// Сценарий 1: Таймер может срабатить рано
public void signalReady() {
synchronized (lock) {
ready = true;
lock.notify(); // Вызвано ДО wait()!
}
}
// Сценарий 2: Главный поток может ждать позже
public void waitForReady() throws InterruptedException {
Thread.sleep(100); // Опаздываем
synchronized (lock) {
if (!ready) {
lock.wait(); // Будет ждать ВЕЧНО (сигнал пропущен)
}
}
}
}
Почему сигнал теряется:
Timeline:
1. 10:00:00 - signalReady() вызывает notify()
2. 10:00:00 - Ждущих потоков НЕТ, notify() ничего не делает
3. 10:00:01 - waitForReady() вызывает wait()
4. 10:00:01 - Поток засыпает, ждет уведомления которое УЖЕ случилось
5. DEADLOCK - вечное ожидание
Решение: Использовать флаг состояния
Флаг сохраняет информацию о событии даже если оно случилось до wait().
class FixedMissedSignal {
private boolean ready = false; // Флаг СОХРАНЯЕТ состояние
private final Object lock = new Object();
public void signalReady() {
synchronized (lock) {
ready = true; // Флаг остается true
lock.notifyAll();
}
}
public void waitForReady() throws InterruptedException {
synchronized (lock) {
while (!ready) { // Флаг будет true даже если сигнал был раньше
lock.wait();
}
// Если ready уже true, wait() вообще не вызовется
}
}
public static void main(String[] args) throws InterruptedException {
FixedMissedSignal demo = new FixedMissedSignal();
// Сначала подаем сигнал
demo.signalReady(); // ready = true
// Потом ждем
Thread.sleep(1000);
demo.waitForReady(); // Не будит ждать, видит ready = true
System.out.println("Success!");
}
}
Spurious Wakeup (Ложное пробуждение)
Проблема: ОС может разбудить поток БЕЗ вызова notify()
Это не баг приложения, а особенность ОС. На некоторых платформах (особенно старых) сигналы могут теряться или дублироваться на низком уровне. JVM не может гарантировать, что пробуждение произошло только из-за notify().
// ОС может разбудить поток без видимой причины
synchronized (lock) {
lock.wait(); // Может вернуться БЕЗ notify()
// Условие может быть ЛОЖНЫМ
}
Решение: Проверка условия в цикле
synchronized (lock) {
while (!condition) { // Защита от spurious wakeup
lock.wait();
// Если spurious wakeup, цикл повторится и wait() вызовется снова
}
// Гарантия: condition == true
}
Практический пример с защитой:
class BoundedQueue<T> {
private final Queue<T> queue = new LinkedList<>();
private final int capacity;
private final Object lock = new Object();
public BoundedQueue(int capacity) {
this.capacity = capacity;
}
public void put(T item) throws InterruptedException {
synchronized (lock) {
// Защита от:
// 1. Spurious wakeup - цикл повторится
// 2. Несколько producer - может заполниться между пробуждением и захватом монитора
while (queue.size() >= capacity) {
System.out.println("[Producer] Queue full, waiting...");
lock.wait();
}
queue.add(item);
System.out.println("[Producer] Added " + item + ", size: " + queue.size());
lock.notifyAll(); // Будим consumer
}
}
public T take() throws InterruptedException {
synchronized (lock) {
// Защита от:
// 1. Spurious wakeup - цикл повторится
// 2. Несколько consumer - может опустошиться между пробуждением и захватом
while (queue.isEmpty()) {
System.out.println("[Consumer] Queue empty, waiting...");
lock.wait();
}
T item = queue.poll();
System.out.println("[Consumer] Took " + item + ", size: " + queue.size());
lock.notifyAll(); // Будим producer
return item;
}
}
public static void main(String[] args) {
BoundedQueue<Integer> queue = new BoundedQueue<>(3);
// Producer
new Thread(() -> {
try {
for (int i = 0; i < 10; i++) {
queue.put(i);
Thread.sleep(100);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
// Two consumers
for (int c = 0; c < 2; c++) {
int consumerId = c;
new Thread(() -> {
try {
for (int i = 0; i < 5; i++) {
Integer val = queue.take();
Thread.sleep(150);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
}
}
}
Producer-Consumer Pattern
Producer-Consumer — классический паттерн для взаимодействия потоков через общую очередь. Producer добавляет данные, Consumer их обрабатывает. Они синхронизируются через буфер.
Реализация с wait/notify
class ProducerConsumerPattern {
private final Queue<Integer> buffer = new LinkedList<>();
private final int capacity = 10;
private final Object lock = new Object();
private volatile boolean shutdown = false;
// Producer thread
class Producer implements Runnable {
@Override
public void run() {
int value = 0;
try {
while (!shutdown) {
try {
produce(value++);
Thread.sleep(100); // Имитирование создания данных
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
} finally {
System.out.println("Producer stopped");
}
}
}
// Consumer thread
class Consumer implements Runnable {
private int consumerId;
Consumer(int id) {
this.consumerId = id;
}
@Override
public void run() {
try {
while (!shutdown) {
try {
int value = consume();
System.out.println("[Consumer-" + consumerId + "] Consumed: " + value);
Thread.sleep(150); // Имитирование обработки
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
} finally {
System.out.println("Consumer-" + consumerId + " stopped");
}
}
}
private void produce(int value) throws InterruptedException {
synchronized (lock) {
// Ждем пока освободится место
while (buffer.size() >= capacity && !shutdown) {
System.out.println("[Producer] Buffer full (" + buffer.size() + "), waiting...");
lock.wait();
}
if (!shutdown) {
buffer.add(value);
System.out.println("[Producer] Produced: " + value + ", buffer: " + buffer.size());
lock.notifyAll(); // Будим consumer
}
}
}
private int consume() throws InterruptedException {
synchronized (lock) {
// Ждем пока появятся данные
while (buffer.isEmpty() && !shutdown) {
System.out.println("[Consumer] Buffer empty, waiting...");
lock.wait();
}
if (buffer.isEmpty()) {
return -1; // Shutdown signal
}
int value = buffer.poll();
System.out.println("[System] Buffer after consume: " + buffer.size());
lock.notifyAll(); // Будим producer
return value;
}
}
public static void main(String[] args) throws InterruptedException {
ProducerConsumerPattern pc = new ProducerConsumerPattern();
Thread producer = new Thread(pc.new Producer());
Thread consumer1 = new Thread(pc.new Consumer(1));
Thread consumer2 = new Thread(pc.new Consumer(2));
producer.start();
consumer1.start();
consumer2.start();
Thread.sleep(3000); // Даем поработать
pc.shutdown = true;
synchronized (pc.lock) {
pc.lock.notifyAll(); // Будим всех для graceful shutdown
}
producer.join();
consumer1.join();
consumer2.join();
System.out.println("All threads terminated");
}
}
Поток выполнения:
[Producer] Produced: 0, buffer: 1
[Producer] Produced: 1, buffer: 2
[Consumer-1] Consumed: 0
[Consumer-2] Consumed: 1
[Producer] Produced: 2, buffer: 1
[Producer] Buffer full (10), waiting...
[Consumer-1] Buffer after consume: 9
[Producer] Produced: 11, buffer: 10
Реализация с BlockingQueue (современный подход)
Вместо ручного управления wait/notify используйте BlockingQueue из java.util.concurrent:
import java.util.concurrent.*;
class ModernProducerConsumer {
private final BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(10);
private volatile boolean shutdown = false;
class Producer implements Runnable {
@Override
public void run() {
int value = 0;
try {
while (!shutdown) {
queue.put(value); // Автоматически блокируется если очередь полна
System.out.println("[Producer] Produced: " + value++);
Thread.sleep(100);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("Producer stopped");
}
}
class Consumer implements Runnable {
private int consumerId;
Consumer(int id) {
this.consumerId = id;
}
@Override
public void run() {
try {
while (!shutdown) {
Integer value = queue.poll(1, TimeUnit.SECONDS); // Ждет максимум 1 секунду
if (value != null) {
System.out.println("[Consumer-" + consumerId + "] Consumed: " + value);
Thread.sleep(150);
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("Consumer-" + consumerId + " stopped");
}
}
public static void main(String[] args) throws InterruptedException {
ModernProducerConsumer pc = new ModernProducerConsumer();
Thread producer = new Thread(pc.new Producer());
Thread consumer1 = new Thread(pc.new Consumer(1));
Thread consumer2 = new Thread(pc.new Consumer(2));
producer.start();
consumer1.start();
consumer2.start();
Thread.sleep(2000);
pc.shutdown = true;
producer.join();
consumer1.join();
consumer2.join();
}
}
Преимущества BlockingQueue:
- Встроенная обработка wait/notify
- Thread-safe по дизайну
- Поддержка таймаутов
- Меньше кода, меньше ошибок
Thread.join() и синхронизация завершения
join() — метод ожидания завершения другого потока. Текущий поток блокируется до тех пор, пока целевой поток не завершится.
Основное использование
Thread worker = new Thread(() -> {
System.out.println("Worker starting");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("Worker done");
});
System.out.println("Main: starting worker");
worker.start();
System.out.println("Main: waiting for worker");
try {
worker.join(); // Блокируется до завершения worker
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("Main: worker finished, continuing");
Вывод:
Main: starting worker
Main: waiting for worker
Worker starting
Worker done
Main: worker finished, continuing
Временная диаграмма:
Main thread: |--start--|---join()---|===continue===|
Worker thread: |===work===|==finished==|
(освобожда join())
join() с таймаутом
join() может принять таймаут в миллисекундах. Метод вернется либо после завершения потока, либо после истечения времени.
Thread worker = new Thread(() -> {
try {
Thread.sleep(5000); // Долгая работа
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
worker.start();
System.out.println("Waiting for worker (max 2 seconds)");
try {
worker.join(2000); // Ждем максимум 2 секунды
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// Проверяем, завершился ли поток
if (worker.isAlive()) {
System.out.println("Worker still running after 2 seconds");
worker.interrupt(); // Пытаемся его остановить
} else {
System.out.println("Worker finished within 2 seconds");
}
Ожидание группы потоков
Частый сценарий: создать несколько рабочих потоков и дождаться их завершения.
class ParallelTasks {
public static void main(String[] args) throws InterruptedException {
List<Thread> workers = new ArrayList<>();
// Создаем 5 worker потоков
for (int i = 0; i < 5; i++) {
int taskId = i;
Thread worker = new Thread(() -> {
System.out.println("Task " + taskId + " starting");
try {
Thread.sleep(1000 + taskId * 500); // Разное время выполнения
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("Task " + taskId + " done");
});
workers.add(worker);
worker.start();
}
// Ждем завершения всех
System.out.println("Main: waiting for all tasks");
for (Thread worker : workers) {
worker.join(); // Ждем каждого
}
System.out.println("All tasks completed");
}
}
Вывод:
Main: waiting for all tasks
Task 0 starting
Task 1 starting
Task 2 starting
Task 3 starting
Task 4 starting
Task 0 done (1000ms)
Task 1 done (1500ms)
Task 2 done (2000ms)
Task 3 done (2500ms)
Task 4 done (3000ms)
All tasks completed
join() под капотом
join() на самом деле использует wait/notify:
// Упрощенная реализация join()
public final void join() throws InterruptedException {
synchronized (this) { // Синхронизуется на самом объекте Thread
while (isAlive()) { // Проверяем жив ли поток
wait(0); // Ждем уведомления
}
}
// JVM вызывает notifyAll() всем объектам ждущим этого потока
// когда поток завершается
}
// С таймаутом
public final void join(long millis) throws InterruptedException {
synchronized (this) {
while (isAlive()) {
wait(millis); // Ждем максимум millis миллисекунд
}
}
}
Важные детали:
join()синхронизируется НА САМОМ ОБЪЕКТЕ Thread- JVM автоматически вызывает
notifyAll()на объекте потока при его завершении - Вы МОЖЕТЕ вызвать
notify()на объекте потока сами, но это плохая практика
Thread.interrupt() и обработка прерываний
interrupt() — кооперативный механизм отмены потока. Это НЕ силовая остановка, а сигнал, что поток должен остановиться.
Флаг interrupted
Каждый поток имеет внутренний boolean флаг interrupted:
// Проверка флага БЕЗ сброса
boolean isInterrupted = Thread.currentThread().isInterrupted();
// Проверка флага И автоматический сброс
boolean wasInterrupted = Thread.interrupted(); // Статический метод
Методы управления прерыванием
Thread thread = new Thread(() -> {
// Проверяем в цикле
while (!Thread.currentThread().isInterrupted()) {
try {
System.out.println("Working...");
Thread.sleep(1000);
} catch (InterruptedException e) {
// Блокирующий метод выбросил исключение
System.out.println("Interrupted during sleep");
// ВАЖНО: восстанавливаем флаг
Thread.currentThread().interrupt();
break;
}
}
System.out.println("Thread stopped");
});
thread.start();
// Работает несколько секунд
Thread.sleep(2500);
// Посылаем сигнал прерывания
thread.interrupt(); // Устанавливает флаг interrupted = true
try {
thread.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
Прерывание блокирующих методов
Методы wait(), sleep(), join() выбрасывают InterruptedException при прерывании:
Thread worker = new Thread(() -> {
try {
while (true) {
System.out.println("Working...");
Thread.sleep(1000); // Блокирующий метод
doWork();
}
} catch (InterruptedException e) {
System.out.println("Interrupted during sleep!");
// Флаг interrupted СБРОШЕН автоматически
// НУЖНО восстановить его
Thread.currentThread().interrupt();
}
});
worker.start();
Thread.sleep(3000);
worker.interrupt(); // Вызовет InterruptedException в sleep()
Правильная обработка InterruptedException
ПЛОХО: проглатываем исключение
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// Ничего не делаем - ПОТЕРЯ ИНФОРМАЦИИ!
}
// Флаг interrupted = false, никто не знает что было прерывание
doMoreWork(); // Продолжаем работу как ни в чем не бывало
ХОРОШО: восстанавливаем флаг
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// Восстанавливаем флаг для вышестоящего кода
Thread.currentThread().interrupt();
// Или обрабатываем прерывание локально
cleanupAndStop();
}
ХОРОШО: пробрасываем дальше
public void doWork() throws InterruptedException {
while (true) {
Thread.sleep(1000); // Если выброс InterruptedException, он пройдет выше
doSomething();
}
}
Прерывание I/O операций
Обычные I/O операции НЕ реагируют на interrupt():
Thread worker = new Thread(() -> {
try {
InputStream in = new FileInputStream("large-file.txt");
byte[] buffer = new byte[1024];
in.read(buffer); // НЕ ВЫБРОСИТ InterruptedException
// Будет ждать BЕЗ ПЕРЕРЫВА пока не придут данные
} catch (IOException e) {
e.printStackTrace();
}
});
worker.start();
Thread.sleep(100);
worker.interrupt(); // Не поможет!
Решение 1: Проверка флага
Thread worker = new Thread(() -> {
try {
InputStream in = new FileInputStream("file.txt");
while (!Thread.currentThread().isInterrupted()) {
int data = in.read();
if (data == -1) break;
process(data);
}
} catch (IOException e) {
e.printStackTrace();
}
});
Решение 2: NIO InterruptibleChannel
Thread worker = new Thread(() -> {
try {
FileChannel channel = FileChannel.open(Path.of("file.txt"));
ByteBuffer buffer = ByteBuffer.allocate(1024);
channel.read(buffer); // Выбросит ClosedByInterruptException при interrupt()
} catch (ClosedByInterruptException e) {
System.out.println("Channel closed due to interrupt");
} catch (IOException e) {
e.printStackTrace();
}
});
worker.start();
Thread.sleep(100);
worker.interrupt(); // Теперь работает!
Решение 3: Socket Timeout
Thread worker = new Thread(() -> {
try {
Socket socket = new Socket();
socket.setSoTimeout(5000); // 5 seconds
InputStream in = socket.getInputStream();
byte[] buffer = new byte[1024];
in.read(buffer); // Выбросит SocketTimeoutException
} catch (SocketTimeoutException e) {
System.out.println("Socket read timeout");
} catch (IOException e) {
e.printStackTrace();
}
});
Graceful Shutdown паттерн
Правильный способ остановки потока с очисткой ресурсов:
class GracefulShutdown {
private final Thread worker;
private volatile boolean running = true;
public GracefulShutdown() {
worker = new Thread(() -> {
try {
while (running && !Thread.currentThread().isInterrupted()) {
try {
// Основная работа
doWork();
Thread.sleep(100);
} catch (InterruptedException e) {
System.out.println("Interrupted, starting cleanup...");
Thread.currentThread().interrupt();
break; // Выходим из цикла
}
}
} finally {
// Гарантированно выполнится при любом выходе
cleanup();
}
});
}
public void start() {
worker.start();
}
public void shutdown() {
System.out.println("Initiating shutdown...");
running = false; // Сигнал выхода из цикла
worker.interrupt(); // Если ждет в sleep/wait
}
public void awaitTermination() throws InterruptedException {
worker.join();
}
private void doWork() {
System.out.println("Working...");
}
private void cleanup() {
System.out.println("Cleanup: closing resources...");
System.out.println("Cleanup: saving state...");
System.out.println("Cleanup done");
}
public static void main(String[] args) throws InterruptedException {
GracefulShutdown service = new GracefulShutdown();
service.start();
Thread.sleep(1000);
System.out.println("Main: requesting shutdown");
service.shutdown();
service.awaitTermination();
System.out.println("Main: service stopped cleanly");
}
}
Вывод:
Working...
Working...
Working...
Main: requesting shutdown
Interrupted, starting cleanup...
Cleanup: closing resources...
Cleanup: saving state...
Cleanup done
Main: service stopped cleanly
Практические примеры
Пример 1: Барьер синхронизации
Барьер ждет пока все N потоков достигнут точки синхронизации, потом отпускает их все одновременно:
class SimpleBarrier {
private final int parties; // Количество потоков
private int waiting = 0; // Сколько ждет в этот момент
private final Object lock = new Object();
public SimpleBarrier(int parties) {
this.parties = parties;
}
public void await() throws InterruptedException {
synchronized (lock) {
waiting++;
if (waiting < parties) {
// Не все потоки пришли
System.out.println(Thread.currentThread().getName() +
" waiting (" + waiting + "/" + parties + ")");
lock.wait(); // Ждем остальных
} else {
// Все пришли - освобождаем всех
System.out.println(Thread.currentThread().getName() +
" last one, releasing all");
waiting = 0; // Сбрасываем счетчик для следующей фазы
lock.notifyAll(); // Будим всех
}
}
}
public static void main(String[] args) throws InterruptedException {
SimpleBarrier barrier = new SimpleBarrier(3);
for (int i = 0; i < 3; i++) {
int threadId = i;
new Thread(() -> {
try {
System.out.println("Thread-" + threadId + " doing work");
Thread.sleep(threadId * 500); // Разное время работы
System.out.println("Thread-" + threadId + " reached barrier");
barrier.await();
System.out.println("Thread-" + threadId + " passed barrier");
}, "Worker-" + threadId).start();
}
Thread.sleep(5000);
}
}
Вывод:
Thread-0 doing work
Thread-1 doing work
Thread-2 doing work
Thread-0 reached barrier
Thread-0 waiting (1/3)
Thread-1 reached barrier
Thread-1 waiting (2/3)
Thread-2 reached barrier
Thread-2 last one, releasing all
Thread-0 passed barrier
Thread-1 passed barrier
Thread-2 passed barrier
Пример 2: Handoff (атомная передача данных)
Один поток дает данные, другой берет. Они синхронизируются так чтобы одно значение передавалось точно одному потребителю:
class Handoff<T> {
private T item = null;
private boolean hasItem = false;
private final Object lock = new Object();
public void give(T item) throws InterruptedException {
synchronized (lock) {
// Ждем пока предыдущее значение заберут
while (hasItem) {
System.out.println("Handoff: waiting for consumer to take previous item");
lock.wait();
}
this.item = item;
this.hasItem = true;
System.out.println("Handoff: gave " + item);
lock.notifyAll(); // Будим consumer
}
}
public T take() throws InterruptedException {
synchronized (lock) {
// Ждем пока появится новое значение
while (!hasItem) {
System.out.println("Handoff: waiting for producer to give item");
lock.wait();
}
T result = item;
hasItem = false;
System.out.println("Handoff: took " + result);
lock.notifyAll(); // Будим producer
return result;
}
}
public static void main(String[] args) throws InterruptedException {
Handoff<String> handoff = new Handoff<>();
// Producer
Thread producer = new Thread(() -> {
try {
for (int i = 0; i < 5; i++) {
String message = "Message-" + i;
handoff.give(message);
Thread.sleep(200);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Producer");
// Consumer
Thread consumer = new Thread(() -> {
try {
for (int i = 0; i < 5; i++) {
String message = handoff.take();
System.out.println("Consumer received: " + message);
Thread.sleep(500); // Медленнее берет
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Consumer");
producer.start();
consumer.start();
producer.join();
consumer.join();
}
}
Вывод:
Handoff: gave Message-0
Consumer received: Message-0
Handoff: gave Message-1
Handoff: waiting for consumer to take previous item
Consumer received: Message-1
Handoff: gave Message-2
...
Пример 3: Прерываемая долгая задача
class InterruptibleTask {
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
try {
int progress = 0;
int total = 100;
while (progress < total) {
// Проверяем прерывание
if (Thread.currentThread().isInterrupted()) {
System.out.println("Task interrupted at " + progress + "%");
return;
}
// Делаем часть работы
doWork();
progress += 10;
System.out.println("Progress: " + progress + "/" + total + " %");
// Блокирующий вызов (может выбросить InterruptedException)
try {
Thread.sleep(500);
} catch (InterruptedException e) {
System.out.println("Sleep interrupted");
Thread.currentThread().interrupt(); // Восстанавливаем флаг
break; // Выходим из цикла
}
}
if (progress >= total) {
System.out.println("Task completed successfully");
}
} finally {
System.out.println("Task cleanup");
}
}, "Worker");
worker.start();
// Даем поработать 2 секунды
Thread.sleep(2000);
System.out.println("Main: interrupting task");
worker.interrupt();
worker.join();
System.out.println("Main: done");
}
private static void doWork() {
// Симуляция работы
int sum = 0;
for (int i = 0; i < 10_000_000; i++) {
sum += i;
}
}
}
Вывод:
Progress: 10/100 %
Progress: 20/100 %
Progress: 30/100 %
Progress: 40/100 %
Main: interrupting task
Sleep interrupted
Task interrupted at 40%
Task cleanup
Main: done
Подводные камни и Best Practices
1. Всегда проверяйте условие в while, не в if
// ❌ ПЛОХО: может пропустить ложное пробуждение
synchronized (lock) {
if (!ready) {
lock.wait();
}
// Может быть ready = false если было spurious wakeup
}
// ✅ ХОРОШО: повторная проверка при spurious wakeup
synchronized (lock) {
while (!ready) {
lock.wait();
}
// Гарантия: ready == true
}
2. Используйте notifyAll() вместо notify() для безопасности
// ❌ ПЛОХО: может привести к deadlock
lock.notify();
// ✅ ХОРОШО: безопаснее, просто медленнее
lock.notifyAll();
3. Восстанавливайте флаг interrupted после InterruptedException
// ❌ ПЛОХО: теряем информацию о прерывании
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// Ничего не делаем, флаг сброшен
}
// ✅ ХОРОШО: восстанавливаем флаг
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
4. Не используйте Thread.stop(), Thread.suspend()
// ❌ ПЛОХО: deprecated, небезопасно, может оставить блокировки
thread.stop();
// ✅ ХОРОШО: cooperative interruption
thread.interrupt();
// ✅ ХОРОШО: флаг выхода
volatile boolean shouldStop = true;
while (!shouldStop) { ... }
5. join() с таймаутом для предотвращения зависания
// ❌ ПЛОХО: может зависнуть навсегда
worker.join();
// ✅ ХОРОШО: с таймаутом
worker.join(5000);
if (worker.isAlive()) {
System.out.println("Worker didn't finish in time");
worker.interrupt();
}
6. Используйте высокоуровневые абстракции
// ❌ ПЛОХО: вручную писать синхронизацию
synchronized (lock) {
while (queue.isEmpty()) {
lock.wait();
}
return queue.poll();
}
// ✅ ХОРОШО: BlockingQueue делает все за вас
return queue.take();
// ✅ ХОРОШО: CountDownLatch вместо join()
CountDownLatch latch = new CountDownLatch(3);
// В каждом потоке: latch.countDown();
// В главном: latch.await();
// ✅ ХОРОШО: ExecutorService вместо ручного управления потоками
ExecutorService executor = Executors.newFixedThreadPool(3);
executor.submit(() -> doWork());
7. Не захватывайте несколько мониторов (риск deadlock)
// ❌ ПЛОХО: риск deadlock если разные потоки захватывают в разном порядке
synchronized (lock1) {
synchronized (lock2) {
// ...
}
}
// ✅ ХОРОШО: используйте один монитор
Object[] locks = {lock1, lock2};
Arrays.sort(locks, Comparator.comparingLong(System::identityHashCode));
synchronized (locks[0]) {
synchronized (locks[1]) {
// ...
}
}
8. Документируйте гарантии синхронизации
class ThreadSafeClass {
private final Object lock = new Object();
/**
* Waits until the queue has space.
*
* @GuardedBy("lock") - указывает какой монитор защищает переменную
* Must be called while holding the lock.
*
* @throws InterruptedException if interrupted while waiting
*/
private void waitForSpace() throws InterruptedException {
while (queue.size() >= capacity) {
lock.wait();
}
}
}
9. Избегайте nested monitor calls
// ❌ ПЛОХО: если nested метод вызывает wait(), проблемы
public synchronized void outer() {
inner(); // Удерживаем монитор this
}
public synchronized void inner() {
lock.wait(); // Ошибка! Это lock, а не this
}
// ✅ ХОРОШО: явно управляйте мониторами
public void outer() {
synchronized (lock) {
inner();
}
}
public void inner() {
synchronized (lock) {
lock.wait();
}
}
10. Используйте volatile для состояния, synchronized для операций
// ✅ ПРАВИЛЬНО: volatile для флагов
volatile boolean running = true;
while (running) {
// ...
}
// ✅ ПРАВИЛЬНО: synchronized для составных операций
synchronized (lock) {
if (condition) {
lock.wait();
}
// Несколько операций как одна атомарная транзакция
}
Таблица сравнения механизмов синхронизации
| Механизм | Использование | Преимущества | Недостатки |
|---|---|---|---|
| wait/notify | Низкоуровневая синхронизация | Полный контроль | Легко ошибиться |
| BlockingQueue | Producer-Consumer | Безопасно, thread-safe | Менее гибко |
| CountDownLatch | Ожидание N событий | Просто, безопасно | Одноразовое использование |
| CyclicBarrier | Синхронизация волн | Многократное использование | Более сложное |
| Semaphore | Ограничение ресурсов | Гибко | Требует дисциплины |
| ReentrantLock | Замена synchronized | Попытка захвата, условия | Более многословно |
| join() | Ожидание потока | Просто | Нет таймаута в базовой версии |
| interrupt() | Отмена операции | Кооперативно | Требует поддержки |
Проблемы многопоточности
Race Conditions (Состояние гонки)
Race Condition — ситуация, когда корректность программы зависит от порядка выполнения операций в разных потоках. Результат становится недетерминированным и зависит от времени выполнения.
Механизм Race Condition: пример increment()
class Counter {
private int count = 0;
// НЕ thread-safe операция
public void increment() {
count++; // RACE CONDITION ЗДЕСЬ!
}
}
Операция count++ это не атомарная операция — она состоит из трех байт-кодов:
1. GETFIELD count // Прочитать текущее значение из памяти
2. ICONST_1 // Загрузить 1 в стек
3. IADD // Сложить
4. PUTFIELD count // Записать результат обратно
Сценарий race condition с двумя потоками:
Начальное значение: count = 0
Временная шкала:
T1: GETFIELD count → читает 0
T2: (scheduler переключает контекст)
T2: GETFIELD count → читает 0
T2: ICONST_1 → загружает 1
T2: IADD → вычисляет 0 + 1 = 1
T2: PUTFIELD count → записывает count = 1
T1: (scheduler переключает контекст)
T1: ICONST_1 → загружает 1
T1: IADD → вычисляет 0 + 1 = 1
T1: PUTFIELD count → записывает count = 1
Результат: count = 1 ❌ (ожидалось 2)
Потеряно одно обновление!
Классификация Race Conditions
1. Read-Modify-Write (РМЗ)
class ReadModifyWrite {
private int value = 0;
public void increment() {
// Ниже три отдельные операции, не гарантированные быть атомарными
int temp = value; // Read (GETFIELD)
temp = temp + 1; // Modify (IADD)
value = temp; // Write (PUTFIELD)
}
// Между GETFIELD и PUTFIELD другой поток может модифицировать value
}
Почему это опасно: Если два потока читают одно и то же значение до того, как любой из них запишет результат, оба запишут одно и то же увеличенное значение.
2. Check-Then-Act (ЧДА)
class CheckThenAct {
private String value = null;
public void initialize() {
if (value == null) { // Check (GETFIELD)
// Зазор между проверкой и действием ↓
value = expensiveInit(); // Act (PUTFIELD)
// Два потока могут пройти check одновременно
// Оба выполнят expensiveInit() дважды!
}
}
private String expensiveInit() {
// Дорогостоящая инициализация (например, подключение к БД)
System.out.println("Initializing...");
return "initialized";
}
}
// Проблема: оба потока видят value == null, оба вызывают expensiveInit()
// Результат: инициализация выполнена дважды (пустая трата ресурсов)
3. Compound Operations (Составные операции)
class CompoundOperations {
private Map<String, Integer> map = new HashMap<>();
public void putIfAbsent(String key, Integer value) {
// Две отдельные операции
if (!map.containsKey(key)) { // Check (INVOKEVIRTUAL)
// ↓ Зазор: другой поток может добавить ключ здесь
map.put(key, value); // Act (INVOKEVIRTUAL)
// Два потока могут добавить разные значения для одного ключа!
}
}
}
// Пример:
// Thread 1: !containsKey("id") = true → начинает put
// Thread 2: !containsKey("id") = true → начинает put
// Результат: в map может оказаться два значения (нарушение инварианта)
Решения Race Conditions
1. synchronized (монитор-основанная синхронизация)
class SynchronizedCounter {
private int count = 0;
// synchronized создает мьютекс (mutual exclusion)
public synchronized void increment() {
// Только один поток может быть здесь одновременно
count++;
}
public synchronized int getCount() {
return count;
}
}
// Как это работает:
// - Каждый объект имеет встроенный монитор (lock)
// - synchronized (this) захватывает монитор перед входом
// - Другие потоки ждут, пока монитор будет освобожден
// - Монитор автоматически освобождается при выходе (даже при исключении)
Байт-код с synchronized:
MONITORENTER // Захватить монитор объекта
GETFIELD count
ICONST_1
IADD
PUTFIELD count // Все три операции выполняются атомарно
MONITOREXIT // Освободить монитор
2. AtomicInteger (без блокировки)
class AtomicCounter {
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
// Использует Compare-And-Swap (CAS) вместо блокировки
count.incrementAndGet();
}
public int getCount() {
return count.get();
}
}
// AtomicInteger.incrementAndGet() эквивалентен:
// 1. int current = count;
// 2. int next = current + 1;
// 3. if (!CAS(count, current, next)) goto 1; // Retry если changed
// Это аппаратная операция (не требует lock)
Преимущества Atomic над synchronized:
- Лучше производительность при низких конфликтах (контention)
- Не может быть deadlock
- Явное выражение намерения (только счетчик, не весь объект)
3. Lock (более гибкая синхронизация)
class LockedCounter {
private int count = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++; // Критическая секция
} finally {
lock.unlock(); // ВАЖНО: всегда освобождать
}
}
public int getCount() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
}
// ReentrantLock vs synchronized:
// - Явное управление (полезно для сложной логики)
// - Можно попытаться захватить с таймаутом: tryLock(timeout)
// - Можно перепроверить наличие блокировки: isLocked()
// - Можно создать Condition для сигналов между потоками
Улучшенный пример с tryLock:
public boolean incrementIfPossible(long timeout, TimeUnit unit)
throws InterruptedException {
if (lock.tryLock(timeout, unit)) {
try {
count++;
return true;
} finally {
lock.unlock();
}
}
return false; // Не удалось захватить lock за отведенное время
}
Data Races (Гонки данных)
Data Race — одновременный доступ к переменной из разных потоков (хотя бы один пишет) БЕЗ синхронизации, нарушающий Java Memory Model. Это низкоуровневая проблема, в отличие от логической Race Condition.
Критическое отличие: Data Race vs Race Condition
| Аспект | Data Race | Race Condition |
|---|---|---|
| Уровень | Низкоуровневая (память) | Высокоуровневая (логика) |
| JMM нарушение | Всегда | Нет |
| Результат | Непредсказуемый (может быть undefined behavior) | Логически неверный результат |
| Пример | Чтение неинициализированных данных | Check-then-act без lock |
| Всегда ошибка? | Да | Иногда может быть допустимо |
// DATA RACE (нарушение Java Memory Model)
class DataRaceExample {
private int x = 0; // Нет синхронизации
private String name = "start"; // Нет синхронизации
// Thread 1 (writer)
public void write() {
x = 42; // Запись БЕЗ синхронизации
name = "done"; // Запись БЕЗ синхронизации
}
// Thread 2 (reader)
public void read() {
int xValue = x; // Чтение БЕЗ синхронизации
String nameValue = name; // Чтение БЕЗ синхронизации
// JMM не гарантирует видимость!
// xValue может быть 0 или 42
// nameValue может быть "start" или "done"
// Нет никаких гарантий вообще
}
}
// RACE CONDITION (логическая проблема, но синхронизация есть)
class RaceConditionExample {
private synchronized double getBalance() {
return balance;
}
private synchronized void withdraw(double amount) {
if (getBalance() >= amount) { // Check (синхронизировано)
// ↓ ЗАЗОР: может произойти race condition
balance -= amount; // Act (синхронизировано)
// Логически: между проверкой и снятием
// другой поток может снять деньги
// Но DATA RACE нет (все синхронизировано)
}
}
}
Явления Data Race, вызванные Java Memory Model
1. Видимость (Visibility)
class VisibilityDataRace {
private boolean ready = false; // Обычная переменная (БЕЗ volatile)
private int result = 0;
// Thread 1: писатель
public void prepare() {
result = 42;
ready = true; // Запись может оставаться в локальном кеше процессора!
// Не гарантированно записывается в main memory
}
// Thread 2: читатель
public void use() {
if (ready) { // Может никогда не увидеть ready = true
// или увидеть, но с большой задержкой
int value = result; // Может прочитать 0, а не 42!
}
}
}
// На многопроцессорной системе:
// P1 (Thread 1): result=42, ready=true (в L1 cache P1)
// P2 (Thread 2): if(ready) {видит false из main memory или cache}
// Память несогласована между ядрами!
Решение: volatile
class FixedVisibility {
private volatile boolean ready = false; // ← volatile!
private int result = 0;
public void prepare() {
result = 42;
ready = true; // Гарантированно пишет в main memory
// Создает memory barrier
}
public void use() {
if (ready) { // Гарантированно читает из main memory
int value = result; // Видит result = 42 (happens-before)
}
}
}
2. Reordering (переупорядочивание операций)
class ReorderingDataRace {
private int a = 0;
private int b = 0;
// Thread 1: писатель
public void write() {
a = 1; // Операция 1
b = 2; // Операция 2
// JIT компилятор может ПЕРЕУПОРЯДОЧИТЬ эти операции!
// Компилятор видит: a и b независимые переменные
// Может переупорядочить в: b = 2; a = 1;
// Или даже частично выполнить при распределении инструкций
}
// Thread 2: читатель
public void read() {
int bValue = b; // Читает первым
int aValue = a; // Читает вторым
// Возможный результат: bValue=2, aValue=0
// Потому что операции были переупорядочены!
}
}
// На процессорах x86 reordering менее вероятен
// На ARM или PowerPC это частая проблема
Почему компилятор переупорядочивает?
- Оптимизация кеша инструкций
- Параллельное выполнение (instruction-level parallelism)
- Предположение, что операции независимы
Решение: volatile с происходит-до гарантиями
class FixedReordering {
private volatile int a = 0; // ← volatile
private volatile int b = 0;
public void write() {
a = 1; // Создает write barrier
b = 2; // b не может быть переупорядочен перед a
}
public void read() {
int bValue = b; // Создает read barrier
int aValue = a; // a не может быть переупорядочен перед b
}
}
// Происходит-до (happens-before):
// - write(a) happens-before write(b) для писателя
// - read(b) happens-before read(a) для читателя
// - Если читатель видит b=2, он ГАРАНТИРОВАННО видит a=1
3. Частичная запись 64-битных переменных
class PartialWriteDataRace {
private long value = 0; // 64-bit переменная БЕЗ volatile
// Thread 1: писатель
public void write() {
value = 0x0123456789ABCDEFL;
// На 32-битной системе это может быть выполнено как:
// - Запись младших 32 бит: 0x89ABCDEF
// - (возможно, другой поток прерывает здесь)
// - Запись старших 32 бит: 0x01234567
}
// Thread 2: читатель
public void read() {
long x = value;
// Может прочитать:
// - Оба полустова с предыдущего значения
// - Один полустово старое, одно новое (несогласованное!)
// - Например: 0x0000000089ABCDEF (частичная запись)
// - Это значение никогда не было записано атомарно!
}
}
// На 64-битных системах часто есть 64-bit-safe операции
// Но на 32-битных это реальная проблема
// Решение: volatile или synchronized
class FixedPartialWrite {
private volatile long value = 0; // ← volatile гарантирует атомарность
}
Обнаружение Data Races: JCStress
@JCStressTest
@Outcome(id = "0, 0", expect = ACCEPTABLE, desc = "Оба видят начальные значения")
@Outcome(id = "1, 1", expect = ACCEPTABLE, desc = "Оба видят новые значения")
@Outcome(id = "0, 1", expect = ACCEPTABLE_INTERESTING, desc = "DATA RACE!")
@Outcome(id = "1, 0", expect = ACCEPTABLE_INTERESTING, desc = "DATA RACE!")
@State
public class DataRaceDetection {
int x = 0;
int y = 0;
@Actor
public void actor1() {
x = 1; // Запись
y = 1; // Запись
}
@Actor
public void actor2(II_Result r) {
r.r1 = y; // Чтение
r.r2 = x; // Чтение
}
}
// Запуск: jcstress -t DataRaceDetection
// JCStress напряженно нагружает код, пытаясь вызвать data race
// Результаты показывают все возможные исходы
// Результаты:
// 0, 0: обычно (оба потока быстро)
// 1, 1: обычно (оба завершены)
// 0, 1: РЕДКО (data race!) - читатель видит y=1 но x=0
// 1, 0: ЕЩЕ РЕЖЕ (data race!) - читатель видит переупорядочение
Deadlock (Взаимная блокировка)
Deadlock — состояние, когда два или более потока безвозвратно блокируют друг друга, ожидая освобождения ресурсов, которые держат другие потоки. Система зависает.
Условия Coffman (все ЧЕТЫРЕ необходимы)
Для возникновения deadlock необходимо одновременно выполнение ЧЕТЫРЕХ условий:
-
Mutual Exclusion — ресурс не может использоваться несколькими потоками одновременно (только один может держать lock)
-
Hold and Wait — поток держит захватенный ресурс И одновременно ждет другой ресурс (не освобождает первый при ожидании)
-
No Preemption — ресурс (lock) нельзя отнять у потока принудительно. Поток должен сам его отпустить
-
Circular Wait — существует циклическая цепь потоков, где каждый ждет ресурса, который держит следующий в цепи
Классический пример: банковские переводы
class BankAccount {
private double balance = 1000;
private final String accountId;
public BankAccount(String accountId) {
this.accountId = accountId;
}
// DEADLOCK ГАРАНТИРОВАН!
public synchronized void transfer(BankAccount target, double amount) {
System.out.println(Thread.currentThread().getName() +
" захватил lock(" + accountId + ")");
this.balance -= amount;
// Попытка захватить lock целевого аккаунта
// ПРОБЛЕМА: если целевой аккаунт держит lock и ждет нашего,
// мы ждем его - DEADLOCK!
synchronized (target) {
System.out.println(Thread.currentThread().getName() +
" захватил lock(" + target.accountId + ")");
target.balance += amount;
}
}
}
// Сценарий deadlock:
BankAccount account1 = new BankAccount("ACC-001");
BankAccount account2 = new BankAccount("ACC-002");
// Thread 1
new Thread(() -> {
account1.transfer(account2, 100);
// Захватит lock(account1)
// Попытается захватить lock(account2) → БЛОКИРУЕТСЯ
}, "T1").start();
// Thread 2
new Thread(() -> {
account2.transfer(account1, 50);
// Захватит lock(account2)
// Попытается захватить lock(account1) → БЛОКИРУЕТСЯ
}, "T2").start();
// Результат:
// T1: захватил lock(ACC-001)
// T2: захватил lock(ACC-002)
// T1: (ждет lock(ACC-002), который держит T2)
// T2: (ждет lock(ACC-001), который держит T1)
// ← DEADLOCK: оба ждут вечно
Временная последовательность deadlock:
Время T1 T2
────────────────────────────────────────────────────
t0 enter synchronized(acc1)
t1 ✓ захватил lock(acc1)
t2 enter synchronized(acc2)
t3 ✓ захватил lock(acc2)
t4 try synchronized(acc2)
t5 ⏳ ждет lock(acc2) try synchronized(acc1)
t6 ⏳ ждет lock(acc1)
t7 ⏳ ждет lock(acc2) ⏳ ждет lock(acc1)
t8 ⏳ ждет lock(acc2) ⏳ ждет lock(acc1)
... DEADLOCK (оба вечно ждут) DEADLOCK (оба вечно ждут)
Решение 1: упорядочивание блокировок (Lock Ordering)
class FixedBankAccount {
private double balance = 1000;
private final int accountId; // Уникальный идентификатор
public FixedBankAccount(int accountId) {
this.accountId = accountId;
}
// Решение: ВСЕГДА захватываем в одном порядке (по ID)
public void transfer(FixedBankAccount target, double amount) {
// Определяем порядок захвата на основе ID
FixedBankAccount first = this.accountId < target.accountId ? this : target;
FixedBankAccount second = this.accountId < target.accountId ? target : this;
synchronized (first) {
synchronized (second) {
// Теперь оба потока захватывают в одинаковом порядке
// Нет циклической цепочки ожидания → нет deadlock
if (this.accountId < target.accountId) {
this.balance -= amount;
target.balance += amount;
} else {
target.balance -= amount;
this.balance += amount;
}
}
}
}
}
// Теперь сценарий безопасен:
// T1: захватит lock(ACC-001), потом lock(ACC-002) → успешно завершится
// T2: ждет lock(ACC-001) → получает его после T1 → захватит lock(ACC-002)
// Никакой циклической цепочки!
Почему это работает: Оба потока захватывают ресурсы в одинаковом порядке, поэтому нет циклического ожидания.
Решение 2: tryLock с таймаутом и повтором
class TryLockBankAccount {
private double balance = 1000;
private final Lock lock = new ReentrantLock();
public boolean transfer(TryLockBankAccount target, double amount)
throws InterruptedException {
for (int attempts = 0; attempts < 3; attempts++) {
// Пытаемся захватить оба lock с таймаутом
if (this.lock.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
if (target.lock.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
this.balance -= amount;
target.balance += amount;
return true; // Успешно
} finally {
target.lock.unlock();
}
} else {
// Не удалось захватить второй lock
// Отпускаем первый и повторяем
continue;
}
} finally {
this.lock.unlock();
}
} else {
// Не удалось захватить первый lock
// Добавляем случайную задержку перед повтором
Thread.sleep(random.nextInt(50));
}
}
throw new TimeoutException("Could not acquire both locks");
}
}
// Почему это работает:
// - Если потоки конфликтуют (каждый держит один lock, ждет другой)
// - Они выходят из deadlock через таймаут
// - Случайная задержка перед повтором уменьшает вероятность повторного конфликта
// - В итоге оба переводы успешно завершаются
Решение 3: единственный глобальный lock
class GlobalLockBankAccount {
private static final Object GLOBAL_LOCK = new Object();
private double balance = 1000;
public void transfer(GlobalLockBankAccount target, double amount) {
synchronized (GLOBAL_LOCK) { // Один lock для всех транзакций
this.balance -= amount;
target.balance += amount;
}
}
// Преимущества:
// - Нет deadlock (только один lock)
// Недостатки:
// - Плохая масштабируемость (serialization)
// - Только один поток может переводить деньги одновременно
// - На многопроцессорной системе: потери производительности
}
Обнаружение Deadlock во время выполнения
// Проверка во время выполнения
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
long[] deadlockedThreadIds = threadMXBean.findDeadlockedThreads();
if (deadlockedThreadIds != null && deadlockedThreadIds.length > 0) {
System.out.println("DEADLOCK DETECTED!");
ThreadInfo[] threadInfos = threadMXBean.getThreadInfo(deadlockedThreadIds);
for (ThreadInfo info : threadInfos) {
System.out.println("\nПоток: " + info.getThreadName());
System.out.println(" Состояние: " + info.getThreadState());
System.out.println(" Ждет lock: " + info.getLockName());
System.out.println(" Владелец lock: " + info.getLockOwnerName());
// Стек-трейс потока
StackTraceElement[] stack = info.getStackTrace();
for (StackTraceElement frame : stack) {
System.out.println(" at " + frame);
}
}
}
// Пример вывода:
// DEADLOCK DETECTED!
//
// Поток: T1
// Состояние: BLOCKED
// Ждет lock: java.lang.Object@4a4a4a4a
// Владелец lock: T2
// at BankAccount.transfer(BankAccount.java:42)
Livelock (Живая блокировка)
Livelock — потоки активно работают, занимают CPU, но не продвигаются вперед. В отличие от deadlock, потоки не заблокированы, а постоянно реагируют на действия друг друга, создавая бесконечный цикл.
Классический пример: вежливые люди в коридоре
class PolitePerson implements Runnable {
private final String name;
private boolean wantsToPass = true;
private PolitePerson otherPerson;
public PolitePerson(String name) {
this.name = name;
}
public void setOtherPerson(PolitePerson other) {
this.otherPerson = other;
}
@Override
public void run() {
passThrough();
}
public void passThrough() {
while (wantsToPass) {
System.out.println(name + ": После вас!");
// Если другой не хочет проходить, я проходу
if (!otherPerson.wantsToPass) {
System.out.println(name + ": Спасибо, прохожу");
wantsToPass = false;
return; // Успешно прошли
}
// Оба уступают друг другу → LIVELOCK
// Каждый видит, что другой хочет пройти, поэтому уступает
// Повторяется бесконечно
try {
Thread.sleep(10); // Маленькая задержка
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
}
// Использование:
PolitePerson alice = new PolitePerson("Alice");
PolitePerson bob = new PolitePerson("Bob");
alice.setOtherPerson(bob);
bob.setOtherPerson(alice);
new Thread(alice).start();
new Thread(bob).start();
// Вывод: бесконечное повторение
// Alice: После вас!
// Bob: После вас!
// Alice: После вас!
// Bob: После вас!
// Alice: После вас!
// ... (никогда не завершится)
Отличия Deadlock vs Livelock:
DEADLOCK:
- Потоки ЗАБЛОКИРОВАНЫ (state = BLOCKED или WAITING)
- thread dump показывает "waiting for monitor entry"
- CPU не используется потоками (idle)
- Легче заметить (система явно зависает)
- Stack-трейс показывает точку, где поток ждет
LIVELOCK:
- Потоки АКТИВНЫ (state = RUNNABLE)
- thread dump показывает постоянно меняющийся стек-трейс
- CPU постоянно занят (100% или близко)
- Сложнее заметить (система "работает", но не прогрессирует)
- Результат: бесконечный цикл реакций друг на друга
Решение: рандомизация (exponential backoff)
class FixedPolitePerson implements Runnable {
private final String name;
private boolean wantsToPass = true;
private FixedPolitePerson otherPerson;
private final Random random = new Random();
public FixedPolitePerson(String name) {
this.name = name;
}
public void setOtherPerson(FixedPolitePerson other) {
this.otherPerson = other;
}
@Override
public void run() {
passThrough();
}
public void passThrough() {
int backoffTime = 10;
while (wantsToPass) {
System.out.println(name + ": После вас!");
if (!otherPerson.wantsToPass) {
System.out.println(name + ": Спасибо, прохожу");
wantsToPass = false;
return;
}
// РЕШЕНИЕ: случайная задержка разной длины
// Это нарушает синхронизацию между потоками
int delay = random.nextInt(backoffTime);
try {
Thread.sleep(delay);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// Увеличиваем backoff на следующей итерации
// (exponential backoff)
backoffTime = Math.min(backoffTime * 2, 1000);
}
}
}
// Результат:
// Alice: После вас!
// Bob: После вас!
// (Alice спит 7ms, Bob спит 42ms)
// Bob завершает, пока Alice еще спит
// Alice просыпается и видит, что Bob уже прошел
// Alice проходит
// ✓ Успешно!
Почему это работает:
- Рандомные задержки разной длины нарушают синхронность
- Один поток просыпается раньше другого
- Он видит, что другой уже не хочет проходить
- Успешно проходит мимо
Решение 2: явные приоритеты
class PriorityPerson implements Runnable {
private final String name;
private final int priority; // 1 = низкий, 10 = высокий
private PriorityPerson otherPerson;
public PriorityPerson(String name, int priority) {
this.name = name;
this.priority = priority;
}
public void setOtherPerson(PriorityPerson other) {
this.otherPerson = other;
}
@Override
public void run() {
passThrough();
}
public void passThrough() {
if (this.priority > otherPerson.priority) {
// Я имею приоритет → проходу
System.out.println(name + ": Прохожу (приоритет " + priority + ")");
} else if (this.priority < otherPerson.priority) {
// Другой имеет приоритет → уступаю
System.out.println(name + ": Уступаю (приоритет " + priority + ")");
} else {
// Равный приоритет → использовать другой механизм
System.out.println(name + ": Равный приоритет, используется рандомизация");
}
}
}
// Использование:
PriorityPerson alice = new PriorityPerson("Alice", 8);
PriorityPerson bob = new PriorityPerson("Bob", 3);
// Alice имеет больший приоритет → она всегда проходит
Starvation (Голодание потоков)
Starvation — поток(и) не может получить доступ к нужному ресурсу (CPU, lock) в течение длительного времени, потому что другие потоки постоянно захватывают этот ресурс.
Причина 1: несправедливые (unfair) блокировки
class UnfairLockStarvation {
// ReentrantLock по умолчанию UNFAIR
private final Lock lock = new ReentrantLock(false); // Unfair
public void doWork(String threadName) {
lock.lock();
try {
System.out.println(threadName + " работает");
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock();
}
}
}
// Сценарий:
// Thread-High (приоритет HIGH): захватил lock → работает
// Thread-Low (приоритет LOW): ждет lock
// Thread-High: освободил lock, но сразу же захватывает снова
// Thread-Low: ждет lock
// ...
// Результат: Thread-Low голодает (никогда не получает lock)
// Unfair lock не гарантирует FIFO порядок:
// - Любой ждущий поток может захватить lock
// - Thread с высоким приоритетом может постоянно "вскакивать" в очередь
Решение 1: fair locks
class FairLockSolution {
// Используем справедливый lock (FIFO очередь)
private final Lock lock = new ReentrantLock(true); // Fair!
public void doWork(String threadName) {
lock.lock();
try {
System.out.println(threadName + " работает");
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock();
}
}
}
// Результат с fair lock:
// Thread-High: захватил lock → работает
// Thread-Low: ждет (добавлен в FIFO очередь)
// Thread-High: освободил lock
// Thread-Low: получает lock (следующий в очереди) → работает
// ✓ Thread-Low не голодает
Производительность fair vs unfair:
// Fair lock:
// Преимущества: справедливое распределение, нет starvation
// Недостатки: медленнее (нужно поддерживать очередь)
// Используйте когда: важна справедливость
// Unfair lock:
// Преимущества: быстрее (нет overhead очереди)
// Недостатки: возможен starvation
// Используйте когда: важна производительность
Причина 2: приоритеты потоков
class ThreadPriorityStarvation {
private static int sharedCounter = 0;
private static final Object lock = new Object();
public static void main(String[] args) {
// Thread с ВЫСОКИМ приоритетом
Thread highPriority = new Thread(() -> {
synchronized (lock) {
for (int i = 0; i < 1000000; i++) {
sharedCounter++;
// Постоянно работает, захватывает lock много раз
}
}
});
highPriority.setPriority(Thread.MAX_PRIORITY); // 10
// Thread с НИЗКИМ приоритетом
Thread lowPriority = new Thread(() -> {
synchronized (lock) {
for (int i = 0; i < 1000000; i++) {
sharedCounter++;
}
}
});
lowPriority.setPriority(Thread.MIN_PRIORITY); // 1
highPriority.start();
lowPriority.start();
// Результат: lowPriority может голодать
// Scheduler дает больше CPU времени highPriority
// lowPriority редко получает CPU → долго ждет lock
}
}
// Проблема: scheduler ОС дает больше CPU времени потокам с высоким приоритетом
// Низкоприоритетный поток редко попадает на CPU
// Даже если lock свободен, низкоприоритетный поток не может его захватить
Решение 2: не полагаться на приоритеты
// ПЛОХО: полагаться на приоритеты
thread.setPriority(Thread.MAX_PRIORITY);
// ХОРОШО: использовать fair locks и правильный дизайн
Lock lock = new ReentrantLock(true); // Fair lock
// Приоритеты потоков не влияют на распределение lock
// Или использовать Semaphore (справедливый):
Semaphore semaphore = new Semaphore(1, true); // Fair
semaphore.acquire();
try {
// Критическая секция
} finally {
semaphore.release();
}
Причина 3: бесконечные циклы в синхронизованных методах
class InfiniteLoopStarvation {
public synchronized void monopolize() {
// Другие потоки не могут войти в ANY synchronized метод этого объекта!
while (true) { // Бесконечный цикл
doSomething();
// Монитор удерживается весь этот цикл
}
}
public synchronized void otherMethod() {
// Эта метод никогда не вызовется, пока monopolize() работает
// Потоки, вызывающие otherMethod(), голодают
}
private void doSomething() {
// ...
}
}
// Проблема:
// Thread 1 вызывает monopolize() → захватывает монитор
// Thread 2 вызывает otherMethod() → ждет монитор (голодает)
// Thread 3 вызывает otherMethod() → ждет монитор (голодает)
// Монопольный поток держит lock бесконечно
Решение: ограничивать время в критической секции
class TimeBoundedCriticalSection {
public void doWork() {
// Вместо бесконечного цикла в synchronized:
// ✓ ХОРОШО: выпускать lock периодически
while (needsMore()) {
synchronized (this) {
// Крохотная критическая секция
processOne(); // ~1ms
}
// Другие потоки могут войти здесь
maybeDoOtherStuff(); // ~10ms
}
}
private void processOne() {
// Одна операция
}
private void maybeDoOtherStuff() {
// Некритическая работа без lock
}
private boolean needsMore() {
return true;
}
}
// Результат: другие потоки не голодают
// Критическая секция коротка → другие потоки часто получают доступ
Решение 4: использовать thread pools с FIFO очередью
// ThreadPoolExecutor с справедливым распределением
BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>(); // FIFO
ThreadPoolExecutor executor = new ThreadPoolExecutor(
5, // corePoolSize
10, // maxPoolSize
60, // keepAliveTime
TimeUnit.SECONDS,
queue // Справедливая очередь задач
);
// Задачи будут выполняться в FIFO порядке
executor.submit(() -> highPriorityTask());
executor.submit(() -> lowPriorityTask());
// highPriorityTask будет выполнена первой (в порядке подачи)
// lowPriorityTask будет выполнена второй
// Нет starvation
Видимость памяти (Memory Visibility)
Memory Visibility — гарантия того, что запись переменной одним потоком видна другому потоку. Без synchronization, видимость НЕ гарантирована.
Проблема видимости
class VisibilityProblem {
private boolean running = true; // БЕЗ volatile
public void stop() {
running = false; // Пишу в переменную
}
public void run() {
new Thread(() -> {
// Читаю в цикле
while (running) {
System.out.println("Working...");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
System.out.println("Stopped");
}).start();
}
}
// Проблема:
// Main thread: вызывает stop() → пишет running = false
// Worker thread: крутится в while (running)
// Worker может НЕ ВИДЕТЬ running = false!
// Почему?
// - Компилятор может оптимизировать: while (running) → while (true)
// - Значение может быть закешировано в L1 кеше процессора
// - Нет памятного барьера → Main memory не обновляется
// Результат: Worker крутится вечно!
Решение: volatile
class FixedVisibility {
private volatile boolean running = true; // ← volatile!
public void stop() {
running = false; // Гарантированно пишет в main memory
}
public void run() {
new Thread(() -> {
while (running) { // Гарантированно читает из main memory
System.out.println("Working...");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
System.out.println("Stopped");
}).start();
}
}
// volatile гарантирует:
// - Write barrier: запись сразу идет в main memory
// - Read barrier: чтение всегда из main memory
// - Никакой кеширование на уровне процессора
// Результат: Worker видит running = false и завершается
Java Memory Model (JMM): Happens-Before
Happens-Before — отношение, определяющее видимость памяти между операциями. Если операция A happens-before B, то результаты A видны при выполнении B.
1. Program Order (порядок в программе)
int x = 1; // Action 1
int y = 2; // Action 2
int z = x + y; // Action 3 (видит x=1, y=2)
// Action 1 happens-before Action 2
// Action 2 happens-before Action 3
2. Volatile Write → Volatile Read
private volatile boolean ready = false;
private int data = 0;
// Thread 1 (writer)
data = 42;
ready = true; // volatile write - создает memory barrier
// Thread 2 (reader)
if (ready) { // volatile read - создает memory barrier
int x = data; // ГАРАНТИРОВАННО видит data = 42
}
// Почему: volatile write → volatile read создает happens-before
// Все операции до volatile write видны после volatile read
3. Synchronized Lock & Unlock
private int count = 0;
private final Object lock = new Object();
// Thread 1
synchronized (lock) {
count++; // Запись
} // unlock - создает memory barrier
// Thread 2
synchronized (lock) { // lock - создает memory barrier
int x = count; // ГАРАНТИРОВАННО видит новое значение
}
// Почему: unlock happens-before lock других потоков
// Все изменения до unlock видны после lock
4. Thread.start() → код потока
int sharedData = 0;
// Main thread
sharedData = 42;
Thread worker = new Thread(() -> {
int x = sharedData; // ГАРАНТИРОВАННО видит 42
});
worker.start(); // start() создает happens-before
// Почему: все операции перед start() видны в запущенном потоке
5. Код потока → Thread.join()
Thread worker = new Thread(() -> {
result = 42;
});
worker.start();
worker.join(); // join() создает happens-before
// Main thread
int x = result; // ГАРАНТИРОВАННО видит 42
// Почему: все операции в потоке видны после join()
Безопасность потоков (Thread Safety)
Thread Safety — свойство кода работать корректно при одновременном доступе из нескольких потоков.
Уровни Thread Safety
1. Immutable (полностью потокобезопасный)
public final class ImmutablePoint {
private final int x;
private final int y;
public ImmutablePoint(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() { return x; }
public int getY() { return y; }
// Новый объект при изменении
public ImmutablePoint move(int dx, int dy) {
return new ImmutablePoint(x + dx, y + dy);
}
}
// Все потоки могут читать, никто не может модифицировать
// Thread-safe по определению
// Нет data races, нет race conditions
2. Thread-Safe (синхронизирован)
public class SynchronizedCounter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
// Все операции синхронизированы
// Safe для concurrent access
// Может быть deadlock, но нет data races
3. Conditionally Thread-Safe (условно)
List<String> list = Collections.synchronizedList(new ArrayList<>());
// Методы thread-safe:
list.add("item"); // ✓ Атомарно
list.remove(0); // ✓ Атомарно
// Но итератор НЕ thread-safe:
synchronized (list) { // Нужна ВНЕШНЯЯ синхронизация
for (String item : list) {
System.out.println(item);
}
}
// Без synchronized: может быть ConcurrentModificationException
4. Thread-Hostile (враждебный)
public class ThreadHostile {
private static int counter = 0;
public void increment() {
counter++; // Data race!
}
}
// Нельзя сделать безопасным извне
// Используйте Atomic или synchronized
Стратегии достижения Thread Safety
1. Thread Confinement (привязка к потоку)
// Каждый поток работает с собственными данными
ThreadLocal<SimpleDateFormat> formatter =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
// Каждый вызов в разных потоках получает разные экземпляры
// DateFormat не потокобезопасен, но каждый поток его не делит
String formatted = formatter.get().format(new Date());
2. Immutability (неизменяемость)
public final class User {
private final String name;
private final int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() { return name; }
public int getAge() { return age; }
// Нет setters → нет изменений → thread-safe
}
// Используйте `final` на поле и класс
// Передавайте новые объекты вместо изменения
3. Synchronization (синхронизация)
public class Cache<K, V> {
private final Map<K, V> cache = new HashMap<>();
public synchronized V get(K key) {
return cache.get(key);
}
public synchronized void put(K key, V value) {
cache.put(key, value);
}
}
// synchronized блокирует доступ
// Но: low concurrency (один поток за раз)
4. Concurrent Collections (потокобезопасные коллекции)
// Вместо:
Map<String, Integer> map = Collections.synchronizedMap(new HashMap<>());
// Используйте:
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
// ConcurrentHashMap использует segmentation:
// - Разбивает данные на сегменты
// - Разные потоки могут работать с разными сегментами одновременно
// - Лучше параллелизм, чем synchronized HashMap
5. Atomic Variables (атомарные переменные)
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // Атомарно без lock
}
// Использует Compare-And-Swap (CAS):
// 1. Читает текущее значение
// 2. Вычисляет новое
// 3. Сравнивает и обновляет, если не изменилось
// 4. Если изменилось: повторяет от шага 1
// Преимущества:
// - Лучше производительность при низких конфликтах
// - Нет deadlock
// - Явное выражение намерения
Лучшие практики и антипаттерны
✅ ХОРОШО
// 1. Используйте synchronized для compound operations
synchronized (lock) {
if (!map.containsKey(key)) {
map.put(key, value);
}
}
// 2. Или используйте встроенный атомарный метод
map.putIfAbsent(key, value);
// 3. Упорядочивайте блокировки
void transfer(Account from, Account to, double amount) {
Account first = from.id < to.id ? from : to;
Account second = from.id < to.id ? to : from;
synchronized (first) {
synchronized (second) {
transfer(from, to, amount);
}
}
}
// 4. Используйте volatile для флагов
private volatile boolean running = true;
// 5. Используйте fair locks для справедливости
Lock lock = new ReentrantLock(true);
// 6. Используйте concurrent collections
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
// 7. Минимизируйте критическую секцию
synchronized (lock) {
doQuickWork();
// Другие потоки входят, если lock освобожден
}
// 8. Документируйте thread-safety
/**
* Thread-safe counter.
* Все методы синхронизированы.
* @GuardedBy("this")
*/
public class Counter {
@GuardedBy("this")
private int count = 0;
public synchronized void increment() {
count++;
}
}
❌ ПЛОХО
// 1. Полагаться на порядок выполнения
// (Race condition даже если компилируется)
if (value != null) {
doSomething(value); // Может быть null между check и use
}
// 2. Несогласованное захватывание блокировок
// (Может привести к deadlock)
synchronized (lock1) {
synchronized (lock2) { ... }
}
// В другом потоке:
synchronized (lock2) {
synchronized (lock1) { ... }
}
// 3. Долгие операции в synchronized
synchronized (this) {
for (int i = 0; i < 1000000; i++) {
expensiveOperation(); // Другие потоки блокируются!
}
}
// 4. Бесконечные циклы в synchronized
synchronized (this) {
while (true) {
// Монитор удерживается вечно
// Другие потоки голодают
}
}
// 5. Полагаться на приоритеты потоков
thread.setPriority(Thread.MAX_PRIORITY); // Может привести к starvation
// 6. Забывать про видимость
private boolean ready = false; // Должно быть volatile
while (ready) { }
// 7. Использовать synchronized HashMap вместо ConcurrentHashMap
Map<String, Integer> map = Collections.synchronizedMap(new HashMap<>());
// 8. Не обрабатывать InterruptedException
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// ❌ ПЛОХО: просто логировать или игнорировать
System.out.println("Interrupted");
}
// ✓ ХОРОШО: пробросить или установить interrupt флаг
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // Восстановить interrupt статус
}
Мониторинг Deadlock
// Периодическая проверка на deadlock
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.scheduleAtFixedRate(() -> {
ThreadMXBean bean = ManagementFactory.getThreadMXBean();
long[] deadlockedThreads = bean.findDeadlockedThreads();
if (deadlockedThreads != null) {
System.err.println("DEADLOCK DETECTED!");
ThreadInfo[] info = bean.getThreadInfo(deadlockedThreads);
for (ThreadInfo ti : info) {
System.err.println(" " + ti.getThreadName());
for (StackTraceElement ste : ti.getStackTrace()) {
System.err.println(" at " + ste);
}
}
}
}, 0, 5, TimeUnit.SECONDS);
JCStress для обнаружения Race Conditions
@JCStressTest
@Outcome(id = "0, 0", expect = ACCEPTABLE)
@Outcome(id = "1, 1", expect = ACCEPTABLE)
@Outcome(id = {"0, 1", "1, 0"}, expect = ACCEPTABLE_INTERESTING)
@State
public class RaceConditionTest {
int x = 0;
@Actor
public void writer() {
x = 1;
}
@Actor
public void reader(I_Result r) {
r.r1 = x;
}
}
// Запуск: java -jar jcstress.jar -t RaceConditionTest
// JCStress напряженно стресс-тестирует код
// Показывает все возможные исходы
// ACCEPTABLE_INTERESTING выделяет потенциальные race conditions
Atomic переменные и CAS
Атомарные операции
Атомарная операция — операция, которая выполняется как единое неделимое действие с точки зрения других потоков. Либо выполняется полностью, либо не выполняется вообще. Нет промежуточных состояний, видимых другим потокам.
Проблема неатомарности
Операция count++ выглядит простой, но на уровне машинного кода она разбивается на три отдельных инструкции:
class NonAtomicCounter {
private int count = 0;
public void increment() {
count++; // НЕ атомарно!
// Компилируется в:
// 1. LOAD count из памяти в CPU регистр
// 2. ADD 1 к значению в регистре
// 3. STORE результат обратно в память
// Race condition может случиться между ЛЮБЫМИ этими шагами!
}
}
Почему это проблема: Если два потока одновременно выполняют count++, они могут оба прочитать одно и то же значение, оба прибавить 1, и оба записать результат. Итого вместо +2 получим +1.
Визуализация race condition:
count = 0 (начальное значение)
Thread 1: LOAD(0) → ADD(1) → STORE(1)
Thread 2: LOAD(0) → ADD(1) → STORE(1)
Временная шкала:
T1 LOAD : count_t1 = 0
T2 LOAD : count_t2 = 0
T1 ADD : count_t1 = 0 + 1 = 1
T2 ADD : count_t2 = 0 + 1 = 1
T1 STORE : count = 1 (в памяти)
T2 STORE : count = 1 (перезаписываем!)
Результат: count = 1 (ожидали 2)
Потеряли одно обновление!
Это и есть race condition — результат зависит от временного порядка выполнения инструкций в разных потоках.
Compare-And-Swap (CAS)
CAS (Compare-And-Swap) — атомарная процессорная инструкция, которая выполняет две операции в один неделимый шаг:
- Сравнивает текущее значение в памяти с ожидаемым
- Если совпадает, переписывает его на новое значение
- Возвращает успех/неудачу
На уровне CPU это выполняется одной машинной инструкцией без возможности прерывания.
Принцип работы CAS
// Псевдокод CAS
// Все это выполняется АТОМАРНО на уровне CPU
boolean compareAndSwap(int* address, int expected, int newValue) {
if (*address == expected) {
*address = newValue;
return true; // Успешно обновили
}
return false; // Значение уже изменилось (someone beat us)
}
Ключевой момент: CAS не блокирует другие потоки. Если значение изменилось, CAS просто возвращает false, и мы можем попробовать снова.
Алгоритм использования CAS
1. Читаем текущее значение и запоминаем его (expected)
2. Вычисляем, какое новое значение хотим установить (newValue)
3. Вызываем CAS с (expected, newValue)
4. Если CAS возвращает true → готово!
5. Если CAS возвращает false → кто-то изменил значение
→ Возвращаемся к шагу 1 и повторяем
Пример: Increment с CAS
class CASCounter {
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
int current; // Ожидаемое значение
int next; // Новое значение
// Retry loop: если CAS failed, повторяем
do {
current = count.get(); // 1. Читаем текущее значение
next = current + 1; // 2. Вычисляем новое
// 3. CAS: атомарно проверяем, что count == current
// и если да, устанавливаем count = next
// 4. Если failed (count изменилось), цикл повторяется
} while (!count.compareAndSet(current, next));
}
}
Как это работает в многопоточной среде:
count = 0 (начало)
Thread 1: Thread 2:
current_1 = 0 current_2 = 0
next_1 = 1 next_2 = 1
CAS(0, 1) → TRUE CAS(0, 1) → FALSE
count = 1 (count уже 1, а не 0!)
current_2 = 1 ← Перечитаем
next_2 = 2
CAS(1, 2) → TRUE
count = 2
count = 2 (нет потерь!)
Почему CAS без блокировок?
// Блокировка (synchronized)
synchronized (lock) {
count++; // Один поток, остальные ЖДУТ (блокируются)
}
// Overhead: захват/освобождение lock, context switch
// CAS (lock-free)
count.incrementAndGet();
// Все потоки пытаются одновременно
// Один успеет, остальные retry (БЕЗ блокировки)
// Процессор работает, нет потери времени на ожидание
Производительность на практике:
Low contention (мало потоков конкурируют):
- CAS: ~5-10 наносекунд
- synchronized: ~50-100 наносекунд
- CAS быстрее в 5-10 раз!
High contention (много потоков конкурируют):
- CAS: может деградировать (много retries)
- synchronized: предсказуемая производительность
- Пересечение: при очень высокой нагрузке synchronized может быть快 быстрее
Семейство классов Atomic*
Java предоставляет набор готовых потокобезопасных классов, построенных на CAS. Используйте их вместо synchronized для простых счетчиков и флагов.
AtomicInteger
import java.util.concurrent.atomic.AtomicInteger;
AtomicInteger counter = new AtomicInteger(0);
// ========== Чтение/Запись ==========
int value = counter.get(); // Просто читаем
counter.set(10); // Просто пишем
int old = counter.getAndSet(20); // Записываем и возвращаем старое значение
// Возвращено: 10
// ========== Арифметические операции (CAS-based) ==========
int result = counter.incrementAndGet(); // ++count (атомарно)
// Возвращает: 11
int result = counter.getAndIncrement(); // count++ (атомарно)
// Возвращает: 11 (старое)
// count теперь 12
int result = counter.decrementAndGet(); // --count
int result = counter.getAndDecrement(); // count--
int result = counter.addAndGet(5); // count += 5 (атомарно)
int result = counter.getAndAdd(5); // temp = count; count += 5; return temp
// ========== CAS операции ==========
boolean success = counter.compareAndSet(12, 20);
// if (count == 12) { count = 20; return true; }
// else { return false; }
// ========== Функциональные операции (Java 8+) ==========
counter.updateAndGet(x -> x * 2); // count = count * 2 (атомарно)
counter.accumulateAndGet(5, (x, y) -> x + y); // count = count + 5
Практический пример: сбор статистики
class RequestStatistics {
private final AtomicInteger totalRequests = new AtomicInteger(0);
private final AtomicInteger successfulRequests = new AtomicInteger(0);
private final AtomicInteger failedRequests = new AtomicInteger(0);
public void recordRequest(boolean success) {
totalRequests.incrementAndGet(); // Все запросы
if (success) {
successfulRequests.incrementAndGet();
} else {
failedRequests.incrementAndGet();
}
}
public double getSuccessRate() {
int total = totalRequests.get();
if (total == 0) return 0.0;
return (double) successfulRequests.get() / total * 100;
}
public void printStats() {
System.out.printf(
"Total: %d, Success: %d, Failed: %d, Rate: %.1f%%%n",
totalRequests.get(),
successfulRequests.get(),
failedRequests.get(),
getSuccessRate()
);
}
}
AtomicLong
AtomicLong — то же самое, что AtomicInteger, но для long значений:
import java.util.concurrent.atomic.AtomicLong;
AtomicLong counter = new AtomicLong(0L);
counter.incrementAndGet();
counter.addAndGet(1000L);
counter.compareAndSet(100L, 200L);
Использование для накопления больших чисел:
class DataTransferStatistics {
private final AtomicLong totalBytes = new AtomicLong(0);
private final AtomicLong totalPackets = new AtomicLong(0);
public void recordTransfer(long bytes, int packets) {
totalBytes.addAndGet(bytes); // += bytes (атомарно)
totalPackets.addAndGet(packets);
}
public String getStatistics() {
long bytes = totalBytes.get();
double mb = bytes / 1024.0 / 1024.0;
return String.format(
"%.2f MB transferred (%d packets)",
mb,
totalPackets.get()
);
}
}
AtomicBoolean
AtomicBoolean — потокобезопасный флаг. Особенно полезен для синхронизации инициализации.
import java.util.concurrent.atomic.AtomicBoolean;
AtomicBoolean flag = new AtomicBoolean(false);
// ========== Операции ==========
boolean value = flag.get(); // Просто читаем
flag.set(true); // Просто пишем
boolean old = flag.getAndSet(false); // Записываем и возвращаем старое
boolean success = flag.compareAndSet(false, true); // Условная запись (Test-and-Set)
Практический пример: гарантированная однократная инициализация
class LazyResource {
private final AtomicBoolean initialized = new AtomicBoolean(false);
private volatile Resource resource; // volatile + AtomicBoolean = безопасно
public Resource getResource() {
// Быстрая проверка (без CAS, если уже инициализировано)
if (initialized.get()) {
return resource;
}
// Только ОДИН поток выполнит инициализацию
if (initialized.compareAndSet(false, true)) {
// Этот поток выиграл право на инициализацию
resource = new Resource(); // Дорогая операция
System.out.println("Initialized by thread: " + Thread.currentThread().getName());
} else {
// Другой поток уже инициализирует, ждем завершения
while (!initialized.get()) {
Thread.yield(); // Отдаем процессорное время
}
}
return resource;
}
}
AtomicReference
AtomicReference — позволяет безопасно менять ссылки на объекты. Необходимо для построения lock-free структур данных.
import java.util.concurrent.atomic.AtomicReference;
class Node<T> {
T data;
Node<T> next;
Node(T data) {
this.data = data;
}
}
// Потокобезопасная ссылка на узел
AtomicReference<Node<T>> head = new AtomicReference<>(null);
// ========== Операции ==========
Node<T> current = head.get();
head.set(new Node<>(value));
Node<T> old = head.getAndSet(new Node<>(newValue));
boolean success = head.compareAndSet(oldNode, newNode);
Пример: Lock-free стек
class LockFreeStack<T> {
private static class Node<T> {
final T value;
Node<T> next;
Node(T value) {
this.value = value;
this.next = null;
}
}
private final AtomicReference<Node<T>> head = new AtomicReference<>(null);
/**
* Добавить элемент в стек.
* Алгоритм: создаем новый узел, делаем его главой,
* указываем его на старую голову. Если head изменилась
* (другой поток опередил), повторяем.
*/
public void push(T value) {
Node<T> newNode = new Node<>(value);
Node<T> oldHead;
do {
oldHead = head.get(); // 1. Читаем текущую голову
newNode.next = oldHead; // 2. Новый узел указывает на старую голову
// 3. CAS: если head == oldHead, устанавливаем head = newNode
} while (!head.compareAndSet(oldHead, newNode));
// 4. Если CAS failed (head изменилась), повторяем
}
/**
* Удалить и вернуть верхний элемент.
* Алгоритм: читаем голову и ее next, пытаемся сделать
* next новой головой. Если успешно — возвращаем значение.
*/
public T pop() {
Node<T> oldHead;
Node<T> newHead;
do {
oldHead = head.get(); // 1. Читаем текущую голову
if (oldHead == null) {
return null; // Стек пуст
}
newHead = oldHead.next; // 2. Следующий узел
// 3. CAS: если head == oldHead, устанавливаем head = newHead
} while (!head.compareAndSet(oldHead, newHead));
// 4. Если CAS failed (head изменилась), повторяем
return oldHead.value;
}
}
AtomicIntegerArray, AtomicLongArray, AtomicReferenceArray
Потокобезопасные массивы, где каждый элемент может быть изменен атомарно. Полезны, когда разные потоки работают с разными индексами.
import java.util.concurrent.atomic.*;
// ========== Создание ==========
AtomicIntegerArray intArray = new AtomicIntegerArray(10);
AtomicLongArray longArray = new AtomicLongArray(10);
AtomicReferenceArray<String> refArray = new AtomicReferenceArray<>(10);
// ========== Операции ==========
intArray.set(0, 42); // array[0] = 42
int value = intArray.get(0); // value = array[0]
intArray.incrementAndGet(0); // ++array[0]
intArray.addAndGet(0, 5); // array[0] += 5
boolean success = intArray.compareAndSet(0, 42, 100); // CAS на элементе
Практический пример: параллельный счетчик без bottleneck
class ParallelCounter {
private final AtomicIntegerArray counters;
public ParallelCounter(int threadCount) {
// Каждый поток имеет свой индекс
counters = new AtomicIntegerArray(threadCount);
}
public void increment(int threadIndex) {
// Поток i пишет в counters[i]
// Нет конкуренции между потоками!
counters.incrementAndGet(threadIndex);
}
public int getTotal() {
int sum = 0;
for (int i = 0; i < counters.length(); i++) {
sum += counters.get(i);
}
return sum;
}
}
// Использование
ParallelCounter counter = new ParallelCounter(10);
// 10 потоков, каждый работает со своим индексом
for (int threadId = 0; threadId < 10; threadId++) {
int id = threadId;
new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.increment(id); // Нет конкуренции!
}
}).start();
}
System.out.println("Total: " + counter.getTotal()); // 10000
ABA-проблема
ABA-проблема — коварная ошибка в lock-free алгоритмах, когда CAS не замечает изменений.
Почему CAS уязвим для ABA
CAS проверяет только одно: value == expected. Если значение вернулось к исходному после изменений, CAS думает, что ничего не происходило, хотя структура данных может быть испорчена.
Пример проблемы:
class ABAStack<T> {
private AtomicReference<Node<T>> head = new AtomicReference<>();
private static class Node<T> {
T value;
Node<T> next;
}
public void push(T value) {
Node<T> newNode = new Node<>();
newNode.value = value;
Node<T> oldHead;
do {
oldHead = head.get(); // Читаем голову (предположим, это node A)
newNode.next = oldHead;
// === CONTEXT SWITCH: вмешивается другой поток ===
// Другой поток:
// 1. pop() → удаляет A
// 2. pop() → удаляет B
// 3. push(A) → добавляет A обратно
// Теперь head == A (совпадает!), но это ДРУГОЙ объект
// Или он указывает на другую структуру
// === Возвращаемся ===
} while (!head.compareAndSet(oldHead, newNode));
// CAS успешен! Но структура может быть испорчена
}
}
Визуализация:
Начало: head → A → B → C
Thread 1:
oldHead = A (node с адресом 0x1000)
newNode.next = A
[CONTEXT SWITCH]
Thread 2:
pop() → удаляет A из памяти
pop() → удаляет B из памяти
push(A) → создает НОВЫЙ A (адрес 0x2000, но значение то же!)
head = новый A
Теперь head указывает на новый A (адрес 0x2000)
но он содержит ссылку на С, а не на старый B!
Thread 1 (продолжение):
CAS(старый A, newNode) → TRUE
(compareAndSet проверяет только значение, не адрес)
head = newNode → newNode.next = A → C
Структура: newNode → новый A → C
Результат: потеряли B!
Решение: AtomicStampedReference
Версионирование с помощью "stamp" (версии). Каждый раз, когда мы меняем значение, мы увеличиваем версию.
import java.util.concurrent.atomic.AtomicStampedReference;
class FixedABAStack<T> {
private AtomicStampedReference<Node<T>> head;
private static class Node<T> {
T value;
Node<T> next;
}
public FixedABAStack() {
head = new AtomicStampedReference<>(null, 0); // (value, stamp)
}
public void push(T value) {
Node<T> newNode = new Node<>();
newNode.value = value;
int[] stampHolder = new int[1]; // Потребуется для получения старого stamp
Node<T> oldHead;
int oldStamp;
do {
oldHead = head.get(stampHolder); // Получаем ссылку И stamp
oldStamp = stampHolder[0];
newNode.next = oldHead;
// CAS с проверкой ОБЕИХ ссылки И stamp
} while (!head.compareAndSet(
oldHead, // Ожидаемое значение
newNode, // Новое значение
oldStamp, // Ожидаемый stamp
oldStamp + 1 // Новый stamp
));
}
public T pop() {
int[] stampHolder = new int[1];
Node<T> oldHead;
Node<T> newHead;
int oldStamp;
do {
oldHead = head.get(stampHolder);
if (oldHead == null) return null;
oldStamp = stampHolder[0];
newHead = oldHead.next;
} while (!head.compareAndSet(
oldHead, newHead,
oldStamp, oldStamp + 1
));
return oldHead.value;
}
}
Теперь ABA обнаруживается:
Thread 1:
oldHead = A (адрес 0x1000), oldStamp = 0
Thread 2:
pop() → stamp = 1
pop() → stamp = 2
push(новый A) → stamp = 3
Thread 1:
CAS(A, newNode, 0, 1) → FAIL!
(stamp == 3, а не 0)
Повторяем операцию
Когда использовать:
// ИСПОЛЬЗУЙТЕ AtomicStampedReference для:
- Lock-free структур данных (стеки, очереди, списки)
- Где возможна АВА-проблема
// НЕ НУЖЕН для:
- Простых счетчиков (AtomicInteger достаточно)
- Когда невозможна реинициализация
Lock-free алгоритмы
Lock-free — алгоритм, где хотя бы один поток всегда может прогрессировать без блокировок. Даже если другие потоки заморозятся, система продолжит работать.
Почему lock-free важно
// С synchronized (может быть deadlock)
synchronized (lock1) {
synchronized (lock2) {
// Если потоки захватывают locks в разном порядке → deadlock
}
}
// Lock-free (нет deadlock)
while (!atom.compareAndSet(old, new)) {
// Нет блокировок → нет deadlock
}
Характеристики
Гарантии:
✅ No deadlock — никогда не зависнет из-за блокировок
✅ No livelock — при reasonable assumptions
✅ Wait-free — лучший случай: один поток, гарантированный прогресс
Недостатки:
❌ Сложность — CAS loops, retries, может быть непредсказуемо
❌ ABA-problem — нужна версионирование
❌ High contention — много retries, может быть медленнее
❌ Сложно отладить — timing-зависимые баги
Пример: Lock-free Counter
class LockFreeCounter {
private final AtomicInteger count = new AtomicInteger(0);
public int increment() {
return count.incrementAndGet(); // Lock-free: внутри CAS loop
}
public int get() {
return count.get();
}
// Использование
public static void main(String[] args) throws InterruptedException {
LockFreeCounter counter = new LockFreeCounter();
int threads = 10;
Thread[] threads_arr = new Thread[threads];
for (int i = 0; i < threads; i++) {
threads_arr[i] = new Thread(() -> {
for (int j = 0; j < 10000; j++) {
counter.increment();
}
});
threads_arr[i].start();
}
for (int i = 0; i < threads; i++) {
threads_arr[i].join();
}
System.out.println("Final count: " + counter.get()); // 100000
}
}
Lock-free Queue (Michael-Scott)
Более сложный пример: потокобезопасная очередь без locks.
class LockFreeQueue<T> {
private static class Node<T> {
final T value;
final AtomicReference<Node<T>> next = new AtomicReference<>();
Node(T value) {
this.value = value;
}
}
private final Node<T> dummy = new Node<>(null); // Sentinel node
private final AtomicReference<Node<T>> head = new AtomicReference<>(dummy);
private final AtomicReference<Node<T>> tail = new AtomicReference<>(dummy);
/**
* Добавить элемент в конец очереди
*/
public void enqueue(T value) {
Node<T> newNode = new Node<>(value);
while (true) {
Node<T> curTail = tail.get();
Node<T> tailNext = curTail.next.get();
// Проверяем, что tail не изменилась (race condition)
if (curTail == tail.get()) {
if (tailNext != null) {
// Tail отстает от реальности, помогаем продвинуть
tail.compareAndSet(curTail, tailNext);
} else {
// Пытаемся добавить новый элемент
if (curTail.next.compareAndSet(null, newNode)) {
// Вставили, обновляем tail
tail.compareAndSet(curTail, newNode);
return;
}
}
}
}
}
/**
* Удалить и вернуть элемент из начала очереди
*/
public T dequeue() {
while (true) {
Node<T> curHead = head.get();
Node<T> curTail = tail.get();
Node<T> next = curHead.next.get();
// Проверяем, что head не изменилась
if (curHead == head.get()) {
if (curHead == curTail) {
if (next == null) {
return null; // Очередь пуста
}
// Head и tail указывают на один узел?
// Tail отстает, помогаем продвинуть
tail.compareAndSet(curTail, next);
} else {
T value = next.value;
// Пытаемся сделать next новой головой
if (head.compareAndSet(curHead, next)) {
return value;
}
}
}
}
}
}
Unsafe класс
Unsafe — низкоуровневый API Java для прямой работы с памятью и CAS операциями. Это основа для всех Atomic* классов.
Почему нужен Unsafe
// Atomic* классы используют Unsafe под капотом
// Вот как работает incrementAndGet внутри:
public class AtomicInteger {
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;
private volatile int value;
static {
try {
// Находим смещение поля value в памяти объекта
valueOffset = unsafe.objectFieldOffset(
AtomicInteger.class.getDeclaredField("value")
);
} catch (Exception e) {
throw new Error(e);
}
}
public final int incrementAndGet() {
while (true) {
int current = unsafe.getIntVolatile(this, valueOffset);
int next = current + 1;
// CAS на уровне Unsafe
if (unsafe.compareAndSwapInt(this, valueOffset, current, next)) {
return next;
}
}
}
}
Получение Unsafe (рефлексия)
import sun.misc.Unsafe;
import java.lang.reflect.Field;
public class UnsafeHelper {
public static Unsafe getUnsafe() {
try {
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
return (Unsafe) field.get(null);
} catch (Exception e) {
throw new RuntimeException("Unsafe не доступен", e);
}
}
}
// Использование
Unsafe unsafe = UnsafeHelper.getUnsafe();
CAS операции с Unsafe
class UnsafeCounter {
private volatile int count = 0;
private static final Unsafe unsafe = UnsafeHelper.getUnsafe();
private static final long countOffset;
static {
try {
// Находим адрес поля count
countOffset = unsafe.objectFieldOffset(
UnsafeCounter.class.getDeclaredField("count")
);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public void increment() {
int current;
// CAS loop using Unsafe
do {
current = count;
} while (
!unsafe.compareAndSwapInt(this, countOffset, current, current + 1)
);
}
public int get() {
return count;
}
}
Почему избегать Unsafe
❌ Internal API — может измениться в любой версии Java
❌ Опасно — может привести к segfault
❌ Непортируемо — зависит от реализации JVM
❌ Для высокого уровня имеем VarHandle (Java 9+)
Рекомендация: Используйте java.util.concurrent.atomic.Atomic* вместо Unsafe.
Подводные камни и Best Practices
1. Используйте Atomic* вместо synchronized для счетчиков
// ❌ ПЛОХО: большой overhead синхронизации
private int counter = 0;
public synchronized void increment() {
counter++; // На каждый вызов: захват/освобождение lock
}
// ✅ ХОРОШО: lock-free, эффективно
private AtomicInteger counter = new AtomicInteger(0);
public void increment() {
counter.incrementAndGet(); // CAS, нет блокировок
}
Когда synchronized все же лучше:
- High contention (много потоков конкурируют за один lock)
- Нужно синхронизировать сложную логику (несколько операций)
2. CAS может деградировать при высокой нагрузке
// ❌ ОПАСНО: при высокой контенции много retries
AtomicInteger sharedCounter = new AtomicInteger(0);
// 100 потоков пытаются incrementAndGet()
// Много потоков провалят CAS и повторят
// CPU потратит время на бесполезные retries
// ✅ ЛУЧШЕ: LongAdder для высокой контенции
LongAdder counter = new LongAdder();
counter.increment(); // Распределяет нагрузку по ячейкам
long result = counter.sum();
3. ABA-проблема требует версионирования
// ❌ УЯЗВИМО для ABA
AtomicReference<Node> head = new AtomicReference<>();
// ✅ ЗАЩИЩЕНО от ABA
AtomicStampedReference<Node> head =
new AtomicStampedReference<>(null, 0);
4. Не используйте Unsafe в production
// ❌ ОПАСНО: internal API
Unsafe unsafe = UnsafeHelper.getUnsafe();
unsafe.compareAndSwapInt(...);
// ✅ БЕЗОПАСНО: public API
AtomicInteger atomic = new AtomicInteger();
atomic.compareAndSet(...);
// ✅ СОВРЕМЕННО (Java 9+)
VarHandle handle = MethodHandles.lookup().findVarHandle(...);
handle.compareAndSet(obj, expected, new);
5. volatile + CAS для правильной синхронизации
// Ключевое правило:
// - volatile → видимость между потоками
// - CAS → атомарность операции
class SafeAtomicValue {
private volatile int value; // volatile нужен!
public boolean compareAndSet(int expected, int newValue) {
// CAS гарантирует атомарность
return unsafeCompareAndSwap(expected, newValue);
}
}
6. Избегайте длинных CAS loops
// ❌ ПЛОХО: дорогие вычисления внутри CAS loop
do {
current = ref.get();
newValue = expensiveComputation(current); // Может пересчитаться много раз!
} while (!ref.compareAndSet(current, newValue));
// ✅ ХОРОШО: вычисляем заранее
newValue = expensiveComputation(initial);
do {
current = ref.get();
final = quickUpdate(current, newValue); // Быстро
} while (!ref.compareAndSet(current, final));
7. LongAdder для высокой нагрузки
// ❌ AtomicLong при 100+ потоков
AtomicLong counter = new AtomicLong();
// Bottleneck: все потоки конкурируют на одно значение
// ✅ LongAdder для распределенной нагрузки
LongAdder counter = new LongAdder();
counter.increment(); // Внутри: распределение по ячейкам (striping)
long result = counter.sum(); // Сумма всех ячеек
Производительность:
AtomicLong с 16 потоками: ~10M ops/sec (bottleneck на одно значение)
LongAdder с 16 потоками: ~200M ops/sec (распределено)
8. Тестируйте lock-free код тщательно
Lock-free алгоритмы очень сложны для отладки. Используйте специализированные инструменты:
// Используйте JCStress для stress-тестирования
// https://github.com/openjdk/jcstress
// Или ConcurrentUnit для unit-тестов concurrent кода
@Test
public void testLockFreeStack() throws Exception {
LockFreeStack<Integer> stack = new LockFreeStack<>();
// Push из разных потоков
Thread t1 = new Thread(() -> stack.push(1));
Thread t2 = new Thread(() -> stack.push(2));
// ...
}
9. Документируйте lock-free алгоритмы
/**
* Lock-free LIFO Stack, основан на Treiber алгоритме.
*
* Потокобезопасен для параллельных push/pop операций.
*
* ⚠️ Потенциальная ABA-проблема если Node переиспользуется.
* Решение: использовать AtomicStampedReference или гарантировать,
* что Node не переиспользуется.
*
* Сложность:
* - push: O(1) среднее, но с retries при contention
* - pop: O(1) среднее, но с retries при contention
*
* @param <T> тип элементов в стеке
*/
class LockFreeStack<T> {
// ...
}
10. Измеряйте производительность
Не полагайтесь на теорию — всегда измеряйте!
// Используйте JMH (Java Microbenchmark Harness)
@Benchmark
public void benchmarkAtomicInteger(Blackhole bh) {
bh.consume(atomicCounter.incrementAndGet());
}
@Benchmark
public void benchmarkSynchronized(Blackhole bh) {
bh.consume(synchronizedCounter.increment());
}
// Запуск: java -jar jmh-benchmark.jar
Итоговая сравнительная таблица
| Механизм | Использование | Плюсы | Минусы |
|---|---|---|---|
| synchronized | Простая синхронизация сложной логики | Простой, надежный, хорошо на high contention | Блокировки, overhead, возможен deadlock |
| AtomicInteger | Счетчики, флаги | Lock-free, быстро на low contention | Может деградировать на high contention |
| AtomicReference | Ссылки на объекты | Lock-free, чистый API | ABA-проблема, сложность реализации |
| LongAdder | Счетчики высокой нагрузки | Отличная производительность при contention | Результат не мгновенный (sum не атомарен) |
| Lock-free алгоритмы | Структуры данных (стеки, очереди) | Нет deadlock, отличная масштабируемость | Очень сложно, ABA-проблема, трудно отладить |
| Unsafe | Внутренняя реализация (не используйте) | Полный контроль | Опасно, зависит от JVM, внутренний API |
Дополнительные ресурсы
Классические книги:
- "Concurrent Programming in Java" — Brian Goetz (основной источник знаний о concurrent Java)
- "The Art of Multiprocessor Programming" — Maurice Herlihy (детальные lock-free алгоритмы)
Инструменты для тестирования:
- JMH — для бенчмарков
- JCStress — для stress-тестирования concurrent кода
- ThreadSanitizer — для детекции data races
Примеры в JDK:
java.util.concurrent.atomic.*— Atomic классыjava.util.concurrent.ConcurrentHashMap— lock-free операции с bucketsjava.lang.invoke.VarHandle(Java 9+) — современная альтернатива Unsafe
Concurrent коллекции
Введение
Concurrent коллекции — это специальные потокобезопасные коллекции из пакета java.util.concurrent, спроектированные для эффективной работы в многопоточной среде без внешней синхронизации.
В отличие от синхронизированных обёрток (Collections.synchronizedMap()), concurrent коллекции используют продвинутые техники синхронизации:
- Lock-free алгоритмы (CAS операции) — атомарные сравнение-и-обмен операции без явных блокировок
- Сегментация данных (lock striping) — разделение на независимые участки с отдельными блокировками
- Copy-on-write стратегии — создание копии при модификации вместо блокировки читателей
- Неблокирующие алгоритмы — структуры данных, работающие без синхронизации
Почему synchronized недостаточно
Обычная коллекция + синхронизация:
HashMap + synchronized → один поток за раз
┌─────────────────────────────────┐
│ Synchronized HashTable │
│ [Entry] [Entry] [Entry] [Entry] │ ← Один глобальный lock
└─────────────────────────────────┘
↓
Только один поток пишет/читает одновременно
Остальные потоки ждут (контекстные переключения, падение производительности)
Concurrent коллекция (ConcurrentHashMap):
┌──────────────────────────────────────────────────────┐
│ ConcurrentHashMap (16 сегментов) │
├─────────┬──────────┬──────────┬──────────┬──────────┤
│ Seg 1 │ Seg 2 │ Seg 3 │ Seg 4 │ ... │
│ lock 1 │ lock 2 │ lock 3 │ lock 4 │ │
├─────────┼──────────┼──────────┼──────────┼──────────┤
│ Поток 1 │ Поток 2 │ Поток 3 │ Поток 4 │ Поток 5 │
│ пишет │ пишет │ читает │ пишет │ читает │
└─────────┴──────────┴──────────┴──────────┴──────────┘
↓
Множество потоков работают параллельно (разные сегменты)
Зачем нужны concurrent коллекции?
- Производительность: параллельный доступ без глобальной блокировки (линейная масштабируемость с ростом CPU ядер)
- Консистентность: потокобезопасность без необходимости внешней синхронизации
- Специализация: оптимизированные коллекции для конкретных сценариев (очереди, кеши, наблюдатели)
- Масштабируемость: throughput растёт с числом CPU ядер, не падает из-за конкуренции
ConcurrentHashMap: внутреннее устройство
ConcurrentHashMap — потокобезопасная hash-таблица с высокой производительностью, использующая сегментацию и CAS операции.
Архитектура ConcurrentHashMap (Java 7 и ранее): Segmentation
До Java 8 использовался подход с явным разделением на сегменты:
Старый подход (Java 7): Segmentation (разделение на 16 сегментов)
ConcurrentHashMap:
├── Segment 0 (ReentrantLock)
│ ├── Bucket 0 → Entry → Entry
│ ├── Bucket 1 → Entry
│ └── Bucket 2 → Entry → Entry → Entry
├── Segment 1 (ReentrantLock)
│ ├── Bucket 0 → Entry
│ ├── Bucket 1 → Entry → Entry
│ └── Bucket 2 → [пусто]
├── ...
└── Segment 15 (ReentrantLock)
Как это работает:
- Каждый сегмент = независимый ReentrantLock (по умолчанию 16 сегментов)
- Хеш кода key определяет, в какой сегмент он попадает
- Максимум 16 потоков пишут параллельно (по одному в каждый сегмент)
- Чтение блокируется на конкретный сегмент (не на всю таблицу)
- Много потоков могут читать одновременно из разных сегментов
- Итерация требует блокировки всех сегментов (дорого!)
Проблемы Java 7:
- Итерация требует полной синхронизации (все сегменты)
- 16 сегментов — фиксированное число (не масштабируется на системах с >16 ядрами)
- Больше памяти на объекты Segment
Архитектура ConcurrentHashMap (Java 8+): CAS + Synchronized
Java 8 принесла принципиально новый подход:
Новый подход (Java 8+): CAS операции + synchronized на bucket уровне
ConcurrentHashMap:
┌──────────────────────────────────────┐
│ Массив buckets (Node) │
├──────────────────────────────────────┤
│ Bucket 0: Node (volatile) ──────┐ │
│ Bucket 1: Node → Node → Node │ │
│ Bucket 2: TreeNode (при >8 коллизий) │ ← Red-Black дерево
│ Bucket 3: Node → Node │ │
│ ... ↓ │
└──────────────────────────────────────┘
synchronized при write
Как это работает:
- Вместо явных сегментов: массив buckets = массив hash-таблицы (как HashMap)
- Синхронизация на конкретный bucket (не на весь сегмент)
- CAS операции для атомарных действий без lock (fast path)
- Чтение через volatile reads (видимость изменений)
- TreeNode вместо LinkedList при коллизиях > 8 (O(log n) вместо O(n))
Преимущества Java 8+:
- Масштабируется на системах с >16 ядрами
- Быстрее на read-heavy workloads (volatile reads без lock)
- Эффективнее на hash collision heavy (красно-чёрное дерево)
- Проще итерация (не требует полной синхронизации)
- Меньше объектов в памяти (нет явных Segment объектов)
CAS операции (Compare-And-Swap)
Как работает CAS в ConcurrentHashMap.put():
Поток пытается добавить entry в Bucket[i]:
for (;;) { // CAS loop
// 1. Атомарно прочитать текущее значение bucket
Node<K,V> f = tabAt(tab, i);
if (f == null) {
// Bucket пустой
// Пытаемся атомарно установить новый Node
// casTabAt(tab, i, null, new Node)
CPU выполняет (атомарно):
if (tab[i] == null) {
tab[i] = new Node;
return success; // Успех! Остальные потоки не возьмут этот bucket
} else {
// Другой поток успел первый
// Повторить loop (retry)
}
} else {
// Bucket занят: блокируем только этот bucket
synchronized (f) {
// Добавляем в LinkedList или TreeNode
}
}
}
Преимущества CAS:
- Нет блокировок при f == null (fast path)
- Очень быстро на многопроцессорных системах
- Нет context switch при успешном CAS
Пример использования
import java.util.concurrent.ConcurrentHashMap;
class ConcurrentHashMapDemo {
public static void main(String[] args) throws InterruptedException {
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
// Параллельная запись из 10 потоков
// Каждый поток добавляет 1000 элементов
Runnable writer = () -> {
for (int i = 0; i < 1000; i++) {
String key = "key-" + i;
map.put(key, i);
}
};
// Создаём и запускаем потоки
Thread[] writers = new Thread[10];
long startTime = System.currentTimeMillis();
for (int i = 0; i < writers.length; i++) {
writers[i] = new Thread(writer);
writers[i].start();
}
// Ждём всех потоков
for (Thread t : writers) {
t.join();
}
long elapsed = System.currentTimeMillis() - startTime;
System.out.println("Total entries: " + map.size()); // 1000 (последний put wins)
System.out.println("Time: " + elapsed + " ms");
// С synchronized: ~3000 ms
// С ConcurrentHashMap: ~150 ms (20x быстрее!)
}
}
Атомарные операции
ConcurrentHashMap предоставляет методы, которые выполняются атомарно (без race conditions):
ConcurrentHashMap<String, Integer> cache = new ConcurrentHashMap<>();
// 1. putIfAbsent(key, value): добавить ЕСЛИ нет
// Проверка + добавление = ОДНА атомарная операция
Integer prev = cache.putIfAbsent("counter", 1);
if (prev != null) {
System.out.println("Key already existed with value: " + prev);
}
// Гарантия: два параллельных потока не добавят разные значения
// 2. replace(key, oldValue, newValue): заменить ЕСЛИ значение == oldValue
// Компаре + обмен = ОДНА атомарная операция
boolean replaced = cache.replace("counter", 1, 2);
System.out.println("Successfully replaced: " + replaced);
// Гарантия: замена произойдёт только если значение ещё == 1
// 3. compute(key, remappingFunction): вычислить новое значение
// Чтение + вычисление + запись = ОДНА атомарная операция
cache.compute("counter", (key, oldValue) -> {
int current = (oldValue == null) ? 0 : oldValue;
return current + 1; // Атомарный инкремент
});
// Гарантия: никто не изменит значение между чтением и записью
// 4. computeIfAbsent(key, mappingFunction): вычислить ЕСЛИ нет
// Проверка + вычисление + добавление = ОДНА атомарная операция
Integer value = cache.computeIfAbsent("expensive", k -> {
// Дорогостоящее вычисление (запрос в БД, API)
return loadUserFromDatabase(k); // Вызовется максимум один раз!
});
// Гарантия: даже если 100 потоков запросят simultaneiously,
// loadUserFromDatabase вызовется только один раз
// 5. merge(key, value, remappingFunction): объединить старое и новое
// Чтение + функция + запись = ОДНА атомарная операция
cache.merge("counter", 1, Integer::sum); // counter = old + 1
// Гарантия: атомарный инкремент без ручной синхронизации!
// Пример: подсчёт количества запросов по типу
cache.merge("GET_requests", 1, Integer::sum); // thread-safe +1
cache.merge("POST_requests", 1, Integer::sum); // thread-safe +1
Производительность: ConcurrentHashMap vs synchronized vs нативный HashMap
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
class HashMapBenchmark {
private static final int ITERATIONS = 1_000_000;
private static final int THREADS = 16;
static class SyncHashMapWrapper {
// ПЛОХО: synchronized обёртка (неэффективно)
private final Map<String, Integer> map = Collections.synchronizedMap(new HashMap<>());
public void put(String key, Integer value) {
map.put(key, value); // Глобальная блокировка
}
public Integer get(String key) {
return map.get(key); // Блокировка даже для чтения!
}
}
static class ManualSyncHashMap {
// ПЛОХО: ручная синхронизация (очень неэффективно)
private final Map<String, Integer> map = new HashMap<>();
public synchronized void put(String key, Integer value) {
map.put(key, value); // Только один поток за раз
}
public synchronized Integer get(String key) {
return map.get(key); // Только один поток читает за раз
}
}
static class ConcurrentHashMapImpl {
// ХОРОШО: ConcurrentHashMap (эффективно)
private final ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
public void put(String key, Integer value) {
map.put(key, value); // Блокировка только конкретного bucket
}
public Integer get(String key) {
return map.get(key); // Часто без блокировок (volatile read)
}
}
public static void benchmark(String name, Runnable warmup, Runnable test) {
// Разогрев JIT
for (int i = 0; i < 10000; i++) {
warmup.run();
}
// Тест
long start = System.nanoTime();
for (int i = 0; i < ITERATIONS; i++) {
test.run();
}
long elapsed = System.nanoTime() - start;
System.out.printf("%s: %.2f ns/op%n", name, (double) elapsed / ITERATIONS);
}
public static void main(String[] args) throws InterruptedException {
System.out.println("Benchmark: 16 потоков, 1M операций");
System.out.println("50% reads + 50% writes\n");
// Collections.synchronizedMap
SyncHashMapWrapper syncMap = new SyncHashMapWrapper();
long syncStart = System.currentTimeMillis();
Thread[] syncThreads = new Thread[THREADS];
for (int i = 0; i < THREADS; i++) {
final int threadId = i;
syncThreads[i] = new Thread(() -> {
for (int j = 0; j < ITERATIONS / THREADS; j++) {
if (j % 2 == 0) {
syncMap.put("key-" + (j % 100), j);
} else {
syncMap.get("key-" + (j % 100));
}
}
});
syncThreads[i].start();
}
for (Thread t : syncThreads) t.join();
long syncTime = System.currentTimeMillis() - syncStart;
System.out.println("Collections.synchronizedMap: " + syncTime + " ms");
// ConcurrentHashMap
ConcurrentHashMapImpl concMap = new ConcurrentHashMapImpl();
long concStart = System.currentTimeMillis();
Thread[] concThreads = new Thread[THREADS];
for (int i = 0; i < THREADS; i++) {
final int threadId = i;
concThreads[i] = new Thread(() -> {
for (int j = 0; j < ITERATIONS / THREADS; j++) {
if (j % 2 == 0) {
concMap.put("key-" + (j % 100), j);
} else {
concMap.get("key-" + (j % 100));
}
}
});
concThreads[i].start();
}
for (Thread t : concThreads) t.join();
long concTime = System.currentTimeMillis() - concStart;
System.out.println("ConcurrentHashMap: " + concTime + " ms");
System.out.println("Speedup: " + (double) syncTime / concTime + "x");
// Результаты:
// Collections.synchronizedMap: 8234 ms
// ConcurrentHashMap: 1045 ms
// Speedup: 7.8x
}
}
Внутренние механизмы: упрощённая реализация
// Упрощённая иллюстрация внутреннего устройства ConcurrentHashMap Java 8+
class SimplifiedConcurrentHashMap<K, V> {
// Массив buckets с volatile для видимости изменений
private volatile Node<K, V>[] table;
private static final int DEFAULT_CAPACITY = 16;
static class Node<K, V> {
final int hash;
final K key;
volatile V value; // volatile для видимости
volatile Node<K, V> next; // volatile для видимости
volatile Node<K, V>[] children; // для TreeNode при коллизиях
Node(int hash, K key, V value) {
this.hash = hash;
this.key = key;
this.value = value;
}
// Может расшириться в TreeNode при >8 коллизий
static class TreeNode<K, V> extends Node<K, V> {
TreeNode<K, V> parent;
TreeNode<K, V> left;
TreeNode<K, V> right;
// Red-Black дерево для O(log n) search вместо O(n) LinkedList
}
}
public SimplifiedConcurrentHashMap() {
table = new Node[DEFAULT_CAPACITY];
}
// Быстрое чтение без блокировок (volatile read обеспечивает видимость)
public V get(K key) {
Node<K, V>[] tab = table;
if (tab != null) {
// Вычисляем hash и индекс в массиве
int hash = spread(key.hashCode());
int index = (tab.length - 1) & hash;
// Читаем из bucket (volatile read)
Node<K, V> e = tabAt(tab, index);
// Проходим по цепочке (или дереву)
while (e != null) {
if (e.hash == hash && key.equals(e.key)) {
return e.value; // Volatile read ← видимость изменений
}
e = e.next;
}
}
return null;
}
// Запись с использованием CAS и synchronized
public V put(K key, V value) {
int hash = spread(key.hashCode());
Node<K, V>[] tab = table;
V oldValue = null;
for (;;) { // CAS loop - повторяем до успеха
if (tab == null) {
tab = initTable(); // Ленивая инициализация
continue;
}
int index = (tab.length - 1) & hash;
Node<K, V> f = tabAt(tab, index); // Volatile read
if (f == null) {
// Fast path: bucket пустой
// Пытаемся атомарно установить Node без блокировки
if (casTabAt(tab, index, null, new Node<>(hash, key, value))) {
return oldValue; // Успех! Никакой lock
}
// Другой поток успел первый → retry loop
} else if (f.hash == -1) {
// Таблица resizing → помогаем процессу
tab = helpTransfer(tab, f);
} else {
// Bucket занят: блокируем только этот bucket
synchronized (f) {
if (tabAt(tab, index) == f) { // Проверка after lock
// Добавляем в цепочку или преобразуем в дерево
Node<K, V> e = f;
int binCount = 0;
while (e != null) {
if (e.hash == hash && key.equals(e.key)) {
oldValue = e.value;
e.value = value; // Volatile write
break;
}
e = e.next;
binCount++;
}
if (e == null) {
// Новый ключ - добавляем в начало
Node<K, V> newNode = new Node<>(hash, key, value);
newNode.next = f;
casTabAt(tab, index, f, newNode); // Volatile write
}
// Если binCount >= 8: преобразуем LinkedList в TreeNode
}
}
return oldValue;
}
}
}
// Атомарные операции с использованием CAS
public V computeIfAbsent(K key, Function<K, V> mappingFunction) {
int hash = spread(key.hashCode());
Node<K, V>[] tab = table;
for (;;) {
if (tab != null) {
int index = (tab.length - 1) & hash;
Node<K, V> f = tabAt(tab, index);
if (f == null) {
// Bucket пустой: вычисляем и добавляем
V value = mappingFunction.apply(key);
if (casTabAt(tab, index, null, new Node<>(hash, key, value))) {
return value; // Успех - никто другой не добавил
}
// Retry: другой поток добавил - повторим
} else {
synchronized (f) {
// Ищем ключ в цепочке
for (Node<K, V> e = f; e != null; e = e.next) {
if (e.hash == hash && key.equals(e.key)) {
return e.value; // Ключ существует
}
}
// Вычисляем и добавляем
V value = mappingFunction.apply(key);
// Добавляем в начало цепочки
casTabAt(tab, index, f, new Node<>(hash, key, value));
return value;
}
}
} else {
tab = initTable();
}
}
}
// Хеш функция для лучшего распределения
private int spread(int h) {
return (h ^ (h >>> 16)) & 0x7fffffff;
}
// Volatile read bucket
private Node<K, V> tabAt(Node<K, V>[] tab, int i) {
return (Node<K, V>) U.getObjectVolatile(tab, ((long) i << ASHIFT) + ABASE);
}
// Atomic Compare-And-Swap на bucket
private boolean casTabAt(Node<K, V>[] tab, int i, Node<K, V> c, Node<K, V> v) {
return U.compareAndSwapObject(tab, ((long) i << ASHIFT) + ABASE, c, v);
}
private Node<K, V>[] initTable() {
// Ленивая инициализация с использованием CAS
// ...
return table;
}
// Вспомогательные константы для Unsafe операций
private static final sun.misc.Unsafe U = getUnsafe();
private static final long ABASE;
private static final int ASHIFT;
static {
try {
ABASE = U.arrayBaseOffset(Node[].class);
int scale = U.arrayIndexScale(Node[].class);
if ((scale & (scale - 1)) != 0)
throw new Error("data type scale not a power of two");
ASHIFT = 31 - Integer.numberOfLeadingZeros(scale);
} catch (Exception e) {
throw new Error(e);
}
}
private static sun.misc.Unsafe getUnsafe() {
try {
return sun.misc.Unsafe.getUnsafe();
} catch (SecurityException tried) {
try {
return java.security.AccessController.doPrivileged
((java.security.PrivilegedExceptionAction<sun.misc.Unsafe>) () ->
sun.misc.Unsafe.getUnsafe());
} catch (java.security.PrivilegedActionException e) {
throw new RuntimeException("Could not initialize intrinsics",
e.getCause());
}
}
}
}
CopyOnWriteArrayList
CopyOnWriteArrayList — потокобезопасный список, где каждая модификация создаёт новую копию внутреннего массива. Это избегает блокировки читателей.
Принцип Copy-on-Write
Copy-on-Write: разделение чтения и записи
Состояние 1: Несколько читателей
readers[A, B, C] ──→ pointing to → [1, 2, 3] ← массив (разделяемый)
Поток D хочет добавить 4:
1. Захватываем lock (для писателей)
2. Создаём копию массива: [1, 2, 3, 4] ← новый массив
3. Вычисления на копии (не трогаем исходный)
Состояние 2: Во время копирования
readers[A, B, C] ──→ [1, 2, 3] ← старый массив (A, B, C видят это)
writer[D] ──→ [1, 2, 3, 4] ← новый массив (D работает с этим)
Состояние 3: После успешной записи
readers[A, B, C] ──→ [1, 2, 3, 4] ← обновлён (атомарный swap)
Когда использовать CopyOnWriteArrayList
Идеально подходит:
- Чтение >>> Запись (99% reads, 1% writes или менее)
- Небольшие коллекции (копирование дешевое, <10K элементов)
- Итерация частая (нет ConcurrentModificationException)
- Event listeners, observers, subscriptions (редко добавляются, часто вызываются)
- Списки конфигов (загружаются один раз, читаются тысячи раз)
НЕ подходит:
- Частые модификации (копирование O(n) дорого)
- Большие коллекции (миллионы элементов → горячее копирование памяти)
- Требуется strict consistency (читатель может видеть старое состояние)
- Работа в реальном времени (GC пауза при копировании)
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
class EventListenerRegistry {
// Listeners редко добавляются/удаляются (0.1% writes)
// Но часто уведомляются (99.9% reads)
private final List<EventListener> listeners = new CopyOnWriteArrayList<>();
private int eventsFired = 0;
public void addListener(EventListener listener) {
// Редко вызывается - копирование приемлемо
listeners.add(listener); // Копирование массива O(n)
}
public void removeListener(EventListener listener) {
// Редко вызывается - копирование приемлемо
listeners.remove(listener); // Копирование массива O(n)
}
public void notifyListeners(Event event) {
// Часто вызывается - должно быть быстро
// Используем snapshot iterator
for (EventListener listener : listeners) {
try {
// Даже если listener добавится/удалится из другого потока
// Мы видим snapshot массива - нет ConcurrentModificationException
listener.onEvent(event);
} catch (Exception e) {
System.err.println("Listener error: " + e.getMessage());
}
}
eventsFired++;
}
public void printStats() {
System.out.println("Events fired: " + eventsFired);
System.out.println("Listeners: " + listeners.size());
}
interface EventListener {
void onEvent(Event event);
}
static class Event {
final String type;
Event(String type) { this.type = type; }
}
}
// Использование
class EventSystem {
public static void main(String[] args) throws InterruptedException {
EventListenerRegistry registry = new EventListenerRegistry();
// Добавляем listeners
registry.addListener(e -> System.out.println("Listener 1: " + e.type));
registry.addListener(e -> System.out.println("Listener 2: " + e.type));
registry.addListener(e -> System.out.println("Listener 3: " + e.type));
// Поток 1: генерирует события
Thread eventProducer = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
registry.notifyListeners(new EventListenerRegistry.Event("TYPE_" + (i % 5)));
Thread.sleep(10); // Некоторая задержка
}
});
// Поток 2: добавляет listener во время работы
Thread listenerAdder = new Thread(() -> {
try {
Thread.sleep(500); // Начать через 500ms
registry.addListener(e -> System.out.println("Late listener: " + e.type));
System.out.println("Added listener after start");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
eventProducer.start();
listenerAdder.start();
eventProducer.join();
listenerAdder.join();
registry.printStats();
// Нет ConcurrentModificationException!
// Late listener добавляется без проблем
}
}
Внутреннее устройство CopyOnWriteArrayList
// Упрощённая иллюстрация CopyOnWriteArrayList
class SimplifiedCopyOnWriteArrayList<E> {
// Внутренний массив - volatile для видимости обновления
private volatile Object[] array;
// Lock только для писателей (читатели не блокируются!)
private final Object lock = new Object();
public SimplifiedCopyOnWriteArrayList() {
array = new Object[0];
}
// Чтение: БЫСТРО, без блокировок
public E get(int index) {
// Просто читаем из массива (volatile read обеспечивает видимость)
return (E) array[index];
}
public int size() {
return array.length; // Volatile read
}
// Запись: ДОРОГО (копирование), но редко
public boolean add(E e) {
synchronized (lock) { // Блокировка только для писателей
Object[] oldArray = array;
int len = oldArray.length;
// Копируем весь массив (+1 элемент)
Object[] newArray = new Object[len + 1];
System.arraycopy(oldArray, 0, newArray, 0, len);
newArray[len] = e;
// Атомарное переключение на новый массив (volatile write)
array = newArray;
return true;
}
}
public boolean remove(Object o) {
synchronized (lock) { // Блокировка для писателя
Object[] oldArray = array;
int len = oldArray.length;
// Ищем элемент
int newlen = len - 1;
for (int i = 0; i < newlen; i++) {
if (o.equals(oldArray[i])) {
// Нашли - копируем без этого элемента
Object[] newArray = new Object[newlen];
System.arraycopy(oldArray, 0, newArray, 0, i);
System.arraycopy(oldArray, i + 1, newArray, i, newlen - i);
array = newArray; // Volatile write
return true;
}
}
return false;
}
}
// Итератор: snapshot iterator
public Iterator<E> iterator() {
// Берём снимок массива в момент создания итератора
return new CowIterator<>(array, 0);
}
private static class CowIterator<E> implements Iterator<E> {
private final Object[] snapshot; // Неизменяемый снимок
private int cursor;
CowIterator(Object[] snapshot, int start) {
this.snapshot = Arrays.copyOf(snapshot, snapshot.length);
this.cursor = start;
}
@Override
public boolean hasNext() {
return cursor < snapshot.length;
}
@Override
public E next() {
if (cursor >= snapshot.length) {
throw new NoSuchElementException();
}
return (E) snapshot[cursor++];
}
@Override
public void remove() {
// Итератор неизменяемый!
// Даже если основной список изменится, итератор видит снимок
throw new UnsupportedOperationException("remove on snapshot iterator");
}
}
}
// Пример использования
class CowExample {
public static void main(String[] args) throws InterruptedException {
SimplifiedCopyOnWriteArrayList<String> list = new SimplifiedCopyOnWriteArrayList<>();
// Исходные данные
list.add("A");
list.add("B");
list.add("C");
// Поток 1: итерирует список
Thread reader = new Thread(() -> {
Iterator<String> iterator = list.iterator(); // Снимок: [A, B, C]
while (iterator.hasNext()) {
System.out.println("Read: " + iterator.next());
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
// Поток 2: модифицирует список
Thread writer = new Thread(() -> {
try {
Thread.sleep(150); // Дождёмся начала итерации
System.out.println("Adding D...");
list.add("D"); // Копирование + атомарный swap
System.out.println("Adding E...");
list.add("E");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
reader.start();
writer.start();
reader.join();
writer.join();
// Вывод:
// Read: A
// Adding D...
// Read: B
// Adding E...
// Read: C
// Нет ConcurrentModificationException!
// Reader видел [A, B, C] (снимок)
// Но список теперь [A, B, C, D, E]
}
}
Сравнение с обычным ArrayList
// ArrayList + synchronized
synchronized (list) {
for (String item : list) { // Блокировка ВЕСЬ итерационный процесс
System.out.println(item);
Thread.sleep(100); // Во время sleep: lock держится!
}
}
// Другие потоки не могут добавить элемент - ждут 100ms * 1000 элементов
// CopyOnWriteArrayList
for (String item : list) { // Нет блокировки! Snapshot iterator
System.out.println(item);
Thread.sleep(100); // Другие потоки могут добавить элементы
}
// Другие потоки добавляют быстро (O(n) копирование, но один раз)
BlockingQueue: очереди с координацией потоков
BlockingQueue — интерфейс для очередей с блокирующими операциями:
put()— блокируется если очередь полная (для bounded queues)take()— блокируется если очередь пустая- Идеален для producer-consumer паттерна
Producer-Consumer паттерн
Классическая проблема: Producer-Consumer
Problem:
- Producer генерирует данные (может быть быстро или медленно)
- Consumer обрабатывает данные (может быть быстро или медленно)
- Нужна координация: Producer не должен создавать слишком много data,
Consumer не должен ждать, когда Producer создаст data
Solution: BlockingQueue!
Producer BlockingQueue (capacity=3) Consumer
↓ ↓ ↓
put(item1) ──→ [item1, _, _]
put(item2) ──→ [item1, item2, _]
put(item3) ──→ [item1, item2, item3] ← очередь ПОЛНАЯ
put(item4) ──→ БЛОКИРУЕТСЯ здесь!
(Producer чекает, пока Consumer не забирает)
──→ take() → item1
Место освободилось ↓
put(item4) ──→ [item2, item3, item4]
──→ take() → item2
──→ take() → item3
──→ take() → item4
──→ take() БЛОКИРУЕТСЯ здесь!
(Consumer чекает, пока Producer не добавит)
Преимущества:
- Backpressure: Producer не может перегрузить систему
- Load balancing: queue автоматически балансирует
- Decoupling: Producer не знает о Consumer (и наоборот)
Реализации BlockingQueue
| Реализация | Хранилище | Ёмкость | Порядок | Блокировка | Использование |
|---|---|---|---|---|---|
| ArrayBlockingQueue | Массив | Bounded (фиксированная) | FIFO | put/take блокируют | Фиксированный размер буфера |
| LinkedBlockingQueue | Связный список | Bounded/Unbounded | FIFO | put/take независимо | Гибкий размер, лучший throughput |
| PriorityBlockingQueue | Куча (heap) | Unbounded | По приоритету | только take блокирует | Приоритетная обработка |
| SynchronousQueue | Нет хранилища | Capacity=0 | Rendezvous | put и take паруются | Прямая передача потокам |
| DelayQueue | Куча | Unbounded | По delay | только take блокирует | Отложенные задачи |
ArrayBlockingQueue
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
class ArrayBlockingQueueExample {
public static void main(String[] args) throws InterruptedException {
// Очередь с ёмкостью 3 элемента
BlockingQueue<String> queue = new ArrayBlockingQueue<>(3);
// Producer: генерирует 5 задач
Thread producer = new Thread(() -> {
try {
for (int i = 1; i <= 5; i++) {
String task = "Task-" + i;
System.out.println(" [P] Producing: " + task);
queue.put(task); // Блокируется если очередь полная
System.out.println(" [P] Queue size: " + queue.size());
Thread.sleep(500); // Симуляция производства
}
queue.put("STOP"); // Сигнал завершения
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
// Consumer: обрабатывает задачи
Thread consumer = new Thread(() -> {
try {
while (true) {
String task = queue.take(); // Блокируется если очередь пустая
if (task.equals("STOP")) {
System.out.println("[C] Stopping consumer");
break;
}
System.out.println(" [C] Consuming: " + task);
System.out.println(" [C] Queue size: " + queue.size());
Thread.sleep(1000); // Симуляция обработки (медленнее производства)
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
producer.start();
consumer.start();
producer.join();
consumer.join();
// Вывод:
// [P] Producing: Task-1
// [P] Queue size: 1
// [P] Producing: Task-2
// [P] Queue size: 2
// [P] Producing: Task-3
// [P] Queue size: 3
// [P] Producing: Task-4
// (БЛОКИРУЕТСЯ - очередь полная, ждёт Consumer)
//
// [C] Consuming: Task-1
// [C] Queue size: 2
// [P] Queue size: 3
// [P] Producing: Task-5
// (СНОВА полная)
//
// [C] Consuming: Task-2
// [C] Consuming: Task-3
// [C] Consuming: Task-4
// [C] Consuming: Task-5
// [C] Stopping consumer
}
}
LinkedBlockingQueue: лучший throughput
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.BlockingQueue;
class LinkedBlockingQueueVsArray {
static class Benchmark {
static void benchmark(String name, BlockingQueue<Integer> queue) throws InterruptedException {
long start = System.currentTimeMillis();
int items = 100_000;
// Producers (4 потока)
Thread[] producers = new Thread[4];
for (int p = 0; p < 4; p++) {
final int producerId = p;
producers[p] = new Thread(() -> {
try {
for (int i = 0; i < items / 4; i++) {
queue.put(i);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
producers[p].start();
}
// Consumers (4 потока)
Thread[] consumers = new Thread[4];
for (int c = 0; c < 4; c++) {
final int consumerId = c;
consumers[c] = new Thread(() -> {
try {
for (int i = 0; i < items / 4; i++) {
queue.take();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
consumers[c].start();
}
for (Thread p : producers) p.join();
for (Thread c : consumers) c.join();
long elapsed = System.currentTimeMillis() - start;
System.out.printf("%s (%d items, 4 producers, 4 consumers): %d ms%n",
name, items, elapsed);
}
}
public static void main(String[] args) throws InterruptedException {
// Benchmark результаты:
// ArrayBlockingQueue (100000 items): ~2500 ms
// LinkedBlockingQueue (100000 items): ~1200 ms (2x быстрее!)
// Причина: LinkedBlockingQueue использует два separate locks
// - putLock: контролирует put операции
// - takeLock: контролирует take операции
// Producers и consumers могут работать параллельно!
// В то время как ArrayBlockingQueue использует один lock
// для put и take (контлящей)
}
}
PriorityBlockingQueue: приоритетная очередь
import java.util.concurrent.PriorityBlockingQueue;
class PriorityQueueExample {
static class Task implements Comparable<Task> {
private final String name;
private final int priority; // Чем меньше, тем выше приоритет (0 = критична!)
private final long createdAt = System.currentTimeMillis();
Task(String name, int priority) {
this.name = name;
this.priority = priority;
}
@Override
public int compareTo(Task other) {
// Сортируем по приоритету (меньше = выше приоритет)
if (this.priority != other.priority) {
return Integer.compare(this.priority, other.priority);
}
// Если приоритет одинаковый - первый добавленный первый обработан (FIFO)
return Long.compare(this.createdAt, other.createdAt);
}
@Override
public String toString() {
return name + "[priority=" + priority + "]";
}
}
public static void main(String[] args) throws InterruptedException {
PriorityBlockingQueue<Task> queue = new PriorityBlockingQueue<>();
// Producer: добавляет задачи с разными приоритетами
Thread producer = new Thread(() -> {
try {
queue.put(new Task("BackgroundJob", 10));
queue.put(new Task("NormalJob", 5));
queue.put(new Task("CriticalAlert", 1));
queue.put(new Task("LowPriorityJob", 8));
queue.put(new Task("HighPriorityJob", 2));
queue.put(new Task("STOP", Integer.MAX_VALUE));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
// Consumer: обрабатывает задачи по приоритету
Thread consumer = new Thread(() -> {
try {
while (true) {
Task task = queue.take();
if (task.name.equals("STOP")) {
break;
}
System.out.println("Processing: " + task);
Thread.sleep(100);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
producer.start();
consumer.start();
producer.join();
consumer.join();
// Вывод (в порядке приоритета, не в порядке добавления):
// Processing: CriticalAlert[priority=1]
// Processing: HighPriorityJob[priority=2]
// Processing: NormalJob[priority=5]
// Processing: LowPriorityJob[priority=8]
// Processing: BackgroundJob[priority=10]
}
}
SynchronousQueue: прямая передача потокам
import java.util.concurrent.SynchronousQueue;
class SynchronousQueueExample {
public static void main(String[] args) throws InterruptedException {
// SynchronousQueue = очередь ёмкостью 0 (ничего не хранит)
// Producer ОБЯЗАН ждать Consumer и наоборот
// Это паттерн "rendezvous" - встреча двух потоков
SynchronousQueue<String> queue = new SynchronousQueue<>();
// Producer: пытается передать
Thread producer = new Thread(() -> {
try {
System.out.println("[P] Готов передать item1 (буду ждать Consumer)");
long t1 = System.currentTimeMillis();
queue.put("item1");
long elapsed = System.currentTimeMillis() - t1;
System.out.println("[P] item1 передан Consumer (ждал " + elapsed + "ms)");
System.out.println("[P] Готов передать item2");
long t2 = System.currentTimeMillis();
queue.put("item2");
elapsed = System.currentTimeMillis() - t2;
System.out.println("[P] item2 передан Consumer (ждал " + elapsed + "ms)");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
// Consumer: берёт
Thread consumer = new Thread(() -> {
try {
Thread.sleep(2000); // Задержка - Producer будет ждать!
System.out.println(" [C] Беру item1");
String item1 = queue.take();
System.out.println(" [C] Получил " + item1);
Thread.sleep(2000); // Снова задержка
System.out.println(" [C] Беру item2");
String item2 = queue.take();
System.out.println(" [C] Получил " + item2);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
producer.start();
consumer.start();
producer.join();
consumer.join();
// Вывод:
// [P] Готов передать item1 (буду ждать Consumer)
// (ждёт 2 секунды...)
// [C] Беру item1
// [P] item1 передан Consumer (ждал 2000ms)
// [P] Готов передать item2
// [C] Получил item1
// (ждёт 2 секунды...)
// [C] Беру item2
// [P] item2 передан Consumer (ждал 2000ms)
// [C] Получил item2
// Использование SynchronousQueue в Executors:
System.out.println("\n\nExecutors.newCachedThreadPool() использует SynchronousQueue");
// - Каждая задача передаётся свежему потоку
// - Если нет свободного потока - создаётся новый
// - Потоки, которые ничего не делают 60 секунд - умирают
}
}
Операции BlockingQueue
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;
class BlockingQueueOperations {
public static void main(String[] args) throws InterruptedException {
BlockingQueue<String> queue = new ArrayBlockingQueue<>(3);
System.out.println("=== Блокирующие операции (бесконечное ожидание) ===");
// 1. put(E e): добавить элемент
// Блокируется если очередь ПОЛНАЯ
queue.put("item1");
queue.put("item2");
queue.put("item3");
System.out.println("Queue full, size: " + queue.size());
// queue.put("item4"); // Заблокируется здесь навечно!
// 2. take(): забрать элемент
// Блокируется если очередь ПУСТАЯ
String item = queue.take();
System.out.println("Took: " + item + ", size: " + queue.size());
System.out.println("\n=== Операции с таймаутом (конечное ожидание) ===");
// 3. offer(E e, long timeout, TimeUnit unit): попробовать добавить
// Возвращает: true если добавлено, false если таймаут истёк
boolean added = queue.offer("item4", 1, TimeUnit.SECONDS);
System.out.println("offer('item4'): " + added); // true - есть место
// queue.offer("item5", 1, TimeUnit.SECONDS); // false - очередь заполнится
// 4. poll(long timeout, TimeUnit unit): попробовать забрать
// Возвращает: элемент или null если таймаут истёк
String polled = queue.poll(1, TimeUnit.SECONDS);
System.out.println("poll(1s): " + polled); // item2
System.out.println("\n=== Немедленные операции (не блокируют) ===");
// 5. offer(E e): попробовать добавить
// Возвращает: true если добавлено, false если очередь полная
queue.clear();
queue.offer("A");
queue.offer("B");
queue.offer("C");
boolean success = queue.offer("D"); // false - очередь полная
System.out.println("offer('D') on full queue: " + success);
// 6. poll(): попробовать забрать
// Возвращает: элемент или null если очередь пустая
String polled2 = queue.poll();
System.out.println("poll(): " + polled2);
String polled3 = queue.poll();
System.out.println("poll(): " + polled3);
// 7. peek(): посмотреть элемент без удаления
String peeked = queue.peek(); // Посмотрели
System.out.println("peek(): " + peeked);
String peeked2 = queue.peek(); // Тот же элемент
System.out.println("peek(): " + peeked2);
System.out.println("\n=== Операции с исключениями (бросают Exception) ===");
queue.clear();
// 8. add(E e): добавить или выбросить IllegalStateException
queue.add("X");
queue.add("Y");
queue.add("Z");
// queue.add("W"); // IllegalStateException - очередь полная!
// 9. remove(): забрать или выбросить NoSuchElementException
System.out.println("remove(): " + queue.remove());
System.out.println("remove(): " + queue.remove());
// 10. element(): посмотреть или выбросить NoSuchElementException
String elem = queue.element();
System.out.println("element(): " + elem);
queue.remove();
// queue.element(); // NoSuchElementException - очередь пустая!
System.out.println("\n=== Сводная таблица операций ===");
// put() ───────────────────────── блокирует если полная
// offer(E) ────────────────────── false если полная
// offer(E, timeout, unit) ──────── false если таймаут
// take() ───────────────────────── блокирует если пустая
// poll() ────────────────────────── null если пустая
// poll(timeout, unit) ─────────── null если таймаут
// add(E) ────────────────────────── Exception если полная
// remove() ──────────────────────── Exception если пустая
// element() ─────────────────────── Exception если пустая
// peek() ────────────────────────── null если пустая
}
}
ConcurrentLinkedQueue: неблокирующая очередь
ConcurrentLinkedQueue — неблокирующая (lock-free) FIFO очередь, использующая CAS операции. Операции offer() и poll() никогда не блокируют.
ConcurrentLinkedQueue: lock-free архитектура с CAS
Head (указатель на начало) Tail (указатель на конец)
↓ ↓
[Node1] → [Node2] → [Node3] → [Node4] → [Node5]
↑ ↑
poll() отсюда offer() сюда
Множество потоков одновременно:
- offer() (добавление в tail) - CAS операции
- poll() (удаление с head) - CAS операции
- peek() (чтение head) - volatile read
ВАЖНО: все операции используют CAS
- CAS = Compare-And-Swap (атомарно без явных lock)
- Нет блокировок = нет context switch overhead
- Если CAS не прошёл - retry (loop)
Результат: очень высокая производительность на многоядерных системах
Когда использовать ConcurrentLinkedQueue vs BlockingQueue
ConcurrentLinkedQueue:
+ Очень быстро (lock-free)
+ Масштабируется с числом ядер
+ Unbounded (никогда не переполняется)
- Нет блокировки (producer не может ждать)
- poll() возвращает null если пустая (нужно polling)
- Нет backpressure (producer может перегрузить)
BlockingQueue:
+ Есть блокировка (producer может ждать)
+ Backpressure (защита от перегрузки)
+ Проще код (take() ждёт, не нужен polling)
- Блокировки медленнее на высокой конкуренции
- Требует обработки InterruptedException
- Fixed capacity (нужно выбирать размер)
Пример использования
import java.util.concurrent.ConcurrentLinkedQueue;
class ConcurrentLinkedQueueExample {
public static void main(String[] args) throws InterruptedException {
ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();
// Множество producers (неблокирующие добавления)
Runnable producer = () -> {
for (int i = 0; i < 1000; i++) {
String item = "item-" + i + "-" + Thread.currentThread().getName();
queue.offer(item); // Никогда не блокируется
}
};
// Множество consumers (неблокирующие удаления)
Runnable consumer = () -> {
int count = 0;
for (int i = 0; i < 500; i++) {
String item = queue.poll(); // null если пустая
if (item != null) {
count++;
if (count % 100 == 0) {
System.out.println(Thread.currentThread().getName() +
" consumed " + count + " items");
}
} else {
i--; // Повторить, если пустая (busy-wait)
// В реальном коде: Thread.yield() или LockSupport.parkNanos()
}
}
};
Thread[] threads = new Thread[20];
long start = System.currentTimeMillis();
// Создаём 10 producers + 10 consumers
for (int i = 0; i < 10; i++) {
threads[i] = new Thread(producer, "Producer-" + i);
threads[i + 10] = new Thread(consumer, "Consumer-" + i);
}
for (Thread t : threads) t.start();
for (Thread t : threads) t.join();
long elapsed = System.currentTimeMillis() - start;
System.out.println("Completed in " + elapsed + "ms");
System.out.println("Remaining in queue: " + queue.size());
}
}
Производительность: ConcurrentLinkedQueue vs LinkedBlockingQueue
class QueueBenchmark {
static void benchmark(String name, Queue<Integer> queue, int ops) throws InterruptedException {
long start = System.nanoTime();
int threads = Runtime.getRuntime().availableProcessors();
// Producers
Thread[] producers = new Thread[threads / 2];
for (int i = 0; i < threads / 2; i++) {
producers[i] = new Thread(() -> {
for (int j = 0; j < ops / (threads / 2); j++) {
if (queue instanceof ConcurrentLinkedQueue) {
((ConcurrentLinkedQueue<Integer>) queue).offer(j);
} else {
try {
((BlockingQueue<Integer>) queue).put(j);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
});
producers[i].start();
}
// Consumers
Thread[] consumers = new Thread[threads / 2];
for (int i = 0; i < threads / 2; i++) {
consumers[i] = new Thread(() -> {
for (int j = 0; j < ops / (threads / 2); j++) {
if (queue instanceof ConcurrentLinkedQueue) {
while (((ConcurrentLinkedQueue<Integer>) queue).poll() == null);
} else {
try {
((BlockingQueue<Integer>) queue).take();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
});
consumers[i].start();
}
for (Thread t : producers) t.join();
for (Thread t : consumers) t.join();
long elapsed = System.nanoTime() - start;
System.out.printf("%s (%d ops, %d threads): %.2f ns/op%n",
name, ops, threads, (double) elapsed / ops);
}
public static void main(String[] args) throws InterruptedException {
int ops = 1_000_000;
// Результаты:
// ConcurrentLinkedQueue (1000000 ops, 16 threads): 42.35 ns/op
// LinkedBlockingQueue (1000000 ops, 16 threads): 156.88 ns/op (3.7x медленнее!)
System.out.println("ConcurrentLinkedQueue: очень быстро, lock-free");
System.out.println("LinkedBlockingQueue: медленнее, но есть блокировка");
}
}
TransferQueue: расширение BlockingQueue
TransferQueue (интерфейс) и LinkedTransferQueue (реализация) — расширение BlockingQueue с методом transfer(), который гарантирует передачу элемента конкретному потоку.
Отличие от SynchronousQueue:
SynchronousQueue:
- capacity = 0
- Producer ОБЯЗАН ждать, пока Consumer возьмёт (rendezvous)
- Нет буферизации
TransferQueue:
- capacity > 0 (может хранить элементы в очереди)
- transfer() опционально ждёт (есть буферизация как резервный план)
- put() не ждёт Consumer
- Можно спросить: hasWaitingConsumer(), getWaitingConsumerCount()
Методы TransferQueue
import java.util.concurrent.LinkedTransferQueue;
import java.util.concurrent.TransferQueue;
class TransferQueueExample {
public static void main(String[] args) throws InterruptedException {
TransferQueue<String> queue = new LinkedTransferQueue<>();
System.out.println("=== transfer() vs put() ===\n");
// put(): добавить в очередь (не ждёт Consumer)
System.out.println("put('item1'):");
queue.put("item1");
System.out.println(" Не заблокировалось - item1 в очереди");
// transfer(): ждёт пока Consumer возьмёт
System.out.println("\ntransfer('item2'):");
Thread transferThread = new Thread(() -> {
try {
System.out.println(" Вызвал transfer('item2')");
long t = System.currentTimeMillis();
queue.transfer("item2"); // Блокируется здесь!
long elapsed = System.currentTimeMillis() - t;
System.out.println(" transfer() вернулся (ждал " + elapsed + "ms)");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
transferThread.start();
// Даём transfer() время заблокироваться
Thread.sleep(1000);
System.out.println(" queue.size() = " + queue.size() + " (item2 не в очереди!)");
System.out.println(" hasWaitingConsumer() = " + queue.hasWaitingConsumer());
// Consumer забирает
String item2 = queue.take();
System.out.println(" Consumer взял: " + item2);
transferThread.join();
System.out.println("\n=== tryTransfer() ===\n");
// tryTransfer(): пытается передать немедленно (returns false если нет)
System.out.println("tryTransfer('item3') (нет Consumer): " +
queue.tryTransfer("item3")); // false - нет ожидающего Consumer
System.out.println("put('item4'):");
queue.put("item4");
System.out.println(" queue.size() = " + queue.size());
// tryTransfer с таймаутом
System.out.println("\ntryTransfer('item5', 1s):");
boolean transferred = queue.tryTransfer("item5", 1, java.util.concurrent.TimeUnit.SECONDS);
System.out.println(" transferred = " + transferred + " (таймаут истёк)");
// Но put() добавил в очередь
System.out.println(" queue.size() = " + queue.size());
}
}
Гарантия обработки с transfer()
class RequestResponsePattern {
static class Request {
final String id;
final String data;
Request(String id, String data) {
this.id = id;
this.data = data;
}
}
public static void main(String[] args) throws InterruptedException {
TransferQueue<Request> queue = new LinkedTransferQueue<>();
// Consumer: обрабатывает запросы
Thread consumer = new Thread(() -> {
try {
for (int i = 0; i < 3; i++) {
Request req = queue.take();
System.out.println("[C] Обрабатываю: " + req.id);
Thread.sleep(500); // Симуляция обработки
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
// Producer: отправляет критичные запросы
Thread producer = new Thread(() -> {
try {
for (int i = 0; i < 3; i++) {
Request req = new Request("REQ-" + i, "data-" + i);
System.out.println("[P] Отправляю: " + req.id);
// transfer(): ГАРАНТИРУЕТ что Consumer примет
long t = System.currentTimeMillis();
queue.transfer(req); // Ждёт пока Consumer возьмёт!
long elapsed = System.currentTimeMillis() - t;
System.out.println("[P] " + req.id + " гарантированно обработан (ждал " + elapsed + "ms)");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
consumer.start();
producer.start();
producer.join();
consumer.join();
// Вывод: Producer всегда знает когда его запрос взяли
}
}
Практические сценарии и anti-patterns
Сценарий 1: Concurrent кеш с lazy loading
import java.util.concurrent.ConcurrentHashMap;
class UserCache {
private final ConcurrentHashMap<Long, User> cache = new ConcurrentHashMap<>();
// Ленивая загрузка: вычислить только если нет в кеше
// ГАРАНТИЯ: функция вызовется максимум один раз на ключ
public User getUser(Long userId) {
return cache.computeIfAbsent(userId, id -> {
System.out.println("[DB] Loading user " + id);
return loadUserFromDatabase(id);
});
}
// Попытка обновления: только если ключ существует
public void updateAge(Long userId, int newAge) {
cache.computeIfPresent(userId, (id, user) -> {
user.age = newAge;
return user;
});
}
// Слияние счётчиков
public void recordLogin(Long userId) {
cache.merge(userId, 1, (oldCount, one) -> oldCount + one);
}
private User loadUserFromDatabase(Long id) {
// Имитация медленной загрузки
try { Thread.sleep(100); } catch (InterruptedException e) {}
return new User(id, "User" + id);
}
static class User {
Long id;
String name;
int age;
User(Long id, String name) { this.id = id; this.name = name; }
}
}
Сценарий 2: Event bus с многоподписными слушателями
import java.util.concurrent.CopyOnWriteArrayList;
class EventBus {
private final CopyOnWriteArrayList<EventHandler> handlers = new CopyOnWriteArrayList<>();
public void subscribe(EventHandler handler) {
handlers.add(handler); // Копирование (редко)
}
public void publish(Event event) {
// Итерация на snapshot (даже если handlers изменятся)
for (EventHandler handler : handlers) {
try {
handler.handle(event);
} catch (Exception e) {
System.err.println("Handler error: " + e);
}
}
}
interface EventHandler {
void handle(Event event);
}
static class Event {
String type;
Object data;
}
}
Сценарий 3: Producer-Consumer pipeline
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
class DataPipeline {
private final BlockingQueue<Data> stage1Queue = new ArrayBlockingQueue<>(100);
private final BlockingQueue<Data> stage2Queue = new ArrayBlockingQueue<>(100);
public void start() {
// Stage 1: Source
new Thread(this::stage1Producer).start();
// Stage 2: Transform
new Thread(this::stage2Transform).start();
// Stage 3: Sink
new Thread(this::stage3Consumer).start();
}
private void stage1Producer() {
try {
for (int i = 0; i < 1000; i++) {
Data data = new Data(i);
stage1Queue.put(data); // Блокируется если полная
}
stage1Queue.put(new Data(-1)); // Signal end
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private void stage2Transform() {
try {
while (true) {
Data data = stage1Queue.take(); // Блокируется если пустая
if (data.id == -1) {
stage2Queue.put(data);
break;
}
data.value *= 2; // Transform
stage2Queue.put(data);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private void stage3Consumer() {
try {
while (true) {
Data data = stage2Queue.take();
if (data.id == -1) break;
System.out.println("Final: " + data);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
static class Data {
int id;
int value;
Data(int id) { this.id = id; this.value = id * 10; }
}
}
Anti-pattern 1: Неправильное использование computeIfAbsent
// ПЛОХО: не потокобезопасно для составных операций
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
// ❌ НЕПРАВИЛЬНО (race condition)
if (!map.containsKey("count")) {
map.put("count", 0); // Другой поток может добавить между проверкой и put
}
// ✅ ПРАВИЛЬНО (атомарная операция)
map.putIfAbsent("count", 0);
// ❌ НЕПРАВИЛЬНО (дорогостоящее вычисление вызовется несколько раз)
map.computeIfAbsent("expensive", k -> expensiveOperation()); // Может быть гонка
// ✅ ПРАВИЛЬНО (гарантированно один раз)
map.computeIfAbsent("expensive", k -> {
return expensiveOperation(); // Вызовется максимум один раз
});
Anti-pattern 2: CopyOnWriteArrayList для частых модификаций
// ❌ ПЛОХО: копирование при каждом add (O(n))
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
for (int i = 0; i < 10_000; i++) {
list.add("item-" + i); // 10,000 копирований массива!
}
// ✅ ХОРОШО: batch операция
List<String> batch = new ArrayList<>();
for (int i = 0; i < 10_000; i++) {
batch.add("item-" + i);
}
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>(batch);
Anti-pattern 3: Unbounded очереди
// ❌ ОПАСНО: может вызвать OutOfMemoryError
LinkedBlockingQueue<Task> queue = new LinkedBlockingQueue<>();
// Если producer быстрее consumer → очередь растёт бесконечно
// ✅ БЕЗОПАСНО: bounded очередь
LinkedBlockingQueue<Task> queue = new LinkedBlockingQueue<>(1000);
// Producer заблокируется если очередь переполнится (backpressure)
// ✅ АЛЬТЕРНАТИВА: если нужна unbounded
SynchronousQueue<Task> queue = new SynchronousQueue<>();
// Но нужно гарантировать что consumer активен
Anti-pattern 4: Забыли обработать InterruptedException
// ❌ ПЛОХО: перехватили Exception и проигнорили
try {
String item = queue.take();
} catch (InterruptedException e) {
// Молчаливо проигнорировали - интерпретация потока потеряна!
}
// ✅ ПРАВИЛЬНО: восстановить флаг interrupt
try {
String item = queue.take();
} catch (InterruptedException e) {
// Восстановить флаг для верхних уровней
Thread.currentThread().interrupt();
// Или прокинуть дальше:
throw new RuntimeException("Interrupted", e);
}
// ✅ АЛЬТЕРНАТИВА: poll с таймаутом
String item = queue.poll(1, TimeUnit.SECONDS); // Не выбросит, вернёт null
Anti-pattern 5: ConcurrentHashMap.size() - дорого!
// ❌ ПЛОХО: size() обходит всю очередь (O(n) даже для ConcurrentLinkedQueue)
ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();
if (queue.size() > 1000) { // Очень медленно!
// ...
}
// ✅ ХОРОШО: isEmpty() - O(1)
if (!queue.isEmpty()) {
String item = queue.poll();
}
// ✅ АЛЬТЕРНАТИВА: поддерживать счётчик
AtomicInteger size = new AtomicInteger(0);
queue.offer(item);
size.incrementAndGet();
Заключение: выбор правильной concurrent коллекции
| Сценарий | Рекомендация | Причина |
|---|---|---|
| Высокая конкуренция на чтение-запись | ConcurrentHashMap | Lock-free операции, lock striping |
| Редкая запись, частое чтение (read-heavy) | CopyOnWriteArrayList | Без блокировки при чтении |
| Producer-Consumer с bounded ёмкостью | ArrayBlockingQueue или LinkedBlockingQueue | Backpressure, блокировка |
| Приоритетная очередь задач | PriorityBlockingQueue | Гарантированный порядок приоритета |
| Прямая передача потокам (rendezvous) | SynchronousQueue | Нет буферизации, прямая передача |
| Очень высокая производительность | ConcurrentLinkedQueue | Lock-free, CAS операции |
| Event listeners (subscribe-publish) | CopyOnWriteArrayList | Итерация без блокировок |
| Гарантия обработки producer-consumer | LinkedTransferQueue | Метод transfer() гарантирует |
Золотое правило:
- Начните с ConcurrentHashMap если нужен map
- Используйте BlockingQueue если нужна координация между потоками
- Выбирайте ConcurrentLinkedQueue если нужна максимальная производительность без блокировок
- Не забывайте про CopyOnWriteArrayList для read-heavy сценариев!
Executor Framework и Thread Pools
Введение
Executor Framework — высокоуровневая API для управления потоками и выполнения асинхронных задач, введённая в Java 5 (java.util.concurrent).
Вместо ручного создания потоков (new Thread()), Executor Framework предоставляет:
- Thread Pools: переиспользование потоков для множества задач
- Асинхронное выполнение: submit задач и получение результатов через Future
- Управление жизненным циклом: graceful shutdown, ожидание завершения
- Гибкость: настраиваемые политики выполнения и обработки ошибок
Проблема без Executor Framework
// ПРОБЛЕМНЫЙ КОД
for (int i = 0; i < 1000; i++) {
new Thread(() -> doWork()).start(); // 1000 потоков!
}
Почему это плохо:
-
Overhead создания потоков: каждый поток требует выделения памяти, инициализации, регистрации в ОС. Создание 1000 потоков занимает значительное время.
-
Context switching: процессор должен переключаться между потоками. С 1000 потоками это происходит постоянно, накладные расходы на сохранение/восстановление состояния потока становятся значительными.
-
Утечка памяти: каждый поток занимает примерно 1-2 МБ памяти на размер стека. 1000 потоков = 1-2 ГБ только на стеки потоков.
-
OutOfMemoryError: при определённом количестве потоков система исчерпает память.
Решение с Executor Framework
ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < 1000; i++) {
executor.submit(() -> doWork()); // 10 потоков, 1000 задач
}
executor.shutdown();
Преимущества:
-
Переиспользование потоков: 10 потоков выполнят 1000 задач. Поток, завершив одну задачу, берёт другую из очереди.
-
Меньше context switches: переключение между 10 потоками вместо 1000 значительно быстрее.
-
Контролируемое потребление ресурсов: памяти используется примерно 10-20 МБ вместо 1-2 ГБ.
-
Гибкость: легко масштабировать, изменяя размер пула.
ExecutorService: основной интерфейс
ExecutorService — интерфейс для управления выполнением задач и жизненным циклом executor'а.
Иерархия интерфейсов
Executor (самый базовый интерфейс)
↓
void execute(Runnable command)
Предоставляет минимальные возможности для выполнения задачи.
Нет управления жизненным циклом.
ExecutorService (расширение Executor)
↓
Future<?> submit(Runnable task)
<T> Future<T> submit(Callable<T> task)
void shutdown()
List<Runnable> shutdownNow()
boolean awaitTermination(long timeout, TimeUnit unit)
List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks)
T invokeAny(Collection<? extends Callable<T>> tasks)
Добавляет управление жизненным циклом и получение результатов.
ScheduledExecutorService (расширение ExecutorService)
↓
ScheduledFuture<?> schedule(Runnable, long delay, TimeUnit)
ScheduledFuture<?> scheduleAtFixedRate(...)
ScheduledFuture<?> scheduleWithFixedDelay(...)
Добавляет возможность отложенного и периодического выполнения.
Основные методы ExecutorService
import java.util.concurrent.*;
ExecutorService executor = Executors.newFixedThreadPool(5);
// 1. submit(Runnable): выполнить задачу без результата
Future<?> future1 = executor.submit(() -> {
System.out.println("Task executed");
});
// 2. submit(Callable): выполнить задачу с результатом
Future<Integer> future2 = executor.submit(() -> {
return 42;
});
Integer result = future2.get(); // Блокируется до завершения
// 3. submit(Runnable, T result): выполнить и вернуть результат
String resultObject = "Done";
Future<String> future3 = executor.submit(() -> {
// Работа
}, resultObject);
String res = future3.get(); // Вернёт "Done"
// 4. invokeAll: выполнить все задачи
List<Callable<Integer>> tasks = List.of(
() -> 1,
() -> 2,
() -> 3
);
List<Future<Integer>> futures = executor.invokeAll(tasks);
for (Future<Integer> f : futures) {
System.out.println(f.get()); // Все задачи гарантированно завершены
}
// 5. invokeAny: выполнить и вернуть первый результат
Integer firstResult = executor.invokeAny(tasks);
System.out.println("First: " + firstResult);
// 6. Graceful shutdown
executor.shutdown(); // Не принимает новые задачи
boolean terminated = executor.awaitTermination(1, TimeUnit.MINUTES);
// 7. Принудительный shutdown
List<Runnable> notExecuted = executor.shutdownNow(); // Прерывает активные задачи
Типы Thread Pools
1. FixedThreadPool
Фиксированное число потоков в пуле. Задачи ждут в очереди, если все потоки заняты.
ExecutorService fixedPool = Executors.newFixedThreadPool(5);
// Внутренняя реализация (что происходит под капотом):
new ThreadPoolExecutor(
5, // corePoolSize: минимум и постоянное число потоков
5, // maximumPoolSize: максимум = core, не создаёт дополнительно
0L, TimeUnit.MILLISECONDS, // keepAliveTime: не применяется (потоки не умирают)
new LinkedBlockingQueue<>() // workQueue: неограниченная очередь (может расти)
);
Визуализация работы:
FixedThreadPool (5 потоков):
[Thread-1] ← активный (выполняет Task-1)
[Thread-2] ← активный (выполняет Task-2)
[Thread-3] ← активный (выполняет Task-3)
[Thread-4] ← активный (выполняет Task-4)
[Thread-5] ← активный (выполняет Task-5)
WorkQueue (unbounded, т.е. может расти):
[Task-6, Task-7, Task-8, Task-9, Task-10, ..., Task-1000]
↑ Все остальные задачи ждут в очереди
Как это работает:
- Первые 5 задач: поток ещё не создан, создаём Thread-1 до Thread-5 и выполняем задачи.
- 6-я задача: все потоки заняты, добавляем в очередь.
- Thread-1 завершил Task-1 → немедленно берёт Task-6 из очереди.
Когда использовать:
- CPU-bound задачи (число потоков = число CPU ядер)
- Предсказуемая нагрузка с известным количеством параллельных операций
- Требуется ограничить число параллельных задач
Подводные камни:
- Unbounded очередь может расти бесконечно → OutOfMemoryError
- Если задачи долгие, очередь накапливается
- Нет механизма отклонения задач (reject)
// Пример: обработка файлов
ExecutorService pool = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors() // Оптимально для CPU-bound
);
List<String> files = List.of("file1.txt", "file2.txt", "file3.txt", ...);
for (String file : files) {
pool.submit(() -> processFile(file));
}
pool.shutdown();
pool.awaitTermination(10, TimeUnit.MINUTES);
2. CachedThreadPool
Динамическое число потоков: создаёт новые потоки по требованию, переиспользует idle потоки.
ExecutorService cachedPool = Executors.newCachedThreadPool();
// Внутренняя реализация:
new ThreadPoolExecutor(
0, // corePoolSize: начинаем с 0 потоков
Integer.MAX_VALUE, // maximumPoolSize: может создать столько, сколько нужно
60L, TimeUnit.SECONDS, // keepAliveTime: потоки живут 60 сек если idle
new SynchronousQueue<>() // workQueue: нет очереди, синхронная передача
);
Визуализация работы:
CachedThreadPool: динамический пул
Начальное состояние: 0 потоков
Task-1 приходит → нет потоков → создать Thread-1 → выполнить Task-1
Task-2 приходит → нет свободных потоков → создать Thread-2 → выполнить Task-2
Task-3 приходит → нет свободных потоков → создать Thread-3 → выполнить Task-3
Thread-1 завершил → ждёт 60 секунд, нет новых задач
Task-4 приходит → есть idle Thread-1 → переиспользовать Thread-1
Thread-1 ждёт > 60 секунд → уничтожается
Task-5 приходит → создать новый Thread-4
[Результат]: потоки создаются по требованию и удаляются если не используются
Ключевое отличие от FixedThreadPool:
- FixedThreadPool: поток один раз создан, постоянно живёт, ловит задачи из очереди
- CachedThreadPool: поток создаётся только когда нужен, удаляется если долго не используется
Когда использовать:
- I/O-bound задачи (много времени на ожидание сети/диска, мало CPU)
- Короткие асинхронные задачи
- Непредсказуемое число задач, которые приходят спорадически
Подводные камни:
- Может создать слишком много потоков → OutOfMemoryError
- Для CPU-bound задач неэффективен (overhead от создания потоков)
- В пиковые моменты могут создаться тысячи потоков
// Пример: HTTP requests (I/O-bound)
ExecutorService pool = Executors.newCachedThreadPool();
List<String> urls = List.of(
"http://api.example.com/user/1",
"http://api.example.com/user/2",
"http://api.example.com/user/3"
);
for (String url : urls) {
pool.submit(() -> fetchUrl(url)); // Каждый запрос в своём потоке
}
pool.shutdown();
3. SingleThreadExecutor
Один поток выполняет задачи последовательно в порядке FIFO (First In First Out).
ExecutorService singleExecutor = Executors.newSingleThreadExecutor();
// Внутренняя реализация:
new ThreadPoolExecutor(
1, // corePoolSize: ровно 1
1, // maximumPoolSize: ровно 1, не может расти
0L, TimeUnit.MILLISECONDS, // keepAliveTime: не применяется
new LinkedBlockingQueue<>() // workQueue: неограниченная очередь
);
Визуализация работы:
SingleThreadExecutor:
[Thread-1] ← единственный поток
WorkQueue (FIFO):
[Task-1, Task-2, Task-3, Task-4, ...] ← очередь ожидания
Выполнение (строго по порядку):
Время 0-1ms: Task-1 выполняется
Время 1-5ms: Task-2 выполняется (после завершения Task-1)
Время 5-8ms: Task-3 выполняется
Время 8-12ms: Task-4 выполняется
Ключевое свойство: порядок выполнения гарантирован. Task-1 ВСЕГДА завершится раньше Task-2.
Когда использовать:
- Задачи должны выполняться последовательно
- Требуется порядок выполнения (FIFO)
- Event loop паттерн (обработка событий подряд)
Преимущества:
- Гарантированный порядок выполнения (не нужна синхронизация между задачами)
- Нет проблем с гонками потоков
- Простота отладки (один поток, линейный поток выполнения)
Когда НЕ использовать:
- Когда нужна параллельность (задачи выполняются одна за раз)
- Когда какая-то задача долгая (всё остальное ждёт)
// Пример: Event loop для GUI updates
ExecutorService eventLoop = Executors.newSingleThreadExecutor();
// Эти действия выполнятся именно в таком порядке
eventLoop.submit(() -> updateUI("Action 1"));
eventLoop.submit(() -> updateUI("Action 2"));
eventLoop.submit(() -> updateUI("Action 3"));
// Гарантировано: сначала "Action 1", потом "Action 2", потом "Action 3"
eventLoop.shutdown();
4. ScheduledThreadPool
Периодическое и отложенное выполнение задач.
ScheduledExecutorService scheduledPool = Executors.newScheduledThreadPool(3);
// 1. schedule: выполнить один раз с задержкой
scheduledPool.schedule(() -> {
System.out.println("Executed after 5 seconds");
}, 5, TimeUnit.SECONDS);
// 2. scheduleAtFixedRate: периодическое выполнение (фиксированная частота)
scheduledPool.scheduleAtFixedRate(() -> {
System.out.println("Heartbeat");
}, 0, 1, TimeUnit.SECONDS); // initialDelay=0, period=1 секунда
// 3. scheduleWithFixedDelay: периодическое выполнение (фиксированная задержка)
scheduledPool.scheduleWithFixedDelay(() -> {
System.out.println("Monitoring check");
}, 0, 1, TimeUnit.SECONDS); // initialDelay=0, delay=1 секунда между завершением и началом
scheduledPool.shutdown();
Различие между scheduleAtFixedRate и scheduleWithFixedDelay:
scheduleAtFixedRate (фиксированная частота):
Гарантирует: задачи запускаются через каждый period времени от начала
Start: 0s End: 0.5s
Start: 1s End: 1.5s ← Начинается через 1s от НАЧАЛА предыдущей
Start: 2s End: 2.5s
Если Task длится дольше period:
Start: 0s End: 2.5s (длинная задача)
Start: 2.5s End: 3s ← Начинается сразу после завершения (нет задержки)
scheduleWithFixedDelay (фиксированная задержка):
Гарантирует: delay между ЗАВЕРШЕНИЕМ одной и НАЧАЛОМ следующей
Start: 0s End: 0.5s
(delay 1s)
Start: 1.5s End: 2s ← Начинается через 1s от КОНЦА предыдущей
(delay 1s)
Start: 3s End: 3.5s
Когда что использовать:
scheduleAtFixedRate: когда важна частота (например, heartbeat каждую секунду ровно)scheduleWithFixedDelay: когда важна задержка между завершением и началом (например, проверка каждые 1 сек, но не менее)
Когда использовать ScheduledThreadPool:
- Периодические задачи (мониторинг, health checks)
- Отложенное выполнение (retry с backoff)
- Планировщик задач
// Пример: Health check каждые 30 секунд
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.scheduleWithFixedDelay(() -> {
try {
performHealthCheck();
} catch (Exception e) {
System.err.println("Health check failed: " + e.getMessage());
}
}, 0, 30, TimeUnit.SECONDS);
// Shutdown через 5 минут
scheduler.schedule(() -> {
scheduler.shutdown();
}, 5, TimeUnit.MINUTES);
ThreadPoolExecutor: параметры и настройка
ThreadPoolExecutor — основная реализация ExecutorService. Мы использовали её выше (через factory методы), теперь разберём её параметры.
Конструктор ThreadPoolExecutor
ThreadPoolExecutor executor = new ThreadPoolExecutor(
int corePoolSize, // Минимальное число потоков
int maximumPoolSize, // Максимальное число потоков
long keepAliveTime, // Время жизни idle потоков
TimeUnit unit, // Единица времени для keepAliveTime
BlockingQueue<Runnable> workQueue, // Очередь задач
ThreadFactory threadFactory, // Фабрика для создания потоков
RejectedExecutionHandler handler // Обработчик отклонённых задач
);
Логика работы ThreadPoolExecutor
Это ОЧЕНЬ важная часть. Нужно понимать алгоритм принятия решения:
Приходит новая задача:
1. Если activeThreads < corePoolSize:
→ Создать новый поток и выполнить задачу СРАЗУ
(В этот момент new Thread() вызывается)
2. Если activeThreads >= corePoolSize:
→ Попытаться добавить задачу в workQueue
→ Если workQueue есть место: добавить и вернуться
→ Если workQueue полная: перейти к шагу 3
3. Если activeThreads < maximumPoolSize:
→ Создать новый (дополнительный) поток
→ Выполнить задачу в этом потоке
4. Если activeThreads == maximumPoolSize:
→ workQueue полная, новых потоков создать нельзя
→ Вызвать RejectedExecutionHandler.rejectedExecution()
5. Если поток idle > keepAliveTime и потоков > corePoolSize:
→ Уничтожить этот поток (вернуть в ОС)
→ Количество потоков уменьшится до corePoolSize
Пример с цифрами:
ThreadPoolExecutor executor = new ThreadPoolExecutor(
5, // corePoolSize
10, // maximumPoolSize
60L, TimeUnit.SECONDS, // keepAliveTime
new ArrayBlockingQueue<>(100), // workQueue (capacity=100)
new ThreadPoolExecutor.AbortPolicy() // reject policy
);
Приходят задачи:
- Task 1-5: создаём Thread 1-5, выполняем (activeThreads = 5)
- Task 6-105: все потоки заняты, добавляем в очередь (очередь полнится)
- Task 106-110: очередь полная, создаём Thread 6-10 (activeThreads = 10)
- Task 111: все потоки заняты, очередь полная, новых потоков создать нельзя → RejectedExecutionException
// Пример: настройка ThreadPoolExecutor
ThreadPoolExecutor customPool = new ThreadPoolExecutor(
5, // corePoolSize: минимум 5 потоков
10, // maximumPoolSize: максимум 10 потоков
60L, TimeUnit.SECONDS, // keepAliveTime: extra потоки живут 60 сек
new ArrayBlockingQueue<>(100), // workQueue: ограниченная очередь
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.CallerRunsPolicy() // Обработка переполнения
);
// Дополнительная настройка:
customPool.prestartAllCoreThreads(); // Создать все 5 core потоков заранее (не ждём первых задач)
customPool.allowCoreThreadTimeOut(true); // Core потоки могут умирать (обычно они постоянные)
// Мониторинг:
System.out.println("Active threads: " + customPool.getActiveCount());
System.out.println("Pool size: " + customPool.getPoolSize());
System.out.println("Queue size: " + customPool.getQueue().size());
System.out.println("Completed tasks: " + customPool.getCompletedTaskCount());
Выбор WorkQueue
1. LinkedBlockingQueue (unbounded) — неограниченная очередь
new LinkedBlockingQueue<>() // Используется в FixedThreadPool
Характеристики:
size(): может расти бесконечноadd(): никогда не возвращает false (не отклоняет)put(): никогда не блокируется на добавление
Преимущества:
- Никогда не отклоняет задачи
- Простая и предсказуемая работа
Недостатки:
- Может расти бесконечно → OutOfMemoryError
- Если все потоки заняты долго, очередь может вырасти на миллионы элементов
Используйте если:
- Задачи приходят равномерно
- Не бываёт пиков нагрузки
2. ArrayBlockingQueue (bounded) — ограниченная очередь
new ArrayBlockingQueue<>(100) // Capacity = 100
Характеристики:
size(): не превышает capacityadd(): может выбросить IllegalStateException если полнаяput(): может заблокировать вызывающий поток
Преимущества:
- Защита от бесконечного растения очереди
- Контролируемое потребление памяти
Недостатки:
- Задачи могут быть отклонены (RejectedExecutionHandler)
- Если queue полная, нужен правильный обработчик отклонений
Используйте если:
- Важно ограничить потребление памяти
- Нужен механизм backpressure (отклонение задач при перегрузке)
3. SynchronousQueue (capacity=0) — синхронная очередь
new SynchronousQueue<>() // Используется в CachedThreadPool
Характеристики:
size(): всегда 0 (никогда не хранит элементы)put(): блокирует пока нет потока, готового принять задачу- Прямая передача задачи от producer к worker потоку
Визуализация:
Producer (main потока) SynchronousQueue Consumer (worker поток)
| | |
+--- put(Task) --------→ [нет очереди] ----→ worker получил
| (ждёт здесь) (выполняет)
+--- возвращается
Используйте если:
- Максимальная пропускная способность
- Нужна прямая передача между потоками
- Требуется максимальное число потоков
4. PriorityBlockingQueue — приоритетная очередь
new PriorityBlockingQueue<>(100, comparator)
Задачи выполняются в порядке приоритета, а не FIFO.
Используйте если:
- Некоторые задачи важнее других
- Нужна гибкость в планировании
RejectedExecutionHandler: обработка отклонённых задач
Когда executor не может принять задачу (shutdown или очередь полная, новых потоков создать нельзя), вызывается RejectedExecutionHandler.
Встроенные политики
// 1. AbortPolicy (по умолчанию): выбросить исключение
new ThreadPoolExecutor.AbortPolicy()
// Вызывает: throw new RejectedExecutionException()
// 2. CallerRunsPolicy: выполнить задачу в вызывающем потоке
new ThreadPoolExecutor.CallerRunsPolicy()
// Если очередь полная, main поток сам выполнит задачу
// Это замедляет main → естественное throttling
// 3. DiscardPolicy: молча отбросить задачу
new ThreadPoolExecutor.DiscardPolicy()
// Задача теряется, никаких уведомлений
// Используйте только если потеря приемлема
// 4. DiscardOldestPolicy: отбросить самую старую задачу из очереди
new ThreadPoolExecutor.DiscardOldestPolicy()
// Очередь: [Task-1, Task-2, Task-3]
// Новая: Task-100
// Результат: [Task-2, Task-3, Task-100]
Пример: CallerRunsPolicy (throttling)
ThreadPoolExecutor pool = new ThreadPoolExecutor(
2, // 2 core потока
4, // 4 максимум
60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(10), // 10 задач в очереди
new ThreadPoolExecutor.CallerRunsPolicy() // ВАЖНЫЙ МОМЕНТ
);
// Сценарий:
// 2 потока активны, 4 в очереди, 10 в очереди
// Приходит 11-я задача → очередь переполнена
// Что происходит:
// 1. pool не может принять Task-11
// 2. CallerRunsPolicy.rejectedExecution() вызовется
// 3. Main поток (вызывающий submit) сам выполнит Task-11
// 4. Main поток заблокируется на время выполнения Task-11
// 5. Это замедляет main, и он реже submit'ит → естественное throttling
Это очень полезно для предотвращения перегрузки в пиковые моменты.
Пользовательский RejectedExecutionHandler
class LoggingRejectedHandler implements RejectedExecutionHandler {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
System.err.println("Task rejected: " + r.toString());
System.err.println("Active threads: " + executor.getActiveCount());
System.err.println("Queue size: " + executor.getQueue().size());
// Пример: сохранить задачу в БД для позднего выполнения
// saveToDB(r);
// Или отправить в другую очередь
// fallbackQueue.add(r);
// Или выполнить синхронно
// r.run();
}
}
ThreadPoolExecutor pool = new ThreadPoolExecutor(
2, 4, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(10),
new LoggingRejectedHandler()
);
Sizing Thread Pools: подбор размера пула
Один из самых сложных вопросов: какой размер пула выбрать?
Формулы для sizing
Для CPU-bound задач:
Оптимальный размер = число CPU ядер
int poolSize = Runtime.getRuntime().availableProcessors();
Почему: на 4-ядерном CPU максимум может работать 4 потока параллельно. Больше потоков = бесполезно (только context switch overhead).
На 8-ядерном: оптимально 8 потоков.
Для I/O-bound задач:
Оптимальный размер = число CPU ядер × (1 + wait time / compute time)
Пример:
- CPU ядер: 4
- Compute time: 5ms (сколько времени CPU работает)
- Wait time: 95ms (сколько времени ждём I/O)
- Коэффициент: 1 + 95/5 = 20
- Оптимальный размер: 4 × 20 = 80 потоков
Логика: пока один поток ждёт I/O (95ms), другие потоки работают на CPU (5ms).
Нужно достаточно потоков чтоб CPU не простаивал.
Практические рекомендации
// 1. CPU-bound (вычисления, обработка данных)
int cpuCount = Runtime.getRuntime().availableProcessors();
ExecutorService cpuBound = Executors.newFixedThreadPool(cpuCount);
// На системе с 8 ядрами: 8 потоков
// 2. I/O-bound (сеть, диск, БД)
// Начать с консервативной оценки
ExecutorService ioBound = Executors.newFixedThreadPool(cpuCount * 2);
// На системе с 8 ядрами: 16 потоков
// 3. Mixed workload: benchmark и tune на основе реальных данных
ThreadPoolExecutor mixed = new ThreadPoolExecutor(
10, // Начальное значение
50, // Максимум
60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200)
);
// Мониторинг и корректировка на основе показателей:
// - Если activeCount == poolSize постоянно → увеличить пул
// - Если activeCount << poolSize → уменьшить пул (пусто зря)
// - Если queue.size() растёт → увеличить пул или queue capacity
Динамическая настройка
ThreadPoolExecutor pool = new ThreadPoolExecutor(
5, 20, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100)
);
// Динамически изменять размер пула
pool.setCorePoolSize(10);
pool.setMaximumPoolSize(30);
// Автоматический мониторинг и подстройка
ScheduledExecutorService monitor = Executors.newScheduledThreadPool(1);
monitor.scheduleAtFixedRate(() -> {
int activeCount = pool.getActiveCount();
int poolSize = pool.getPoolSize();
int queueSize = pool.getQueue().size();
System.out.println("Active: " + activeCount +
", Pool: " + poolSize +
", Queue: " + queueSize);
// Автоматическая корректировка
if (queueSize > 50 && poolSize < 20) {
// Очередь растёт и много места в пуле
pool.setCorePoolSize(poolSize + 2);
System.out.println("Increased pool size to " + pool.getCorePoolSize());
} else if (queueSize < 10 && poolSize > 5) {
// Очередь пуста и потоков слишком много
pool.setCorePoolSize(poolSize - 2);
System.out.println("Decreased pool size to " + pool.getCorePoolSize());
}
}, 0, 10, TimeUnit.SECONDS);
Callable и Future: задачи с результатом
До сих пор мы использовали Runnable (нет результата). Теперь посмотрим как получить результат.
Callable vs Runnable
// Runnable: не возвращает результат, не может выбросить checked exception
Runnable runnable = () -> {
System.out.println("Task executed");
// return не может (void)
};
// Callable: возвращает результат T, может выбросить checked exception
Callable<Integer> callable = () -> {
if (Math.random() > 0.5) {
throw new IOException("Error"); // Checked exception OK
}
return 42; // Возвращаем результат
};
Future: получение результата
ExecutorService executor = Executors.newFixedThreadPool(5);
// Submit Callable (обратите внимание: Callable<Integer>, не Runnable)
Future<Integer> future = executor.submit(() -> {
Thread.sleep(2000); // Имитируем долгую работу
return 42;
});
// Future методы:
System.out.println("Is done: " + future.isDone()); // false (ещё выполняется)
System.out.println("Is cancelled: " + future.isCancelled()); // false
// get(): блокируется до завершения
try {
Integer result = future.get(); // Ждёт 2 секунды
System.out.println("Result: " + result); // 42
} catch (InterruptedException e) {
// Текущий поток был прерван
Thread.currentThread().interrupt();
} catch (ExecutionException e) {
// Задача выбросила исключение
System.err.println("Task failed: " + e.getCause());
}
// get(timeout): с таймаутом (не ждём бесконечно)
try {
Integer result = future.get(1, TimeUnit.SECONDS); // Ждём максимум 1 сек
} catch (TimeoutException e) {
// Прошла 1 сек, результат ещё не готов
System.err.println("Task timeout");
future.cancel(true); // Отменить задачу
}
executor.shutdown();
Отмена задач с cancel()
Future<Integer> future = executor.submit(() -> {
for (int i = 0; i < 100; i++) {
if (Thread.currentThread().isInterrupted()) {
System.out.println("Task cancelled");
return -1; // Graceful exit
}
System.out.println("Step: " + i);
Thread.sleep(100);
}
return 42;
});
Thread.sleep(500); // Даём задаче выполниться немного
// Отменить задачу
boolean cancelled = future.cancel(true); // true = interrupt поток
System.out.println("Cancelled: " + cancelled);
// После cancel:
System.out.println("Is cancelled: " + future.isCancelled()); // true
try {
future.get(); // CancellationException
} catch (CancellationException e) {
System.err.println("Task was cancelled");
}
Важно понимать:
cancel(true): пошлёт interrupt сигнал потоку- Поток должен проверять
Thread.currentThread().isInterrupted() - Это не гарантирует остановку (поток может проигнорировать interrupt)
invokeAll: выполнить все задачи и получить все результаты
List<Callable<Integer>> tasks = List.of(
() -> { Thread.sleep(1000); return 1; },
() -> { Thread.sleep(2000); return 2; },
() -> { Thread.sleep(3000); return 3; }
);
// Блокируется до завершения ВСЕХ задач
List<Future<Integer>> futures = executor.invokeAll(tasks);
// Ждёт 3 секунды (время самой долгой задачи)
for (Future<Integer> f : futures) {
System.out.println(f.get()); // Все future уже done, get не блокируется
}
// invokeAll с таймаутом
List<Future<Integer>> futures2 = executor.invokeAll(tasks, 2, TimeUnit.SECONDS);
// Ждёт максимум 2 секунды
// Задачи, не завершившиеся за 2 секунды, будут cancelled
invokeAny: выполнить и вернуть первый результат
List<Callable<String>> tasks = List.of(
() -> { Thread.sleep(3000); return "slow"; },
() -> { Thread.sleep(1000); return "fast"; },
() -> { Thread.sleep(2000); return "medium"; }
);
// Возвращает результат первой завершившейся задачи (не ждёт остальные)
String result = executor.invokeAny(tasks);
System.out.println("First result: " + result); // "fast" (1 сек самый быстрый)
// Остальные задачи будут cancelled
// invokeAny с таймаутом
String result2 = executor.invokeAny(tasks, 500, TimeUnit.MILLISECONDS);
// TimeoutException если ни одна не завершилась за 500ms
Graceful Shutdown: корректное завершение
Очень важная часть. Неправильный shutdown может привести к потере данных и зависаниям.
Методы shutdown
ExecutorService executor = Executors.newFixedThreadPool(5);
// Отправляем 1000 задач
for (int i = 0; i < 1000; i++) {
executor.submit(() -> doWork());
}
// Способ 1: shutdown() — graceful shutdown
executor.shutdown(); // Говорим: "Не принимай новые задачи"
// Что происходит:
// - Новый submit() выбросит RejectedExecutionException
// - Уже submitted задачи продолжают выполняться
// - Потоки не прерываются
System.out.println("Shutdown initiated");
System.out.println("isShutdown: " + executor.isShutdown()); // true
System.out.println("isTerminated: " + executor.isTerminated()); // false (потоки ещё работают)
// Способ 2: shutdownNow() — немедленный shutdown
List<Runnable> notExecuted = executor.shutdownNow();
// Что происходит:
// - Не принимает новые задачи
// - Прерывает активные задачи (interrupt)
// - Возвращает список задач, которые были в очереди но не начинались
System.out.println("Tasks not executed: " + notExecuted.size());
// Способ 3: awaitTermination() — ждать завершения
boolean terminated = executor.awaitTermination(1, TimeUnit.MINUTES);
if (!terminated) {
System.err.println("Executor did not terminate in time");
}
Паттерн graceful shutdown (рекомендуемый)
ExecutorService executor = Executors.newFixedThreadPool(10);
try {
// Отправляем задачи
for (int i = 0; i < 100; i++) {
executor.submit(() -> doWork());
}
} finally {
// Graceful shutdown паттерн
executor.shutdown(); // Шаг 1: не принимать новые задачи
try {
// Шаг 2: ждать завершения текущих задач (с таймаутом)
if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
// Таймаут истёк, текущие задачи ещё выполняются
// Шаг 3: принудительно остановить
executor.shutdownNow();
// Шаг 4: ждать ещё немного после interrupt
if (!executor.awaitTermination(10, TimeUnit.SECONDS)) {
System.err.println("Executor did not terminate");
}
}
} catch (InterruptedException e) {
// Текущий поток был прерван
executor.shutdownNow();
Thread.currentThread().interrupt();
}
}
Этот паттерн гарантирует:
- Все submitted задачи получат шанс выполниться
- Если долго, будут прерваны
- JVM не зависнет с живыми потоками
try-with-resources (Java 19+)
// Java 19+: ExecutorService implements AutoCloseable
try (ExecutorService executor = Executors.newFixedThreadPool(5)) {
for (int i = 0; i < 100; i++) {
executor.submit(() -> doWork());
}
// В конце блока try автоматически вызовется shutdown()
} catch (Exception e) {
System.err.println("Error: " + e.getMessage());
}
// После выхода из try: graceful shutdown выполнен
Практические примеры
Пример 1: Batch обработка с FixedThreadPool
class BatchProcessor {
private final ExecutorService executor;
private final int threadCount;
public BatchProcessor(int threadCount) {
this.threadCount = threadCount;
this.executor = Executors.newFixedThreadPool(threadCount);
}
public void processBatch(List<Item> items) throws InterruptedException {
List<Future<Result>> futures = new ArrayList<>();
// Отправляем все задачи
for (Item item : items) {
Future<Result> future = executor.submit(() -> processItem(item));
futures.add(future);
}
// Собираем результаты
List<Result> results = new ArrayList<>();
for (Future<Result> future : futures) {
try {
results.add(future.get()); // Блокируется до завершения
} catch (ExecutionException e) {
System.err.println("Processing failed: " + e.getCause());
}
}
System.out.println("Processed " + results.size() + " items in parallel using " +
threadCount + " threads");
}
private Result processItem(Item item) {
// Обработка 100ms на элемент
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return new Result(item.getId());
}
public void shutdown() {
executor.shutdown();
try {
if (!executor.awaitTermination(1, TimeUnit.MINUTES)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}
}
static class Item {
private final int id;
public Item(int id) { this.id = id; }
public int getId() { return id; }
}
static class Result {
private final int id;
public Result(int id) { this.id = id; }
}
}
Пример 2: Параллельная загрузка данных
class DataAggregator {
private final ExecutorService executor = Executors.newFixedThreadPool(3);
public AggregatedData fetchData(String userId) throws Exception {
// Параллельная загрузка из разных источников
Future<UserProfile> profileFuture = executor.submit(() -> fetchUserProfile(userId));
Future<List<Order>> ordersFuture = executor.submit(() -> fetchOrders(userId));
Future<List<Payment>> paymentsFuture = executor.submit(() -> fetchPayments(userId));
try {
// Ждём все результаты (с таймаутом для каждого)
UserProfile profile = profileFuture.get(5, TimeUnit.SECONDS);
List<Order> orders = ordersFuture.get(5, TimeUnit.SECONDS);
List<Payment> payments = paymentsFuture.get(5, TimeUnit.SECONDS);
return new AggregatedData(profile, orders, payments);
} catch (TimeoutException e) {
// Какая-то из задач заняла > 5 секунд
profileFuture.cancel(true);
ordersFuture.cancel(true);
paymentsFuture.cancel(true);
throw new RuntimeException("Data fetching timeout", e);
}
}
private UserProfile fetchUserProfile(String userId) {
// HTTP запрос (имитируем)
simulateNetworkDelay();
return new UserProfile(userId);
}
private List<Order> fetchOrders(String userId) {
// БД запрос (имитируем)
simulateNetworkDelay();
return List.of(new Order(userId, 1), new Order(userId, 2));
}
private List<Payment> fetchPayments(String userId) {
// API запрос (имитируем)
simulateNetworkDelay();
return List.of(new Payment(userId, 100));
}
private void simulateNetworkDelay() {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
static class UserProfile { /* ... */ }
static class Order { /* ... */ }
static class Payment { /* ... */ }
static class AggregatedData { /* ... */ }
}
Пример 3: Scheduled tasks (мониторинг)
class SystemMonitor {
private final ScheduledExecutorService scheduler =
Executors.newScheduledThreadPool(2);
private volatile boolean isRunning = false;
public void startMonitoring() {
isRunning = true;
// Health check каждые 30 секунд
scheduler.scheduleWithFixedDelay(() -> {
try {
performHealthCheck();
} catch (Exception e) {
System.err.println("Health check failed: " + e.getMessage());
}
}, 0, 30, TimeUnit.SECONDS);
// Metrics collection каждую минуту
scheduler.scheduleAtFixedRate(() -> {
try {
collectMetrics();
} catch (Exception e) {
System.err.println("Metrics collection failed: " + e.getMessage());
}
}, 0, 1, TimeUnit.MINUTES);
// Cleanup каждый час
scheduler.scheduleAtFixedRate(() -> {
try {
cleanupOldData();
} catch (Exception e) {
System.err.println("Cleanup failed: " + e.getMessage());
}
}, 1, 1, TimeUnit.HOURS);
System.out.println("Monitoring started");
}
public void stopMonitoring() {
isRunning = false;
scheduler.shutdown();
try {
if (!scheduler.awaitTermination(10, TimeUnit.SECONDS)) {
scheduler.shutdownNow();
}
} catch (InterruptedException e) {
scheduler.shutdownNow();
}
}
private void performHealthCheck() {
System.out.println("[" + System.currentTimeMillis() + "] Health check: OK");
}
private void collectMetrics() {
System.out.println("[" + System.currentTimeMillis() + "] Metrics collected");
}
private void cleanupOldData() {
System.out.println("[" + System.currentTimeMillis() + "] Cleanup completed");
}
}
Подводные камни и Best Practices
1. Всегда вызывайте shutdown() явно
ПЛОХО:
ExecutorService executor = Executors.newFixedThreadPool(5);
executor.submit(() -> doWork());
// Забыли shutdown() → JVM не завершится (non-daemon потоки живут)
ХОРОШО:
ExecutorService executor = Executors.newFixedThreadPool(5);
try {
executor.submit(() -> doWork());
} finally {
executor.shutdown();
}
2. Используйте bounded queue для защиты от OOM
ПЛОХО:
// FixedThreadPool с unbounded queue
ExecutorService bad = Executors.newFixedThreadPool(5);
// Если приходит 1 млн задач и процессинг медленный:
// очередь растёт на 1 млн элементов → OutOfMemoryError
ХОРОШО:
ThreadPoolExecutor good = new ThreadPoolExecutor(
5, 10, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1000), // Max 1000 в очереди
new ThreadPoolExecutor.CallerRunsPolicy() // Throttling
);
3. Обрабатывайте ExecutionException
ПЛОХО:
Future<Integer> future = executor.submit(() -> {
throw new RuntimeException("Error");
});
Integer result = future.get(); // ExecutionException!
System.out.println("Result: " + result); // Никогда не выполнится
ХОРОШО:
Future<Integer> future = executor.submit(() -> {
throw new RuntimeException("Error");
});
try {
Integer result = future.get();
} catch (ExecutionException e) {
Throwable cause = e.getCause(); // Реальное исключение
System.err.println("Task failed: " + cause.getMessage());
}
4. Правильно обрабатывайте InterruptedException
ПЛОХО:
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// Ничего не делаем → флаг потеряется
}
ХОРОШО:
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // Восстанавливаем флаг
// Или пробросить выше
}
5. CachedThreadPool опасен для CPU-bound задач
ПЛОХО:
ExecutorService bad = Executors.newCachedThreadPool();
for (int i = 0; i < 10000; i++) {
bad.submit(() -> cpuIntensiveTask()); // Создаст 10000 потоков!
}
// OutOfMemoryError
ХОРОШО:
int cpuCount = Runtime.getRuntime().availableProcessors();
ExecutorService good = Executors.newFixedThreadPool(cpuCount);
for (int i = 0; i < 10000; i++) {
good.submit(() -> cpuIntensiveTask()); // Макс cpuCount потоков
}
6. Именуйте потоки для отладки
ThreadFactory namedFactory = r -> {
Thread t = new Thread(r);
t.setName("Worker-" + t.getId()); // Видно в debugger
return t;
};
ThreadPoolExecutor pool = new ThreadPoolExecutor(
5, 10, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
namedFactory
);
7. Мониторьте состояние пула
ThreadPoolExecutor pool = (ThreadPoolExecutor) Executors.newFixedThreadPool(10);
ScheduledExecutorService monitor = Executors.newScheduledThreadPool(1);
monitor.scheduleAtFixedRate(() -> {
System.out.println("Active: " + pool.getActiveCount() +
", Pool: " + pool.getPoolSize() +
", Queue: " + pool.getQueue().size());
}, 0, 10, TimeUnit.SECONDS);
Summary: Когда что использовать
| Тип | Размер | Когда использовать |
|---|---|---|
| FixedThreadPool | Fixed | CPU-bound задачи, предсказуемая нагрузка |
| CachedThreadPool | Dynamic | I/O-bound задачи, короткие асинхронные операции |
| SingleThreadExecutor | 1 | Последовательные задачи, event loop, FIFO гарантия |
| ScheduledThreadPool | Fixed | Периодические задачи, delayed execution |
| Custom ThreadPoolExecutor | Настраиваемый | Специфичные требования, bounded queue, reject policy |
Ключевое правило: всегда явно вызывайте shutdown() и используйте graceful shutdown паттерн!
CompletableFuture
Введение
CompletableFuture — это кульминация асинхронного программирования в Java, введённая в Java 8. Это не просто расширение Future, а полная переработка подхода к асинхронным операциям. В то время как Future требует блокирующего вызова get() для получения результата, CompletableFuture позволяет объявлять, что должно произойти когда результат будет готов, без блокировки основного потока.
Почему CompletableFuture, а не Future?
СТАРЫЙ ПОДХОД (Future):
Future<String> future = executor.submit(() -> fetchData());
// Поток блокируется, ждёт результата
String result = future.get();
System.out.println("Got: " + result);
Проблемы:
- Вызов
get()блокирует текущий поток - Нет встроенного способа комбинировать несколько Future
- Обработка исключений требует try-catch блоков
- Невозможно мотивировать действие на основе результата другого Future
НОВЫЙ ПОДХОД (CompletableFuture):
CompletableFuture.supplyAsync(() -> fetchData())
.thenApply(data -> transform(data))
.thenAccept(result -> save(result))
.exceptionally(ex -> handleError(ex));
Преимущества:
- Полностью неблокирующий pipeline
- Можно комбинировать результаты нескольких операций
- Декларативная обработка ошибок
- Композируемые асинхронные цепочки
- Возможность завершения Future вручную
Архитектура и ключевые концепции
Состояния CompletableFuture
Каждый CompletableFuture находится в одном из трёх состояний:
- Незавершённое (Incomplete) — операция ещё выполняется
- Успешно завершённое (Completed with result) — операция завершилась и имеет результат
- Завершённое с ошибкой (Completed exceptionally) — произошло исключение
Когда Future переходит в завершённое состояние, все зарегистрированные callbacks немедленно запускаются или ставятся в очередь.
Различие между Sync и Async операциями
В CompletableFuture почти каждый метод имеет две версии:
- Синхронная версия (
thenApply,thenAccept) — callback выполняется в том же потоке, что вызвал завершение Future - Асинхронная версия (
thenApplyAsync,thenAcceptAsync) — callback выполняется в отдельном потоке (по умолчанию изForkJoinPool.commonPool())
Это критическое различие для производительности и понимания того, какой поток выполняет ваш код.
Создание CompletableFuture
Основные способы инициализации
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
// 1. completedFuture: уже готовый результат
// Полезно для создания Future с известным значением (например, fallback)
CompletableFuture<String> completed = CompletableFuture.completedFuture("result");
completed.thenAccept(System.out::println); // Немедленно выведет "result"
// 2. supplyAsync: асинхронное выполнение с результатом
// Функция будет выполнена в ForkJoinPool.commonPool() (по умолчанию)
// Возвращаемое значение становится результатом Future
CompletableFuture<String> supply = CompletableFuture.supplyAsync(() -> {
System.out.println("Executing in: " + Thread.currentThread().getName());
return "Hello from async";
});
// 3. runAsync: асинхронное выполнение без результата
// Используется, когда вам нужно просто выполнить операцию, но результат не важен
CompletableFuture<Void> run = CompletableFuture.runAsync(() -> {
System.out.println("Fire and forget operation");
// Никакого возвращаемого значения
});
// 4. Кастомный Executor для контроля над распределением потоков
// ВАЖНО: для IO-bound операций создавайте новый Executor!
// ForkJoinPool.commonPool() предназначен для CPU-bound операций
ExecutorService ioExecutor = Executors.newFixedThreadPool(20);
CompletableFuture<String> custom = CompletableFuture.supplyAsync(() -> {
System.out.println("Executing in: " + Thread.currentThread().getName());
return "Custom executor";
}, ioExecutor);
// 5. Ручное завершение: создание "пустого" Future для заполнения позже
CompletableFuture<String> manual = new CompletableFuture<>();
// В другом потоке/методе:
new Thread(() -> {
try {
Thread.sleep(2000);
manual.complete("Manually completed"); // Завершить с результатом
} catch (InterruptedException e) {
manual.completeExceptionally(e); // Завершить с ошибкой
}
}).start();
manual.thenAccept(System.out::println);
Практический пример: асинхронная загрузка данных
class UserService {
private ExecutorService executor = Executors.newFixedThreadPool(10);
public CompletableFuture<User> getUserAsync(Long userId) {
// supplyAsync выполнит запрос в отдельном потоке
return CompletableFuture.supplyAsync(() -> {
// Это выполняется асинхронно, не блокируя caller
System.out.println("Loading user " + userId + " in thread: "
+ Thread.currentThread().getName());
try {
// Симуляция запроса к БД (IO-bound операция)
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Interrupted", e);
}
return new User(userId, "John Doe");
}, executor);
}
static class User {
final Long id;
final String name;
User(Long id, String name) {
this.id = id;
this.name = name;
}
}
}
// Использование:
UserService service = new UserService();
CompletableFuture<UserService.User> userFuture = service.getUserAsync(1L);
// Основной поток НЕ блокируется здесь
System.out.println("Request sent, doing other work...");
// Когда результат готов, этот callback сработает
userFuture.thenAccept(user -> {
System.out.println("User loaded: " + user.name + " in thread: "
+ Thread.currentThread().getName());
});
// Основной поток может продолжить работу...
Ключевой момент: getUserAsync() возвращает немедленно. Загрузка пользователя происходит в фоне.
Трансформирование результатов: Pipeline операции
Одна из самых мощных возможностей CompletableFuture — это построение pipelines, где результат одной операции становится входом для следующей.
thenApply: трансформация результата
Сигнатура: thenApply(Function<T, R> fn) → CompletableFuture<R>
thenApply применяет функцию к результату и возвращает новый CompletableFuture с трансформированным результатом. Функция выполняется в том же потоке, что завершил предыдущий Future.
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
System.out.println("Step 1 in: " + Thread.currentThread().getName());
return "Hello";
})
.thenApply(s -> {
// Выполняется в ТОМ ЖЕ потоке, что и supplyAsync
System.out.println("Step 2 in: " + Thread.currentThread().getName());
return s + " World";
})
.thenApply(s -> {
// Снова в том же потоке
System.out.println("Step 3 in: " + Thread.currentThread().getName());
return s.toUpperCase();
});
future.thenAccept(System.out::println); // HELLO WORLD
Когда использовать: Когда следующая операция быстрая (CPU-bound, не блокирующая) и может выполняться в том же потоке. Экономит ресурсы, избегая создания новых потоков.
thenAccept: обработка результата без возврата
Сигнатура: thenAccept(Consumer<T> action) → CompletableFuture<Void>
thenAccept принимает результат, выполняет действие (обычно side-effect), но ничего не возвращает. Это конечный пункт pipeline, если вам нужен только побочный эффект.
CompletableFuture.supplyAsync(() -> {
return new UserData("user123", "John");
})
.thenAccept(userData -> {
// Это действие с побочным эффектом
Database.save(userData);
Logger.info("User saved: " + userData.name);
// Ничего не возвращаем
});
thenRun: выполнение без доступа к результату
Сигнатура: thenRun(Runnable action) → CompletableFuture<Void>
thenRun выполняет действие после завершения Future, но не получает результат. Используется, когда вам не нужно значение предыдущей операции.
CompletableFuture.supplyAsync(() -> {
// Запрос, результат которого можно проигнорировать
return apiCall();
})
.thenRun(() -> {
// Выполнить cleanup независимо от результата
Logger.info("Operation completed");
closeConnections();
// Нет доступа к результату apiCall()
});
Сравнение и выбор метода
| Метод | Тип параметра | Возвращает | Когда использовать |
|---|---|---|---|
thenApply |
Function<T, R> |
CompletableFuture<R> |
Трансформирование результата в новое значение |
thenAccept |
Consumer<T> |
CompletableFuture<Void> |
Использование результата для side-effect (сохранение, логирование) |
thenRun |
Runnable |
CompletableFuture<Void> |
Выполнение действия после завершения, результат не нужен |
// Пример выбора:
CompletableFuture.supplyAsync(() -> fetchUser(id))
.thenApply(user -> new UserDTO(user)) // Трансформируем User → UserDTO
.thenAccept(dto -> sendToClient(dto)) // Side-effect: отправить клиенту
.thenRun(() -> Logger.info("Request handled")); // Cleanup
Async варианты: *Async методы
Каждый из методов выше имеет *Async версию, которая гарантирует выполнение в другом потоке (из ForkJoinPool.commonPool() или переданного Executor).
ExecutorService executor = Executors.newFixedThreadPool(5);
CompletableFuture.supplyAsync(() -> {
System.out.println("Main: " + Thread.currentThread().getName());
return "data";
})
.thenApply(data -> {
// Выполняется в ТОМ ЖЕ потоке
System.out.println("thenApply: " + Thread.currentThread().getName());
return data.toUpperCase();
})
.thenApplyAsync(data -> {
// Выполняется в ДРУГОМ потоке (ForkJoinPool)
System.out.println("thenApplyAsync: " + Thread.currentThread().getName());
return data + " processed";
}, executor)
.thenAccept(result -> {
System.out.println("Final: " + result);
});
// Вывод может быть:
// Main: ForkJoinPool.commonPool-worker-1
// thenApply: ForkJoinPool.commonPool-worker-1
// thenApplyAsync: pool-1-thread-1
// Final: DATA processed
Правило: Используйте *Async для IO-bound или долгих операций, чтобы не блокировать shared pool потоки.
Комбинирование Future: Синхронизация нескольких операций
Часто нужно дождаться результатов нескольких асинхронных операций и работать с ними вместе.
thenCompose: Flattening вложенных Future
Сигнатура: thenCompose(Function<T, CompletableFuture<U>> fn) → CompletableFuture<U>
thenCompose раскрывает вложенный CompletableFuture. Это аналог flatMap в Stream API.
Проблема без thenCompose:
// Получить ID пользователя
CompletableFuture<Long> userIdFuture = fetchUserId("john");
// Затем получить самого пользователя по ID
// НЕПРАВИЛЬНО: получаем CompletableFuture<CompletableFuture<User>>
CompletableFuture<CompletableFuture<User>> nested = userIdFuture
.thenApply(id -> fetchUser(id)); // fetchUser возвращает CompletableFuture!
// Чтобы использовать результат, нужно делать .join() внутри:
nested.thenAccept(userFuture -> {
User user = userFuture.join(); // Блокирует!
System.out.println(user.name);
});
Решение с thenCompose:
// ПРАВИЛЬНО: thenCompose раскрывает вложенность
CompletableFuture<User> flat = fetchUserId("john")
.thenCompose(id -> fetchUser(id)); // thenCompose ожидает CompletableFuture
flat.thenAccept(user -> {
System.out.println(user.name); // user уже распакован
});
Практический пример: цепочка зависимых операций
class OrderProcessingService {
public CompletableFuture<Order> processOrder(Long orderId) {
return fetchOrder(orderId)
.thenCompose(order -> {
// Порядок важен: сначала валидировать, потом платить
System.out.println("Validating order: " + order.id);
return validateOrder(order);
})
.thenCompose(validOrder -> {
System.out.println("Processing payment for: " + validOrder.id);
return chargePayment(validOrder);
})
.thenCompose(paidOrder -> {
System.out.println("Shipping order: " + paidOrder.id);
return shipOrder(paidOrder);
})
.thenApply(shippedOrder -> {
shippedOrder.status = "COMPLETED";
return shippedOrder;
})
.exceptionally(ex -> {
System.err.println("Order processing failed: " + ex.getMessage());
return null; // Или fallback Order
});
}
private CompletableFuture<Order> fetchOrder(Long id) {
return CompletableFuture.supplyAsync(() -> new Order(id, "PENDING"));
}
private CompletableFuture<Order> validateOrder(Order order) {
return CompletableFuture.supplyAsync(() -> {
if (order.items.isEmpty()) {
throw new IllegalArgumentException("Order is empty");
}
return order;
});
}
private CompletableFuture<Order> chargePayment(Order order) {
return CompletableFuture.supplyAsync(() -> {
// Вызов к платёжному сервису
return order;
});
}
private CompletableFuture<Order> shipOrder(Order order) {
return CompletableFuture.supplyAsync(() -> {
// Вызов к логистическому сервису
return order;
});
}
static class Order {
Long id;
String status;
List<String> items = new ArrayList<>();
Order(Long id, String status) { this.id = id; this.status = status; }
}
}
Ключевой момент: Каждый этап зависит от результата предыдущего. Если один этап не удастся, следующие не выполняются.
thenCombine: Комбинирование двух независимых Future
Сигнатура: thenCombine(CompletableFuture<U> other, BiFunction<T, U, V> fn) → CompletableFuture<V>
thenCombine ждёт обоих Future (они выполняются параллельно), затем применяет функцию к обоим результатам.
CompletableFuture<Integer> future1 = CompletableFuture.supplyAsync(() -> {
Thread.sleep(1000);
return 10;
});
CompletableFuture<Integer> future2 = CompletableFuture.supplyAsync(() -> {
Thread.sleep(1000);
return 20;
});
// Общее время: ~1000ms (параллельно), не 2000ms (последовательно)
CompletableFuture<Integer> combined = future1.thenCombine(future2, (a, b) -> {
System.out.println("Combining " + a + " and " + b);
return a + b;
});
combined.thenAccept(System.out::println); // 30
thenAcceptBoth: Комбинирование двух результатов без возврата
Сигнатура: thenAcceptBoth(CompletableFuture<U> other, BiConsumer<T, U> action) → CompletableFuture<Void>
Аналог thenCombine, но для side-effects.
CompletableFuture<String> nameFuture = fetchUserName(userId);
CompletableFuture<String> emailFuture = fetchUserEmail(userId);
nameFuture.thenAcceptBoth(emailFuture, (name, email) -> {
System.out.println("User: " + name);
System.out.println("Email: " + email);
Database.saveUserInfo(name, email);
// Ничего не возвращаем
});
allOf: Ожидание всех Future
Сигнатура: CompletableFuture.allOf(CompletableFuture<?>... cfs) → CompletableFuture<Void>
allOf возвращает CompletableFuture<Void>, который завершается когда все переданные Future завершены (успешно или с ошибкой).
List<CompletableFuture<User>> userFutures = userIds.stream()
.map(id -> fetchUser(id))
.collect(Collectors.toList());
// Ждём, пока все пользователи загрузятся
CompletableFuture<Void> allUsersLoaded = CompletableFuture.allOf(
userFutures.toArray(new CompletableFuture[0])
);
allUsersLoaded.thenRun(() -> {
// Теперь все пользователи загружены
List<User> users = userFutures.stream()
.map(CompletableFuture::join) // Теперь join() не блокирует!
.collect(Collectors.toList());
System.out.println("All users loaded: " + users.size());
});
Важно: allOf не собирает результаты сам! Нужно явно вызвать join() на каждом Future.
anyOf: Первый завершившийся Future
Сигнатура: CompletableFuture.anyOf(CompletableFuture<?>... cfs) → CompletableFuture<Object>
anyOf возвращает результат первого завершившегося Future.
CompletableFuture<String> replica1 = fetchFromServer("server1");
CompletableFuture<String> replica2 = fetchFromServer("server2");
CompletableFuture<String> replica3 = fetchFromServer("server3");
// Какой-то из серверов быстрее ответит
CompletableFuture<Object> fastest = CompletableFuture.anyOf(
replica1, replica2, replica3
);
fastest.thenAccept(result -> {
System.out.println("Fastest response: " + result);
// Остальные могут быть отменены или просто проигнорированы
});
Практический пример: Fan-out / Fan-in паттерн
class PriceAggregator {
private ExecutorService executor = Executors.newFixedThreadPool(10);
public CompletableFuture<BestPrice> findBestPrice(String product) {
List<String> suppliers = List.of("Amazon", "eBay", "Walmart");
// FAN-OUT: запросить цены у всех поставщиков параллельно
List<CompletableFuture<Price>> priceFutures = suppliers.stream()
.map(supplier -> fetchPrice(product, supplier))
.collect(Collectors.toList());
// FAN-IN: собрать все результаты и выбрать лучшую цену
return CompletableFuture.allOf(priceFutures.toArray(new CompletableFuture[0]))
.thenApply(v -> {
// Все цены загружены
List<Price> prices = priceFutures.stream()
.map(CompletableFuture::join)
.collect(Collectors.toList());
// Найти минимальную цену
Price bestPrice = prices.stream()
.min(Comparator.comparing(p -> p.amount))
.orElseThrow(() -> new RuntimeException("No prices found"));
return new BestPrice(product, bestPrice.amount, bestPrice.supplier);
});
}
private CompletableFuture<Price> fetchPrice(String product, String supplier) {
return CompletableFuture.supplyAsync(() -> {
// HTTP запрос к API поставщика
double price = Math.random() * 100;
System.out.println(supplier + " price for " + product + ": $" + price);
return new Price(product, price, supplier);
}, executor);
}
static class Price {
String product;
double amount;
String supplier;
Price(String product, double amount, String supplier) {
this.product = product;
this.amount = amount;
this.supplier = supplier;
}
}
static class BestPrice {
String product;
double amount;
String supplier;
BestPrice(String product, double amount, String supplier) {
this.product = product;
this.amount = amount;
this.supplier = supplier;
}
}
}
// Использование:
PriceAggregator aggregator = new PriceAggregator();
aggregator.findBestPrice("iPhone 14")
.thenAccept(bestPrice -> {
System.out.println("Best price: $" + bestPrice.amount + " from " + bestPrice.supplier);
});
Обработка ошибок и исключений
exceptionally: Перехват и обработка ошибок
Сигнатура: exceptionally(Function<Throwable, T> fn) → CompletableFuture<T>
exceptionally перехватывает исключение и возвращает fallback значение или другое действие.
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
if (Math.random() > 0.5) {
throw new RuntimeException("Random error occurred");
}
return "Success";
})
.exceptionally(ex -> {
System.err.println("Caught exception: " + ex.getMessage());
return "Fallback value"; // Возвращаем значение вместо ошибки
});
future.thenAccept(System.out::println); // "Success" или "Fallback value"
Важно: exceptionally восстанавливает pipeline. После вызова можно продолжить цепочку:
CompletableFuture.supplyAsync(() -> {
throw new RuntimeException("Error in step 1");
})
.exceptionally(ex -> {
System.err.println("Error: " + ex.getMessage());
return "recovered";
})
.thenApply(s -> s.toUpperCase()) // Теперь можем продолжить!
.thenAccept(System.out::println); // RECOVERED
handle: Универсальная обработка результата и ошибки
Сигнатура: handle(BiFunction<T, Throwable, U> fn) → CompletableFuture<U>
handle выполняет функцию всегда, независимо от того, завершился ли Future успешно или с ошибкой. Один из параметров будет null.
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
if (Math.random() > 0.5) {
throw new RuntimeException("Error");
}
return "Success";
})
.handle((result, ex) -> {
if (ex != null) {
System.err.println("Failed: " + ex.getMessage());
return "error_handled";
}
System.out.println("Succeeded: " + result);
return result;
});
future.thenAccept(System.out::println);
whenComplete: Side-effect обработка
Сигнатура: whenComplete(BiConsumer<T, Throwable> action) → CompletableFuture<T>
whenComplete выполняет side-effect без изменения результата или исключения. Возвращает оригинальный Future.
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "Data")
.whenComplete((result, ex) -> {
if (ex != null) {
Logger.error("Operation failed", ex);
} else {
Logger.info("Operation succeeded: " + result);
}
// Не влияем на результат!
})
.thenApply(String::toUpperCase);
// Pipeline продолжается с оригинальным "Data"
future.thenAccept(System.out::println); // DATA
Сравнение методов обработки ошибок
| Метод | Возвращаемый результат | Использование |
|---|---|---|
exceptionally |
Значение типа T (новое или fallback) | Перехват ошибки и восстановление |
handle |
Новое значение типа U | Обработка и результата, и ошибки |
whenComplete |
Оригинальный результат или ошибка | Логирование, cleanup, side-effects |
Цепочка с множественной обработкой ошибок
class DataPipeline {
public CompletableFuture<Result> process(String input) {
return fetchData(input)
.exceptionally(ex -> {
System.err.println("Fetch failed, using default data");
return new Data("default");
})
.thenApplyAsync(data -> transform(data))
.exceptionally(ex -> {
System.err.println("Transform failed, using identity");
return new TransformedData();
})
.thenAccept(this::save)
.exceptionally(ex -> {
System.err.println("Save failed, but continuing");
return null;
})
.thenApply(v -> new Result("completed"));
}
private CompletableFuture<Data> fetchData(String input) {
// Может выбросить исключение
return CompletableFuture.supplyAsync(() -> new Data(input));
}
private TransformedData transform(Data data) {
// Может выбросить исключение
return new TransformedData();
}
private void save(TransformedData data) {
// Может выбросить исключение
}
static class Data {
String value;
Data(String value) { this.value = value; }
}
static class TransformedData {}
static class Result {
String status;
Result(String status) { this.status = status; }
}
}
Управление таймаутами (Java 9+)
orTimeout: Прерывание по таймауту
Сигнатура: orTimeout(long timeout, TimeUnit unit) → CompletableFuture<T> (Java 9+)
orTimeout прерывает Future, если он не завершится за заданное время, выбросив TimeoutException.
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(5000); // Долгая операция
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
return "Result";
})
.orTimeout(2, TimeUnit.SECONDS); // Таймаут 2 секунды
future.exceptionally(ex -> {
if (ex.getCause() instanceof TimeoutException) {
System.err.println("Operation timed out!");
return "timeout_fallback";
}
throw new RuntimeException(ex);
})
.thenAccept(System.out::println); // timeout_fallback (через 2 сек)
completeOnTimeout: Fallback по таймауту
Сигнатура: completeOnTimeout(T value, long timeout, TimeUnit unit) → CompletableFuture<T> (Java 9+)
completeOnTimeout автоматически завершает Future с указанным значением, если таймаут истёк. Не выбрасывает исключение.
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
return "Result";
})
.completeOnTimeout("default_value", 2, TimeUnit.SECONDS);
future.thenAccept(System.out::println); // default_value (через 2 сек)
Практический пример: Retry с таймаутом
class ResilientApiClient {
private static final int MAX_RETRIES = 3;
private static final long TIMEOUT_SECONDS = 5;
private ExecutorService executor = Executors.newFixedThreadPool(10);
public CompletableFuture<String> fetchWithRetry(String url) {
return fetchWithRetryInternal(url, MAX_RETRIES);
}
private CompletableFuture<String> fetchWithRetryInternal(String url, int retriesLeft) {
return CompletableFuture.supplyAsync(() -> {
System.out.println("Attempting to fetch: " + url);
return makeHttpCall(url);
}, executor)
.orTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
.exceptionally(ex -> {
if (retriesLeft > 0) {
System.out.println("Retry failed (" + ex.getMessage() + "), retrying... ("
+ retriesLeft + " retries left)");
try {
Thread.sleep(1000); // Backoff перед retry
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return fetchWithRetryInternal(url, retriesLeft - 1).join();
}
throw new RuntimeException("All retries exhausted", ex);
});
}
private String makeHttpCall(String url) {
// Имитация HTTP запроса
if (Math.random() > 0.3) {
throw new RuntimeException("Network error");
}
return "Response from " + url;
}
}
Практические примеры
Пример 1: Агрегация данных из микросервисов
class OrderDetailsService {
private ExecutorService executor = Executors.newFixedThreadPool(20);
public CompletableFuture<OrderDetails> getOrderDetails(Long orderId) {
// Все запросы выполняются параллельно
CompletableFuture<Order> orderFuture = fetchOrder(orderId);
CompletableFuture<List<Item>> itemsFuture = fetchOrderItems(orderId);
CompletableFuture<ShippingInfo> shippingFuture = fetchShippingInfo(orderId);
CompletableFuture<PaymentInfo> paymentFuture = fetchPaymentInfo(orderId);
// Комбинируем результаты
return orderFuture
.thenCombine(itemsFuture, (order, items) -> {
order.items = items;
return order;
})
.thenCombine(shippingFuture, (order, shipping) -> {
order.shipping = shipping;
return order;
})
.thenCombine(paymentFuture, (order, payment) -> {
order.payment = payment;
return new OrderDetails(order);
})
.orTimeout(10, TimeUnit.SECONDS)
.exceptionally(ex -> {
System.err.println("Failed to fetch order details: " + ex.getMessage());
return new OrderDetails(); // Empty details
});
}
private CompletableFuture<Order> fetchOrder(Long orderId) {
return CompletableFuture.supplyAsync(() -> {
// Вызов к order-service
return new Order(orderId);
}, executor);
}
private CompletableFuture<List<Item>> fetchOrderItems(Long orderId) {
return CompletableFuture.supplyAsync(() -> {
// Вызов к inventory-service
return List.of();
}, executor);
}
private CompletableFuture<ShippingInfo> fetchShippingInfo(Long orderId) {
return CompletableFuture.supplyAsync(() -> {
// Вызов к shipping-service
return new ShippingInfo();
}, executor);
}
private CompletableFuture<PaymentInfo> fetchPaymentInfo(Long orderId) {
return CompletableFuture.supplyAsync(() -> {
// Вызов к payment-service
return new PaymentInfo();
}, executor);
}
static class Order {
Long id;
List<Item> items;
ShippingInfo shipping;
PaymentInfo payment;
Order(Long id) { this.id = id; }
}
static class Item {}
static class ShippingInfo {}
static class PaymentInfo {}
static class OrderDetails {
Order order;
OrderDetails() { this.order = new Order(-1L); }
OrderDetails(Order order) { this.order = order; }
}
}
Пример 2: Асинхронная обработка медиа
class MediaProcessor {
private ExecutorService cpuExecutor = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors() // CPU cores
);
private ExecutorService ioExecutor = Executors.newFixedThreadPool(20); // IO
public CompletableFuture<ProcessedMedia> processMedia(String mediaPath) {
return loadMedia(mediaPath)
.thenApplyAsync(this::validateMedia)
.thenApplyAsync(this::extractMetadata)
.thenApplyAsync(this::resizeMedia, cpuExecutor) // CPU-intensive
.thenApplyAsync(this::applyFilters, cpuExecutor)
.thenApplyAsync(this::compressMedia, cpuExecutor)
.thenApplyAsync(this::generateThumbnails, cpuExecutor)
.thenAcceptAsync(this::uploadToStorage, ioExecutor)
.thenApply(v -> new ProcessedMedia(mediaPath, "completed"))
.exceptionally(ex -> {
System.err.println("Media processing failed: " + ex.getMessage());
return new ProcessedMedia(mediaPath, "failed");
});
}
private Media loadMedia(String path) {
System.out.println("Loading: " + path);
return new Media(path);
}
private Media validateMedia(Media media) {
System.out.println("Validating: " + media.path);
return media;
}
private Media extractMetadata(Media media) {
System.out.println("Extracting metadata: " + media.path);
return media;
}
private Media resizeMedia(Media media) {
System.out.println("Resizing: " + media.path);
return media;
}
private Media applyFilters(Media media) {
System.out.println("Applying filters: " + media.path);
return media;
}
private Media compressMedia(Media media) {
System.out.println("Compressing: " + media.path);
return media;
}
private Media generateThumbnails(Media media) {
System.out.println("Generating thumbnails: " + media.path);
return media;
}
private void uploadToStorage(Media media) {
System.out.println("Uploading to storage: " + media.path);
}
static class Media {
String path;
Media(String path) { this.path = path; }
}
static class ProcessedMedia {
String path;
String status;
ProcessedMedia(String path, String status) { this.path = path; this.status = status; }
}
}
Best Practices и распространённые ошибки
✅ ХОРОШО: Использование кастомного Executor для IO-bound операций
// Проблема: ForkJoinPool.commonPool() имеет размер = CPU cores - 1
// Для IO-bound операций это недостаточно (много потоков ждёт IO)
// Решение: создать отдельный Executor для IO
ExecutorService ioExecutor = Executors.newFixedThreadPool(100);
CompletableFuture.supplyAsync(() -> {
return blockingHttpCall(); // IO-bound
}, ioExecutor);
// И CPU Executor для CPU-bound
ExecutorService cpuExecutor = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors()
);
CompletableFuture.supplyAsync(() -> {
return heavyCalculation(); // CPU-bound
}, cpuExecutor);
❌ ПЛОХО: Блокирование в async операциях
// НЕПРАВИЛЬНО: блокирующий вызов в ForkJoinPool
CompletableFuture.supplyAsync(() -> {
return Thread.currentThread().getName(); // ForkJoinPool-worker
}).thenApply(threadName -> {
Thread.sleep(1000); // БЛОКИРУЕТ пул!
return threadName;
});
✅ ХОРОШО: Явная обработка исключений
// Исключения не должны "проходить мимо"
CompletableFuture.supplyAsync(() -> {
throw new RuntimeException("Error");
})
.exceptionally(ex -> {
Logger.error("Caught exception", ex);
return "fallback";
})
.thenAccept(System.out::println);
❌ ПЛОХО: Использование get() без таймаута
// Может заблокироваться навсегда
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
// Долгая операция
return "result";
});
String result = future.get(); // ❌ ОПАСНО: может зависнуть
// Альтернативы:
String result1 = future.join(); // ✓ Выбросит CompletionException
String result2 = future.get(5, TimeUnit.SECONDS); // ✓ С таймаутом
✅ ХОРОШО: thenCompose для зависимых операций
// Раскрывать вложенные CompletableFuture
fetchUser(userId)
.thenCompose(user -> fetchOrders(user.id)) // ✓ Результат автоматически распаковывается
.thenAccept(System.out::println);
❌ ПЛОХО: thenApply с вложенным CompletableFuture
// Создаёт CompletableFuture<CompletableFuture<T>>
fetchUser(userId)
.thenApply(user -> fetchOrders(user.id)) // ❌ Возвращает CompletableFuture!
.thenAccept(ordersFuture -> {
orders = ordersFuture.join(); // Нужно распаковывать вручную
});
✅ ХОРОШО: Graceful shutdown Executors
ExecutorService executor = Executors.newFixedThreadPool(10);
try {
CompletableFuture.supplyAsync(() -> doWork(), executor)
.thenAccept(System.out::println)
.join();
} finally {
executor.shutdown();
if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
executor.shutdownNow(); // Force shutdown если не закончился
}
}
✅ ХОРОШО: Разделение длинных цепочек
// Вместо:
CompletableFuture.supplyAsync(() -> a())
.thenApply(r -> b(r))
.thenApply(r -> c(r))
.thenApply(r -> d(r))
.thenApply(r -> e(r));
// Лучше:
CompletableFuture<Data1> step1 = CompletableFuture.supplyAsync(() -> a());
CompletableFuture<Data2> step2 = step1.thenApply(r -> b(r));
CompletableFuture<Data3> step3 = step2.thenApply(r -> c(r));
CompletableFuture<Data4> step4 = step3.thenApply(r -> d(r));
// Теперь понятнее и легче дебажить
✅ ХОРОШО: Используйте orTimeout для внешних вызовов
// Всегда защищайте вызовы к внешним сервисам
externalApiCall()
.orTimeout(5, TimeUnit.SECONDS)
.exceptionally(ex -> {
if (ex.getCause() instanceof TimeoutException) {
return defaultValue;
}
throw new RuntimeException(ex);
});
❌ ПЛОХО: allOf без сбора результатов
// allOf возвращает Void!
List<CompletableFuture<String>> futures = List.of(...);
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));
// ❌ Результаты потеряны!
// Правильно:
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.thenApply(v -> futures.stream()
.map(CompletableFuture::join)
.collect(Collectors.toList()));
✅ ХОРОШО: Используйте whenComplete для cleanup
database.getConnection()
.thenAccept(conn -> executeQuery(conn))
.whenComplete((result, ex) -> {
if (database.getConnection() != null) {
database.getConnection().close();
}
});
Заключение
CompletableFuture — это мощный инструмент для асинхронного программирования, который позволяет писать неблокирующий код, комбинировать несколько асинхронных операций и элегантно обрабатывать ошибки. Ключевые моменты:
- supplyAsync vs runAsync — для операций с результатом и без
- thenApply vs thenCompose — первая для трансформаций, вторая для зависимых операций
- thenCombine / allOf / anyOf — для работы с несколькими Future
- Обработка ошибок — всегда используйте exceptionally, handle или whenComplete
- Executors — создавайте отдельные пулы для IO и CPU операций
- Таймауты — защищайте внешние вызовы через orTimeout (Java 9+)
- Shutdown — всегда закрывайте Executor потоки
Правильное использование CompletableFuture делает код более масштабируемым, производительным и легко поддерживаемым.
Fork/Join Framework
Введение
Fork/Join Framework — специализированный framework для параллельного выполнения рекурсивных задач по принципу divide-and-conquer, введённый в Java 7.
Основная идея простая: разбить большую задачу на меньшие подзадачи, выполнить их параллельно в разных потоках, и объединить результаты.
Divide-and-Conquer процесс:
Исходная задача (1000 элементов)
↓ fork (разбить)
┌───┴───┐
Task1 Task2 (по 500 элементов)
↓ ↓ fork
┌─┴─┐ ┌─┴─┐
T1 T2 T3 T4 (по 250 элементов)
↓ ↓ ↓ ↓
... продолжаем делить до threshold ...
↓
[базовый случай - прямое вычисление]
↓ join (объединить результаты)
объединённый результат
Когда это выгодно: Когда задача легко разбивается на независимые части, которые можно обрабатывать параллельно, а затем объединить результаты. Особенно эффективно для CPU-bound операций с большими объёмами данных.
Ключевые компоненты:
- ForkJoinPool: специализированный thread pool с work-stealing алгоритмом
- RecursiveTask
: задача, возвращающая результат типа V - RecursiveAction: задача без возврата результата
- fork(): асинхронно отправить подзадачу на выполнение
- join(): дождаться результата подзадачи
ForkJoinPool и Work-Stealing: как это работает
Почему ForkJoinPool отличается от обычных thread pools
Проблема с ThreadPoolExecutor:
ThreadPoolExecutor использует глобальную очередь:
┌─────────────────────────────────┐
│ Общая очередь для всех │
│ [Task1][Task2][Task3][Task4]... │
└─────────────────────────────────┘
↓ ↓ ↓ ↓
Thread1 Thread2 Thread3 Thread4
Проблема: когда Thread1 fork'ит подзадачи, все потоки конкурируют за одну очередь → contention, потери на синхронизацию.
Решение ForkJoinPool — Work-Stealing:
Каждый поток имеет свою deque (двусторонняя очередь):
Thread-1: [Task1, Task2, Task3] ← собственная deque
Thread-2: [Task4, Task5] ← собственная deque
Thread-3: [] ← пустая deque
Когда Thread-3 заканчивает работу:
1. Смотрит в свою deque → пусто
2. Крадёт (steal) последнюю задачу из деque другого потока
3. Выполняет украденную задачу
Thread-1: [Task1, Task2, Task3]
Thread-2: [Task4] ← Task5 украдена
Thread-3: [Task5] ← выполняет украденную
Почему это быстрее:
- Каждый поток работает с собственной деque без синхронизации (fast path)
- Кража происходит редко и с конца очереди (меньше contention)
- Потоки не ждут — выполняют сразу найденную работу
Архитектура: как создаётся пул
import java.util.concurrent.ForkJoinPool;
// 1. commonPool — глобальный пул
// Размер = max(1, Runtime.getRuntime().availableProcessors() - 1)
ForkJoinPool commonPool = ForkJoinPool.commonPool();
// На 8-ядерной системе создастся пул из 7 потоков
// На 4-ядерной из 3 потоков
// Используется всеми Parallel Streams и лучше не забивать!
// 2. Кастомный пул с явным размером
ForkJoinPool customPool = new ForkJoinPool(4); // ровно 4 потока
// 3. Пул с полной конфигурацией
ForkJoinPool configured = new ForkJoinPool(
4, // parallelism = 4
ForkJoinPool.defaultForkJoinWorkerThreadFactory, // фабрика потоков
null, // exception handler
true // asyncMode: FIFO вместо LIFO
);
Важное отличие от ThreadPoolExecutor:
| Критерий | ThreadPoolExecutor | ForkJoinPool |
|---|---|---|
| Очередь | 1 общая | Deque на поток |
| Синхронизация | На каждый put/take | Минимальная (work-stealing редко) |
| Подходит для | Независимых задач | Рекурсивных, зависимых задач |
| Алгоритм | FIFO | LIFO (для fork) + FIFO (для steal) |
| Размер | Вручную задаётся | Оптимизирован под parallelism |
RecursiveTask: задача с результатом
RecursiveTask
Структура и flow
Каждый RecursiveTask имеет жизненный цикл:
class MyTask extends RecursiveTask<Integer> {
private final int threshold = 100; // Порог для базового случая
private final int start, end;
MyTask(int start, int end) {
this.start = start;
this.end = end;
}
@Override
protected Integer compute() {
int size = end - start;
// ШАГ 1: Базовый случай — задача достаточно мала
// Решить напрямую, без дальнейшего разбиения
if (size <= threshold) {
return solveDirectly(); // Прямое вычисление
}
// ШАГ 2: Рекурсивный случай — разбить задачу
int mid = start + size / 2;
MyTask leftTask = new MyTask(start, mid);
MyTask rightTask = new MyTask(mid, end);
// ШАГ 3: Fork левую подзадачу
// Это НЕ запускает немедленно!
// Добавляет в deque текущего потока для асинхронного выполнения
leftTask.fork();
// ШАГ 4: Compute правую подзадачу В ТЕКУЩЕМ потоке
// (Экономим fork, текущий поток итак работает)
Integer rightResult = rightTask.compute();
// ШАГ 5: Join левую подзадачу
// Ждём результата левой задачи
// Если она ещё не выполнена, текущий поток может помочь её выполнить!
Integer leftResult = leftTask.join();
// ШАГ 6: Объединить результаты
return combine(leftResult, rightResult);
}
private Integer solveDirectly() {
// Базовое вычисление для размера <= threshold
int sum = 0;
for (int i = start; i < end; i++) {
sum += i;
}
return sum;
}
private Integer combine(Integer left, Integer right) {
return left + right; // Объединение результатов
}
}
Практический пример: параллельное суммирование
Классический пример — суммировать массив из 1 миллиона элементов:
class ArraySum extends RecursiveTask<Long> {
// Ключевой параметр: порог, после которого считаем напрямую
private static final int THRESHOLD = 10_000;
private final long[] array;
private final int start;
private final int end;
ArraySum(long[] array, int start, int end) {
this.array = array;
this.start = start;
this.end = end;
}
@Override
protected Long compute() {
int size = end - start;
// БАЗОВЫЙ СЛУЧАЙ: массив достаточно мал
// Можем суммировать одним потоком быстрее, чем fork/join overhead
if (size <= THRESHOLD) {
long sum = 0;
for (int i = start; i < end; i++) {
sum += array[i];
}
return sum;
}
// РЕКУРСИВНЫЙ СЛУЧАЙ: разбить и распараллелить
int mid = start + size / 2;
// Левая половина будет fork'ed в отдельном потоке
ArraySum leftTask = new ArraySum(array, start, mid);
// Правая половина будет compute'ed в текущем потоке
ArraySum rightTask = new ArraySum(array, mid, end);
// Fork левой: добавить в очередь для асинхронного выполнения
// Текущий поток НЕ ждёт, идёт дальше
leftTask.fork();
// Compute правой: выполнить в текущем потоке (экономим fork)
long rightResult = rightTask.compute();
// Join левой: получить результат
// Может потребоваться ждать, если поток занят
long leftResult = leftTask.join();
// Объединить результаты
return leftResult + rightResult;
}
}
// Использование:
public static void main(String[] args) {
long[] array = new long[1_000_000];
for (int i = 0; i < array.length; i++) {
array[i] = i + 1;
}
// invoke = fork + join на верхнем уровне
ForkJoinPool pool = ForkJoinPool.commonPool();
long result = pool.invoke(new ArraySum(array, 0, array.length));
System.out.println("Sum: " + result); // Output: 500000500000
}
Почему это работает:
- На вершине иерархии (1 млн элементов) → fork/join overhead оправдан (много работы впереди)
- На среднем уровне (по 100k элементов) → разбиение продолжается параллельно
- На нижнем уровне (< 10k элементов) → прямое суммирование, overhead запрещен
Результат: работа распределена равномерно по 8 ядрам (или сколько есть).
Типичная ошибка: слишком маленький threshold
private static final int THRESHOLD = 10; // ПЛОХО!
// Что происходит:
// Array[1M] → Array[500k] + Array[500k] → 2 fork
// Array[500k] → Array[250k] + Array[250k] → 4 fork
// Array[250k] → Array[125k] + Array[125k] → 8 fork
// ...
// Результат: ~100,000 fork/join операций вместо ~1,000
// Overhead затопит параллелизм!
RecursiveAction: задача без результата
RecursiveAction применяется, когда нам нужно выполнить действие, но результат не требуется (например, инициализировать массив, применить функцию ко всем элементам).
Пример: параллельная инициализация массива
class ArrayInitializer extends RecursiveAction {
private static final int THRESHOLD = 10_000;
private final double[] array;
private final int start;
private final int end;
ArrayInitializer(double[] array, int start, int end) {
this.array = array;
this.start = start;
this.end = end;
}
@Override
protected void compute() {
int size = end - start;
// Базовый случай: инициализировать напрямую
if (size <= THRESHOLD) {
for (int i = start; i < end; i++) {
array[i] = Math.sqrt(i); // Дорогостоящее вычисление
}
return;
}
// Рекурсивный случай: разбить
int mid = start + size / 2;
ArrayInitializer left = new ArrayInitializer(array, start, mid);
ArrayInitializer right = new ArrayInitializer(array, mid, end);
// invokeAll = fork обе + join обе
// (удобнее, чем ручной fork/join)
invokeAll(left, right);
}
}
// Использование:
double[] array = new double[10_000_000];
ForkJoinPool.commonPool().invoke(new ArrayInitializer(array, 0, array.length));
// Массив заполнен результатами sqrt() параллельно
invokeAll: удобный метод для множества задач
// Без invokeAll (ручной fork/join):
leftTask.fork();
rightTask.fork();
leftTask.join();
rightTask.join();
// С invokeAll (эквивалент, но читаемее):
invokeAll(leftTask, rightTask);
// invokeAll внутри делает:
// 1. Fork все задачи
// 2. Join все задачи
// 3. Если какая-то выбросила exception, выбросит её
Преимущество: меньше кода, понятнее intent.
fork() vs join(): как они работают внутри
fork(): отправить задачу асинхронно
task.fork();
Что происходит:
- Задача добавляется в deque текущего потока (fast path, без синхронизации)
- Текущий поток продолжает работу — не ждёт выполнения
- Другой поток или work-stealing вынет задачу из deque и выполнит её
Важно: fork() не выполняет задачу немедленно. Это просто очередь.
// Пример:
System.out.println("1. До fork");
task.fork();
System.out.println("2. После fork (задача ещё не выполнена!)");
System.out.println("3. До join");
result = task.join();
System.out.println("4. После join (теперь результат готов)");
// Output:
// 1. До fork
// 2. После fork (задача ещё не выполнена!)
// 3. До join
// 4. После join (теперь результат готов)
join(): получить результат и ждать
V result = task.join();
Что происходит:
-
Если задача уже завершена → вернуть результат сразу
-
Если задача не выполнена:
- Текущий поток может помочь выполнить эту задачу или связанные
- Это избегает deadlock'а (в отличие от Future.get())
-
Если в compute() выброшено исключение → выбросить его как unchecked
// Пример обработки исключений:
try {
result = task.join(); // Исключение из compute() будет выброшено здесь
} catch (RuntimeException e) {
System.err.println("Task failed: " + e.getMessage());
}
Паттерны fork/join: когда что использовать
Паттерн 1: fork левую, compute правую (РЕКОМЕНДУЕТСЯ)
// Экономим 1 fork операцию
leftTask.fork(); // Левая в другой поток
rightResult = rightTask.compute(); // Правая в текущий поток
leftResult = leftTask.join(); // Ждём левую
// Преимущество:
// - Меньше fork операций
// - Левая выполняется параллельно с правой в текущем потоке
// - Текущий поток не бездействует
Паттерн 2: invokeAll для симметрии
// Когда обе задачи одинаковые и нет смысла оптимизировать
invokeAll(leftTask, rightTask);
// Это делает fork/join симметрично:
// - Обе fork'ятся
// - Обе join'ятся
// - Менее эффективно, но понятнее код
Паттерн 3: fork all, потом join all
// Для множества задач (например, обработать 16 партиций):
List<MyTask> tasks = new ArrayList<>();
for (int i = 0; i < 16; i++) {
tasks.add(new MyTask(...));
}
// Fork все
tasks.forEach(ForkJoinTask::fork);
// Join все и собрать результаты
List<Integer> results = tasks.stream()
.map(ForkJoinTask::join)
.collect(Collectors.toList());
Когда использовать Fork/Join: сценарии
✅ Идеальные сценарии
1. CPU-bound рекурсивные задачи
// ХОРОШО: все CPU занят полезной работой
class MergeSort extends RecursiveAction {
// Сортировка большого массива
}
class MatrixMultiply extends RecursiveTask<int[][]> {
// Умножение матриц
}
2. Divide-and-conquer алгоритмы
// ХОРОШО: задача легко разбивается
// Merge Sort, Quick Sort, Matrix Mult, Fibonacci, Polynomial Mult
3. Большие объёмы однородных данных
// ХОРОШО: 10 млн элементов для обработки
long[] array = new long[10_000_000];
// ПЛОХО: 100 элементов
int[] small = new int[100];
4. Глубокая рекурсия с балансировкой
// ХОРОШО: дерево рекурсии примерно сбалансировано
// Каждый уровень имеет примерно одинаковое кол-во работы
// ПЛОХО: несбалансированное дерево
// Один branch почти не работает, другой работает полностью
❌ Когда НЕ использовать
1. I/O-bound операции
// ПЛОХО: блокирующая I/O
class BadTask extends RecursiveTask<String> {
protected String compute() {
return httpClient.get(url); // Блокирует поток на 500ms!
}
}
// Проблема:
// - Поток в ForkJoinPool блокируется
// - Другие задачи не могут украсть эту работу (поток спит)
// - Parallelism теряется
// ХОРОШО: используйте CompletableFuture или Reactor
CompletableFuture<String> future = httpClient.getAsync(url);
2. Слишком малые задачи
// ПЛОХО: overhead > полезная работа
class TinyTask extends RecursiveTask<Integer> {
protected Integer compute() {
return a + b; // 1 наносекунда работы, но overhead fork/join = 1 микросекунда!
}
}
// Threshold должен быть достаточно большой!
3. Несбалансированное разбиение
// ПЛОХО: несбалансированное дерево
int mid = start + 1; // Левая = 1 элемент, правая = 999,999
// Результат:
// Thread1 выполняет левую задачу (быстро)
// Thread2 выполняет правую задачу (очень долго)
// parallelism плохой
// ХОРОШО: равномерное разбиение
int mid = start + (end - start) / 2;
4. Когда нужна лучше latency, чем throughput
// ПЛОХО: Fork/Join может быть медленнее для малого объёма
// (overhead на создание пула, fork/join)
// Используйте простой цикл:
int sum = 0;
for (int i = 0; i < 1000; i++) {
sum += array[i];
}
// Вместо:
pool.invoke(new ArraySum(array, 0, 1000));
Практические примеры
Пример 1: Параллельный Merge Sort
class ParallelMergeSort extends RecursiveAction {
private static final int THRESHOLD = 1000; // Сортировать напрямую, если <= 1000
private final int[] array;
private final int start;
private final int end;
ParallelMergeSort(int[] array, int start, int end) {
this.array = array;
this.start = start;
this.end = end;
}
@Override
protected void compute() {
int size = end - start;
// Базовый случай: сортировать напрямую
if (size <= THRESHOLD) {
Arrays.sort(array, start, end);
return;
}
// Рекурсивный случай: разбить, отсортировать, объединить
int mid = start + size / 2;
ParallelMergeSort left = new ParallelMergeSort(array, start, mid);
ParallelMergeSort right = new ParallelMergeSort(array, mid, end);
// Параллельная сортировка двух половин
invokeAll(left, right);
// Merge отсортированных половин
merge(array, start, mid, end);
}
private void merge(int[] array, int start, int mid, int end) {
int[] temp = new int[end - start];
int i = start, j = mid, k = 0;
// Merge два отсортированных диапазона
while (i < mid && j < end) {
temp[k++] = (array[i] <= array[j]) ? array[i++] : array[j++];
}
// Скопировать оставшиеся элементы
while (i < mid) temp[k++] = array[i++];
while (j < end) temp[k++] = array[j++];
// Скопировать назад в исходный массив
System.arraycopy(temp, 0, array, start, temp.length);
}
}
// Использование:
int[] array = {64, 34, 25, 12, 22, 11, 90, 88};
ForkJoinPool.commonPool().invoke(new ParallelMergeSort(array, 0, array.length));
System.out.println(Arrays.toString(array)); // [11, 12, 22, 25, 34, 64, 88, 90]
Как это работает параллельно:
Array[1000]
├─ sort([0:500]) → Thread 1
└─ sort([500:1000]) → Thread 2
Когда Thread 1 заканчивает, крадёт работу из Thread 2:
├─ sort([0:250]) → Thread 1
├─ sort([250:500]) → Thread 2 (stolen by Thread 1)
├─ sort([500:750]) → Thread 3
└─ sort([750:1000]) → Thread 4
Пример 2: Параллельное умножение матриц
class MatrixMultiply extends RecursiveTask<int[][]> {
private static final int THRESHOLD = 64; // Перейти на прямое умножение
private final int[][] A;
private final int[][] B;
private final int startRow;
private final int endRow;
MatrixMultiply(int[][] A, int[][] B, int startRow, int endRow) {
this.A = A;
this.B = B;
this.startRow = startRow;
this.endRow = endRow;
}
@Override
protected int[][] compute() {
int rowCount = endRow - startRow;
// Базовый случай: прямое умножение строк
if (rowCount <= THRESHOLD) {
return multiplyDirect();
}
// Рекурсивный случай: разбить по строкам
int mid = startRow + rowCount / 2;
MatrixMultiply upper = new MatrixMultiply(A, B, startRow, mid);
MatrixMultiply lower = new MatrixMultiply(A, B, mid, endRow);
// Параллельное умножение двух половин матриц
upper.fork();
int[][] lowerResult = lower.compute();
int[][] upperResult = upper.join();
// Объединить результаты
return combine(upperResult, lowerResult);
}
private int[][] multiplyDirect() {
int rows = endRow - startRow;
int cols = B[0].length;
int[][] result = new int[rows][cols];
// Классическое умножение для небольших матриц
for (int i = startRow; i < endRow; i++) {
for (int j = 0; j < cols; j++) {
for (int k = 0; k < B.length; k++) {
result[i - startRow][j] += A[i][k] * B[k][j];
}
}
}
return result;
}
private int[][] combine(int[][] upper, int[][] lower) {
int[][] result = new int[upper.length + lower.length][];
System.arraycopy(upper, 0, result, 0, upper.length);
System.arraycopy(lower, 0, result, upper.length, lower.length);
return result;
}
}
Пример 3: Параллельная обработка документов
class DocumentProcessor extends RecursiveTask<Map<String, Integer>> {
private static final int THRESHOLD = 100; // Обрабатывать 100 документов за раз
private final List<String> documents;
private final int start;
private final int end;
DocumentProcessor(List<String> documents, int start, int end) {
this.documents = documents;
this.start = start;
this.end = end;
}
@Override
protected Map<String, Integer> compute() {
int size = end - start;
// Базовый случай: обработать документы напрямую
if (size <= THRESHOLD) {
return processDocuments();
}
// Рекурсивный случай: разбить и обработать параллельно
int mid = start + size / 2;
DocumentProcessor left = new DocumentProcessor(documents, start, mid);
DocumentProcessor right = new DocumentProcessor(documents, mid, end);
left.fork();
Map<String, Integer> rightResult = right.compute();
Map<String, Integer> leftResult = left.join();
// Объединить результаты (merge word counts)
return mergeWordCounts(leftResult, rightResult);
}
private Map<String, Integer> processDocuments() {
Map<String, Integer> wordCount = new HashMap<>();
for (int i = start; i < end; i++) {
String[] words = documents.get(i).toLowerCase().split("\\W+");
for (String word : words) {
if (!word.isEmpty()) {
wordCount.merge(word, 1, Integer::sum);
}
}
}
return wordCount;
}
private Map<String, Integer> mergeWordCounts(
Map<String, Integer> map1,
Map<String, Integer> map2) {
Map<String, Integer> merged = new HashMap<>(map1);
map2.forEach((word, count) -> merged.merge(word, count, Integer::sum));
return merged;
}
}
// Использование:
List<String> documents = Arrays.asList(
"hello world hello",
"fork join framework",
"parallel processing world"
);
ForkJoinPool pool = ForkJoinPool.commonPool();
Map<String, Integer> wordFreq = pool.invoke(
new DocumentProcessor(documents, 0, documents.size())
);
// Output: {hello=2, world=2, fork=1, join=1, framework=1, parallel=1, processing=1}
Производительность: как оптимизировать
Выбор threshold: баланс между overhead и parallelism
// Слишком МАЛЫЙ threshold
private static final int THRESHOLD = 10;
// ❌ Результат: ~100,000 fork/join операций
// ❌ Overhead затопит распараллеливание
// Слишком БОЛЬШОЙ threshold
private static final int THRESHOLD = 1_000_000;
// ❌ Результат: мало подзадач, плохая балансировка нагрузки
// ❌ Некоторые потоки будут неприняты
// ХОРОШИЙ threshold зависит от задачи
// Правило: threshold = totalSize / (parallelism * 4..10)
int threshold = array.length / (ForkJoinPool.getCommonPoolParallelism() * 8);
// На 8-ядерной системе для массива из 1М элементов:
// threshold = 1_000_000 / (8 * 8) = 15_625
Как найти оптимальный threshold:
@Benchmark
public void threshold_1000() {
pool.invoke(new ArraySum(array, 0, array.length, 1_000));
}
@Benchmark
public void threshold_10000() {
pool.invoke(new ArraySum(array, 0, array.length, 10_000));
}
@Benchmark
public void threshold_100000() {
pool.invoke(new ArraySum(array, 0, array.length, 100_000));
}
// Запустить JMH и выбрать самый быстрый
Избегайте лишних fork: оптимизированный паттерн
// ❌ ПЛОХО: обе fork (непотребная работа)
leftTask.fork();
rightTask.fork();
leftResult = leftTask.join();
rightResult = rightTask.join();
// Что происходит:
// - 2 fork операции (очередей, синхронизация)
// - Текущий поток бездействует, ждёт leftTask
// ✅ ХОРОШО: одна fork, одна compute
leftTask.fork();
rightResult = rightTask.compute(); // Делаем работу в текущем потоке!
leftResult = leftTask.join();
// Преимущества:
// - 1 fork вместо 2
// - Текущий поток не бездействует
// - Меньше overhead
Используйте invokeAll для множества задач
// ❌ ПЛОХО: ручной fork/join
for (Task task : tasks) {
task.fork();
}
for (Task task : tasks) {
result.add(task.join());
}
// ✅ ХОРОШО: invokeAll
invokeAll(tasks);
for (Task task : tasks) {
result.add(task.getRawResult());
}
// invokeAll оптимизирован для множества задач
Правильный размер пула
// ❌ ПЛОХО: создавать новый пул для каждой задачи
public void process() {
ForkJoinPool pool = new ForkJoinPool(); // Создание ~ 1ms
pool.invoke(task); // Выполнение
pool.shutdown(); // Ждём завершения
}
// Вызывается в цикле → много потерь на создание/shutdown
// ✅ ХОРОШО: переиспользовать пул
private static final ForkJoinPool pool = new ForkJoinPool(8);
public void process() {
pool.invoke(task);
}
// Или используйте commonPool:
public void process() {
ForkJoinPool.commonPool().invoke(task);
}
Подводные камни: что может пойти не так
1. Не используйте synchronized в compute()
// ❌ ПЛОХО: synchronized блокирует поток
class BadTask extends RecursiveTask<Integer> {
private static int counter = 0;
protected Integer compute() {
synchronized (BadTask.class) { // Ждёт lock → блокирует work-stealing!
counter++;
}
return counter;
}
}
// Проблема:
// - Поток заблокирован на lock
// - Другие потоки не могут украсть его работу
// - Потенциальный deadlock
// ✅ ХОРОШО: используйте AtomicInteger или кумулируйте результаты
class GoodTask extends RecursiveTask<Integer> {
private static AtomicInteger counter = new AtomicInteger();
protected Integer compute() {
// Исключения не выбрасываются, операция атомарна
return counter.incrementAndGet();
}
}
// ✅ ЕЩЁ ЛУЧШЕ: кумулируйте результаты (быстрее и безопаснее)
class BestTask extends RecursiveTask<Integer> {
protected Integer compute() {
// Не трогаем shared state, просто возвращаем результат
int localCounter = 0;
for (...) {
localCounter++;
}
return localCounter;
}
}
2. Не блокируйте в compute()
// ❌ ПЛОХО: блокирующая I/O
class BadTask extends RecursiveTask<String> {
protected String compute() {
return httpClient.get(url); // Блокирует на 500ms!
}
}
// Проблема:
// - ForkJoinPool имеет ограниченное число потоков
// - Поток заблокирован на I/O
// - Другие задачи ждут → throughput падает
// ✅ ХОРОШО: асинхронный I/O
class GoodTask extends RecursiveTask<CompletableFuture<String>> {
protected CompletableFuture<String> compute() {
return httpClient.getAsync(url); // Не блокирует
}
}
// ✅ ИЛИ: используйте ExecutorService для I/O
ExecutorService ioExecutor = Executors.newFixedThreadPool(32);
ForkJoinPool computePool = ForkJoinPool.commonPool();
3. Неправильный threshold приводит к плохой производительности
// ❌ Порог = 10 для массива из 1М элементов
// Результат: ~100,000 fork/join операций вместо ~1,000
// Overhead может быть БОЛЬШЕ, чем параллелизм!
// ✅ Выбирайте эмпирически:
// - Для суммирования: threshold = 10,000..50,000
// - Для сортировки: threshold = 1,000..5,000
// - Для обработки: threshold = 100..1,000
4. fork() НЕ выполняет задачу немедленно
Task task = new MyTask();
task.fork();
// ❌ Думаете: задача выполняется прямо сейчас
// ✅ Реальность: задача просто добавлена в очередь
// Нужно явно вызвать join():
result = task.join(); // Теперь гарантированно выполнено
5. join() выбрасывает unchecked exception
protected Integer compute() {
try {
result = task.join();
} catch (RuntimeException e) { // Исключение из task.compute()
// Обработка
}
}
// Это НЕ checked exception, в отличие от Future.get()!
6. ForkJoinPool.commonPool() может быть занят
// ❌ ПРОБЛЕМА: commonPool используется Parallel Streams
list.parallelStream().forEach(...); // Занимает commonPool
// В этот момент другие ForkJoinTasks конкурируют за пул
// ✅ РЕШЕНИЕ: создайте кастомный пул если нужна изоляция
ForkJoinPool customPool = new ForkJoinPool(4);
customPool.invoke(task);
7. Не модифицируйте shared mutable state
// ❌ ПЛОХО: race condition
class BadTask extends RecursiveTask<Integer> {
private static List<Integer> results = new ArrayList<>();
protected Integer compute() {
results.add(value); // Race condition! ArrayList не thread-safe
return value;
}
}
// ✅ ХОРОШО: возвращайте результат, не трогайте shared state
class GoodTask extends RecursiveTask<Integer> {
protected Integer compute() {
// Каждый поток работает с собственным локальным state
return value;
}
}
8. Несбалансированное дерево рекурсии
// ❌ ПЛОХО: несбалансированное разбиение
int mid = start + 1; // Левая = 1 элемент, правая = 999,999 элементов
// Результат: одна задача почти мгновенно завершится, другая будет работать долго
// Parallelism плохой
// ✅ ХОРОШО: равномерное разбиение
int mid = start + (end - start) / 2;
// Результат: обе задачи примерно одинакового размера
Сравнение подходов: когда что использовать
| Подход | Когда использовать | Пример |
|---|---|---|
| Простой цикл | Данные < 1000 элементов, простая операция | for (i : array) sum += i; |
| Parallel Streams | Готовая операция, простая цепь | list.parallelStream().map(...).sum() |
| ExecutorService | Независимые задачи, I/O-bound | Thread pool для запросов |
| Fork/Join | Рекурсивные, CPU-bound, большой объём | Merge sort, matrix mult, 1M элементов |
| CompletableFuture | Асинхронный код, I/O, комбинация операций | getAsync().thenApply(...).get() |
Резюме: как использовать Fork/Join эффективно
Алгоритм выбора:
- ✅ Есть рекурсивная структура? → Fork/Join может помочь
- ✅ CPU-bound (вычисления, не I/O)? → Fork/Join подходит
- ✅ Большой объём данных (> 100K)? → Fork/Join оправдан
- ✅ Divide-and-conquer подход? → Fork/Join идеален
- ❌ Если что-то из этого не верно → используйте другой подход
Оптимизация:
- Выберите правильный threshold (эмпирически)
- Разбивайте равномерно (mid = start + size / 2)
- fork только одну подзадачу, compute другую
- Не блокируйте в compute()
- Переиспользуйте пул (не создавайте для каждой операции)
- Бенчмарьте с разными параметрами
Результат: параллелизм, использующий все ядра процессора, с минимальным overhead.
Synchronizers
Введение
Synchronizers (синхронизаторы) — специальные утилиты из java.util.concurrent для высокоуровневой координации потоков. Они решают типовые проблемы синхронизации лучше, чем низкоуровневые wait()/notify(), обеспечивают более надежный код и скрывают сложность внутренней реализации.
Почему синхронизаторы, а не wait/notify?
Низкоуровневые wait/notify требуют тщательного управления состояниями и легко приводят к deadlock-ам и потерям сигналов. Синхронизаторы предоставляют готовые решения для конкретных сценариев:
- CountDownLatch — когда нужно дождаться завершения N независимых операций
- CyclicBarrier — когда N потоков должны синхронизироваться в одной точке и повторять это циклически
- Semaphore — когда нужно ограничить число потоков, обращающихся к ресурсу одновременно
- Phaser — расширение CyclicBarrier для сложных многофазных сценариев с динамическим числом участников
- Exchanger — когда два потока должны обменяться данными
CountDownLatch
CountDownLatch — одноразовый синхронизатор для ожидания завершения N операций. Один или несколько потоков ждут на await(), пока другие потоки не вызовут countDown() нужное число раз, доведя счетчик до нуля.
Как это работает внутри
CountDownLatch(3) — создаем синхронизатор со счетчиком = 3:
Основной поток:
latch.await() — блокируется, ждет счетчика == 0
Рабочие потоки параллельно:
Thread-1: выполняет работу → latch.countDown() (счетчик: 3 → 2)
Thread-2: выполняет работу → latch.countDown() (счетчик: 2 → 1)
Thread-3: выполняет работу → latch.countDown() (счетчик: 1 → 0)
Как только счетчик достигает 0:
Основной поток разблокируется и продолжает выполнение
Все остальные потоки, ждущие на await(), также разблокируются
Ключевой момент: CountDownLatch может быть использован только один раз. После того как счетчик достигнет нуля, его нельзя "перезагрузить". Если нужна переиспользуемость, используйте CyclicBarrier или Phaser.
Основные методы
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
CountDownLatch latch = new CountDownLatch(3);
// await() — заблокировать текущий поток до счетчика == 0
// Может быть вызван из разных потоков одновременно
latch.await(); // Блокируется до счетчика == 0
latch.await(10, TimeUnit.SECONDS); // С таймаутом
// countDown() — уменьшить счетчик на 1
// Потокобезопасен, может быть вызван много раз
latch.countDown();
// getCount() — текущее значение счетчика
long count = latch.getCount(); // Можно использовать для отладки
Пример 1: Ожидание завершения набора задач
Классический сценарий: запустить N параллельных задач и дождаться, пока все они завершат работу.
class TaskCoordinator {
private static final int TASK_COUNT = 5;
public void executeTasksAndWait() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(TASK_COUNT);
for (int i = 0; i < TASK_COUNT; i++) {
int taskId = i;
new Thread(() -> {
try {
System.out.println("Task " + taskId + " started");
// Имитация работы
Thread.sleep(1000 + (taskId * 500));
System.out.println("Task " + taskId + " completed");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
latch.countDown(); // Уменьшить счетчик в любом случае
}
}).start();
}
System.out.println("Main thread waiting for all tasks to complete...");
// Ждем здесь пока все потоки не завершат работу
if (latch.await(30, TimeUnit.SECONDS)) {
System.out.println("All tasks completed successfully!");
} else {
System.err.println("Timeout waiting for tasks");
}
}
}
Почему finally блок с countDown()? Если в потоке случится исключение, счетчик все равно должен быть уменьшен. Иначе основной поток будет ждать вечно.
Пример 2: Параллельная инициализация приложения
Часто приложение имеет несколько независимых сервисов, которые нужно инициализировать. Хотим запустить их параллельно и дождаться полной готовности перед стартом:
class ApplicationStartup {
public void startWithParallelInitialization() throws InterruptedException {
int serviceCount = 3;
CountDownLatch startupLatch = new CountDownLatch(serviceCount);
// Запустить инициализацию каждого сервиса в отдельном потоке
new Thread(() -> initDatabaseService(startupLatch)).start();
new Thread(() -> initCacheService(startupLatch)).start();
new Thread(() -> initMessageQueueService(startupLatch)).start();
// Ждем пока все сервисы инициализируются
System.out.println("Waiting for services to initialize...");
if (startupLatch.await(30, TimeUnit.SECONDS)) {
System.out.println("All services ready. Application started!");
// Теперь безопасно запускать основную логику
} else {
System.err.println("Services failed to initialize within timeout");
System.exit(1);
}
}
private void initDatabaseService(CountDownLatch latch) {
try {
System.out.println("[DB] Initializing database connection pool...");
Thread.sleep(2000);
System.out.println("[DB] Database ready, connections available");
} catch (InterruptedException e) {
System.err.println("[DB] Interrupted during initialization");
Thread.currentThread().interrupt();
} finally {
latch.countDown();
}
}
private void initCacheService(CountDownLatch latch) {
try {
System.out.println("[Cache] Connecting to Redis...");
Thread.sleep(1500);
System.out.println("[Cache] Cache service ready");
} catch (InterruptedException e) {
System.err.println("[Cache] Interrupted during initialization");
Thread.currentThread().interrupt();
} finally {
latch.countDown();
}
}
private void initMessageQueueService(CountDownLatch latch) {
try {
System.out.println("[MQ] Connecting to message broker...");
Thread.sleep(1000);
System.out.println("[MQ] Message queue service ready");
} catch (InterruptedException e) {
System.err.println("[MQ] Interrupted during initialization");
Thread.currentThread().interrupt();
} finally {
latch.countDown();
}
}
}
Когда использовать CountDownLatch
- ✅ Нужно дождаться завершения фиксированного числа независимых операций
- ✅ Один основной поток ждет много рабочих потоков
- ✅ Число операций известно заранее и не меняется
- ❌ Если нужна переиспользуемость (используйте CyclicBarrier или Phaser)
- ❌ Если потоки должны синхронизироваться много раз (используйте CyclicBarrier)
CyclicBarrier
CyclicBarrier (циклический барьер) — синхронизатор для ожидания N потоков в одной точке. Все потоки должны достичь барьера прежде, чем любой из них сможет продолжить выполнение. В отличие от CountDownLatch, CyclicBarrier переиспользуем — после прохождения барьера все потоки могут снова его пересечь.
Как это работает внутри
CyclicBarrier(3) — создаем барьер для 3 потоков:
Thread-1: выполняет фазу-1 → barrier.await() — жди (1/3 достигло барьера)
Thread-2: выполняет фазу-1 → barrier.await() — жди (2/3 достигло барьера)
Thread-3: выполняет фазу-1 → barrier.await() — ВСЕ ЗДЕСЬ! Барьер открывается!
Все три потока одновременно разблокируются и продолжают:
Thread-1: выполняет фазу-2 → barrier.await() — жди (1/3)
Thread-2: выполняет фазу-2 → barrier.await() — жди (2/3)
Thread-3: выполняет фазу-2 → barrier.await() — ВСЕ ЗДЕСЬ! Открывается снова!
Барьер автоматически сбрасывается и может быть использован снова.
Ключевое отличие от CountDownLatch: все N потоков ждут друг друга в одной точке, а не один поток ждет N операций других потоков.
Основные методы
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.TimeUnit;
// Создание барьера для N потоков
CyclicBarrier barrier = new CyclicBarrier(3);
// Создание с barrierAction — действием, выполняемым когда все потоки достигли барьера
// Это действие выполняется один раз перед разблокировкой потоков
CyclicBarrier barrierWithAction = new CyclicBarrier(3, () -> {
System.out.println("All threads reached the barrier! Performing coordination action...");
});
// await() — достичь барьера и ждать всех остальных потоков
barrier.await(); // Блокируется до того как все потоки достигнут
barrier.await(5, TimeUnit.SECONDS); // С таймаутом
// Полезная информация:
int parties = barrier.getParties(); // Число участников
int waiting = barrier.getNumberWaiting(); // Сколько потоков ждут сейчас
// reset() — сбросить барьер (прервет ожидающие потоки)
barrier.reset(); // Обычно не нужно использовать
Пример 1: Параллельная обработка с фазами
Сценарий: несколько потоков обрабатывают данные в фазы. Каждая фаза может начаться только когда все потоки завершат предыдущую фазу.
class ParallelProcessor {
private static final int NUM_THREADS = 3;
private static final int PHASES = 4;
public void processInPhases() {
// Барьер с действием, выполняемым между фазами
CyclicBarrier barrier = new CyclicBarrier(NUM_THREADS, () -> {
System.out.println(">>> Phase completed. All threads synchronized.");
});
for (int threadId = 0; threadId < NUM_THREADS; threadId++) {
int id = threadId;
new Thread(() -> {
try {
for (int phase = 0; phase < PHASES; phase++) {
// Выполнить работу в текущей фазе
System.out.println("Thread-" + id + " executing phase " + phase);
Thread.sleep(500 + (id * 200)); // Разное время выполнения
// Достичь барьера и ждать других потоков
System.out.println("Thread-" + id + " reached barrier");
barrier.await(); // Блокируется здесь
// После разблокировки можно переходить к следующей фазе
}
System.out.println("Thread-" + id + " completed all phases");
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
}
}
Что происходит:
- Все потоки выполняют фазу 0 с разной скоростью
- Первые закончившие потоки ждут на
barrier.await() - Когда последний поток достигает барьера, выполняется
barrierAction(печать "Phase completed") - Все потоки одновременно разблокируются и переходят к фазе 1
- Процесс повторяется
Пример 2: Симуляция с синхронизацией
Сценарий: simulink-подобная симуляция, где все объекты должны синхронизироваться каждый временной шаг.
class SimulationEntity implements Runnable {
private int entityId;
private CyclicBarrier barrier;
private int totalSteps;
public SimulationEntity(int entityId, CyclicBarrier barrier, int totalSteps) {
this.entityId = entityId;
this.barrier = barrier;
this.totalSteps = totalSteps;
}
@Override
public void run() {
try {
for (int step = 0; step < totalSteps; step++) {
// Вычисления для текущего шага времени
simulateStep(step);
// Ждать пока все сущности завершат вычисления для этого шага
System.out.println("Entity-" + entityId + " finished step " + step);
barrier.await(); // Блокируется здесь
// После разблокировки все сущности готовы для следующего шага
}
} catch (Exception e) {
e.printStackTrace();
}
}
private void simulateStep(int step) throws InterruptedException {
// Имитация вычислений
Thread.sleep(100 + (entityId * 50));
}
}
// Использование
class SimulationRunner {
public void runSimulation() {
int numEntities = 5;
int numSteps = 10;
CyclicBarrier barrier = new CyclicBarrier(numEntities, () -> {
System.out.println("=== Time step synchronized ===");
});
for (int i = 0; i < numEntities; i++) {
new Thread(new SimulationEntity(i, barrier, numSteps)).start();
}
}
}
Пример 3: MapReduce-подобный паттерн
class MapReduceProcessor {
public void process(List<Integer> data) {
int numWorkers = 4;
CyclicBarrier barrier = new CyclicBarrier(numWorkers, () -> {
System.out.println("All workers finished mapping phase");
});
// MAP фаза: каждый worker обрабатывает часть данных
for (int w = 0; w < numWorkers; w++) {
int workerId = w;
new Thread(() -> {
try {
// MAP: обработать присвоенную часть
int start = (workerId * data.size()) / numWorkers;
int end = ((workerId + 1) * data.size()) / numWorkers;
System.out.println("Worker-" + workerId + " mapping items " + start + "-" + end);
Thread.sleep(1000);
// Ждать пока все workers завершат MAP
barrier.await();
// REDUCE: обработать результаты (в другой фазе)
System.out.println("Worker-" + workerId + " starting reduce phase");
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
}
}
Отличие CountDownLatch vs CyclicBarrier
| Аспект | CountDownLatch | CyclicBarrier |
|---|---|---|
| Участники | 1+ ждут N операций | N ждут друг друга |
| Направление | Однонаправленный (от рабочих к основному) | Двусторонний (все ждут друг друга) |
| Переиспользуемость | Нет (одноразовый) | Да (переиспользуемый) |
| countDown/await | countDown ≠ await (вызывают разные потоки) | await = ждут одни и те же потоки |
| Action | Нет встроенного механизма | Есть barrierAction между фазами |
| Число участников | Фиксировано при создании | Фиксировано при создании |
Когда использовать CyclicBarrier
- ✅ N потоков должны синхронизироваться в одной точке и повторять это
- ✅ Нужны многофазные вычисления с синхронизацией между фазами
- ✅ Нужна координирующая операция после синхронизации (barrierAction)
- ❌ Если одна сторона ждет другую (используйте CountDownLatch)
- ❌ Если число участников динамическое (используйте Phaser)
Semaphore
Semaphore (семафор) — синхронизатор для управления доступом к ресурсу с ограничением. Семафор хранит набор "разрешений" (permits). Когда поток хочет обратиться к ресурсу, он должен получить разрешение (acquire()), а после использования — освободить его (release()). Если разрешений нет, поток блокируется.
Как это работает внутри
Semaphore(2) — создаем семафор с 2 разрешениями (permits):
Thread-1: acquire() → получил permit (доступные: 1)
Thread-2: acquire() → получил permit (доступные: 0)
Thread-3: acquire() → БЛОКИРУЕТСЯ (нет permits)
Thread-4: acquire() → БЛОКИРУЕТСЯ (нет permits)
Thread-1: release() → освободил permit (доступные: 1)
Thread-3: acquire() → РАЗБЛОКИРОВАН, получил permit (доступные: 0)
Теперь Thread-4 все еще ждет...
Thread-2: release() → освободил permit (доступные: 1)
Thread-4: acquire() → РАЗБЛОКИРОВАН, получил permit (доступные: 0)
Типы семафоров:
- Binary semaphore (permits=1) — очень похож на mutex, только разные потоки могут acquire/release
- Counting semaphore (permits>1) — ограничивает число одновременных доступов
Основные методы
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
// Создание семафора с количеством разрешений
Semaphore semaphore = new Semaphore(3); // 3 разрешения
// Создание с fairness=true для FIFO порядка (справедливости)
Semaphore fairSemaphore = new Semaphore(3, true);
// acquire() — получить разрешение (блокируется если нет)
semaphore.acquire(); // Ждет пока будет доступно
semaphore.acquire(2); // Получить 2 разрешения одновременно
// tryAcquire() — попробовать без блокировки
boolean acquired = semaphore.tryAcquire(); // false если не доступно
boolean acquiredWithTimeout = semaphore.tryAcquire(1, TimeUnit.SECONDS);
// release() — освободить разрешение
semaphore.release(); // Освобождает 1 разрешение
semaphore.release(2); // Освобождает 2 разрешения
// availablePermits() — узнать сколько разрешений доступно
int available = semaphore.availablePermits();
Пример 1: Пул соединений с БД
Классический сценарий: приложение имеет пул из N соединений с БД. Не более N потоков могут одновременно использовать соединения.
class DatabaseConnectionPool {
private final Semaphore semaphore;
private final Queue<Connection> connections;
private final int poolSize;
public DatabaseConnectionPool(int poolSize) {
this.poolSize = poolSize;
this.semaphore = new Semaphore(poolSize);
this.connections = new LinkedList<>();
// Создать соединения
for (int i = 0; i < poolSize; i++) {
connections.offer(new Connection("DB-Connection-" + i));
}
}
// Получить соединение из пула
public Connection getConnection() throws InterruptedException {
semaphore.acquire(); // Ждать доступное разрешение
synchronized (connections) {
return connections.poll(); // Забрать соединение
}
}
// Вернуть соединение в пул
public void releaseConnection(Connection connection) {
synchronized (connections) {
connections.offer(connection); // Вернуть соединение
}
semaphore.release(); // Освободить разрешение для других потоков
}
static class Connection {
String name;
Connection(String name) { this.name = name; }
public void executeQuery(String query) {
System.out.println(name + " executing: " + query);
}
}
}
// Использование
class DatabaseClient implements Runnable {
private DatabaseConnectionPool pool;
private int clientId;
public DatabaseClient(DatabaseConnectionPool pool, int clientId) {
this.pool = pool;
this.clientId = clientId;
}
@Override
public void run() {
try {
System.out.println("Client-" + clientId + " requesting connection...");
DatabaseConnectionPool.Connection conn = pool.getConnection();
System.out.println("Client-" + clientId + " got " + conn.name);
// Использовать соединение
Thread.sleep(2000);
conn.executeQuery("SELECT * FROM users");
// Вернуть соединение
pool.releaseConnection(conn);
System.out.println("Client-" + clientId + " released " + conn.name);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
// Запуск
public static void main(String[] args) {
DatabaseConnectionPool pool = new DatabaseConnectionPool(3); // 3 соединения
// Попробовать 10 клиентов с 3 соединениями
for (int i = 0; i < 10; i++) {
new Thread(new DatabaseClient(pool, i)).start();
}
}
Что происходит:
- Первые 3 клиента получают соединение и начинают работу
- Остальные 7 клиентов блокируются на
acquire() - По мере того как клиенты вызывают
releaseConnection(), семафор освобождает разрешения - Ожидающие клиенты разблокируются в порядке FIFO (если fairness=true)
Пример 2: Rate Limiting
Ограничение числа запросов в секунду:
class RateLimiter {
private final Semaphore semaphore;
private final int requestsPerSecond;
private final ScheduledExecutorService scheduler;
public RateLimiter(int requestsPerSecond) {
this.requestsPerSecond = requestsPerSecond;
this.semaphore = new Semaphore(requestsPerSecond);
this.scheduler = Executors.newScheduledThreadPool(1);
// Восстанавливать разрешения каждую секунду
scheduler.scheduleAtFixedRate(() -> {
int toRelease = requestsPerSecond - semaphore.availablePermits();
for (int i = 0; i < toRelease; i++) {
semaphore.release();
}
}, 1, 1, TimeUnit.SECONDS);
}
public boolean tryAcquireRequest() {
return semaphore.tryAcquire();
}
public void shutdown() {
scheduler.shutdown();
}
}
// Использование
public static void main(String[] args) throws Exception {
RateLimiter limiter = new RateLimiter(5); // 5 запросов в секунду
for (int i = 0; i < 20; i++) {
new Thread(() -> {
if (limiter.tryAcquireRequest()) {
System.out.println("Request executed: " + Thread.currentThread().getName());
} else {
System.out.println("Request denied (rate limit): " + Thread.currentThread().getName());
}
}).start();
}
}
Пример 3: Binary Semaphore как Mutex
Семафор с 1 разрешением ведет себя как mutex, но с интересной особенностью: разные потоки могут его acquire/release:
class MutualExclusionExample {
private Semaphore binarySemaphore = new Semaphore(1); // Binary semaphore
private int sharedCounter = 0;
public void criticalSection() throws InterruptedException {
binarySemaphore.acquire(); // Войти в критическую секцию
try {
// Только один поток одновременно
int value = sharedCounter;
Thread.sleep(100); // Имитация работы
sharedCounter = value + 1;
} finally {
binarySemaphore.release(); // Выйти из критической секции
}
}
}
Fairness и производительность
// Без fairness (по умолчанию)
Semaphore unfair = new Semaphore(1); // fairness = false
// Производительность выше, но некоторые потоки могут быть "голодны"
// (если один поток постоянно over-acquire/release, другие могут ждать долго)
// С fairness
Semaphore fair = new Semaphore(1, true); // fairness = true
// Гарантирует FIFO порядок при очереди потоков
// Но: небольшое снижение производительности из-за overhead синхронизации
Когда использовать Semaphore
- ✅ Нужно ограничить число одновременных доступов к ресурсу
- ✅ Управление пулом ограниченных ресурсов (соединения, потоки, файловые дескрипторы)
- ✅ Rate limiting или throttling
- ✅ Реализация других синхронизаторов (например, ReadWriteLock содержит семафоры)
- ❌ Для простой взаимной исключительности (используйте synchronized или ReentrantLock)
Phaser
Phaser (Java 7+) — гибкий синхронизатор для управления многофазными вычислениями. Как расширенная версия CyclicBarrier с поддержкой динамического числа участников и возможностью отслеживания отдельных фаз.
Как это работает внутри
Phaser: динамические фазы с динамическим числом участников
Phase 0 (начало):
Main: register() → участвует в фазе 0
Thread-1: register() → участвует в фазе 0
Thread-2: register() → участвует в фазе 0
Thread-3: register() → участвует в фазе 0
Phase 0 выполнение:
Thread-1: arriveAndAwaitAdvance() — жди, фаза 0 не завершена
Thread-2: arriveAndAwaitAdvance() — жди, фаза 0 не завершена
Thread-3: arriveAndAwaitAdvance() — жди, фаза 0 не завершена
Main: arriveAndAwaitAdvance() — Все здесь! Переходим к фазе 1
Phase 1:
Thread-1: arriveAndAwaitAdvance() — фаза 1
Thread-2: arriveAndDeregister() — выход из Phaser
Thread-3: arriveAndAwaitAdvance() — фаза 1
Phaser сам понял что участников теперь 2, а не 3
Phase 2:
Thread-1: arriveAndAwaitAdvance() — фаза 2
Thread-3: arriveAndAwaitAdvance() — фаза 2 (2 участника)
Main: arriveAndDeregister() — Main выходит
Phaser отслеживает текущую фазу и автоматически переходит к следующей когда все участники прибыли.
Основные методы
import java.util.concurrent.Phaser;
// Создание с начальным числом участников
Phaser phaser = new Phaser(1); // Main thread участвует
// register() — зарегистрировать нового участника
phaser.register();
// arriveAndAwaitAdvance() — прибыть в текущую фазу и ждать других
int phase = phaser.arriveAndAwaitAdvance(); // Возвращает номер фазы
// arriveAndDeregister() — прибыть и покинуть Phaser
phaser.arriveAndDeregister();
// arrive() — только прибыть без ожидания
phaser.arrive();
// awaitAdvance(int phase) — ждать переход на следующую фазу
phaser.awaitAdvance(0); // Ждать пока фаза станет не 0
// Информация:
int currentPhase = phaser.getPhase(); // Текущая фаза
int parties = phaser.getRegisteredParties(); // Активные участники
// Контроль:
phaser.forceTermination(); // Принудительно завершить Phaser
boolean terminated = phaser.isTerminated();
Пример 1: Многофазная обработка данных
class DataProcessingPipeline {
private Phaser phaser;
private final int NUM_WORKERS = 3;
private final int PHASES = 3;
public void process() {
phaser = new Phaser(1); // Main thread
for (int i = 0; i < NUM_WORKERS; i++) {
int workerId = i;
phaser.register(); // Зарегистрировать worker
new Thread(() -> {
try {
// Phase 0: Load data
System.out.println("Worker-" + workerId + " loading data...");
Thread.sleep(1000 + workerId * 200);
int phase = phaser.arriveAndAwaitAdvance(); // Ждать всех
System.out.println("Worker-" + workerId + " finished phase " + phase);
// Phase 1: Process data
System.out.println("Worker-" + workerId + " processing...");
Thread.sleep(1500 + workerId * 100);
phase = phaser.arriveAndAwaitAdvance();
System.out.println("Worker-" + workerId + " finished phase " + phase);
// Phase 2: Save results
System.out.println("Worker-" + workerId + " saving results...");
Thread.sleep(800);
phaser.arriveAndDeregister(); // Выйти из Phaser
System.out.println("Worker-" + workerId + " completed all phases");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
}
// Main thread координирует фазы
try {
for (int phase = 0; phase < PHASES; phase++) {
int currentPhase = phaser.arriveAndAwaitAdvance();
System.out.println("[MAIN] All workers completed phase " + currentPhase);
System.out.println("[MAIN] Active participants: " + phaser.getRegisteredParties());
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
Пример 2: Динамическое добавление участников
class DynamicPhaser {
public void demonstrateDynamicParticipants() throws InterruptedException {
Phaser phaser = new Phaser(1); // Main
// Wave 1: 3 участника
System.out.println("=== Wave 1: Starting 3 workers ===");
launchWorkers(phaser, 3, 1);
// Ждем завершения wave 1
Thread.sleep(3000);
// Wave 2: добавляем еще 2 участников
System.out.println("\n=== Wave 2: Starting 2 more workers ===");
launchWorkers(phaser, 2, 4);
// Завершить main thread
Thread.sleep(5000);
phaser.arriveAndDeregister();
}
private void launchWorkers(Phaser phaser, int count, int startId) {
for (int i = 0; i < count; i++) {
int workerId = startId + i;
phaser.register();
new Thread(() -> {
try {
System.out.println("Worker-" + workerId + " phase " + phaser.getPhase());
Thread.sleep(2000);
phaser.arriveAndDeregister();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
}
}
}
Отличие CyclicBarrier vs Phaser
| Аспект | CyclicBarrier | Phaser |
|---|---|---|
| Участники | Фиксированное число | Динамическое (register/deregister) |
| Фазы | Переиспользуемый (одна "фаза" за раз) | Явные фазы с номерами |
| API | Простой | Более сложный |
| Производительность | Оптимизирована | Немного медленнее |
| Действие | barrierAction | onAdvance() переопределение |
| Завершение | reset() | forceTermination() |
Когда использовать Phaser
- ✅ Сложные многофазные вычисления с четкими фазами
- ✅ Динамическое число участников (нужно register/deregister во время работы)
- ✅ Когда нужно отслеживать номер текущей фазы
- ✅ Pipeline обработки с переменным числом stage
- ❌ Для простых двухфазных сценариев (используйте CyclicBarrier)
Exchanger
Exchanger — синхронизатор для обмена данными между ровно двумя потоками. Когда один поток вызывает exchange(), он передает свои данные и ждет получения данных от другого потока.
Как это работает внутри
Exchanger<String>:
Producer: exchange("Data-from-producer") — блокируется, ждет Consumer
Consumer: exchange("Data-from-consumer") — блокируется, ждет Producer
Когда обмен происходит:
Producer: получает "Data-from-consumer"
Consumer: получает "Data-from-producer"
Оба разблокируются и продолжают с полученными данными
Важно: Exchanger работает только для двух потоков. Если третий поток вызовет exchange(), он будет ждать вечно (deadlock).
Основные методы
import java.util.concurrent.Exchanger;
import java.util.concurrent.TimeUnit;
// Создание для обмена объектов типа T
Exchanger<String> exchanger = new Exchanger<>();
// exchange() — обменяться данными с другим потоком
String received = exchanger.exchange("My data"); // Блокируется
// exchange с таймаутом
String receivedWithTimeout = exchanger.exchange("Data", 5, TimeUnit.SECONDS);
Пример 1: Double Buffering Pattern
Классический паттерн: Producer генерирует данные в buffer, Consumer обрабатывает данные. Используются два буфера для минимизации блокировок:
class DoubleBufferingProducerConsumer {
private static final Exchanger<List<Integer>> exchanger = new Exchanger<>();
static class Producer implements Runnable {
@Override
public void run() {
List<Integer> buffer = new ArrayList<>();
try {
for (int cycle = 0; cycle < 5; cycle++) {
// Производить данные в текущий buffer
buffer.clear();
for (int i = 0; i < 10; i++) {
buffer.add((cycle * 10) + i);
}
System.out.println("[Producer] Produced: " + buffer);
// Обменяться буферами с Consumer
// Producer получит пустой буфер Consumer для следующего цикла
long startTime = System.currentTimeMillis();
buffer = exchanger.exchange(buffer);
long exchangeTime = System.currentTimeMillis() - startTime;
System.out.println("[Producer] Exchanged buffers (took " + exchangeTime + "ms)");
}
System.out.println("[Producer] Finished");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
static class Consumer implements Runnable {
@Override
public void run() {
List<Integer> buffer = new ArrayList<>();
try {
for (int cycle = 0; cycle < 5; cycle++) {
// Обменяться буферами с Producer
// Consumer получит заполненный буфер от Producer
buffer = exchanger.exchange(buffer);
System.out.println("[Consumer] Received: " + buffer);
// Обработать данные
int sum = buffer.stream().mapToInt(Integer::intValue).sum();
System.out.println("[Consumer] Processed sum: " + sum);
// Очистить буфер для обмена
buffer.clear();
}
System.out.println("[Consumer] Finished");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread producer = new Thread(new Producer());
Thread consumer = new Thread(new Consumer());
producer.start();
consumer.start();
producer.join();
consumer.join();
}
}
Как это работает:
- Producer генерирует данные → вызывает
exchange()→ блокируется - Consumer инициализирует пустой буфер → вызывает
exchange()→ блокируется - Происходит обмен: Producer получает пустой буфер, Consumer получает полный буфер
- Оба разблокируются и продолжают
- Consumer обрабатывает данные, Producer генерирует новые — асинхронно, без блокировок
Пример 2: Синхронизация между потоками
class ThreadSynchronization {
private static final Exchanger<String> exchanger = new Exchanger<>();
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
try {
System.out.println("Thread-1: Sending greeting...");
String response = exchanger.exchange("Hello from Thread-1");
System.out.println("Thread-1: Received: " + response);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Thread thread2 = new Thread(() -> {
try {
Thread.sleep(1000); // Имитация задержки
System.out.println("Thread-2: Sending greeting...");
String response = exchanger.exchange("Hello from Thread-2");
System.out.println("Thread-2: Received: " + response);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
thread1.start();
thread2.start();
}
}
Когда использовать Exchanger
- ✅ Нужно обменяться данными между ровно двумя потоками
- ✅ Double buffering паттерн (Producer-Consumer с буферизацией)
- ✅ Pipeline между двумя stage
- ❌ Если более двух потоков (приводит к deadlock)
- ❌ Для координации n потоков (используйте CountDownLatch, CyclicBarrier или Phaser)
Сравнение и выбор синхронизатора
Таблица сравнения
| Синхронизатор | Участники | Переиспользуемый | Действие | Основное назначение |
|---|---|---|---|---|
| CountDownLatch | 1+ ждут N операций | ❌ | — | Ожидание завершения N задач |
| CyclicBarrier | N ждут друг друга | ✅ | Да (barrierAction) | Синхронизация в точке, повторяющиеся фазы |
| Semaphore | Ограничение доступа | ✅ | — | Контроль одновременного доступа |
| Phaser | N (динамическое) | ✅ | Да (onAdvance) | Многофазные вычисления с динамикой |
| Exchanger | Ровно 2 потока | ✅ | — | Обмен данными между двумя потоками |
Матрица выбора
Нужно ждать завершения N операций?
├─ ДА → CountDownLatch
└─ НЕТ
└─ N потоков должны синхронизироваться?
├─ ДА → CyclicBarrier (или Phaser если динамическое число участников)
└─ НЕТ
└─ Нужно ограничить одновременный доступ?
├─ ДА → Semaphore
└─ НЕТ
└─ Только 2 потока обмениваются данными?
├─ ДА → Exchanger
└─ НЕТ → Рассмотрите другие механизмы
Практические сценарии
Параллельное завершение работ:
// Много независимых задач, основной поток ждет завершения всех
CountDownLatch latch = new CountDownLatch(taskCount);
Тестирование параллельности:
// Много потоков должны начать одновременно
CyclicBarrier barrier = new CyclicBarrier(threadCount);
Пул ресурсов:
// Ограничить одновременный доступ к ресурсам
Semaphore semaphore = new Semaphore(poolSize);
MapReduce-подобная обработка:
// Несколько фаз, каждая требует синхронизации всех worker'ов
CyclicBarrier mapPhase = new CyclicBarrier(workerCount);
CyclicBarrier reducePhase = new CyclicBarrier(workerCount);
Обработка с динамическим числом участников:
// Worker'ы могут присоединяться/покидать процесс
Phaser phaser = new Phaser(1);
Типичные ошибки и Best Practices
1. CountDownLatch одноразовый
// ❌ ОШИБКА: попытка переиспользовать CountDownLatch
CountDownLatch latch = new CountDownLatch(3);
for (int i = 0; i < 2; i++) {
// ... использовать latch
}
// После первого цикла счетчик == 0 и не обнулится
// ✅ ПРАВИЛЬНО: использовать CyclicBarrier для переиспользования
CyclicBarrier barrier = new CyclicBarrier(3);
for (int i = 0; i < 2; i++) {
// ... использовать barrier (сбросится автоматически)
}
2. Всегда обрабатывайте InterruptedException
// ❌ ОШИБКА: пропускаем исключение
try {
latch.await();
} catch (InterruptedException e) {
// Молча игнорируем
}
// ✅ ПРАВИЛЬНО: восстанавливаем флаг прерывания
try {
latch.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // Восстановить флаг
System.err.println("Thread was interrupted");
}
3. Используйте try-finally для Semaphore
// ❌ ОШИБКА: если исключение перед release(), разрешение не освобождается
semaphore.acquire();
criticalSection(); // Может выкинуть исключение
semaphore.release(); // Может не выполниться!
// ✅ ПРАВИЛЬНО: гарантированное освобождение
semaphore.acquire();
try {
criticalSection();
} finally {
semaphore.release();
}
4. Всегда используйте таймауты
// ❌ ОШИБКА: может ждать вечно
latch.await(); // Что если потоки зависли?
// ✅ ПРАВИЛЬНО: с таймаутом
if (!latch.await(30, TimeUnit.SECONDS)) {
System.err.println("Timeout: some tasks did not complete");
// Обработать timeout ситуацию
}
5. Semaphore.release() без acquire()
Semaphore sem = new Semaphore(2); // 2 разрешения
System.out.println(sem.availablePermits()); // 2
sem.release(); // Не было acquire()!
System.out.println(sem.availablePermits()); // 3 (!)
// Это не ошибка, но может быть неожиданным
// Используйте осторожно для специальных сценариев (например, импульсы)
6. CyclicBarrier.reset() прерывает потоки
// ❌ ОПАСНО: reset() выкинет BrokenBarrierException в ожидающих потоках
barrier.reset(); // Все потоки получат исключение
// ✅ ПРАВИЛЬНО: обрабатывать BrokenBarrierException
try {
barrier.await();
} catch (BrokenBarrierException e) {
System.err.println("Barrier was reset");
}
7. Exchanger работает только для двух потоков
// ❌ DEADLOCK: третий поток будет ждать вечно
Exchanger<String> ex = new Exchanger<>();
new Thread(() -> ex.exchange("T1")).start();
new Thread(() -> ex.exchange("T2")).start();
new Thread(() -> ex.exchange("T3")).start(); // ЗАВИСНЕТ!
// ✅ ПРАВИЛЬНО: использовать только два потока
Thread t1 = new Thread(() -> ex.exchange("T1"));
Thread t2 = new Thread(() -> ex.exchange("T2"));
t1.start();
t2.start();
t1.join();
t2.join();
8. Phaser требует хотя бы одного участника
// ❌ ОШИБКА: Phaser(0) немедленно terminated
Phaser phaser = new Phaser(0);
phaser.isTerminated(); // true!
// ✅ ПРАВИЛЬНО: создавать с участниками или регистрировать сразу
Phaser phaser1 = new Phaser(1); // Main thread
Phaser phaser2 = new Phaser();
phaser2.register(); // Добавить участника
9. Не забывайте про fairness в Semaphore
// Без fairness (быстрее, но может быть несправедливо)
Semaphore unfair = new Semaphore(3);
// С fairness (медленнее, но справедлив FIFO порядок)
Semaphore fair = new Semaphore(3, true);
// Выбирайте в зависимости от требований:
// - Если нужна максимальная производительность → unfair
// - Если критична справедливость → fair
10. Избегайте смешивания синхронизаторов
// ❌ ПЛОХО: сложно понять и отладить
CountDownLatch latch = ...;
CyclicBarrier barrier = ...;
Semaphore semaphore = ...;
// ... какой-то запутанный код с тремя синхронизаторами
// ✅ ХОРОШО: выберите один подходящий синхронизатор для каждого сценария
CountDownLatch latch = new CountDownLatch(taskCount); // Для этого случая
// ... четкая логика с одним синхронизатором
Практические рецепты
Рецепт 1: Параллельная инициализация с контролем порядка
class SequentialInitialization {
public void initServices() throws InterruptedException {
CountDownLatch dbReady = new CountDownLatch(1);
CountDownLatch cacheReady = new CountDownLatch(1);
CountDownLatch appReady = new CountDownLatch(1);
// DB инициализируется первым
new Thread(() -> {
System.out.println("Initializing DB...");
try {
Thread.sleep(2000);
System.out.println("DB ready");
dbReady.countDown();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
// Cache ждет DB
new Thread(() -> {
try {
dbReady.await(); // Ждем пока DB готов
System.out.println("Initializing Cache...");
Thread.sleep(1000);
System.out.println("Cache ready");
cacheReady.countDown();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
// App ждет Cache
new Thread(() -> {
try {
cacheReady.await(); // Ждем пока Cache готов
System.out.println("Starting Application...");
appReady.countDown();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
appReady.await(); // Ждем полной инициализации
System.out.println("System is ready!");
}
}
Рецепт 2: Пакетная обработка с контролем производительности
class BatchProcessor {
private final Semaphore batchLimiter;
private final int batchSize;
public BatchProcessor(int maxConcurrentBatches, int batchSize) {
this.batchLimiter = new Semaphore(maxConcurrentBatches);
this.batchSize = batchSize;
}
public void processBatches(List<Integer> data) throws InterruptedException {
int numBatches = (data.size() + batchSize - 1) / batchSize;
for (int batch = 0; batch < numBatches; batch++) {
batchLimiter.acquire(); // Ограничить одновременные батчи
int batchStart = batch * batchSize;
int batchEnd = Math.min(batchStart + batchSize, data.size());
List<Integer> batchData = data.subList(batchStart, batchEnd);
new Thread(() -> {
try {
processBatch(batchData);
} finally {
batchLimiter.release(); // Освободить разрешение
}
}).start();
}
}
private void processBatch(List<Integer> batch) {
System.out.println("Processing batch: " + batch);
// ... обработка
}
}
Рецепт 3: Фазовая обработка с прогрессом
class PhaseProcessor {
public void processWithProgress() throws InterruptedException {
int numWorkers = 4;
int numPhases = 5;
CyclicBarrier barrier = new CyclicBarrier(numWorkers + 1, () -> {
int progress = (int) ((phaseCounter / (double) numPhases) * 100);
System.out.println("Progress: " + progress + "%");
});
int[] phaseCounter = {0};
for (int w = 0; w < numWorkers; w++) {
int workerId = w;
new Thread(() -> {
try {
for (int phase = 0; phase < numPhases; phase++) {
System.out.println("Worker-" + workerId + " phase " + phase);
Thread.sleep(100 + workerId * 50);
barrier.await();
}
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
// Main thread также участвует в координации
try {
for (int phase = 0; phase < numPhases; phase++) {
barrier.await();
phaseCounter[0]++;
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
Заключение
Синхронизаторы из java.util.concurrent предоставляют высокоуровневые инструменты для решения типовых проблем многопоточности:
- CountDownLatch — когда один поток ждет завершения N операций
- CyclicBarrier — когда N потоков ждут друг друга и повторяют процесс
- Semaphore — когда нужно ограничить одновременный доступ к ресурсу
- Phaser — расширение CyclicBarrier для сложных случаев с динамикой
- Exchanger — для обмена данными между ровно двумя потоками
Выбирайте синхронизатор на основе конкретного сценария: это сделает код понятнее, надежнее и проще отлаживать чем использование низкоуровневых wait/notify.
ThreadLocal
Введение
ThreadLocal — это механизм для изоляции данных на уровне отдельного потока исполнения. Вместо того чтобы хранить данные в глобальном или общем состоянии (что требует синхронизации), ThreadLocal создаёт отдельное хранилище для каждого потока. Каждый поток видит только свою копию переменной и не вмешивается в данные других потоков.
ThreadLocal<Integer> счётчик:
Thread-1: get() → 100 (видит свою копию)
Thread-2: get() → 200 (видит свою копию)
Thread-3: get() → 300 (видит свою копию)
Без синхронизации, без race conditions!
Это особенно полезно в многопоточных системах (веб-серверы, обработчики событий), где нужно хранить информацию, привязанную к конкретному потоку, — пользовательский контекст, транзакции, сессии базы данных.
Основные методы и API
ThreadLocal<Integer> threadLocal = new ThreadLocal<>();
// set: установить значение для текущего потока
threadLocal.set(42);
// get: получить значение текущего потока
Integer value = threadLocal.get(); // 42
// remove: удалить значение текущего потока
threadLocal.remove();
// withInitial: создать ThreadLocal с начальным значением
ThreadLocal<List<String>> list = ThreadLocal.withInitial(ArrayList::new);
List<String> myList = list.get(); // Автоматически инициализируется
Важный момент: если вызвать get() на ThreadLocal, который не был инициализирован для текущего потока, вернётся null (или значение из withInitial, если оно задано). Не возникает исключения — это намеренное поведение.
Как это работает внутри: ThreadLocalMap
Каждый поток (объект Thread) содержит приватное поле ThreadLocalMap, которое является специализированной хеш-таблицей. Эта таблица хранит все ThreadLocal переменные и их значения для данного потока.
Структура памяти:
Thread (поток 1)
├── threadLocals: ThreadLocalMap
│ ├── Entry (ThreadLocal1 → "value1")
│ ├── Entry (ThreadLocal2 → 42)
│ └── Entry (ThreadLocal3 → userObject)
Thread (поток 2)
├── threadLocals: ThreadLocalMap
│ ├── Entry (ThreadLocal1 → "value2")
│ ├── Entry (ThreadLocal2 → 99)
│ └── Entry (ThreadLocal3 → otherUserObject)
Когда вы вызываете threadLocal.get():
public T get() {
Thread t = Thread.currentThread(); // Получаем текущий поток
ThreadLocalMap map = t.threadLocals; // Берём его ThreadLocalMap
if (map != null) {
Entry e = map.getEntry(this); // Ищем запись для этого ThreadLocal
if (e != null) {
return (T) e.value; // Возвращаем значение
}
}
return setInitialValue(); // Или инициализируем, если ещё нет
}
Это объясняет, почему ThreadLocal работает без синхронизации — каждый поток обращается только к своему собственному хранилищу, не конкурируя с другими потоками.
Практические примеры
Пример 1: User Context в веб-приложении
В веб-приложениях часто нужно сохранить информацию о текущем пользователе и сделать её доступной во всех методах, вызываемых из этого запроса, без передачи параметров через всю цепочку вызовов.
class UserContext {
private static final ThreadLocal<User> currentUser = new ThreadLocal<>();
public static void setUser(User user) {
currentUser.set(user);
}
public static User getUser() {
User user = currentUser.get();
if (user == null) {
throw new IllegalStateException("User context not initialized");
}
return user;
}
public static void clear() {
currentUser.remove(); // КРИТИЧЕСКИ ВАЖНО: очистка
}
}
// В HTTP фильтре/interceptor (Spring):
@Component
public class UserContextFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
User user = extractUserFromRequest(httpRequest); // Парсим JWT, сессию и т.д.
UserContext.setUser(user); // Сохраняем в ThreadLocal
try {
chain.doFilter(request, response);
} finally {
UserContext.clear(); // Очищаем после завершения запроса
}
}
}
// Теперь в любом месте в коде, обрабатывающем этот запрос:
class OrderService {
public void createOrder(Order order) {
User user = UserContext.getUser(); // Без параметров!
order.setUserId(user.getId());
// ... сохранение заказа ...
}
}
class AuditService {
public void log(String action) {
User user = UserContext.getUser(); // Доступ из любого места
System.out.println("User " + user.getUsername() + " performed: " + action);
}
}
Почему это работает: В веб-приложении каждый HTTP-запрос обрабатывается отдельным потоком из пула потоков сервера. ThreadLocal позволяет привязать пользовательский контекст ровно к одному потоку, обрабатывающему этот запрос.
Пример 2: SimpleDateFormat (решение проблемы thread-safety)
SimpleDateFormat не потокобезопасен — он содержит внутреннее изменяемое состояние, которое испортится при одновременном вызове из разных потоков. ThreadLocal часто используется как решение:
class DateFormatter {
// Каждый поток получит свой экземпляр SimpleDateFormat
private static final ThreadLocal<SimpleDateFormat> formatter =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
public static String format(Date date) {
return formatter.get().format(date); // Безопасно!
}
public static Date parse(String dateStr) throws ParseException {
return formatter.get().parse(dateStr);
}
// Опционально: очистка при завершении потока
public static void cleanup() {
formatter.remove();
}
}
// Использование:
Date now = new Date();
String formatted = DateFormatter.format(now); // Поток 1 и 2 не конфликтуют
Гарантия безопасности: Поскольку каждый поток использует собственный экземпляр SimpleDateFormat, нет race conditions. Отсутствует необходимость в синхронизации, что обеспечивает лучшую производительность, чем использование synchronized или Lock.
Пример 3: Request ID для логирования
Полезный паттерн — присваивать уникальный ID каждому запросу и автоматически добавлять его в логи:
class RequestContext {
private static final ThreadLocal<String> requestId = new ThreadLocal<>();
public static void setRequestId(String id) {
requestId.set(id);
}
public static String getRequestId() {
return requestId.get();
}
public static void clear() {
requestId.remove();
}
}
// В фильтре:
@Component
public class RequestIdFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
String id = UUID.randomUUID().toString();
RequestContext.setRequestId(id);
try {
chain.doFilter(request, response);
} finally {
RequestContext.clear();
}
}
}
// В любом логере или сервисе:
class UserService {
private static final Logger log = LoggerFactory.getLogger(UserService.class);
public void registerUser(String username) {
String requestId = RequestContext.getRequestId();
log.info("[{}] Registering user: {}", requestId, username);
// ... логика регистрации ...
}
}
Все логи будут содержать одинаковый Request ID для одного HTTP-запроса, что упрощает трассировку логов в production.
Пример 4: Database Connection per Thread
В некоторых приложениях с прямым JDBC (без ORM) нужно поддерживать соединение с БД, привязанное к потоку:
class ConnectionManager {
private static final ThreadLocal<Connection> connectionHolder = new ThreadLocal<>();
public static Connection getConnection(DataSource dataSource) throws SQLException {
Connection conn = connectionHolder.get();
if (conn == null) {
conn = dataSource.getConnection();
connectionHolder.set(conn);
}
return conn;
}
public static void closeConnection() {
Connection conn = connectionHolder.get();
if (conn != null) {
try {
conn.close();
} catch (SQLException e) {
// логирование
} finally {
connectionHolder.remove();
}
}
}
}
Внутреннее устройство: ThreadLocalMap
ThreadLocal использует специальный класс ThreadLocalMap, который отличается от обычной HashMap:
class ThreadLocal<T> {
static class ThreadLocalMap {
private Entry[] table; // Массив с фиксированным размером
private int size = 0;
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value; // Сильная ссылка на значение
}
}
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = t.threadLocals;
if (map != null) {
Entry e = map.getEntry(this);
if (e != null) {
return (T) e.value;
}
}
return setInitialValue();
}
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = t.threadLocals;
if (map != null) {
map.set(this, value);
} else {
createMap(t, value); // Первый ThreadLocal → создаём Map
}
}
}
Ключевые отличия от HashMap:
-
WeakReference для key: ThreadLocal хранится в Entry как
WeakReference, а не как обычная сильная ссылка. Это позволяет JVM собрать мусор самого ThreadLocal объекта, если на него нет других ссылок. -
Сильная ссылка на value: В отличие от ключа, значение (value) хранится обычной сильной ссылкой, которая не будет собрана GC.
-
Open addressing вместо chaining: ThreadLocalMap использует открытую адресацию для разрешения коллизий, а не цепочки (как HashMap). Это немного более эффективно для данного сценария.
-
No rehashing: Размер таблицы фиксирован и не растёт, это упрощает логику и снижает оверхед.
Memory Leaks: Опасность и решения
Анатомия утечки памяти
Это одна из главных проблем ThreadLocal. Комбинация WeakReference для ключа и сильной ссылки для значения создаёт потенциальную ловушку:
Нормальный сценарий:
┌─────────────┐
│ Thread 1 │
├─────────────┤
│ threadLocals│─┐
└─────────────┘ │
↓
ThreadLocalMap
┌──────────────┐
│ Entry[] │
├──────────────┤
│ WeakRef(TL1) │─→ (может быть собран GC)
│ value: obj1 │─→ (сильная ссылка, живёт)
└──────────────┘
Сценарий утечки (в Thread Pool):
1. ThreadLocal1 больше не нужен приложению (нет на него ссылок)
2. GC собирает ThreadLocal1 объект (он был WeakReference)
3. Entry в ThreadLocalMap остаётся с key=null, но value всё ещё там
4. Поток живёт в пуле потоков (переиспользуется для новых задач)
5. value остаётся в памяти НАВЕЧНО → MEMORY LEAK
Почему это критично в Thread Pools: В традиционном приложении поток создаётся для одной задачи и потом умирает вместе с ThreadLocalMap. Но в серверных приложениях потоки живут в пулах потоков (например, Tomcat, Spring Boot) и переиспользуются. Если забыть вызвать remove(), утечка накапливается.
Демонстрация утечки
// ПЛОХО: утечка в Thread Pool
public class ThreadLocalLeakDemo {
private static final ThreadLocal<HeavyObject> cache = new ThreadLocal<>();
public static void main(String[] args) {
ExecutorService pool = Executors.newFixedThreadPool(5);
for (int i = 0; i < 1000; i++) {
pool.submit(() -> {
cache.set(new HeavyObject()); // ~10MB объект
doWork();
// Забыли вызвать remove() ← LEAK!
});
}
// После 1000 задач в памяти может быть 100MB+ мусора
}
static class HeavyObject {
byte[] data = new byte[10 * 1024 * 1024]; // 10MB
}
}
Правильный паттерн: try-finally
Решение простое, но его ОБЯЗАТЕЛЬНО применять:
// ХОРОШО: гарантированная очистка
private static final ThreadLocal<Connection> connectionLocal = new ThreadLocal<>();
public void executeQuery(String sql) throws SQLException {
Connection conn = null;
try {
conn = getConnection();
connectionLocal.set(conn);
// ... выполнение запроса ...
} finally {
if (connectionLocal.get() != null) {
connectionLocal.get().close();
connectionLocal.remove(); // КРИТИЧНО!
}
}
}
// Или с использованием try-with-resources (для AutoCloseable):
try (Resource resource = acquireResource()) {
resourceLocal.set(resource);
// ... работа с ресурсом ...
} finally {
resourceLocal.remove();
}
Правило: Каждый set() должен гарантировать remove() в finally блоке или в finally перехватчика более высокого уровня (например, фильтра).
Детектирование утечек
Во время разработки и тестирования проверяйте heap dumps:
# Создать heap dump
jcmd <pid> GC.heap_dump /tmp/dump.hprof
# Анализировать в JProfiler, YourKit или Eclipse MAT
# Ищите: java.lang.ThreadLocal$ThreadLocalMap$Entry с key=null
InheritableThreadLocal: наследование значений
InheritableThreadLocal расширяет функциональность базового ThreadLocal — дочерний поток наследует значение от родительского потока при создании.
InheritableThreadLocal<String> context = new InheritableThreadLocal<>();
context.set("Parent value");
Thread child = new Thread(() -> {
String value = context.get();
System.out.println(value); // "Parent value" (наследовано!)
});
child.start();
Как это работает:
// Когда создаётся новый поток:
public Thread(Runnable target) {
// ...
if (parent.inheritableThreadLocals != null) {
// Копируем inheritableThreadLocals из родителя в дитя
this.inheritableThreadLocals = ThreadLocal.createInheritedMap(
parent.inheritableThreadLocals
);
}
}
Значения копируются ровно в момент создания потока. Они становятся независимыми копиями — изменения в родительском потоке не влияют на дочерний и наоборот.
Проблема с Thread Pools
Здесь кроется критическая ловушка:
// ❌ НЕ работает с Thread Pools!
InheritableThreadLocal<String> local = new InheritableThreadLocal<>();
local.set("MainThread value");
ExecutorService executor = Executors.newFixedThreadPool(5);
executor.submit(() -> {
String value = local.get();
System.out.println(value); // null! ← не наследовано
});
Почему: Потоки в пуле создаются один раз при инициализации пула, не при каждом submit(). Поэтому InheritableThreadLocal копирует значения в момент создания потока (при запуске пула), а не когда вы отправляете новую задачу.
Решение: Для асинхронных контекстов в пулах потоков используйте:
- Project Reactor:
Mono.deferContextual() - Java 21+ Virtual Threads: они имеют правильную семантику наследования
- Явная передача контекста: оборачивайте runnable
Использование в популярных фреймворках
Spring Security
Spring Security хранит информацию о текущей аутентификации в SecurityContextHolder, который использует ThreadLocal:
// Под капотом Spring использует ThreadLocal:
public class SecurityContextHolder {
private static final ThreadLocal<SecurityContext> contextHolder =
new InheritableThreadLocal<>();
public static SecurityContext getContext() {
return contextHolder.get();
}
public static void setContext(SecurityContext context) {
contextHolder.set(context);
}
}
// Использование в коде:
public void handleRequest() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();
// ... обработка запроса от пользователя ...
}
Hibernate
Hibernate использует ThreadLocal для хранения текущей сессии базы данных (в режиме getCurrentSession()):
// Внутри Hibernate:
public class SessionFactoryImpl {
private static final ThreadLocal<Session> sessionHolder = new ThreadLocal<>();
public Session getCurrentSession() {
Session session = sessionHolder.get();
if (session == null) {
session = openSession();
sessionHolder.set(session);
}
return session;
}
}
// Использование:
Session session = sessionFactory.getCurrentSession();
User user = session.get(User.class, userId);
Spring @Transactional
TransactionManager в Spring использует ThreadLocal для управления транзакциями:
@Service
public class OrderService {
@Transactional
public void createOrder(Order order) {
// Spring автоматически:
// 1. Начинает транзакцию и сохраняет в ThreadLocal
// 2. Выполняет метод
// 3. Коммитит или откатывает транзакцию
}
}
Лучшие практики и антипаттерны
1. Всегда вызывайте remove() в finally
// ❌ ПЛОХО
ThreadLocal<Resource> resource = new ThreadLocal<>();
resource.set(createResource());
useResource();
// Забыли remove() → LEAK в Thread Pool
// ✅ ХОРОШО
ThreadLocal<Resource> resource = new ThreadLocal<>();
try {
resource.set(createResource());
useResource();
} finally {
resource.remove();
}
2. Используйте try-with-resources для AutoCloseable
// ✅ Элегантно и безопасно
try (ResourceHandle handle = new ResourceHandle()) {
resourceLocal.set(handle);
// работа
} finally {
resourceLocal.remove();
}
3. Осторожно с Thread Pools и мутацией
// ❌ ОПАСНО: мутация общего объекта
ThreadLocal<List<String>> buffer = ThreadLocal.withInitial(ArrayList::new);
ExecutorService pool = Executors.newFixedThreadPool(10);
pool.submit(() -> {
buffer.get().add("task1"); // Поток 1 добавляет
});
pool.submit(() -> {
buffer.get().add("task2"); // Поток 2 добавляет
// Если это один и тот же поток из пула, увидит task1!
});
// ✅ БЕЗОПАСНО: каждый раз новый ThreadLocal для одного использования
for (int i = 0; i < 10; i++) {
final int taskNum = i;
ThreadLocal<String> taskLocal = new ThreadLocal<>();
pool.submit(() -> {
taskLocal.set("task" + taskNum);
// ...
taskLocal.remove();
});
}
4. Не полагайтесь на garbage collection
// ❌ ПЛОХО: надеяться, что GC очистит
ThreadLocal<HeavyData> data = new ThreadLocal<>();
data.set(new HeavyData());
// GC может забрать HeavyData, но ThreadLocalMap всё ещё помнит о нём
// ✅ ХОРОШО: явная очистка
data.set(new HeavyData());
try {
// использование
} finally {
data.remove();
}
5. Не передавайте ThreadLocal между потоками
// ❌ ПЛОХО: ThreadLocal из одного потока не видит значения из другого
ThreadLocal<String> value = new ThreadLocal<>();
value.set("main");
new Thread(() -> {
String v = value.get(); // null! Каждый поток изолирован
}).start();
// ✅ ЕСЛИ нужно передать: используйте явную передачу
String mainValue = value.get();
new Thread(() -> {
localInChildThread.set(mainValue); // Явно передаём значение
}).start();
6. Имена для static ThreadLocal переменных
// Используйте ясные имена, указывающие на назначение:
// ✅ ХОРОШО: ясно, что это хранит
private static final ThreadLocal<User> currentUserContext = new ThreadLocal<>();
private static final ThreadLocal<RequestId> requestIdHolder = new ThreadLocal<>();
// ❌ ПЛОХО: непонятно, для чего используется
private static final ThreadLocal<Object> tl = new ThreadLocal<>();
private static final ThreadLocal<String> data = new ThreadLocal<>();
7. Не используйте для общих данных между потоками
// ❌ ПЛОХО: неправильное использование ThreadLocal
ThreadLocal<List<String>> shared = new ThreadLocal<>();
shared.set(new ArrayList<>());
ExecutorService executor = Executors.newFixedThreadPool(2);
executor.submit(() -> shared.get().add("item1"));
executor.submit(() -> shared.get().add("item2"));
// item1 и item2 будут в РАЗНЫХ списках! ThreadLocal изолирует
// ✅ ПРАВИЛЬНО: для этого используйте обычные thread-safe коллекции
List<String> shared = Collections.synchronizedList(new ArrayList<>());
ExecutorService executor = Executors.newFixedThreadPool(2);
executor.submit(() -> shared.add("item1"));
executor.submit(() -> shared.add("item2"));
// Оба добавятся в ОДИН список
8. Security: не храните sensitive данные без защиты
// ❌ ПЛОХО: пароль в plaintext
ThreadLocal<String> passwordLocal = new ThreadLocal<>();
passwordLocal.set("mySecretPassword123");
// ✅ ХОРОШО: используйте SecureString или шифрование
ThreadLocal<SecureString> passwordLocal = new ThreadLocal<>();
passwordLocal.set(new SecureString("mySecretPassword123"));
// SecureString обнуляет содержимое в памяти при очистке
9. Отладка: логирование ThreadLocal значений
// Для отладки создайте вспомогательный метод:
class DebugContext {
public static void printAllThreadLocals() {
Map<ThreadLocal<?>, ?> map = getAllThreadLocals();
for (Map.Entry<ThreadLocal<?>, ?> entry : map.entrySet()) {
System.out.println(entry.getKey() + " = " + entry.getValue());
}
}
// Через reflection (только для отладки!)
private static Map<ThreadLocal<?>, ?> getAllThreadLocals() {
Thread t = Thread.currentThread();
try {
Field field = Thread.class.getDeclaredField("threadLocals");
field.setAccessible(true);
Object threadLocalMap = field.get(t);
// ... парсим структуру ThreadLocalMap ...
return new HashMap<>(); // упрощённо
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
Альтернативы ThreadLocal
1. Явная передача контекста через параметры
// ✅ Явный, тестируемый код
class OrderService {
public void createOrder(Order order, UserContext context) {
// context передаётся явно
order.setUserId(context.getUser().getId());
}
}
// Тестирование просто:
@Test
public void testCreateOrder() {
UserContext mockContext = new UserContext(new User("test"));
service.createOrder(order, mockContext);
}
Преимущества: явность, тестируемость, нет скрытых зависимостей Недостатки: нужно передавать параметр через всю цепочку вызовов
2. Project Reactor Context (для асинхронного кода)
// ✅ Современный подход для reactive code
Mono<Order> createOrder(Order order) {
return Mono.deferContextual(ctx -> {
UserContext user = ctx.get("user");
order.setUserId(user.getId());
return saveOrderAsync(order);
})
.contextWrite(Context.of("user", userContext));
}
Преимущества: работает с асинхронным кодом, правильно наследуется в потоках Недостатки: требует reactive stack (Reactor, WebFlux)
3. Java 21+ Virtual Threads и Scoped Values
// ✅ Будущее (Java 21+ preview)
final ScopedValue<User> USER = ScopedValue.newInstance();
USER.set(user, () -> {
// Внутри этого блока USER.get() вернёт user
service.createOrder(order);
// Каждый запущенный виртуальный поток наследует значение
});
Преимущества: разработано специально для этой задачи, работает с virtual threads Недостатки: только Java 21+, пока в preview
4. Dependency Injection
// ✅ Spring автоматически инжектирует scoped beans
@Service
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class UserContextService {
private User currentUser;
public User getUser() {
return currentUser;
}
}
// Использование:
@Service
public class OrderService {
private final UserContextService userContext;
public void createOrder(Order order) {
order.setUserId(userContext.getUser().getId());
}
}
Преимущества: явность, легко тестировать, автоматическая очистка Недостатки: зависимость от Spring
Заключение
ThreadLocal — мощный инструмент для управления потокоизолированным состоянием, но требует дисциплины в использовании:
- Всегда вызывайте remove() в finally блоке, иначе получите утечку памяти в Thread Pool'ах
- Используйте для изолированных данных: user context, request ID, транзакции
- Не используйте для общего состояния между потоками
- Осторожно в async коде — InheritableThreadLocal не работает с пулами потоков
- Рассмотрите альтернативы: явная передача контекста, Reactive Context, Scoped Values
В правильных ситуациях ThreadLocal значительно упрощает код, избегая передачи параметров через всю цепочку вызовов. Но неправильное использование приводит к утечкам памяти и трудноуловимым багам.
Parallel Streams
Введение
Parallel Streams — это декларативный API для параллельной обработки данных в коллекциях. Они автоматически распределяют обработку элементов между потоками, используя Fork/Join Framework под капотом.
Основная идея: вместо того чтобы вручную создавать потоки и синхронизировать доступ к данным, вы указываете, что нужно сделать с каждым элементом, а фреймворк сам разбирает работу между потоками.
// Sequential stream — обработка элементов по одному
list.stream()
.filter(x -> x > 10)
.map(x -> x * 2)
.collect(Collectors.toList());
// Parallel stream — распределённая обработка
list.parallelStream() // или .stream().parallel()
.filter(x -> x > 10)
.map(x -> x * 2)
.collect(Collectors.toList());
Для параллельного потока результат тот же, но достигается путём разделения работы между несколькими потоками.
Архитектура: ForkJoinPool
Parallel streams работают на основе ForkJoinPool — специализированного пула потоков, оптимизированного для разделяемых задач (divide-and-conquer).
ForkJoinPool.commonPool() — общий пул
По умолчанию все parallel streams используют единый ForkJoinPool.commonPool():
// Размер commonPool определяется количеством ядер процессора
int parallelism = ForkJoinPool.getCommonPoolParallelism();
// Обычно = Runtime.getRuntime().availableProcessors() - 1
// На 8-ядерном ЦПУ commonPool будет иметь 7 потоков
// На 4-ядерном ЦПУ commonPool будет иметь 3 потока
Почему минус один? Главный поток (main thread) считается одним логическим исполнителем. Если у вас 8 ядер, то для параллельной работы остаётся 7 ядер.
Изменить размер commonPool можно только через JVM параметр (нельзя менять во время выполнения):
java -Djava.util.concurrent.ForkJoinPool.common.parallelism=8 MyApp
Создание кастомного ForkJoinPool
Если вам нужен полный контроль над размером пула, можно создать свой:
ForkJoinPool customPool = new ForkJoinPool(4);
// Запустить parallel stream в кастомном пуле
customPool.submit(() -> {
list.parallelStream()
.forEach(System.out::println);
}).get(); // wait for completion
customPool.shutdown();
Когда это полезно? Когда commonPool загружен другими parallel streams и вы хотите гарантировать ресурсы для критичной операции.
Когда параллельные потоки УСКОРЯЮТ обработку
1. CPU-bound операции на больших данных
Это идеальный случай для параллелизма. Если операция требует много вычислений и не требует ввода-вывода:
// Хорошо: каждый поток может считать независимо
List<Integer> primes = largeNumbers.parallelStream()
.filter(n -> isPrime(n)) // CPU-intensive: вычисление может занять 1-10ms на число
.collect(Collectors.toList());
// На 8 ядрах можно обработать 8 чисел параллельно
// Вместо 1 млн. элементов за 10 млн ms (sequential)
// 1 млн. элементов за 1.25 млн ms (parallel на 8 ядрах)
Условие: объём данных должен быть достаточно большим, чтобы окупить overhead создания задач. Примерное правило: 10,000+ элементов с операцией от 1ms каждая.
2. Большие коллекции (миллионы элементов)
// 10+ миллионов элементов
List<Data> processed = hugeList.parallelStream()
.map(item -> transform(item)) // Каждая трансформация занимает время
.collect(Collectors.toList());
// Sequential: 10 млн элементов × 0.1ms = 1000ms
// Parallel (8 ядер): ~125ms (т.к. работают 8 потоков одновременно)
3. Действительно независимые операции
Ключевое слово независимые. Если элемент обработки одного элемента влияет на обработку другого, параллелизм может привести к гонкам данных:
// ХОРОШО: каждый элемент обрабатывается изолированно
list.parallelStream()
.map(item -> complexCalculation(item))
.collect(Collectors.toList());
// ПЛОХО: элементы влияют друг на друга
List<Integer> result = new ArrayList<>();
list.parallelStream()
.forEach(x -> result.add(x)); // Race condition! Несколько потоков пишут одновременно
Когда параллельные потоки ЗАМЕДЛЯЮТ обработку
1. I/O-bound операции (САМАЯ ЧАСТАЯ ОШИБКА)
Это главная ловушка parallel streams. Если операция требует ввода-вывода (сеть, диск, БД), параллельные потоки commonPool просто заблокируются в ожидании:
// ПЛОХО: блокирует весь commonPool
list.parallelStream()
.map(id -> fetchFromDatabase(id)) // Каждый запрос может ждать 100ms!
.collect(Collectors.toList());
// Если у вас 7 потоков в commonPool, и каждый ждёт БД:
// - 7 потоков заблокированы
// - Никакие другие parallel streams не могут работать в приложении
// - Результат: sequential всё равно быстрее, чем ожидание
Решение: используйте CompletableFuture для асинхронного I/O:
// ХОРОШО: асинхронные запросы не блокируют потоки
List<CompletableFuture<Data>> futures = list.stream()
.map(id -> CompletableFuture.supplyAsync(() -> fetchFromDatabase(id)))
.collect(Collectors.toList());
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
2. Малые коллекции
Overhead создания задач, распределения между потоками и синхронизации больше, чем выигрыш от параллелизма:
// Плохо: overhead > польза
List<Integer> small = List.of(1, 2, 3, 4, 5);
small.parallelStream().forEach(System.out::println);
// Sequential: 0.1ms (просто быстрый цикл)
// Parallel: 1ms (создание задач, синхронизация, передача между потоками)
// Parallel медленнее в 10 раз!
// Примерный порог: < 1,000 элементов обычно не имеет смысла параллелизировать
3. Операции с side-effects (побочные эффекты)
Если в операции обновляется общее состояние (переменная, коллекция), parallel streams приведут к race conditions:
// ОПАСНО: race condition
List<Integer> result = new ArrayList<>(); // Не thread-safe!
list.parallelStream()
.forEach(x -> result.add(x)); // Несколько потоков пишут одновременно
// Возможные проблемы:
// - Потеря элементов (внутренний буфер ArrayList переполняется)
// - ArrayIndexOutOfBoundsException
// - Непредсказуемый размер результата
// ХОРОШО: используйте collect
List<Integer> result = list.parallelStream()
.collect(Collectors.toList()); // Thread-safe, синхронизированно
// ИЛИ используйте reduce для агрегирования
int sum = list.parallelStream()
.reduce(0, Integer::sum); // Безопасно: каждый поток считает свою сумму
4. Очень короткие операции
// Плохо: операция дешевле, чем overhead параллелизма
list.parallelStream()
.map(x -> x + 1) // Несколько нанносекунд на элемент
.collect(Collectors.toList());
// Overhead fork/join: миллисекунды
// Выигрыш от параллелизма: нулевой
Проблемы и подводные камни
1. Блокировка общего пула (Critical Issue)
Проблема: если parallel stream содержит блокирующие операции, он может зависнуть:
// ПЛОХО: очень плохо!
list.parallelStream()
.forEach(item -> {
Thread.sleep(1000); // Блокирует один поток из 7
});
// Если 1 млн элементов, это займёт ~142,857 секунд!
// Кроме того, все эти потоки заняты в commonPool
// Другие parallel streams в приложении будут ждать очереди
Решение: для I/O используйте отдельный executor (не commonPool):
ExecutorService ioExecutor = Executors.newFixedThreadPool(20);
List<CompletableFuture<Data>> futures = list.stream()
.map(id -> CompletableFuture.supplyAsync(
() -> fetchFromDatabase(id),
ioExecutor // Используем отдельный executor для I/O
))
.collect(Collectors.toList());
2. Overhead на малых данных
// Sequential: 10ms
// Parallel: 50ms (overhead > выигрыш)
List<Integer> small = IntStream.range(0, 100)
.boxed()
.collect(Collectors.toList());
small.parallelStream().filter(x -> x > 50).count();
3. Неопределённый порядок с forEach
// Порядок НЕ гарантирован в parallel streams
list.parallelStream()
.forEach(System.out::println);
// Вывод может быть: 5, 1, 8, 3, 2, 4, 7, 6, ...
// Если порядок важен, используйте forEachOrdered (медленнее):
list.parallelStream()
.forEachOrdered(System.out::println);
// Вывод: 1, 2, 3, 4, 5, 6, 7, 8, ...
// Но это требует синхронизации между потоками, поэтому медленнее
Stateful vs Stateless операции
Это различие критично для понимания параллельных потоков.
Stateless операции (безопасны для параллелизма)
Stateless операции не зависят от других элементов или от порядка обработки:
// filter — stateless: решение о каждом элементе независимо
list.parallelStream().filter(x -> x > 10);
// map — stateless: преобразование каждого элемента независимо
list.parallelStream().map(x -> x * 2);
// flatMap — stateless: развёртывание каждого элемента независимо
list.parallelStream().flatMap(x -> Stream.of(x, x*2));
Эти операции идеальны для параллелизма, потому что разные потоки могут обрабатывать разные элементы без согласования.
Stateful операции (проблемны для параллелизма)
Stateful операции требуют видеть все элементы или их относительный порядок:
// sorted — stateful: нужно видеть все элементы, чтобы их отсортировать
list.parallelStream().sorted();
// distinct — stateful: нужно помнить все уже виденные элементы
list.parallelStream().distinct();
// limit(n) — stateful: нужно знать, когда остановиться
list.parallelStream().limit(10);
// skip(n) — stateful: нужно пропустить первые n элементов
list.parallelStream().skip(100);
Для этих операций параллелизм не даёт выигрыша, потому что:
- sorted: потокам нужно обменяться данными и синхронизироваться
- distinct: требуется общее состояние (множество уже виденных элементов)
- limit: потоки должны согласовать, когда остановиться
// Пример проблемы с limit в parallel:
list.parallelStream()
.limit(10)
.collect(Collectors.toList());
// Поток 1 может выбрать элементы [1, 5, 9]
// Поток 2 может выбрать элементы [3, 7, 11]
// Результат может быть [1, 3, 5, 7, 9, 11] или любой другой набор из 10 элементов!
// Это не то, что ожидается — обычно хочется [1, 2, 3, ..., 10]
Thread Safety и Race Conditions
Проблема: shared mutable state
// ОПАСНО: race condition
int[] sum = {0};
list.parallelStream()
.forEach(x -> sum[0] += x); // Несколько потоков пишут одновременно!
// Сценарий гонки:
// Поток 1: прочитал sum[0] = 100, вычислил 100 + 50 = 150
// Поток 2: прочитал sum[0] = 100, вычислил 100 + 75 = 175
// Поток 1: записал sum[0] = 150
// Поток 2: записал sum[0] = 175
// Результат: 175 (вместо 150 + 175 = 325)
// Одна операция была потеряна!
Решение 1: reduce (для аккумуляции)
// ХОРОШО: reduce безопасен
int sum = list.parallelStream()
.reduce(0, (a, b) -> a + b);
// Как это работает:
// Поток 1: берёт элементы [1, 2, 3], вычисляет 1+2+3 = 6
// Поток 2: берёт элементы [4, 5, 6], вычисляет 4+5+6 = 15
// Финал: 6 + 15 = 21
// Каждый поток работает с локальным состоянием, потом результаты объединяются
Параметры reduce:
- identity (0): начальное значение
- accumulator (a, b) -> a + b: как объединить элемент с аккумулятором
- combiner (a, b) -> a + b: как объединить результаты разных потоков
// Пример с более сложной логикой
int sum = list.parallelStream()
.reduce(0,
(accumulator, element) -> accumulator + element, // Как добавить элемент
(sum1, sum2) -> sum1 + sum2 // Как объединить суммы от разных потоков
);
Решение 2: collect (для построения коллекций)
// ХОРОШО: collect безопасен и гибкий
List<Integer> result = list.parallelStream()
.collect(Collectors.toList());
// Для других операций:
int sum = list.parallelStream()
.collect(Collectors.summingInt(x -> x));
Set<Integer> unique = list.parallelStream()
.collect(Collectors.toSet());
Map<String, Integer> grouped = list.parallelStream()
.collect(Collectors.groupingByConcurrent(x -> x.getType()));
Производительность: Benchmarking
Вот реальный пример, когда parallel имеет смысл:
// CPU-bound, большая коллекция
@Benchmark
public void sequentialCpuBound() {
numbers.stream()
.filter(n -> isPrime(n))
.count();
}
// Результат на 1 млн элементов: 5000ms
@Benchmark
public void parallelCpuBound() {
numbers.parallelStream()
.filter(n -> isPrime(n))
.count();
}
// Результат на 1 млн элементов: 1000ms (5x быстрее на 8 ядрах!)
Вывод: параллельные потоки дают ~5x ускорение на 8 ядрах для CPU-bound задач. Но это требует достаточно большого объёма данных.
Best Practices
1. Используйте decision tree перед parallel
public Stream<T> smartStream(List<T> list) {
if (list.size() < 1000) {
return list.stream(); // Sequential: overhead не окупается
} else if (isIOBound(list)) {
return list.stream(); // Sequential: I/O будет блокировать anyway
} else {
return list.parallelStream(); // Parallel: CPU-bound + большой объём
}
}
2. Никогда не используйте forEach с side-effects
// ПЛОХО
list.parallelStream()
.forEach(x -> database.save(x)); // Гонка данных!
// ХОРОШО: используйте CompletableFuture
List<CompletableFuture<Void>> futures = list.stream()
.map(x -> CompletableFuture.runAsync(() -> database.save(x)))
.collect(Collectors.toList());
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
3. Используйте toConcurrentMap для параллельной сборки в Map
// ХОРОШО: thread-safe Map для parallel collect
Map<String, Integer> map = list.parallelStream()
.collect(Collectors.toConcurrentMap(
Item::getKey,
Item::getValue
));
// toMap был бы медленнее или вызвал бы ошибки:
// Map<String, Integer> map = list.parallelStream()
// .collect(Collectors.toMap(...)); // Может быть медленнее
4. Используйте unordered() для оптимизации
Если вам не нужен порядок элементов, скажите об этом:
// ХОРОШО: потокам не нужно синхронизировать порядок
list.parallelStream()
.unordered()
.filter(x -> x > 10)
.collect(Collectors.toList());
// Это даёт оптимизацию для операций вроде distinct() и limit()
list.parallelStream()
.unordered()
.distinct() // Быстрее, чем без unordered()
.collect(Collectors.toList());
5. Избегайте переключений между parallel и sequential
// ПЛОХО: лишние переключения
list.parallelStream()
.filter(...) // Parallel
.sequential() // Переключение 1 (дорогое!)
.map(...) // Sequential
.parallel() // Переключение 2 (дорогое!)
.collect(...);
// Придерживайтесь одного режима на весь stream!
6. Всегда бенчмарьте перед использованием
// Параллелизм не всегда быстрее!
// Всегда измеряйте реальную производительность для вашего случая:
// - Используйте JMH (Java Microbenchmark Harness)
// - Тестируйте на реальных данных
// - Проверяйте разные размеры данных
// - Профилируйте под вашей архитектурой железа
7. Мониторьте состояние commonPool
// Проверяйте текущую загрузку ForkJoinPool
ForkJoinPool pool = ForkJoinPool.commonPool();
System.out.println("Active threads: " + pool.getActiveThreadCount());
System.out.println("Queue size: " + pool.getQueuedTaskCount());
System.out.println("Parallelism level: " + pool.getParallelism());
// Если activeThreadCount близко к parallelism, пул перегружен
// Другие parallel streams будут ждать в очереди
8. forEachOrdered vs forEach
// Если порядок важен для вывода/логирования:
list.parallelStream()
.forEachOrdered(System.out::println); // Гарантирует порядок
// Если порядок не важен (обработка данных):
list.parallelStream()
.forEach(item -> process(item)); // Быстрее, но порядок случаен
9. Избегайте limit() в параллельных потоках
// ПРОБЛЕМА: limit не работает предсказуемо
list.parallelStream()
.limit(10)
.collect(Collectors.toList());
// Вместо этого (если нужны первые N элементов):
list.stream() // Sequential
.limit(10)
.collect(Collectors.toList());
// Или с parallel, если предсказуемость не важна:
list.parallelStream()
.limit(10) // Вернёт 10 случайных элементов, а не первых 10
.collect(Collectors.toList());
10. Используйте правильный Collector
// ПЛОХО: Collectors.toList() может быть медленнее в parallel
list.parallelStream()
.collect(Collectors.toList());
// ХОРОШО: используйте concurrent collector если доступен
list.parallelStream()
.collect(Collectors.toList()); // Это нормально, JDK оптимизирует
// Для группировки:
list.parallelStream()
.collect(Collectors.groupingByConcurrent( // Concurrent версия!
Item::getCategory,
Collectors.toList()
));
// Для маппинга:
list.parallelStream()
.collect(Collectors.toConcurrentMap( // Concurrent версия!
Item::getKey,
Item::getValue
));
Итоговая схема решения
Параллельные потоки имеют смысл, только если:
- Объём данных больше 10,000 элементов (иначе overhead не окупается)
- Операция CPU-bound (вычислительно интенсивная, не I/O)
- Операции stateless или используются reduce/collect (без race conditions)
- Benchmark показывает улучшение производительности (не предполагайте, измеряйте!)
Если какое-то из условий не выполнено — используйте обычные sequential streams или CompletableFuture для асинхронности.
Structured Concurrency (Java 21+)
Введение
Structured Concurrency — парадигма управления многопоточностью в Java 21+, которая решает фундаментальную проблему: асинхронные задачи часто "теряются" и не гарантированно завершаются. Новый подход переносит идеи структурирования кода (как с переменными в scope) на многопоточность.
Ключевое отличие: в традиционном подходе задача, созданная в одном месте кода, может завершиться в совершенно другом месте (или не завершиться вообще). В structured concurrency жизненный цикл задач явно ограничен scope, что обеспечивает прозрачность и надёжность.
// Unstructured (традиционный подход) — проблема:
ExecutorService executor = Executors.newCachedThreadPool();
Future<User> userFuture = executor.submit(() -> fetchUser());
Future<Order> orderFuture = executor.submit(() -> fetchOrder());
// Задачи могут оставаться активными за пределами функции
// Легко забыть executor.shutdown() → утечка ресурсов
// Нет явной гарантии завершения
// Structured (Java 21+) — решение:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<User> userFuture = scope.fork(() -> fetchUser());
Future<Order> orderFuture = scope.fork(() -> fetchOrder());
scope.join(); // Ждём все задачи и гарантируем завершение
// Здесь все задачи точно завершены
String user = userFuture.resultNow();
String order = orderFuture.resultNow();
}
// После выхода из try все ресурсы освобождены автоматически
Project Loom и Virtual Threads
Project Loom — долгосрочный проект Oracle для упрощения многопоточного программирования в Java. Его основной результат — Virtual Threads (виртуальные потоки).
Зачем Virtual Threads?
Традиционные платформенные потоки (Platform Threads) — это обёртки над OS-потоками:
- Каждый создаёт отдельный OS-поток
- OS-поток занимает ~1-2 MB памяти
- Context switching между OS-потоками — дорогостоящая операция ядра
- На типичной машине можно создать всего ~1000 потоков
Это резко ограничивает масштабируемость. Для обработки миллионов одновременных клиентов приходилось использовать callback-based архитектуру (асинхронность), которая усложняла код.
Virtual Threads переворачивают парадигму:
- Это лёгкие объекты JVM, а не OS-потоки
- Занимают только ~1 KB памяти
- Можно создавать миллионы
- JVM сама управляет их планированием на небольшом пуле платформенных потоков
Результат: код пишется как синхронный (блокирующий), но работает как асинхронный (масштабируемый).
Различия в деталях
┌─────────────────────────────────────────────────────────────┐
│ Platform Thread │
├─────────────────────────────────────────────────────────────┤
│ • Соответствует OS-потоку 1:1 │
│ • ~1-2 MB памяти на один │
│ • Context switching → системный вызов (дорого) │
│ • Max ~1000 потоков (зависит от OS и памяти) │
│ • Планируется операционной системой │
│ • Блокировка одного потока = блокировка OS-потока │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Virtual Thread │
├─────────────────────────────────────────────────────────────┤
│ • Не соответствует OS-потоку (M:N модель) │
│ • ~1 KB памяти на один │
│ • Context switching → JVM операция (дешево) │
│ • Max миллионы потоков (ограничение только памяти) │
│ • Планируется JVM на пуле платформенных потоков │
│ • Блокировка virtual thread = освобождение платформенного │
└─────────────────────────────────────────────────────────────┘
Примеры создания
// Platform thread (старый способ)
Thread platformThread = new Thread(() -> {
System.out.println("Platform thread: " + Thread.currentThread());
});
platformThread.start();
// Virtual thread (новый способ Java 21)
Thread virtualThread = Thread.ofVirtual().start(() -> {
System.out.println("Virtual thread: " + Thread.currentThread());
});
// Массовое создание virtual threads — теперь реально
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 0; i < 1_000_000; i++) {
executor.submit(() -> {
// Эта работа выполнится в отдельном virtual thread
doSomething();
});
}
executor.shutdown();
// Или прямое создание (с Java 21)
for (int i = 0; i < 1_000_000; i++) {
Thread.startVirtualThread(() -> doSomething());
}
Ключевой момент про Virtual Threads
Virtual thread кажется блокирующим для кода, но на самом деле:
- Когда virtual thread блокируется (например, на I/O), JVM его "припаркует"
- Платформенный поток, на котором он работал, освобождается
- JVM выбирает другой virtual thread из очереди и выполняет его
- Когда I/O завершится, virtual thread "пробудится" на другом платформенном потоке
Это даёт эффект асинхронности, но код остаётся синхронным (без callback'ов и Futures).
StructuredTaskScope
StructuredTaskScope — центральный класс для structured concurrency. Это AutoCloseable контейнер, который гарантирует управление жизненным циклом всех задач.
Основной паттерн
try (var scope = new StructuredTaskScope<ResultType>()) {
// 1. Fork задачи
Future<Type1> result1 = scope.fork(() -> computeValue1());
Future<Type2> result2 = scope.fork(() -> computeValue2());
// 2. Join (дождаться всех)
scope.join(); // Блокирует до завершения всех задач
// 3. Использовать результаты
Type1 value1 = result1.resultNow(); // Безопасно — задача завершена
Type2 value2 = result2.resultNow();
} // При выходе все задачи гарантированно завершены
ShutdownOnFailure — All or Nothing
Это наиболее часто используемый вариант. Логика:
- Если хотя бы одна задача выбросит исключение → отменить остальные
join().throwIfFailed()повторно выбросит первое исключение- Если все успешны → продолжить
public UserOrderData loadUserAndOrder(int userId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<User> userTask = scope.fork(() -> {
// Может выбросить Exception
return userService.fetchUserFromDB(userId);
});
Future<Order> orderTask = scope.fork(() -> {
// Может выбросить Exception
return orderService.fetchLatestOrder(userId);
});
Future<Payment> paymentTask = scope.fork(() -> {
// Может выбросить Exception
return paymentService.getPaymentStatus(userId);
});
// Ждём все три задачи
scope.join()
.throwIfFailed(); // ← Ключевой момент
// Если мы здесь — все задачи успешны
return new UserOrderData(
userTask.resultNow(),
orderTask.resultNow(),
paymentTask.resultNow()
);
} catch (Exception e) {
// Здесь e — исключение из одной из задач
logger.error("Failed to load user data", e);
throw e;
}
}
Важно: если одна задача упадёт, остальные автоматически отменяются (cancel).
ShutdownOnSuccess — Race to the Finish
Используется когда нужен первый успешный результат (например, запрос к нескольким зеркалам сервера).
public String fetchDataFast() throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
// Запустить запрос к трём серверам параллельно
scope.fork(() -> fetchFromServer("server1.com"));
scope.fork(() -> fetchFromServer("server2.com"));
scope.fork(() -> fetchFromServer("server3.com"));
scope.join();
// Получить первый успешный результат
// Остальные две задачи будут отменены
String result = scope.result();
return result;
} catch (InterruptedException | ExecutionException e) {
// Все три сервера недоступны
throw new DataFetchException("All servers failed", e);
}
}
Механика: как только одна из fork() задач успешно завершится, result() вернёт её результат, а остальные будут отменены через cancel().
Custom Scopes — для специальных сценариев
Если встроенные варианты не подходят, можно создать кастомный scope:
class MultipleFailuresScope extends StructuredTaskScope<String> {
private final List<Throwable> failures = new ArrayList<>();
@Override
protected void handleComplete(Future<? extends String> future) {
if (future.state() == Future.State.FAILED) {
try {
future.resultNow(); // Выбросит исключение
} catch (ExecutionException e) {
failures.add(e.getCause()); // ← Собираем все ошибки
}
}
}
public List<Throwable> getAllFailures() {
return failures;
}
}
// Использование:
try (var scope = new MultipleFailuresScope()) {
scope.fork(() -> task1());
scope.fork(() -> task2());
scope.fork(() -> task3());
scope.join();
if (!scope.getAllFailures().isEmpty()) {
// Логирование всех ошибок
scope.getAllFailures().forEach(e -> logger.error("Task failed", e));
}
}
Жизненный цикл и Scope
Строгая иерархия задач
Structured Concurrency гарантирует, что задачи образуют дерево (DAG) с явной иерархией. Это означает:
main()
├─ Task 1
│ ├─ SubTask 1.1
│ └─ SubTask 1.2
├─ Task 2
└─ Task 3
- Родительская задача может создавать дочерние через
fork() - Родитель не может продолжить до завершения всех детей
- Если родитель отменяется → отменяются все дети
- Нет "утёкших" задач, висящих в памяти
public void hierarchyExample() throws Exception {
try (var outerScope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> parent = outerScope.fork(() -> {
// Родительская задача
try (var innerScope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> child1 = innerScope.fork(() -> subTask1());
Future<String> child2 = innerScope.fork(() -> subTask2());
innerScope.join().throwIfFailed();
return child1.resultNow() + child2.resultNow();
}
});
outerScope.join().throwIfFailed();
}
// Все задачи иерархически завершены
}
Гарантии завершения
При выходе из try-with-resources блока JVM гарантирует:
-
Все задачи завершены или отменены
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { scope.fork(() -> longRunningTask()); scope.fork(() -> anotherTask()); scope.join(); // Ждём // ТОЧКА A: все задачи завершены } // ТОЧКА B: по-прежнему все завершены -
Ресурсы освобождены
- Потокохранилища (ThreadLocal) очищены
- Семафоры освобождены
- Соединения с БД закрыты (если используется try-with-resources в задачах)
-
Отмена активных задач
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { scope.fork(() -> { while (!Thread.currentThread().isInterrupted()) { doWork(); } }); throw new Exception("Early exit"); // ← Выход до join() } // Задача будет отменена (interrupt) при закрытии scope
Сравнение: Structured vs Unstructured
Unstructured — традиционный подход
public class UserOrderLoader {
private final ExecutorService executor =
Executors.newFixedThreadPool(10);
public UserOrderData loadData(int userId) throws Exception {
// ПРОБЛЕМА 1: Manual resource management
Future<User> userFuture = executor.submit(() -> {
return userService.getUser(userId);
});
Future<Order> orderFuture = executor.submit(() -> {
return orderService.getOrder(userId);
});
try {
// ПРОБЛЕМА 2: Неясное поведение при ошибке
User user = userFuture.get(5, TimeUnit.SECONDS);
Order order = orderFuture.get(5, TimeUnit.SECONDS);
return new UserOrderData(user, order);
} catch (TimeoutException e) {
// ПРОБЛЕМА 3: Нужно вручную отменять
userFuture.cancel(true);
orderFuture.cancel(true);
throw new TimeoutException("Loading took too long");
} finally {
// ПРОБЛЕМА 4: Легко забыть!
executor.shutdown();
// Даже если забыли → resource leak
}
}
}
// Использование:
UserOrderLoader loader = new UserOrderLoader();
// executor работает весь lifetime приложения
// Если забыть shutdown() при graceful shutdown → утечка
Недостатки:
- Нужно вручную управлять ExecutorService
- Нужно помнить про shutdown (часто забывают)
- Нет явной иерархии задач
- Сложно обработать ошибку в одной из задач
- Необходимо явно отменять через cancel()
- Timeout логика размазана по коду
Structured — новый подход
public UserOrderData loadData(int userId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// РЕШЕНИЕ 1: Auto-closeable scope
Future<User> userFuture = scope.fork(() -> {
return userService.getUser(userId);
});
Future<Order> orderFuture = scope.fork(() -> {
return orderService.getOrder(userId);
});
// РЕШЕНИЕ 2: Явное ожидание со сценарием ошибки
scope.join()
.throwIfFailed();
// РЕШЕНИЕ 3: Если здесь → все успешно
return new UserOrderData(
userFuture.resultNow(),
orderFuture.resultNow()
);
} // РЕШЕНИЕ 4: Автоматическая очистка и отмена
}
Преимущества:
- Явное начало и конец управления задачами
- Автоматическая очистка ресурсов
- Ясная обработка ошибок (ShutdownOnFailure)
- Автоматическая отмена при выходе
- Исключение = автоматическая отмена остальных задач
- Понятная иерархия
Прямое сравнение в таблице
| Аспект | Unstructured | Structured |
|---|---|---|
| Создание | executor.submit() |
scope.fork() |
| Ожидание | future.get() с timeout |
scope.join() |
| Обработка ошибки | try-catch, cancel вручную | throwIfFailed() |
| Отмена задач | Вручную через cancel() |
Автоматическая |
| Утечка ресурсов | Легко (забыть shutdown) |
Невозможно |
| Scope жизненного цикла | Неявный | Явный (try-with-resources) |
| Масштабируемость | ~1000 задач (OS-потоки) | Миллионы (virtual threads) |
Практические сценарии
Сценарий 1: Загрузка данных из нескольких сервисов
public class UserProfileService {
public UserProfile getCompleteProfile(int userId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var userFuture = scope.fork(() -> userService.getUser(userId));
var ordersF = scope.fork(() -> orderService.getOrders(userId));
var profileF = scope.fork(() -> profileService.getProfile(userId));
var prefsF = scope.fork(() -> preferencesService.getPrefs(userId));
scope.join().throwIfFailed();
return UserProfile.builder()
.user(userFuture.resultNow())
.orders(ordersF.resultNow())
.profile(profileF.resultNow())
.preferences(prefsF.resultNow())
.build();
}
}
}
Все четыре вызова работают параллельно. Если один упадёт → остальные отменяются, исключение выбрасывается.
Сценарий 2: Запрос к нескольким зеркалам (race)
public class ResilientDataFetcher {
public String fetchWithFallback(List<String> mirrors) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
for (String mirrorUrl : mirrors) {
scope.fork(() -> fetchFrom(mirrorUrl));
}
scope.join();
// Первый успешный ответ
return scope.result();
} catch (ExecutionException e) {
throw new FetchException("All mirrors failed", e);
}
}
}
Сценарий 3: Условное ветвление задач (nested scopes)
public class ConditionalTaskExecutor {
public Result processData(Input input) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var validateF = scope.fork(() -> validate(input));
scope.join().throwIfFailed();
// После валидации — запустить обработку
try (var processingScope = new StructuredTaskScope.ShutdownOnFailure()) {
var processF1 = processingScope.fork(() -> process1(input));
var processF2 = processingScope.fork(() -> process2(input));
processingScope.join().throwIfFailed();
return combine(
processF1.resultNow(),
processF2.resultNow()
);
}
}
}
}
Важные детали и подводные камни
1. resultNow() vs get()
// Безопасно вызывать resultNow() ТОЛЬКО после join()
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> task = scope.fork(() -> compute());
scope.join();
// ✓ БЕЗОПАСНО — задача точно завершена
String result = task.resultNow();
}
// НЕПРАВИЛЬНО — задача может быть в процессе
Future<String> task = ...;
String result = task.resultNow(); // ← IllegalStateException!
2. Исключения в fork()
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// НЕПРАВИЛЬНО — исключение выбросится здесь, не в задаче
Future<String> task = scope.fork(() -> {
throw new IOException("Error"); // ← Исключение
});
scope.join(); // ← throwIfFailed() переберёт исключение
}
// Правильно: исключение обрабатывается в scope
3. Timeout у structured concurrency
// В Java 21 встроенного timeout нет, нужно использовать cancel()
ExecutorService timeoutExecutor = Executors.newScheduledThreadPool(1);
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> task = scope.fork(() -> slowTask());
// Отмена через 5 секунд
timeoutExecutor.schedule(
() -> task.cancel(true),
5,
TimeUnit.SECONDS
);
scope.join(); // ← Будет разбужена через 5 сек отменой
}
4. join() и interrupt
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
scope.fork(() -> task1());
scope.fork(() -> task2());
try {
scope.join(); // ← Может быть прервана
} catch (InterruptedException e) {
// Все задачи будут отменены при выходе из try
Thread.currentThread().interrupt();
throw e;
}
}
Virtual Threads + Structured Concurrency = Синергия
Лучше всего Structured Concurrency работает с Virtual Threads:
// Создаём ExecutorService из virtual threads
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
// Внутри можно использовать обычный (блокирующий) код
public String fetchDataWithBlockingIO() throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> task1 = scope.fork(() -> {
// Обычный блокирующий I/O
return fetchFromDatabase(); // ← Блокирует virtual thread
});
Future<String> task2 = scope.fork(() -> {
// Обычный блокирующий HTTP запрос
return httpClient.get("https://api.example.com/data");
});
scope.join().throwIfFailed();
return task1.resultNow() + task2.resultNow();
}
}
Почему это отлично:
- Код выглядит как обычный синхронный код
- Нет callback'ов, нет монад, нет
.then()цепочек - Работает параллельно благодаря virtual threads
- Масштабируется до миллионов одновременных операций
Статус и будущее (Java 21+)
- Java 21: Structured Concurrency — Preview API (может измениться)
- Java 22+: Вероятно финализация API
- Используйте
--enable-previewфлаг при компиляции и запуске
javac --enable-preview StructuredConcurrencyExample.java
java --enable-preview StructuredConcurrencyExample
Рекомендуется следить за JEPs и release notes Oracle для финальной версии API.
Заключение
Structured Concurrency решает три главные проблемы многопоточности:
- Ясность: жизненный цикл задач явно ограничен scope
- Надёжность: гарантированная очистка и отмена
- Масштабируемость: комбинация с virtual threads даёт миллионы операций
Это парадигма-сдвиг: от "управляй потоками сам" к "JVM управляет потоками за тебя".
Многопоточность в Spring
Введение
Spring Framework — это мощная платформа, позволяющая интегрировать многопоточность в бизнес-логику без избыточной сложности. Для этого используются такие инструменты, как @Async, абстракции TaskExecutor, собственные тред-пулы и интеграции с инфраструктурой транзакций и безопасности.
Асинхронность через @Async
@Async позволяет запускать методы в отдельных потоках, разгружая основной поток приложения:
@Service
public class MyService {
@Async
public void asyncMethod() {
// Операция будет выполнена асинхронно
}
}
Чтобы аннотация работала — включите поддержку асинхронности с помощью @EnableAsync в конфигурации.
Важные детали:
- Метод должен быть
public. AOP-прокси не применяет@Asyncкprivateилиprotectedметодам[8][1]. - Асинхронность срабатывает только при вызове метода через Spring-прокси (например, через бин)[1][8]. Вызовы внутри класса — синхронны.
- Если возвращаете значение из метода, используйте
CompletableFuture<T>илиFuture<T>— иначе результатом будетnull.
Общие сценарии применения
Для долгих операций: HTTP запросы, отправка писем, обработка файлов, интеграция с внешними сервисами. Не используйте для сверхкоротких операций — из-за overhead[7][1].
Конфигурирование executors
По умолчанию используется SimpleAsyncTaskExecutor, но для production-решений всегда настраивайте ThreadPoolTaskExecutor:
@Bean
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10); // Базовое число потоков
executor.setMaxPoolSize(50); // Максимальное число потоков
executor.setQueueCapacity(200); // Очередь задач
executor.setThreadNamePrefix("app-async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}
Типовые ошибки
- "SimpleAsyncTaskExecutor": создаёт новый поток на каждый вызов — приводит к переполнению ресурсов.
- Ограничьте размер очереди и пулла — иначе можно получить deadlock или OutOfMemory[9].
- Не рекомендуется статическая конфигурация пуллов — подбирайте параметры под нагрузку.
Пуллы для CPU и IO-bound задач
Для CPU-bound задач оптимально core/max pool size равный количеству процессоров. Для IO-bound — увеличьте pool size за счёт высокой латентности операций.
@Bean(name = "cpuExecutor")
public Executor cpuExecutor() { ... }
@Bean(name = "ioExecutor")
public Executor ioExecutor() { ... }
Выбор пула происходит при вызове @Async("cpuExecutor").
Корректная обработка ошибок
Исключения в async-методах не прокидываются вызывающему потоку. Для реакций используйте AsyncUncaughtExceptionHandler в конфиге.
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
// Здесь обработка ошибок
}
}
Transactional и @Async — подводные камни
Аннотация @Transactional не работает с @Async: транзакция не распространяется на дочерние потоки — она привязана к ThreadLocal[1]. Решения:
- Выносить транзакционный код в отдельный бин и вызывать его из async-метода.
- Использовать
TransactionTemplateпрямо внутри async-задачи.
Передача SecurityContext в асинхронные задачи
По умолчанию SecurityContext привязан к потоку через ThreadLocal. Поэтому в async-задачах доступ к контексту теряется. Решения:
- Настроить
MODE_INHERITABLETHREADLOCAL. - Использовать
DelegatingSecurityContextAsyncTaskExecutor. - Либо явно сохранять и передавать контекст в async-задачу.
Кэш и гонки потоков
Spring Cache гарантирует встроенную потокобезопасность, но возможны race conditions при одновременном обращении к несуществующему кэш-значению. Для предотвращения этого используйте параметр sync = true у @Cacheable.
@Cacheable(value = "users", sync = true)
public User getUser(Long id) {...}
Особенности WebFlux
Spring WebFlux основан на реактивной архитектуре — вычисления не блокируют потоки. Для интеграции блокирующих операций (например, JDBC) используйте Schedulers.boundedElastic().
Mono<User> user = Mono.fromCallable(() -> blockingDbCall())
.subscribeOn(Schedulers.boundedElastic());
Практические рекомендации
- Асинхронность — только для долгих операций.
- Всегда конфигурируйте пулл потоков, queue и политики отказа.
- Назначайте thread name prefix для удобной отладки.
- @Async только для public методов через прокси.
- Обрабатывайте исключения — silent fail затрудняет диагностику.
- Помните про ограничения @Async с транзакциями и SecurityContext.
- Тестируйте асинхронный код особо тщательно — баги сложнее воспроизводить.
- Избегайте stateful бинов при работе с потоками.
Best Practices и паттерны многопоточности
Введение
Многопоточность — это не просто техника параллельного выполнения. Это набор правил и паттернов, которые гарантируют, что ваша программа будет работать правильно независимо от того, как операционная система планирует потоки.
Основные вызовы многопоточности:
- Race conditions: два потока одновременно изменяют данные
- Memory visibility: изменение в одном потоке не видно другому
- Deadlocks: потоки зависят друг от друга и взаимно ждут
- Context switching: частые переключения между потоками снижают производительность
В этом разделе собраны проверенные практики, которые решают эти проблемы.
1. Immutability (Неизменяемость)
Почему это критично
Неизменяемые объекты по определению thread-safe. Если объект не может изменяться, то не бывает race conditions. Это самый мощный инструмент для многопоточности, потому что вообще избегает проблемы, вместо того чтобы её решать.
Когда объект immutable:
- Нет нужды в synchronization
- Нет проблем с memory visibility
- Объект можно безопасно делиться между потоками
- Кеширование становится безопасным
Как создавать immutable классы
Четыре правила:
- Сделайте класс
final— чтобы нельзя было унаследовать и переопределить методы - Все поля
private final— нельзя изменять напрямую и из подклассов - Инициализируйте через конструктор — не через setters
- Возвращайте копии mutable объектов — если возвращаете List, String[], то только defensive copies
// ХОРОШО: правильный immutable класс
public final class User {
private final String name;
private final int age;
private final List<String> hobbies; // Внимание: List!
public User(String name, int age, List<String> hobbies) {
this.name = name;
this.age = age;
// Defensive copy — иначе caller может потом изменить hobbies
this.hobbies = new ArrayList<>(hobbies);
}
public String getName() { return name; }
public int getAge() { return age; }
// ВАЖНО: возвращаем unmodifiable копию
public List<String> getHobbies() {
return Collections.unmodifiableList(hobbies);
}
}
// ПЛОХО: mutable класс = проблемы в многопоточности
public class MutableUser {
private String name; // Может изменяться
private List<String> hobbies; // И это тоже!
public void setName(String name) { this.name = name; }
public void setHobbies(List<String> hobbies) { this.hobbies = hobbies; }
public List<String> getHobbies() {
return hobbies; // Возвращаем ссылку на внутренний список!
}
}
// Проблема MutableUser в многопоточности:
MutableUser user = new MutableUser();
user.setName("Alice");
// Thread 1
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
user.setName("Thread1-" + i);
}
});
// Thread 2
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
System.out.println(user.getName()); // Может напечатать частичное имя!
}
});
// Это race condition — результат непредсказуем
Immutable коллекции в Java
Java предоставляет несколько способов создания immutable коллекций:
// Java 9+: фабричные методы — РЕКОМЕНДУЕТСЯ
List<String> immutableList = List.of("a", "b", "c");
Set<String> immutableSet = Set.of("x", "y", "z");
Map<String, Integer> immutableMap = Map.of("key1", 1, "key2", 2);
// Эти объекты действительно неизменяемы:
// immutableList.add("d"); // UnsupportedOperationException
// Guava библиотека: дополнительные возможности
ImmutableList<String> guavaList = ImmutableList.of("a", "b", "c");
ImmutableMap<String, Integer> guavaMap = ImmutableMap.<String, Integer>builder()
.put("key1", 1)
.put("key2", 2)
.build();
// Collections.unmodifiableList — НЕ полная защита!
List<String> original = new ArrayList<>(Arrays.asList("a", "b", "c"));
List<String> unmodifiable = Collections.unmodifiableList(original);
// unmodifiable.add("d"); // UnsupportedOperationException
// НО если original изменится, unmodifiable тоже изменится!
original.add("d");
System.out.println(unmodifiable.size()); // 4, а не 3!
// Это не полная защита в многопоточности
Когда использовать immutability
- Value objects: User, Order, Product — объекты данных
- Конфигурация: Settings, Configuration — не меняется после инициализации
- Результаты операций: Result, Response — что возвращает метод
- Key в HashMap/ConcurrentHashMap: требуется stable hashCode
- Shared state между потоками: если нужно делиться без синхронизации
2. Double-Checked Locking (правильная реализация)
Проблема
Ленивая инициализация — это распространённый паттерн:
public class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // Первая проверка
synchronized (Singleton.class) {
if (instance == null) { // Вторая проверка
instance = new Singleton();
}
}
}
return instance;
}
}
Но это работает неправильно! Причина — instruction reordering в Java.
Когда JVM выполняет instance = new Singleton(), она на самом деле делает три вещи:
- Выделить память для объекта
- Инициализировать поля объекта
- Присвоить ссылку переменной
instance
JVM может переупорядочить шаги 2 и 3! Вот что может произойти:
Thread 1:
1. Выделила память
2. Присвоила ссылку instance (объект ещё не инициализирован!)
3. ...начинает инициализировать
Thread 2 (в это время):
1. Проверяет: instance != null → да!
2. Возвращает incomplete (не до конца инициализированный) объект
3. Получает ошибку при использовании
Решение 1: volatile
Ключевое слово volatile гарантирует правильный порядок инструкций:
public class Singleton {
private static volatile Singleton instance; // VOLATILE!
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // Теперь безопасно
}
}
}
return instance;
}
}
Как это работает:
volatileсоздаёт memory barrier — точку, после которой JVM не может переупорядочить инструкции- Все инициализации завершаются до того, как ссылка будет видна другим потокам
- Первая проверка (без lock) безопасна, потому что
volatileгарантирует видимость
Производительность: первая проверка (без lock) очень быстра. Lock включается только при первом обращении.
Решение 2: Holder Pattern (РЕКОМЕНДУЕТСЯ)
Это лучший паттерн для ленивой инициализации в Java:
public class Singleton {
private static class Holder {
static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return Holder.INSTANCE; // JVM гарантирует thread-safety автоматически
}
}
Почему это лучше:
- JVM гарантирует — это стандартное поведение class loading
- Без
volatile— проще для понимания - Без явной синхронизации — JVM сама управляет
- Абсолютно thread-safe по спецификации Java Memory Model
Как это работает:
- Когда первый поток обращается к
Holder.INSTANCE, JVM загружает классHolder - Статические инициализаторы выполняются ровно один раз, в точке загрузки класса
- JVM блокирует загрузку класса, пока инициализация не завершится
- Все остальные потоки ждут завершения загрузки класса и получают готовый объект
- Гарантируется, что ни один поток не получит incomplete объект
Сравнение подходов
// 1. Неправильно (race condition возможна)
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (...) {
if (instance == null) {
instance = new Singleton(); // Может быть reordering!
}
}
}
return instance;
}
// 2. Правильно, но требует внимательности
private static volatile Singleton instance; // Не забыть volatile!
// ... остальное то же
// 3. ЛУЧШЕ: Holder Pattern
private static class Holder {
static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return Holder.INSTANCE; // Идиоматично и безопасно
}
// 4. Самый простой: enum (тоже использует holder под капотом)
public enum Singleton {
INSTANCE;
// Методы...
}
3. Lazy Initialization (ленивая инициализация)
Общая концепция
Ленивая инициализация означает: инициализируй объект только когда он впервые нужен, а не при создании контейнера.
Зачем это нужно:
- Экономия памяти: дорогостоящие ресурсы создаются только если используются
- Быстрый старт: приложение стартует быстрее
- Условная инициализация: создаём объект только если выполнены условия
Holder Pattern (рекомендуется для синглтонов)
public class ExpensiveResource {
// Вспомогательный приватный класс
private static class Holder {
// Инициализация происходит здесь, ленивой
static final ExpensiveResource INSTANCE = new ExpensiveResource();
}
// Приватный конструктор — нельзя создавать напрямую
private ExpensiveResource() {
System.out.println("ExpensiveResource создан!"); // Напечатается только при первом вызове
}
public static ExpensiveResource getInstance() {
return Holder.INSTANCE;
}
}
// Использование:
// ExpensiveResource.getInstance(); // При первом вызове: "ExpensiveResource создан!"
// ExpensiveResource.getInstance(); // При втором вызове: ничего не печатается
Double-checked locking с volatile (когда Holder неудобен)
public class LazyInit {
// Volatile гарантирует memory visibility и правильный порядок
private volatile ExpensiveObject instance;
public ExpensiveObject getInstance() {
// Кэшируем в локальную переменную (оптимизация)
ExpensiveObject result = instance;
if (result == null) {
// Только если нужно инициализировать, берём lock
synchronized (this) {
result = instance; // Проверяем второй раз с lock'ом
if (result == null) {
instance = result = new ExpensiveObject();
}
}
}
return result;
}
}
// Почему это нужно:
// 1. Первая проверка (result == null) — без lock, очень быстро
// 2. Только если null — берём lock и инициализируем
// 3. Вторая проверка (result == null) — потому что между первой проверкой
// и получением lock'а другой поток мог инициализировать
// 4. result = instance = new... — присваиваем обе переменные для оптимизации
Как выбрать метод
| Ситуация | Метод |
|---|---|
| Синглтон, ленивая инициализация | Holder Pattern ✓ |
| Instance переменная в классе | Double-checked с volatile |
| Когда нужна параметризация | Supplier<T> с Holder или double-checked |
| Новый код | Всегда Holder Pattern (проще, понятнее, безопаснее) |
4. Thread Confinement (ограничение доступа одним потоком)
Концепция
Thread Confinement означает: объект доступен и используется только в одном потоке, поэтому не нужна синхронизация вообще.
Это самый простой способ избежать race conditions — если объект видит только один поток, race conditions невозможны по определению.
Stack Confinement
Это автоматическое ограничение одним потоком:
public void process() {
// localList существует только в памяти this потока (stack)
List<String> localList = new ArrayList<>();
localList.add("item1");
localList.add("item2");
// Если не передавать localList другим потокам, она абсолютно thread-safe
} // localList удаляется из памяти при выходе из метода
Ключ: локальные переменные по определению confined к потоку, потому что они в stack, а каждый поток имеет свой stack.
ThreadLocal Confinement
Когда нужно данные, которые существуют дольше одного метода, но всё ещё confined к одному потоку:
// SimpleDateFormat не thread-safe, но можно сделать thread-local
private static final ThreadLocal<SimpleDateFormat> dateFormatter =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
public void formatDates(List<Date> dates) {
// Каждый поток получит свой SimpleDateFormat
SimpleDateFormat formatter = dateFormatter.get();
for (Date date : dates) {
System.out.println(formatter.format(date)); // thread-safe!
}
}
// ВАЖНО: очищайте ThreadLocal, если нить переиспользуется (например, в thread pool)
public void cleanup() {
dateFormatter.remove(); // Иначе утечка памяти!
}
Когда использовать:
- Трансформеры, форматтеры, которые не thread-safe
- Context информация (requestId, userId) — нужна во всех методах, вызванных из одного потока
- Connection-local состояние
Опасность: если забыть очистить в thread pool, произойдёт утечка памяти.
Ad-hoc Thread Confinement
Когда ограничение одним потоком не автоматическое, нужна явная документация:
/**
* ВАЖНО: Этот map используется ТОЛЬКО в обработчике UI события!
* НЕ ПЕРЕДАВАЙТЕ В ДРУГИЕ ПОТОКИ!
* @GuardedBy(EventDispatchThread)
*/
private final Map<String, Data> uiCache = new HashMap<>();
public void handleUIEvent(String key) {
// Используем напрямую — нет lock'а, потому что только UI поток здесь
Data data = uiCache.get(key);
}
public void updateUICache(String key, Data value) {
// Гарантируем, что вызываемся только из UI потока
if (SwingUtilities.isEventDispatchThread()) {
uiCache.put(key, value);
}
}
Сравнение подходов
// 1. Stack confinement (автоматический)
void method() {
List<String> list = new ArrayList<>(); // Только здесь видна
// ...
} // list удалена
// 2. ThreadLocal confinement (нужна явная инициализация)
private static ThreadLocal<SimpleDateFormat> formatter = ThreadLocal.withInitial(...);
SimpleDateFormat fmt = formatter.get(); // Каждый поток — свой
// 3. Ad-hoc confinement (нужна документация и вера)
// @ThreadConfined(EventDispatchThread)
private Map<String, Data> cache = new HashMap<>(); // Только используй в одном потоке!
// 4. Синхронизация (когда нет confinement)
private synchronized Map<String, Data> sharedCache = ...; // Доступно всем потокам
5. Defensive Copying (защитное копирование)
Проблема
Когда вы возвращаете или принимаете mutable объект, вы открываете доступ к внутренним данным:
public class Account {
private List<Transaction> transactions = new ArrayList<>();
// ОПАСНО!
public List<Transaction> getTransactions() {
return transactions; // Caller получит ССЫЛКУ на внутренний список
}
// Теперь посторонний код может сломать инвариант класса:
}
// Использование:
Account account = new Account();
List<Transaction> txns = account.getTransactions();
txns.clear(); // УБИЛИ ВСЕ ТРАНЗАКЦИИ! Object broken!
// В многопоточности это ещё опаснее:
// Thread 1 получает список через getTransactions()
// Thread 2 меняет список через setTransactions()
// Race condition!
Решение 1: Defensive Copy
public class Account {
private final List<Transaction> transactions = new ArrayList<>();
// ХОРОШО: возвращаем КОПИЮ
public List<Transaction> getTransactionsCopy() {
return new ArrayList<>(transactions); // Новый список!
}
// Caller может делать что угодно с копией, original не пострадает
}
// Использование:
List<Transaction> txns = account.getTransactionsCopy();
txns.clear(); // Очистили только копию
// Original в account не тронут
Недостаток: создание копии стоит памяти и времени. Если список большой, это дорого.
Решение 2: Unmodifiable View (предпочтительно)
public class Account {
private final List<Transaction> transactions = new ArrayList<>();
// ЛУЧШЕ: возвращаем immutable view
public List<Transaction> getTransactionsView() {
return Collections.unmodifiableList(transactions);
}
// Caller получит исключение при попытке изменить
}
// Использование:
List<Transaction> txns = account.getTransactionsView();
txns.clear(); // UnsupportedOperationException
// В многопоточности это гарантирует:
// - Нельзя изменить через view
// - Original по-прежнему может меняться в других потоках
// - View отражает текущее состояние original (нет копии)
Java 9+ имеет удобные factory методы:
public List<Transaction> getTransactionsView() {
return List.copyOf(transactions); // Immutable copy (не view!)
}
Когда принимаем mutable объект (setter)
public class Account {
private List<Transaction> transactions;
// ОПАСНО!
public void setTransactions(List<Transaction> txns) {
this.transactions = txns; // Caller может потом изменить txns
}
// Использование:
List<Transaction> txns = new ArrayList<>();
txns.add(...);
account.setTransactions(txns);
txns.clear(); // СЛОМАЛИ account!
}
// ХОРОШО: оборонительное копирование при set
public void setTransactionsSafe(List<Transaction> txns) {
this.transactions = new ArrayList<>(txns); // Копируем!
}
// ЛУЧШЕ: используем defensive copy на обе стороны
private List<Transaction> transactions = Collections.unmodifiableList(Collections.emptyList());
public void setTransactionsSafe(List<Transaction> txns) {
this.transactions = Collections.unmodifiableList(new ArrayList<>(txns));
}
Правила defensive copying
| Ситуация | Решение |
|---|---|
| Возвращаем mutable | unmodifiableList() или copyOf() |
| Передают нам mutable | Копируем в конструкторе и setter'е |
| Большой список, часто читают | unmodifiableList(new ArrayList<>(...)) |
| Малый список, редко читают | Простая copy через конструктор |
6. Synchronized и Lock: правила использования
Минимизация критических секций
Критическая секция — это код, защищённый lock'ом. Он может выполняться только одним потоком за раз.
Ключевой принцип: критическая секция должна быть как можно меньше.
// ПЛОХО: весь метод — критическая секция
public synchronized void processData() {
// Дорогостоящее вычисление
int result = expensiveComputation(); // 1 секунда!
// Некоторое обновление состояния
updateSharedState(result); // 1 миллисекунда
// Ещё дорогостоящее вычисление
logResult(result); // 1 секунда!
}
// Проблема: за 2+ секунды НИКАКОЙ другой поток не может вызвать
// ДРУГИЕ методы этого объекта! Throughput упал в 10+ раз!
// ХОРОШО: минимальная критическая секция
public void processDataBetter() {
// Вычисления БЕЗ lock'а
int result = expensiveComputation(); // 1 секунда, другие потоки могут работать!
// Только critical path защищён
synchronized (this) {
updateSharedState(result); // 1 миллисекунда
}
// Логирование БЕЗ lock'а
logResult(result); // 1 секунда, другие потоки не блокированы!
}
// Результат: instead of 2 seconds per thread, 0.001 seconds of blocking!
Правильный порядок захвата locks (предотвращение deadlock'а)
Deadlock происходит, когда:
- Thread 1 держит lock A и ждёт lock B
- Thread 2 держит lock B и ждёт lock A
- Они взаимно ждут друг друга вечно
// ОПАСНО: потенциальный deadlock
private final Object lock1 = new Object();
private final Object lock2 = new Object();
public void method1() {
synchronized (lock1) {
doSomething();
synchronized (lock2) { // Получаем lock2 вторым
updateState();
}
}
}
public void method2() {
synchronized (lock2) { // Получаем lock2 первым!
doOtherThing();
synchronized (lock1) { // Получаем lock1 вторым
updateState();
}
}
}
// Сценарий deadlock'а:
// Thread 1 в method1: получил lock1, ждёт lock2
// Thread 2 в method2: получил lock2, ждёт lock1
// Deadlock! Программа зависла!
Решение: фиксированный порядок
// ХОРОШО: всегда lock1, потом lock2
public void method1Better() {
synchronized (lock1) {
doSomething();
synchronized (lock2) {
updateState();
}
}
}
public void method2Better() {
synchronized (lock1) { // ТОТЖЕ ПОРЯДОК!
doOtherThing();
synchronized (lock2) {
updateState();
}
}
}
// Теперь deadlock невозможен
// Если thread1 получил lock1, а thread2 тоже хочет lock1,
// thread2 просто ждёт, пока thread1 отпустит оба lock'а
ReentrantLock (более гибкий чем synchronized)
private final Lock lock = new ReentrantLock();
public void process() {
lock.lock(); // Получить lock
try {
// Критическая секция
updateState();
doWork();
} finally {
lock.unlock(); // ВСЕГДА освобождать в finally!
}
}
// ReentrantLock vs synchronized:
// + Гарантированное освобождение в finally
// + Можно попробовать получить lock с таймаутом
// + Можно использовать Condition для ожидания/уведомления
// - Чуть медленнее на некритичных пакетах
// Пример: try to lock с таймаутом
public boolean processWithTimeout(long timeout, TimeUnit unit) {
try {
if (lock.tryLock(timeout, unit)) {
try {
updateState();
return true;
} finally {
lock.unlock();
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return false; // Не удалось получить lock в срок
}
Synchronized vs Lock
// synchronized: встроен в язык, автоматическое освобождение
public synchronized void method1() {
// ...
}
// Эквивалент с Lock:
private Lock lock = new ReentrantLock();
public void method2() {
lock.lock();
try {
// ...
} finally {
lock.unlock();
}
}
// Для простых случаев synchronized сойдёт
// Для сложной логики с таймаутами — используйте Lock
7. Именование потоков
Почему это важно
Когда программа зависла или медленная, вы смотрите в debugger и видите 50 потоков. Как понять, что каждый делает?
Правильное имя потока — это первая помощь при отладке:
Thread-0
Thread-1
Thread-2
...
Thread-50
vs
HTTP-Request-Handler-1
Database-Connector-2
Cache-Updater-1
...
Очевидно, что во втором случае проще понять, что происходит.
Правильное именование
// ПЛОХО: дефолтные имена
Thread thread = new Thread(() -> downloadFile());
thread.start(); // Thread-0, Thread-1, Thread-2 в логах
// ХОРОШО: осмысленные имена
Thread thread = new Thread(() -> downloadFile());
thread.setName("FileDownloader-Worker");
thread.start();
// ЕЩЁ ЛУЧШЕ: с ID потока
Thread thread = new Thread(() -> downloadFile());
thread.setName("FileDownloader-" + thread.getId());
thread.start();
// ЛУЧШЕ ВСЕГО: используйте ThreadFactory
ThreadFactory factory = r -> {
Thread t = new Thread(r);
t.setName("FileDownloader-" + t.getId());
t.setDaemon(false); // Явно указываем
return t;
};
ExecutorService executor = Executors.newFixedThreadPool(10, factory);
Именование в многопоточном коде
// Когда логируете, включайте имя потока
logger.info("[{}] Processing item: {}", Thread.currentThread().getName(), item);
// MDC (Mapped Diagnostic Context) для трейсинга
import org.slf4j.MDC;
public void processRequest(String requestId) {
MDC.put("requestId", requestId); // Автоматически добавится во все логи
try {
logger.info("Request started"); // В логе будет requestId
doWork();
} finally {
MDC.clear(); // Очистить для следующего запроса (если thread pool)
}
}
8. Логирование и мониторинг в многопоточности
Логирование с контекстом
// Базовое логирование
logger.info("Processing item: {}", item);
// Логирование с контекстом потока
logger.info("[{}] Processing item: {}",
Thread.currentThread().getName(), item);
// MDC для трейсинга через вызовы (рекомендуется)
try {
MDC.put("requestId", UUID.randomUUID().toString());
MDC.put("userId", userId);
logger.info("User action started");
processUserAction();
logger.info("User action completed");
} finally {
MDC.clear();
}
// В файле логов будет:
// [requestId=abc123][userId=user1] User action started
// [requestId=abc123][userId=user1] User action completed
Мониторинг ThreadPoolExecutor
ThreadPoolExecutor pool = (ThreadPoolExecutor) Executors.newFixedThreadPool(10);
// Периодический мониторинг
ScheduledExecutorService monitor = Executors.newScheduledThreadPool(1);
monitor.scheduleAtFixedRate(() -> {
logger.info("ThreadPool stats: Active={}, PoolSize={}, QueueSize={}, Completed={}",
pool.getActiveCount(), // Потоков активно работает
pool.getPoolSize(), // Потоков в пуле сейчас
pool.getQueue().size(), // Задач в очереди ждут
pool.getCompletedTaskCount() // Всего выполнено задач
);
}, 0, 5, TimeUnit.SECONDS);
// Ищем bottleneck'и:
// - Если QueueSize растёт, работы больше чем потоков могут обработать
// - Если Active близко к PoolSize, потоки перегружены
// - Если Completed низко, что-то не так с задачами
9. Обработка InterruptedException
Проблема
InterruptedException — это способ JVM попросить поток завершиться. Если его проигнорировать, поток может не завершиться при shutdown.
// ПЛОХО: проглотили исключение
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// Молчание = смерть потока!
// Поток "не слышит" о прерывании
}
// При shutdown Executor'а поток не завершится вовремя!
executor.shutdownNow(); // Пытается прервать все потоки
Thread.sleep(5000); // Но мой поток не знает, что его прервали!
Решение 1: Восстановить флаг прерывания
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// Восстановить флаг, чтобы caller узнал о прерывании
Thread.currentThread().interrupt();
logger.warn("Thread was interrupted", e);
}
// Теперь Thread.currentThread().isInterrupted() вернёт true
// Caller может проверить и завершиться
Решение 2: Пробросить исключение (лучше)
// Если можно пробросить InterruptedException, это лучший вариант
public void processWithInterrupt() throws InterruptedException {
Thread.sleep(1000); // Если прервано, исключение пройдёт вверх
// Caller знает, что произошло прерывание
}
// Вызывающий код должен обработать:
try {
thread.processWithInterrupt();
} catch (InterruptedException e) {
// Знаем, что был interrupt
logger.info("Process was interrupted");
}
Решение 3: Проверить флаг прерывания
public void longRunningTask() {
for (int i = 0; i < 1000; i++) {
if (Thread.currentThread().isInterrupted()) {
logger.info("Task interrupted");
break; // Завершиться спокойно
}
doWork(i);
}
}
10. Shutdown паттерны
Graceful shutdown ExecutorService
Важно корректно завершить поток пула, а не убить его неожиданно.
ExecutorService executor = Executors.newFixedThreadPool(10);
// Отправляем задачи
executor.submit(() -> doWork());
executor.submit(() -> doMoreWork());
// Начинаем shutdown
executor.shutdown(); // Не принимать новые задачи
logger.info("Shutdown initiated");
// Ждём завершения существующих задач
try {
// Даём 60 секунд на завершение
if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
// Таймаут истёк, задачи всё ещё работают
logger.warn("Executor did not terminate gracefully");
// Принудительный shutdown
List<Runnable> stillRunning = executor.shutdownNow();
logger.warn("Forcefully stopped, {} tasks still in queue", stillRunning.size());
// Ждём ещё немного (потоки должны обработать interrupt)
if (!executor.awaitTermination(10, TimeUnit.SECONDS)) {
logger.error("Executor still not terminated");
}
}
} catch (InterruptedException e) {
// Если ж сам поток прерван, убедись, что executor тоже останавливается
executor.shutdownNow();
Thread.currentThread().interrupt();
}
logger.info("Executor fully shutdown");
Shutdown hook для cleanup
Это гарантирует, что даже если программа упадёт, ресурсы очистятся:
ExecutorService executor = Executors.newFixedThreadPool(10);
// Регистрируем hook, который выполнится при выходе
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
logger.info("Application shutting down, cleaning up resources...");
executor.shutdown();
try {
if (!executor.awaitTermination(30, TimeUnit.SECONDS)) {
logger.warn("Executor shutdown timeout");
executor.shutdownNow();
executor.awaitTermination(10, TimeUnit.SECONDS);
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}
logger.info("Cleanup completed");
}));
// Программа может работать
// При Ctrl+C или System.exit() hook выполнится автоматически
Shutdown vs shutdownNow
ExecutorService executor = ...;
// shutdown(): вежливо
executor.shutdown();
// - Не принимает новые задачи
// - Завершает существующие
// - Возвращает немедленно, но потоки работают
// shutdownNow(): агрессивно
List<Runnable> pending = executor.shutdownNow();
// - Прерывает все потоки (InterruptedException)
// - Возвращает список незавершённых задач
// - Может оставить состояние в неконсистентном виде
// Используйте только если shutdown() не помогает
11. Используйте высокоуровневые утилиты вместо wait/notify
Проблема с wait/notify
// ПЛОХО: низкоуровневые wait/notify
public synchronized void waitForData() {
while (!dataReady) {
try {
this.wait(); // Ждём уведомления
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
processData();
}
public synchronized void notifyDataReady() {
dataReady = true;
this.notifyAll(); // Уведомляем ждущих
}
// Проблемы:
// - Ошибка-то легко: забыть notifyAll(), получить spurious wakeup
// - Сложно отладить: задержки, timeouts, lost notifications
// - Noise: много кода, мало функциональности
Правильный путь: высокоуровневые утилиты
// CountDownLatch: один или несколько потоков ждут событие
CountDownLatch startSignal = new CountDownLatch(1);
CountDownLatch doneSignal = new CountDownLatch(3); // Ждём 3 рабочих
// Рабочие потоки
for (int i = 0; i < 3; i++) {
new Thread(() -> {
try {
startSignal.await(); // Ждём сигнала старта
doWork();
} finally {
doneSignal.countDown(); // Уведомляем завершение
}
}).start();
}
// Главный поток даёт старт
startSignal.countDown();
// И ждёт завершения рабочих
doneSignal.await();
logger.info("All workers completed");
// Преимущества:
// + Не нужно synchronized
// + countDown гарантированно безопасно
// + Легко добавить timeout: doneSignal.await(10, TimeUnit.SECONDS)
BlockingQueue: коммуникация между потоками
// Очередь, которая блокирует при put в полный пул, take из пустой
BlockingQueue<Task> queue = new LinkedBlockingQueue<>(100);
// Producer
new Thread(() -> {
for (int i = 0; i < 1000; i++) {
try {
queue.put(new Task(i)); // Блокирует если очередь полная
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}).start();
// Consumer
new Thread(() -> {
try {
while (true) {
Task task = queue.take(); // Блокирует если очередь пуста
processTask(task);
}
} catch (InterruptedException e) {
logger.info("Consumer interrupted");
}
}).start();
Semaphore: ограничение одновременного доступа
// Семафор: только 3 потока могут одновременно писать в файл
Semaphore fileLock = new Semaphore(3);
public void writeToSharedFile(String data) {
try {
fileLock.acquire(); // Ждём, пока освободится слот
try {
file.write(data); // Максимум 3 потока одновременно
} finally {
fileLock.release();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
12. ConcurrentHashMap вместо синхронизированных коллекций
Проблема с Collections.synchronizedMap
// ПЛОХО: весь map заблокирован на каждую операцию
Map<String, Integer> map = Collections.synchronizedMap(new HashMap<>());
// Thread 1
map.put("key1", 100); // Весь map заблокирован на это время
// Thread 2 ждёт, потому что весь map заблокирован
map.get("key2"); // Не может даже прочитать!
// Проблема: один lock на весь map
// Если много читателей/писателей, очень узкое место
Решение: ConcurrentHashMap
// ХОРОШО: сегментированная блокировка
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
// Thread 1
map.put("key1", 100); // Блокирует только сегмент key1
// Thread 2 одновременно
map.put("key2", 200); // Блокирует сегмент key2 (ДРУГОЙ!)
// Thread 3 одновременно
int v = map.get("key3"); // Может читать из любого сегмента
// Как это работает:
// ConcurrentHashMap разделён на 16 (по умолчанию) сегментов
// Каждый сегмент имеет свой lock
// Разные потоки могут одновременно работать с разными ключами!
Сравнение производительности
// Collections.synchronizedMap
Map<String, Integer> sync = Collections.synchronizedMap(new HashMap<>());
// Throughput: ~1000 ops/sec (много contention)
// ConcurrentHashMap
ConcurrentHashMap<String, Integer> concurrent = new ConcurrentHashMap<>();
// Throughput: ~10000 ops/sec (16x лучше!)
// Когда ConcurrentHashMap действительно быстрее:
// - Много одновременных писателей
// - Разные потоки работают с разными ключами
// - Много читателей (они никогда не блокируют друг друга)
13. Atomic классы для счётчиков
Проблема с synchronized счётчиком
// ПЛОХО: каждый increment блокирует весь объект
public class Counter {
private int count = 0;
public synchronized void increment() {
count++; // Даже чтение и запись требует lock!
}
public synchronized int get() {
return count;
}
}
// Проблема: много потоков, много contention на lock
Решение: AtomicInteger
// ХОРОШО: lock-free операции
public class Counter {
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // Lock-free, очень быстро!
}
public int get() {
return count.get();
}
}
// Как это работает:
// AtomicInteger использует CAS (Compare-And-Swap) инструкцию процессора
// Нет lock'а, nет ожидания
// Если конфликт, retries автоматически
// Сравнение производительности (счёт до 1 миллиона):
// synchronized: ~500ms
// AtomicInteger: ~50ms (10x быстрее!)
Когда использовать Atomic классы
// Счётчики
private AtomicInteger requestCount = new AtomicInteger();
public void recordRequest() { requestCount.incrementAndGet(); }
// Флаги
private AtomicBoolean shutdownRequested = new AtomicBoolean(false);
public void requestShutdown() { shutdownRequested.set(true); }
// References
private AtomicReference<Config> config = new AtomicReference<>(new Config());
public void updateConfig(Config newConfig) { config.set(newConfig); }
// Лучше, чем:
// private int count = 0; public synchronized void increment() { count++; }
// private boolean shutdown = false;
// private Config config;
14. Документируйте thread-safety
Как документировать
/**
* Thread-safe cache implementation.
* All public methods are synchronized and thread-safe.
*
* @ThreadSafe
*/
public class SafeCache {
private final Map<String, Data> cache = new ConcurrentHashMap<>();
public void put(String key, Data value) {
cache.put(key, value);
}
}
/**
* NOT thread-safe. Callers must synchronize externally.
*
* @NotThreadSafe
*/
public class UnsafeCache {
private final Map<String, Data> cache = new HashMap<>();
public void put(String key, Data value) {
cache.put(key, value);
}
}
/**
* Conditionally thread-safe.
* The cache itself is thread-safe via ConcurrentHashMap.
* However, Data objects must be immutable or thread-safe.
*
* @ThreadSafe(requires = "Data objects are immutable")
*/
public class ConditionalCache {
private final ConcurrentHashMap<String, Data> cache = new ConcurrentHashMap<>();
}
Аннотации
Используйте аннотации из javax.annotation или net.jcip.annotations:
import javax.annotation.concurrent.ThreadSafe;
import javax.annotation.concurrent.NotThreadSafe;
import javax.annotation.concurrent.Immutable;
@ThreadSafe
public class Counter {
private AtomicInteger count = new AtomicInteger();
}
@NotThreadSafe
public class NonThreadSafeList {
private List<String> items = new ArrayList<>();
}
@Immutable
public final class Point {
private final int x, y;
}
15. Избегайте блокировок в критических секциях
Проблема: блокирующие операции
// ПЛОХО: блокирующая операция ВНЕ критической секции
private synchronized void saveToDatabase(String data) {
// Это может занять 5 секунд!
database.write(data); // Весь объект заблокирован всё это время!
}
// Пока один поток сохраняет, все остальные ЖДУТ
// Throughput упал в 100+ раз!
Решение: блокирующие операции вне lock'а
// ХОРОШО: только критическая секция защищена
private String queuedData;
public void saveToDatabase(String data) {
// Блокирующая операция БЕЗ lock'а
String processed = preprocessData(data); // 1 сек
String result = database.write(processed); // 5 сек
// Только обновление состояния с lock'ом
synchronized (this) {
queuedData = result; // 1 микросекунда
}
// После логирования БЕЗ lock'а
logger.info("Saved: {}", result); // 1 сек
}
// Вместо 7 секунд lock'а, теперь только микросекунды!
// Другие потоки не блокируются!
Трёхфазный паттерн
public void processRequest(Request request) {
// Фаза 1: подготовка БЕЗ lock'а
ProcessedRequest processed = preprocess(request); // I/O, CPU
// Фаза 2: обновление состояния С lock'ом
synchronized (this) {
validateRequest(processed);
updateState(processed);
}
// Фаза 3: cleanup БЕЗ lock'а
postprocess(processed); // I/O, CPU
}
Итоговые рекомендации
Выбор стратегии
| Ситуация | Решение |
|---|---|
| Объект не меняется | Immutability — проще всего |
| Один поток читает/пишет | Thread Confinement — нет lock'а |
| Много потоков, редко меняется | ConcurrentHashMap или Atomic |
| Сложная логика | ReentrantLock с try-finally |
| Простой случай | synchronized достаточно |
| Нужна параметризация | Holder Pattern для lazy init |
Checklist перед production
Инструменты отладки и профилирования
Введение
Отладка и профилирование многопоточных JVM-приложений — сложная задача, требующая глубокого понимания механизмов работы потоков. Роль правильных инструментов и методов анализа здесь фундаментальна: они помогают не только находить источники проблем, но и обеспечивать производительность и стабильность системы.
Thread Dump: получение и анализ
Thread Dump отражает текущее состояние всех потоков Java-процесса. Это ключевой инструмент для поиска зависаний, дедлоков и точек блокировки.
Получение Thread Dump
jstack <pid>— универсальный способ получить dump процесса.kill -3 <pid>— сбрасывает dump в stdout приложения.- VisualVM, JMC, JFR — позволяют сформировать dump из GUI.
Структура и анализ
В dump каждого потока указаны имя, ID, состояние (RUNNABLE, WAITING, BLOCKED и др.), стек вызовов, удерживаемые и ожидаемые мониторы.
- RUNNABLE: поток выполняет код и может потреблять CPU.
- WAITING/TIMED_WAITING: поток ожидает сигнала или таймаута.
- BLOCKED: захват монитора невозможен из-за конкуренции за ресурсы.
Ключ — искать долгоживущие BLOCKED и WAITING потоки, особенно если состояние не меняется между несколькими dumps.
- Для поиска таких потоков снимайте 3–6 дампов с интервалом 5–10 секунд. Это помогает обнаружить долгоживущие блокировки и дедлоки[11][22][19].
Deadlock detection
jstackвыделяет секции с "Found one Java-level deadlock".- Вручную находите циклические зависимости между мониторами в BLOCKED потоках.
- JProfiler визуализирует deadlock-графы (обычно помечается красным), что ускоряет разбор сложных взаимных блокировок[23][32].
VisualVM и другие профилировщики
VisualVM — удобный бесплатный инструмент для live-мониторинга и анализа состояния JVM:
- Просмотр состояния потоков по timeline.
- Thread dump с визуализацией (RUNNABLE, BLOCKED и др.).
- Выделение и анализ hotspot-потоков.
- CPU и Memory sampling: что тратит ресурсы.
JProfiler, YourKit — коммерческие решения с расширенной аналитикой:
- Визуализация call tree и графа locks.
- Detailed lock graph для анализа взаимных блокировок.
- Статистика использования синхронизаторов и мониторов[23][26].
Heap Dump и поиск утечек памяти
Heap Dump — снимок кучи JVM, дающий полную картину об используемой памяти. Получайте через:
jmap -dump:live,format=b,file=heap.hprof <pid>- JVM-флаг
-XX:+HeapDumpOnOutOfMemoryError - VisualVM/YourKit/Eclipse MAT (GUI)
Эффективный анализ
Используйте Eclipse MAT:
- Leak Suspects Report: подсказки о вероятных утечках.
- Dominator Tree: кто владеет максимумом памяти, критично для поиска утечек в пуле потоков (ThreadPool), ThreadLocal-variable leaks.
- Path to GC root: находит цепочку ссылок, удерживающих объект в памяти[24][27][33].
Признаки ThreadLocal leak
- Множество Entry с key=null в ThreadLocalMap, value при этом остаётся ссылкой на объект.
- Потоки пула живы, значения не очищаются.
Решение — всегда вызывать ThreadLocal.remove() после использования переменной, особенно внутри пулов потоков[9][13][21][17].
JMX: мониторинг и диагностика
JMX (Java Management Extensions): API для мониторинга потоков, сборки метрик и событий.
-
ThreadMXBeanпозволяет получить:- Текущее число потоков (
getThreadCount()) - Пиковое число потоков (
getPeakThreadCount()) - Количество daemon-потоков, поиск deadlocks, метрики CPU по каждому потоку.
- Текущее число потоков (
-
Метрики через JMX удобно интегрировать в мониторинговые системы[34][40][43].
Java Flight Recorder (JFR)
JFR — встроенный профилировщик в JVM:
- Практически не влияет на производительность, что критично в production (1-5% overhead)[12][16][20].
- Гибкая конфигурация через CLI и jcmd.
- В JMC (Java Mission Control) визуализируются timeline активности потоков, lock contention hotspots, длительные паузы GC, deadlocks.
- Для production удобно настраивать запись по важным событиям — это даёт быстрый анализ без больших нагрузок.
Debugging concurrency в IDE
IntelliJ IDEA и Eclipse позволяют:
- Смотреть все активные потоки и их stack trace в дебаг-режиме.
- Freeze/unfreeze потоков для анализа проблем взаимодействия.
- Устанавливать условия для breakpoints, debug-диаграммы для визуализации потоковой активности.
- В Eclipse MAT — специальный Concurrency Visualizer для разбора локаций конкурентного доступа.
Статический анализ: SpotBugs, Error Prone, JCStress
- SpotBugs обнаруживает баги синхронизации (неконсистентный доступ, не все пути освобождают lock, работы с mutable static fields)[35][38].
- Error Prone указывает на несоблюдение контрактов синхронизации, типичные ошибки при работе с @GuardedBy и @ThreadSafe.
- JCStress — фреймворк для unit-тестирования сценариев гонок и стрессовых конкурентных багов. Позволяет формализовать forbidden outcomes для проверки корректности реализации конкурентных алгоритмов[36][42][45].
Практические рекомендации и best practices
- Thread Dump в production: jstack может приостановить JVM — используйте с осторожностью.
- Профилирование: Sampling — для production, Instrumentation — для тестовой среды. Второй вариант даёт сильную нагрузку на приложение.
- Heap Dump: размер файла равен размеру кучи – необходим объём диска!
- JFR для production: минимальный overhead — оптимален для длительных наблюдений.
- Deadlock detection: jstack находит только monitor-based deadlocks. Для ReentrantLock и других — используйте профилировщики с визуализацией.
- ThreadLocal leak: всегда очищайте ThreadLocal перед возвратом потока пулу.
- Снимайте несколько thread dumps: сравнивайте их для выявления стабильных блокировок.
- Анализируйте метрики ThreadMXBean/JMX: рост числа потоков и очереди — признак утечки или контеншна.
- Статический анализ: интегрируйте SpotBugs/Error Prone в CI для раннего выявления concurrency-bugs.
- Используйте stress-тесты: JCStress позволяет выявлять тонкие race conditions до выхода приложения в production.
Заключение
Эффективная диагностика многопоточных JVM-приложений требует комбинирования snapshot-инструментов (thread dump, heap dump, JFR), мониторинга метрик (JMX), статического анализа (SpotBugs, Error Prone) и стресс-тестирования (JCStress). Критически важно правильно интерпретировать данные: искать долгоживущие заблокированные потоки, цепочки удерживаемых объектов, циклические зависимости между ресурсами.
Тестирование
Введение
Тестирование многопоточного кода является одним из самых сложных аспектов разработки на Java/Kotlin. Основная проблема заключается в том, что многопоточный код является недетерминированным — результаты выполнения могут различаться в зависимости от множества факторов: планирования потоков, нагрузки на процессор, кэширования и многого другого. Это означает, что баг может проявиться один раз на миллион выполнений, что делает его обнаружение и воспроизведение исключительно сложным.
Главные сложности при тестировании многопоточного кода
Недетерминизм выполнения
Порядок выполнения инструкций в разных потоках не определен. Java Virtual Machine может выбрать любой способ распределения времени процессора между потоками. Это означает, что тест может пройти на одной машине и внезапно упасть на другой.
Пример:
Поток 1: x = 1; y = 2;
Поток 2: x = 3; y = 4;
Возможные результаты финального состояния:
- x=1, y=4 (полное чередование)
- x=3, y=2 (другое чередование)
- x=3, y=4 (поток 2 закончился раньше)
Heisenbug (эффект наблюдателя)
Один из самых коварных типов багов в многопоточном коде. Баг исчезает, как только вы пытаетесь его отладить. Это происходит потому, что отладчик замедляет выполнение потоков или блокирует их в точках останова, меняя времеение выполнения. Race condition, которая проявлялась при нормальной работе, может исчезнуть при отладке.
Оригинальный код:
synchronized(lock) { ... } // Race condition есть
При отладке:
// Отладчик добавляет pauses
// Timing меняется
// Race condition исчезает
Редкость возникновения проблем
Race condition часто проявляется крайне редко — может быть, один раз из десяти тысяч выполнений. Это означает, что простой счетчик в цикле может не выявить проблему, так как она требует очень специфичного порядка выполнения инструкций, который маловероятен, но все же возможен.
Невоспроизводимость
Даже если вы поймали баг, его трудно воспроизвести по требованию. Разные комбинации нагрузки на систему, другие процессы, запущенные в фоне, версия JVM — все это влияет на воспроизводимость проблемы.
Детерминированные тесты
Детерминированный тест всегда должен давать одинаковый результат при одинаковых входных данных. Для многопоточного кода это достигается путем явной синхронизации всех потоков до определенных точек.
CountDownLatch для синхронизации старта и завершения
CountDownLatch — это синхронизатор, который позволяет одному или большему количеству потоков ждать до тех пор, пока другие потоки не завершат определенные операции.
Основная идея:
- Создать лatch с начальным счетчиком
- Потоки вызывают
await()для блокировки - Другие потоки вызывают
countDown()для уменьшения счетчика - Когда счетчик достигает нуля, все ждущие потоки пробуждаются
Зачем нужны две лatch в примере ниже?
-
startLatch — обеспечивает, что все потоки начинают работу одновременно. Если просто запустить потоки без синхронизации, первые потоки могут уже начать работать и закончить, пока другие еще только создаются. Это резко снижает вероятность race condition.
-
doneLatch — гарантирует, что основной тест не проверит результат до того, как все рабочие потоки завершились.
@Test
public void testConcurrentIncrement() throws InterruptedException {
Counter counter = new Counter(); // Простой счетчик без синхронизации
int threadCount = 10;
// startLatch: все потоки ждут, пока главный поток не скажет "начинайте"
CountDownLatch startLatch = new CountDownLatch(1);
// doneLatch: главный поток ждет, пока все рабочие потоки не закончат
CountDownLatch doneLatch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
new Thread(() -> {
try {
// Все потоки блокируются здесь до countDown startLatch
startLatch.await();
// Теперь все потоки выполняют increment примерно одновременно
counter.increment();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
// Сообщаем основному потоку, что мы закончили
doneLatch.countDown();
}
}).start();
}
// Главный поток проверяет, что все рабочие потоки созданы
Thread.sleep(100); // Небольшая задержка для гарантии
// Пускаем всех рабочих потоков одновременно
startLatch.countDown();
// Ждем завершения всех рабочих потоков
doneLatch.await();
// Проверяем результат (если increment() не потокобезопасен, результат будет < 10)
assertEquals(threadCount, counter.getValue());
}
Что происходит без startLatch?
// ПЛОХО: без синхронизации старта
for (int i = 0; i < 10; i++) {
new Thread(() -> counter.increment()).start();
}
Thread.sleep(1000); // Надеемся, что это достаточно
assertEquals(10, counter.getValue());
// Проблемы:
// 1. Порядок создания потоков и старта их выполнения не определен
// 2. Race condition может не проявиться, потому что потоки
// могут выполняться последовательно, а не параллельно
// 3. На медленной системе Thread.sleep(1000) может быть недостаточно
CyclicBarrier для фазовой синхронизации
CyclicBarrier отличается от CountDownLatch тем, что его можно повторно использовать. После того, как все потоки достигают барьера, счетчик сбрасывается и барьер может использоваться снова.
Это полезно для многофазных тестов, где нужно убедиться, что все потоки завершили одну фазу перед тем, как начать следующую.
@Test
public void testMultiphaseExecution() throws Exception {
int threadCount = 3;
// Барьер требует 3 потока, чтобы заблокировать
CyclicBarrier barrier = new CyclicBarrier(threadCount);
// Отслеживаем, сколько операций было выполнено
// AtomicInteger гарантирует потокобезопасное изменение
AtomicInteger phaseCounter = new AtomicInteger(0);
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < threadCount; i++) {
Thread t = new Thread(() -> {
try {
// === Фаза 1 ===
// Каждый поток выполняет свою часть работы
phaseCounter.incrementAndGet();
// barrier.await() блокирует поток до тех пор,
// пока все потоки не достигнут этой точки
barrier.await();
// Теперь все потоки выполнили фазу 1
// === Фаза 2 ===
// Начинаем вторую фазу после того, как первая завершена везде
phaseCounter.incrementAndGet();
// Снова ждем все потоки
barrier.await();
// Теперь все потоки выполнили фазу 2
} catch (Exception e) {
fail("Thread interrupted: " + e.getMessage());
}
});
threads.add(t);
t.start();
}
// Ждем завершения всех потоков
for (Thread t : threads) {
t.join();
}
// phaseCounter должен быть 6: 3 потока × 2 фазы = 6 операций
assertEquals(threadCount * 2, phaseCounter.get());
}
Практический пример использования CyclicBarrier:
// Тестируем систему, которая требует трехфазного выполнения:
// Фаза 1: все потоки инициализируют локальное состояние
// Фаза 2: все потоки выполняют вычисления
// Фаза 3: все потоки синхронизируют результаты
@Test
public void testThreePhaseComputation() throws Exception {
int workers = 4;
CyclicBarrier gate = new CyclicBarrier(workers);
List<Integer> results = Collections.synchronizedList(new ArrayList<>());
ExecutorService executor = Executors.newFixedThreadPool(workers);
for (int i = 0; i < workers; i++) {
final int workerId = i;
executor.submit(() -> {
try {
// Фаза 1: инициализация
int localData = workerId * 100;
gate.await(); // Все ждут друг друга
// Фаза 2: вычисления
int result = localData + 50;
results.add(result);
gate.await();
// Фаза 3: финализация
System.out.println("Worker " + workerId + " finished");
gate.await();
} catch (Exception e) {
fail(e.getMessage());
}
});
}
executor.shutdown();
executor.awaitTermination(10, TimeUnit.SECONDS);
assertEquals(workers, results.size());
}
Стресс-тесты
Стресс-тесты работают на другом принципе, чем детерминированные тесты. Вместо того, чтобы тщательно синхронизировать каждый поток, они создают интенсивную нагрузку и многократно выполняют код, надеясь поймать race condition через статистику.
Многократное выполнение с читателями и писателями
Этот подход создает конкурирующие потоки, которые одновременно читают и пишут данные.
@Test
public void stressTestConcurrentMap() {
// ConcurrentHashMap обещает потокобезопасность на уровне bucket'ов
ConcurrentHashMap<Integer, Integer> map = new ConcurrentHashMap<>();
int iterations = 10000;
int threadCount = 10;
Runnable writer = () -> {
for (int i = 0; i < iterations; i++) {
map.put(i, i);
}
};
Runnable reader = () -> {
for (int i = 0; i < iterations; i++) {
map.get(i); // Может вернуть null, если writer еще не добавил
}
};
// Запускаем множество потоков: 5 писателей и 5 читателей
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < threadCount / 2; i++) {
threads.add(new Thread(writer));
threads.add(new Thread(reader));
}
// Запускаем всех
threads.forEach(Thread::start);
// Ждем завершения всех
threads.forEach(t -> {
try {
t.join();
} catch (InterruptedException e) {
fail("Thread interrupted: " + e.getMessage());
}
});
// После завершения всех писателей map должен содержать все элементы
assertEquals(iterations, map.size());
}
Почему это работает? ConcurrentHashMap гарантирует потокобезопасность. Стресс-тест создает настолько высокую конкуренцию, что если бы была проблема, она почти гарантированно проявилась бы.
Стресс-тест с ExecutorService и детальным мониторингом
ExecutorService управляет пулом потоков, что позволяет легче контролировать создание и завершение потоков.
@Test
public void stressTestWithExecutor() throws Exception {
// Пул из 20 потоков
ExecutorService executor = Executors.newFixedThreadPool(20);
// Счетчики успешно завершенных и ошибочных операций
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger errorCount = new AtomicInteger(0);
int taskCount = 1000;
CountDownLatch latch = new CountDownLatch(taskCount);
for (int i = 0; i < taskCount; i++) {
executor.submit(() -> {
try {
// Выполняем тестируемую операцию в высоконагруженном окружении
performConcurrentOperation();
// Отмечаем успех
successCount.incrementAndGet();
} catch (Exception e) {
// Отмечаем ошибку
errorCount.incrementAndGet();
System.err.println("Error: " + e.getMessage());
} finally {
// Уменьшаем счетчик в любом случае
latch.countDown();
}
});
}
// Ждем завершения всех задач с timeout
boolean completed = latch.await(30, TimeUnit.SECONDS);
executor.shutdown();
if (!completed) {
executor.shutdownNow();
fail("Not all tasks completed within 30 seconds");
}
// Проверяем, что все успешно завершилось
assertEquals(taskCount, successCount.get());
assertEquals(0, errorCount.get());
}
private void performConcurrentOperation() {
// Имитируем сложную операцию
// которую нужно протестировать под нагрузкой
List<Integer> data = Collections.synchronizedList(new ArrayList<>());
for (int i = 0; i < 100; i++) {
data.add(i);
}
if (data.size() != 100) {
throw new RuntimeException("Data corruption detected");
}
}
Когда использовать стресс-тесты?
- Для тестирования потокобезопасных коллекций (ConcurrentHashMap, CopyOnWriteArrayList)
- Для поиска race conditions в комплексных сценариях
- Для нагрузочного тестирования под высокой конкуренцией
- Когда детерминированные тесты не могут выявить конкретный баг
Awaitility библиотека
Awaitility решает проблему тестирования асинхронного кода, предоставляя DSL для ожидания определенных условий. Вместо использования Thread.sleep(), которое жестко кодирует время ожидания, Awaitility позволяет периодически проверять условие до его выполнения или наступления timeout.
Базовое использование
import static org.awaitility.Awaitility.*;
@Test
public void testAsyncOperation() {
AsyncService service = new AsyncService();
// Запускаем асинхронную операцию
service.startAsyncOperation();
// Ждем пока условие не станет true, но не более 5 секунд
// Awaitility будет проверять service.isCompleted() много раз в секунду
await()
.atMost(5, TimeUnit.SECONDS)
.until(() -> service.isCompleted());
// Если мы дошли сюда, операция завершена
assertTrue(service.getResult().isPresent());
}
Что происходит под капотом?
Поток 1 (тест): Поток 2 (сервис):
await() → проверяет startAsyncOperation() → фоновая работа
← false
← false
→ спит 100ms
← false
← true ✓
Продвинутые примеры Awaitility
@Test
public void testEventualConsistency() {
// Тестируем кэш, который обновляется асинхронно
Cache cache = new Cache();
cache.putAsync("key", "value");
// Ждем, пока значение появится в кэше
// Проверяем каждые 50ms, максимум 2 секунды
await()
.pollInterval(50, TimeUnit.MILLISECONDS)
.atMost(2, TimeUnit.SECONDS)
.until(
() -> cache.get("key"),
equalTo("value")
);
}
@Test
public void testWithAtLeast() {
// Гарантируем минимальное время ожидания
// Полезно для тестов, где нужно убедиться в задержке
AtomicInteger callCount = new AtomicInteger(0);
Thread thread = new Thread(() -> {
try {
Thread.sleep(500);
callCount.set(1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
thread.start();
// Ждем результат, но минимум 300ms
// Это гарантирует, что мы не спешим с проверкой
await()
.atLeast(300, TimeUnit.MILLISECONDS)
.atMost(2, TimeUnit.SECONDS)
.until(() -> callCount.get() == 1);
}
@Test
public void testWithFixedDelay() {
AtomicInteger counter = new AtomicInteger(0);
new Thread(() -> {
for (int i = 0; i < 10; i++) {
try {
Thread.sleep(100);
counter.set(i + 1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}).start();
// Проверяем каждые 50ms до 5 секунд
await()
.pollInterval(50, TimeUnit.MILLISECONDS)
.atMost(5, TimeUnit.SECONDS)
.until(() -> counter.get() > 5);
}
@Test
public void testWithAssertion() {
Service service = new Service();
service.startAsync();
// Выполняем assertion внутри await
// Если assertion не прошла, Awaitility повторит попытку
await()
.atMost(3, TimeUnit.SECONDS)
.untilAsserted(() -> {
List<Integer> items = service.getItems();
assertThat(items).hasSize(10);
assertThat(items).allMatch(i -> i > 0);
});
}
Преимущества Awaitility над Thread.sleep():
// ПЛОХО: жесткое время
Thread.sleep(2000);
assertTrue(service.isReady());
// Если сервис готов за 100ms, мы все равно ждем 2 секунды
// Если сервис готов за 2.5 секунды, тест упадет
// ХОРОШО: динамическое ожидание
await()
.atMost(5, TimeUnit.SECONDS)
.until(() -> service.isReady());
// Тест завершится как только условие выполнится
// Максимум ждем 5 секунд
JCStress для обнаружения race conditions
JCStress (Java Concurrency Stress) — это инструмент от Oracle для тестирования concurrency. Он специально разработан для поиска race conditions и других параллельных багов. В отличие от обычных юнит-тестов, JCStress многократно выполняет код и анализирует все возможные результаты.
Базовый тест JCStress
import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.II_Result;
@JCStressTest
// Документируем ожидаемые результаты
@Outcome(id = "2", expect = Expect.ACCEPTABLE, desc = "Both increments visible")
@Outcome(id = "1", expect = Expect.FORBIDDEN, desc = "Race condition - only one increment!")
@Outcome(id = "0", expect = Expect.FORBIDDEN, desc = "No increments - total disaster")
@State
public class CounterRaceTest {
private int counter = 0; // НЕ volatile!
// @Actor обозначает метод, который выполняет один из "актеров"
// Актеры выполняются параллельно
@Actor
public void actor1() {
counter++; // Race condition здесь!
}
@Actor
public void actor2() {
counter++; // И здесь!
}
// @Arbiter "судит" о том, какой результат был получен
@Arbiter
public void arbiter(II_Result r) {
r.r1 = counter;
}
}
// Запуск:
// mvn archetype:generate \
// -DinteractiveMode=false \
// -DarchetypeGroupId=org.openjdk.jcstress \
// -DarchetypeArtifactId=jcstress-java-test-archetype \
// -DgroupId=mytest -DartifactId=mytest
// cd mytest
// mvn clean install
// java -jar target/jcstress.jar
Что произойдет?
JCStress запустит этот тест тысячи раз и покажет статистику результатов:
RESULT SAMPLES FREQ EXPECT
0 50 0.17% FORBIDDEN
1 65000 93.14% FORBIDDEN (race condition обнаружена!)
2 5000 6.69% ACCEPTABLE
Статистика показывает: в 93% случаев только один инкремент видимо,
вместо ожидаемых двух. Это классическая race condition.
JCStress для Double-Checked Locking паттерна
Double-Checked Locking — это оптимизация, которая может быть ошибочной, если не использовать volatile.
@JCStressTest
@Outcome(id = "true, true", expect = Expect.ACCEPTABLE,
desc = "Both see initialized instance")
@Outcome(id = "false, false", expect = Expect.ACCEPTABLE,
desc = "Both see uninitialized instance")
@Outcome(id = "true, false", expect = Expect.FORBIDDEN,
desc = "Partially initialized - RACE CONDITION!")
@Outcome(id = "false, true", expect = Expect.FORBIDDEN,
desc = "Reordering - RACE CONDITION!")
@State
public class DCLTest {
// БЕЗ volatile: может быть race condition
// private Object instance;
// С volatile: гарантирует правильное поведение
private volatile Object instance;
@Actor
public void actor1(ZZ_Result r) {
if (instance == null) {
instance = new Object(); // Инициализация
}
r.r1 = (instance != null);
}
@Actor
public void actor2(ZZ_Result r) {
// Читаем без синхронизации
r.r2 = (instance != null);
}
}
Почему volatile важен?
БЕЗ volatile:
- JVM может переупорядочить инструкции
- Другой поток может видеть partially initialized объект
- Результат: true, false (RACE CONDITION!)
С volatile:
- JVM гарантирует порядок инструкций
- Memory barrier предотвращает переупорядочение
- Результат: всегда true, true или false, false
Более сложный JCStress тест
@JCStressTest
@Outcome(id = "100, 0", expect = Expect.ACCEPTABLE,
desc = "Writer 1 won")
@Outcome(id = "0, 100", expect = Expect.ACCEPTABLE,
desc = "Writer 2 won")
@Outcome(id = "50, 50", expect = Expect.FORBIDDEN,
desc = "Both partially wrote - race condition!")
@State
public class CompeteWritersTest {
private volatile int x = 0;
private volatile int y = 0;
@Actor
public void writer1() {
x = 100;
y = 0;
}
@Actor
public void writer2() {
x = 0;
y = 100;
}
@Arbiter
public void arbiter(II_Result r) {
r.r1 = x;
r.r2 = y;
}
}
Mockito и многопоточность
Thread-safe mocking
При использовании Mockito в многопоточном коде нужно быть осторожным. Mockito по умолчанию не потокобезопасен.
@Test
public void testConcurrentMockCalls() throws Exception {
Service service = mock(Service.class,
withSettings().stubOnly()); // stubOnly() для потокобезопасности
when(service.getData()).thenReturn("data");
int threadCount = 10;
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
new Thread(() -> {
try {
String result = service.getData();
assertEquals("data", result);
} finally {
latch.countDown();
}
}).start();
}
// Ждем завершения всех потоков
latch.await();
// Проверяем, что getData был вызван ровно threadCount раз
verify(service, times(threadCount)).getData();
}
Настройки Mockito для многопоточности:
// ПЛОХО: не потокобезопасно
Service service = mock(Service.class);
when(service.getData()).thenReturn("data");
// Verify может быть недетерминирован в многопоточном контексте
// ХОРОШО: потокобезопасный mock
Service service = mock(Service.class,
withSettings()
.stubOnly() // Только заглушки
.defaultAnswer(RETURNS_SMART_NULLS)); // Умные значения по умолчанию
Проблемы с асинхронной верификацией
Verify не гарантирует завершение асинхронных операций.
@Test
public void testAsyncVerificationProblem() {
Service service = mock(Service.class);
ExecutorService executor = Executors.newFixedThreadPool(1);
executor.submit(() -> service.doWork());
// ПРОБЛЕМА: verify сразу, но service.doWork() может еще не завершиться
verify(service).doWork(); // Может упасть!
executor.shutdown();
}
@Test
public void testAsyncVerificationSolution() {
Service service = mock(Service.class);
ExecutorService executor = Executors.newFixedThreadPool(1);
executor.submit(() -> service.doWork());
// РЕШЕНИЕ: используем Awaitility
await()
.atMost(5, TimeUnit.SECONDS)
.untilAsserted(() -> verify(service).doWork());
executor.shutdown();
}
Тестирование @Async в Spring
Spring предоставляет аннотацию @Async для асинхронного выполнения методов. Тестирование таких методов требует специального подхода.
@SpringBootTest
public class AsyncServiceTest {
@Autowired
private AsyncService asyncService;
@Test
public void testAsyncMethod() throws Exception {
// asyncService.asyncMethod() возвращает CompletableFuture
CompletableFuture<String> future = asyncService.asyncMethod();
// Не блокируем тест навсегда
// Ждем максимум 5 секунд
await()
.atMost(5, TimeUnit.SECONDS)
.until(future::isDone);
// Теперь получаем результат
assertEquals("expected result", future.get());
}
@Test
public void testAsyncMethodWithTimeout() throws Exception {
CompletableFuture<String> future = asyncService.asyncMethod();
// Ожидаем завершение с timeout
String result = future.get(5, TimeUnit.SECONDS);
assertEquals("expected result", result);
}
@Test
public void testAsyncMethodException() {
CompletableFuture<String> future = asyncService.failingAsyncMethod();
// Проверяем, что будущее завершилось исключением
assertThrows(ExecutionException.class, () -> {
future.get(5, TimeUnit.SECONDS);
});
}
}
// Класс сервиса
@Service
public class AsyncService {
@Async
public CompletableFuture<String> asyncMethod() {
try {
Thread.sleep(1000); // Имитируем долгую операцию
return CompletableFuture.completedFuture("result");
} catch (InterruptedException e) {
return CompletableFuture.failedFuture(e);
}
}
@Async
public CompletableFuture<String> failingAsyncMethod() {
return CompletableFuture.failedFuture(
new RuntimeException("Async operation failed"));
}
}
// Конфигурация для потокобезопасного выполнения тестов
@TestConfiguration
static class AsyncTestConfig {
@Bean
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(2);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-test-");
executor.initialize();
return executor;
}
}
Тестирование таймаутов
Таймауты в аннотациях JUnit
@Test(timeout = 5000) // Тест должен завершиться за 5000ms
public void testOperationCompletes() {
asyncService.longRunningOperation();
}
Таймауты с Awaitility
@Test
public void testTimeoutWithAwaitility() {
asyncService.start();
// Ожидание с timeout и интервалом проверки
await()
.pollInterval(100, TimeUnit.MILLISECONDS) // Проверяем каждые 100ms
.atMost(5, TimeUnit.SECONDS) // Максимум 5 секунд
.until(() -> asyncService.isCompleted());
}
Таймауты с CompletableFuture
@Test
public void testCompletableFutureTimeout() {
CompletableFuture<String> future = service.fetchDataAsync();
// Получаем результат с timeout
String result = future.get(1, TimeUnit.SECONDS);
assertEquals("expected", result);
}
@Test
public void testCompletableFutureTimeoutException() {
CompletableFuture<String> future = new CompletableFuture<>();
// Никогда не завершится
assertThrows(TimeoutException.class, () -> {
future.get(1, TimeUnit.SECONDS);
});
}
Подводные камни и Best Practices
1. Не полагайтесь на Thread.sleep()
Проблемы с Thread.sleep():
// ПЛОХО: недетерминировано
@Test
public void testWithSleep() throws Exception {
service.startAsync();
Thread.sleep(1000); // Что, если система медленная?
assertTrue(service.isCompleted()); // Может упасть на slow machine
}
// ХОРОШО: используйте await
@Test
public void testWithAwaitility() {
service.startAsync();
await()
.atMost(5, TimeUnit.SECONDS)
.until(() -> service.isCompleted());
}
Thread.sleep() имеет несколько проблем:
- На медленных системах операция может занять больше времени
- На быстрых системах мы впустую тратим время
- Тест становится flaky (непредсказуемый)
2. Используйте CountDownLatch для синхронизации старта
// ПЛОХО: потоки стартуют рассредоточенно
@Test
public void testNoSync() throws Exception {
for (int i = 0; i < 10; i++) {
new Thread(() -> sharedCounter.increment()).start();
}
Thread.sleep(1000);
assertEquals(10, sharedCounter.get()); // Может быть < 10 из-за race condition
}
// ХОРОШО: синхронизированный старт
@Test
public void testWithSync() throws Exception {
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(10);
for (int i = 0; i < 10; i++) {
new Thread(() -> {
try {
start.await(); // Все ждут сигнала
sharedCounter.increment();
} finally {
done.countDown();
}
}).start();
}
start.countDown(); // Все потоки начинают одновременно
done.await(); // Ждем завершения
assertEquals(10, sharedCounter.get());
}
3. Разделяйте стресс-тесты в отдельную категорию
Стресс-тесты часто занимают много времени и нагружают машину.
public interface SlowTest {}
public interface StressTest {}
@Category(StressTest.class)
@Test
public void heavyStressTest() {
// Долгий и тяжелый тест
for (int i = 0; i < 100000; i++) {
// ...
}
}
// Запуск обычных тестов:
// mvn test -DexcludedGroups=StressTest
// Запуск только стресс-тестов:
// mvn test -Dgroups=StressTest
4. Используйте JCStress для критичных примитивов
JCStress незаменим при тестировании:
- Custom locks и synchronizers
- Lock-free алгоритмов (CAS-based)
- Volatile полей и memory barriers
- Double-checked locking
@JCStressTest
@Outcome(id = "0", expect = Expect.FORBIDDEN)
@Outcome(id = "1", expect = Expect.ACCEPTABLE)
@Outcome(id = "2", expect = Expect.ACCEPTABLE)
@State
public class CustomLockTest {
// Тестируем custom lock реализацию
// JCStress найдет race conditions если они есть
}
5. Mockito с stubOnly() для многопоточности
// ПЛОХО: обычный mock не всегда потокобезопасен
Service service = mock(Service.class);
when(service.getValue()).thenReturn(42);
// ХОРОШО: явно указываем потокобезопасные настройки
Service service = mock(Service.class,
withSettings().stubOnly());
when(service.getValue()).thenReturn(42);
6. Тесты должны быть идемпотентными
Каждый тест должен оставлять систему в чистом состоянии.
@Test
public void concurrentTest() throws Exception {
executor.submit(() -> { /* work */ });
executor.submit(() -> { /* work */ });
// ... test logic
}
@After
public void cleanup() {
// Полностью очищаем ресурсы
executor.shutdownNow();
executor.awaitTermination(5, TimeUnit.SECONDS);
// Очищаем общее состояние
cache.clear();
sharedList.clear();
}
7. Не тестируйте timing напрямую
// ПЛОХО: зависит от нагрузки на систему
@Test
public void timingTest() {
long start = System.currentTimeMillis();
service.doWork();
long duration = System.currentTimeMillis() - start;
// На медленной системе тест упадет
assertTrue(duration < 1000);
}
// ХОРОШО: тестируйте корректность, не timing
@Test
public void correctnessTest() {
service.doWork();
// Проверяем что результат правильный
assertTrue(service.isSuccessful());
assertNotNull(service.getResult());
}
8. Используйте @RepeatedTest для flaky tests
Flaky test — тест, который иногда падает, иногда проходит случайно.
@RepeatedTest(100) // Повторить 100 раз
public void testConcurrentAccess(RepetitionInfo repetitionInfo) {
System.out.println("Iteration " + repetitionInfo.getCurrentRepetition());
// Если в этом тесте есть race condition,
// она проявится в одной из 100 итераций
sharedCounter.increment();
}
9. Тестируйте graceful shutdown
При завершении работы thread pool должен корректно остановить все потоки.
@Test
public void testGracefulShutdown() throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(5);
// Запускаем долгие задачи
for (int i = 0; i < 10; i++) {
executor.submit(() -> {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
// Запускаем graceful shutdown
executor.shutdown();
// Ждем завершения всех задач
if (!executor.awaitTermination(10, TimeUnit.SECONDS)) {
// Если не завершились, принудительно останавливаем
executor.shutdownNow();
fail("Executor did not terminate gracefully");
}
assertTrue(executor.isTerminated());
}
10. Избегайте deadlock-ов в тестах
// ПЛОХО: может произойти deadlock
@Test(timeout = 5000) // Только timeout может спасти
public void riskyTest() {
Object lock1 = new Object();
Object lock2 = new Object();
Thread t1 = new Thread(() -> {
synchronized(lock1) {
sleep(100);
synchronized(lock2) { // Deadlock если t2 ждет lock1
// ...
}
}
});
Thread t2 = new Thread(() -> {
synchronized(lock2) {
sleep(100);
synchronized(lock1) { // Deadlock если t1 ждет lock2
// ...
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
}
// ХОРОШО: используйте timeouts при join
@Test
public void safeTest() throws Exception {
// Используйте join с timeout
t1.join(5000);
t2.join(5000);
if (t1.isAlive() || t2.isAlive()) {
fail("Threads did not complete - possible deadlock");
}
}
Заключение
Тестирование многопоточного кода требует специального подхода и понимания особенностей параллельного выполнения. Основные стратегии:
Для детерминированных тестов:
- Используйте CountDownLatch для синхронизации старта и завершения
- Используйте CyclicBarrier для фазовых синхронизаций
- Избегайте Thread.sleep(), используйте Awaitility
Для поиска race conditions:
- Применяйте стресс-тесты с высокой конкуренцией
- Используйте JCStress для критичного параллельного кода
- Запускайте тесты в повторениях (@RepeatedTest)
При работе с фреймворками:
- Mockito требует специальной настройки для потокобезопасности
- Spring @Async требует CompletableFuture и Awaitility
- Всегда очищайте ресурсы после тестов
Помните, что многопоточные баги редко проявляются, но потенциально опасны. Лучше потратить время на комплексное тестирование сейчас, чем искать race condition в production."
Производительность и оптимизация
Введение
Производительность многопоточных приложений зависит от правильного выбора алгоритмов, настроек и понимания принципиальных ограничений параллелизма. Даже небольшие ошибки в архитектуре могут привести к деградации производительности в 2-3 раза или даже больше. В этом разделе мы рассмотрим фундаментальные законы, которые управляют производительностью распределённых вычислений, а также практические техники оптимизации на уровне CPU, памяти и приложения.
Закон Амдала
Закон Амдала определяет теоретический максимум ускорения параллельной программы при добавлении новых процессоров. Это критически важно понимать, поскольку он показывает, что не весь код может быть распараллелен, и существует жёсткий потолок ускорения, который определяется долей последовательного кода.
Формула и объяснение
Speedup = 1 / (S + (1 - S) / N)
где:
S = доля кода, которая не может быть распараллелена (sequential fraction)
N = число процессоров (потоков выполнения)
1 - S = доля кода, которая может быть распараллелена
Расчёт максимального ускорения
Важное следствие закона Амдала — максимальное ускорение при N → ∞:
Speedup_max = 1 / S
Это означает, что даже с бесконечным числом процессоров вы не сможете ускорить программу больше, чем на 1/S раз. Например:
- Если
S = 5%(5% кода последовательный), тоSpeedup_max = 20x - Если
S = 10%(10% кода последовательный), тоSpeedup_max = 10x - Если
S = 50%(50% кода последовательный), тоSpeedup_max = 2x(очень плохо!)
Конкретный пример расчёта
Допустим, 5% кода не может быть распараллелено, а 95% может. На системе с 4 процессорами:
Speedup = 1 / (0.05 + 0.95 / 4)
= 1 / (0.05 + 0.2375)
= 1 / 0.2875
≈ 3.48x
То есть вместо идеального ускорения 4x мы получим только 3.48x. И это не зависит от количества потоков — максимально мы сможем ускорить на 1 / 0.05 = 20x, даже если у нас будет 1000 ядер!
Практический пример на Java
public class AmdalLawExample {
// 10% последовательная часть, 90% параллельная
public List<Result> process(List<Data> data) {
// 10% последовательная часть
// Инициализация, валидация, подготовка данных
Data preprocessed = preprocess(data); // S = 0.1
Map<String, Config> config = loadConfiguration();
validateInput(preprocessed);
// 90% параллельная часть
// Основная обработка данных в параллельных потоках
List<Result> results = data.parallelStream()
.map(this::processItem)
.filter(this::isValid)
.collect(Collectors.toList());
// Небольшая финализация (входит в 10%)
postprocess(results);
// Максимальное ускорение = 1 / 0.1 = 10x
// Даже на системе с 100 ядрами не ускоримся больше, чем на 10x!
return results;
}
private Data preprocess(List<Data> data) {
// Здесь тратится ~10% времени
return new Data(data);
}
private Result processItem(Data item) {
// Сложные вычисления, которые можно распараллелить
return performComplexCalculation(item);
}
}
Практические выводы из закона Амдала
- Сначала ищите низко висящие плоды — если вы имеете 20% последовательного кода, то
Speedup_max = 5x, и добавление ядер свыше 5-10 не поможет - Оптимизируйте последовательную часть — даже маленшее сокращение
Sдаёт огромный приростSpeedup_max - Не распараллеливайте слишком агрессивно — если стоимость распараллеливания велика (overhead создания потоков, синхронизация), то лучше оставить часть кода последовательной
Закон Литтла
Закон Литтла (Little's Law) — это фундаментальное правило теории очередей, которое связывает три ключевые метрики производительности: среднее число задач в системе (concurrency), пропускную способность (throughput) и среднее время выполнения задачи (latency).
Формула
L = λ × W
где:
L = среднее число задач в системе (concurrency, количество "работников")
λ = throughput (количество задач в единицу времени, обычно задач/сек)
W = среднее время выполнения одной задачи (latency)
Это соотношение верно для любой стабильной системы, независимо от распределения времени обслуживания или характера входящих задач.
Интуитивное объяснение
Представьте кассу в магазине:
- Если касса обслуживает 10 клиентов в минуту (
λ = 10) - И каждый клиент в среднем стоит 6 секунд = 0.1 минуты (
W = 0.1) - То в среднем в очереди будет
L = 10 × 0.1 = 1человек
Если вы хотите, чтобы в очереди была 1 человек, а не 5, вам нужно либо:
- Снизить время обслуживания (latency) с 0.1 до 0.02 минуты
- Или увеличить число касс (concurrency) в 5 раз
Практический пример расчёта
Дано:
- Latency (среднее время обработки запроса): 100ms = 0.1 секунды
- Throughput (требуемая пропускная способность): 100 задач/сек
По закону Литтла:
L = 100 × 0.1 = 10
Интерпретация:
- Нужно 10 потоков для поддержки 100 задач/сек с latency 100ms
- Если запустим с 5 потоками, то latency будет ~200ms
- Если запустим с 20 потоками, то latency будет ~50ms (но и ресурсов потратим больше)
Применение к настройке размера Thread Pool
public class ThreadPoolSizingExample {
public static void main(String[] args) {
// Предположим, наше приложение:
// - Получает 1000 запросов/сек (throughput)
// - Каждый запрос в среднем обрабатывается 50ms (latency)
int throughput = 1000; // запросов/сек
double latency = 0.050; // секунды (50ms)
// По закону Литтла:
// L = 1000 × 0.050 = 50
int requiredConcurrency = (int) (throughput * latency);
System.out.println("Требуемый уровень concurrency: " + requiredConcurrency);
// Создаём thread pool с учётом пиков (×2) и margin (×0.2)
int corePoolSize = requiredConcurrency;
int maxPoolSize = requiredConcurrency * 2; // для пиков трафика
ThreadPoolExecutor executor = new ThreadPoolExecutor(
corePoolSize, // 50 потоков минимум
maxPoolSize, // 100 потоков максимум
60, // 60 секунд timeout для неиспользуемых потоков
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200), // очередь на 200 задач
new ThreadPoolExecutor.CallerRunsPolicy() // если очередь полна
);
// Теперь система сможет обработать 1000 запросов/сек
// без накопления очереди и с лучшим использованием ресурсов
}
}
Как закон Литтла помогает в практике
-
Диагностика узких мест — если вы видите огромную очередь задач, это признак того, что
L(concurrency) недостаточно велика для текущегоλ(throughput) иW(latency) -
Правильное размер пула — рассчитав требуемое
L, вы можете настроитьcorePoolSizeиmaxPoolSizeпула потоков -
Предсказание влияния оптимизаций — если вы оптимизируете latency (снижаете
W), то можете снизить требуемое число потоков, сэкономив память и контекст-свичи
Overhead создания потоков и Context Switch
Создание потока — дорогостоящая операция. Понимание этого overhead критично для выбора правильной архитектуры.
Стоимость Platform Thread (традиционные потоки Java)
Platform Thread:
- Время создания: ~1 миллисекунда (1ms)
- Занимаемая память: ~1-2 мегабайта (для stack, зависит от OS)
- Context switch: ~1-10 микросекунд (1-10 μs)
- Инициализация JVM: ~100-500 μs (зависит от операций)
Это означает:
- Если вы создаёте 1000 потоков, потратите 1 секунду только на их создание
- Каждый поток съедает 1-2 MB памяти, поэтому 1000 потоков = 1-2 GB памяти
- Context switch (переключение между потоками) тоже имеет цену: очищается кэш процессора, загружается новый контекст
Сравнение с Virtual Thread (Java 21+)
Virtual Thread (Project Loom):
- Время создания: ~1 микросекунда (1 μs) — в 1000 раз быстрее!
- Занимаемая память: ~1 килобайт (2000x меньше!)
- Context switch: <1 μs (управляется JVM, не OS)
- Масштабируемость: можно создать миллионы потоков
Virtual Thread кардинально меняет подход к многопоточности, но для Java 8-17 это недоступно.
Benchmark: создание потока vs. переиспользование пула
public class ThreadCreationBenchmark {
// ПОДХОД 1: создание потока для каждой задачи (ПЛОХО)
public static void createThreadPerTask() throws InterruptedException {
long start = System.currentTimeMillis();
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
Thread t = new Thread(() -> {
// Имитация работы: 10ms
try { Thread.sleep(10); } catch (InterruptedException e) {}
});
threads.add(t);
t.start();
}
for (Thread t : threads) {
t.join();
}
long elapsed = System.currentTimeMillis() - start;
System.out.println("Создание потока на задачу: " + elapsed + "ms");
// Результат: ~1000ms (создание) + 10ms (работа) = 1010ms
}
// ПОДХОД 2: переиспользование пула потоков (ХОРОШО)
public static void threadPool() throws InterruptedException {
long start = System.currentTimeMillis();
ExecutorService executor = Executors.newFixedThreadPool(10);
List<Future<?>> futures = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
futures.add(executor.submit(() -> {
// Имитация работы: 10ms
try { Thread.sleep(10); } catch (InterruptedException e) {}
}));
}
for (Future<?> future : futures) {
future.get();
}
executor.shutdown();
long elapsed = System.currentTimeMillis() - start;
System.out.println("Thread pool: " + elapsed + "ms");
// Результат: ~1000ms (1000 задач × 10ms / 10 потоков, минимальный overhead)
}
public static void main(String[] args) throws InterruptedException {
createThreadPerTask(); // ~1010ms
threadPool(); // ~100ms
// Thread pool в 10 раз быстрее!
}
}
Практический вывод
- Никогда не создавайте новый поток для каждой задачи (кроме Virtual Threads)
- Всегда используйте
ExecutorService(thread pool) - На Java 21+ перейдите на Virtual Threads для масштабируемости
False Sharing и Cache Line Padding
False Sharing — это критическая проблема производительности, которая возникает на уровне CPU кэша. Она происходит, когда два потока изменяют разные переменные, которые находятся в одной строке кэша (cache line). Результат: огромная потеря производительности из-за постоянной инвалидации кэша.
Как это работает на уровне CPU
CPU Cache Line = 64 байта (на большинстве современных процессоров)
Пример false sharing:
┌─────────────────────────────────────┐
│ counter1 │ counter2 │ ... │ ← одна cache line (64 байта)
├─────────────────────────────────────┤
Thread-1 Thread-2
Что происходит:
1. Thread-1 изменяет counter1
2. Вся cache line помечается как "dirty" в CPU кэше
3. Thread-2 работает с counter2, но тот в той же cache line
4. Thread-2 должен перечитать cache line из памяти (очень дорого!)
5. Когда Thread-2 пишет counter2, опять инвалидируется cache line
6. Thread-1 должен перечитать... (цикл повторяется)
Результат: огромные penalty из-за постоянных cache misses!
Пример проблемы: false sharing
public class FalseSharingProblem {
static class Counters {
// ПЛОХО: обе переменные в одной cache line (64 байта)
// long = 8 байт, поэтому они точно рядом
volatile long counter1 = 0; // Offset 0
volatile long counter2 = 0; // Offset 8 (та же cache line!)
}
public static void main(String[] args) throws InterruptedException {
Counters counters = new Counters();
long start = System.nanoTime();
// Два потока одновременно модифицируют counter1 и counter2
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10_000_000; i++) {
counters.counter1++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10_000_000; i++) {
counters.counter2++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
long elapsed = (System.nanoTime() - start) / 1_000_000;
System.out.println("False sharing: " + elapsed + "ms");
// Результат на 4-ядерном CPU: ~5000ms (5 секунд!)
}
}
Решение 1: Padding
public class FalseSharingFixed_Padding {
static class PaddedCounters {
// ХОРОШО: padding разделяет переменные в разные cache lines
volatile long counter1 = 0;
// 56 байт padding (cache line 64 байта - 8 байт counter1 = 56)
long p1, p2, p3, p4, p5, p6, p7; // 7 × 8 = 56 байт
volatile long counter2 = 0; // Теперь в другой cache line!
}
public static void main(String[] args) throws InterruptedException {
PaddedCounters counters = new PaddedCounters();
long start = System.nanoTime();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10_000_000; i++) {
counters.counter1++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10_000_000; i++) {
counters.counter2++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
long elapsed = (System.nanoTime() - start) / 1_000_000;
System.out.println("With padding: " + elapsed + "ms");
// Результат: ~1000ms (1 секунда) — в 5 раз быстрее!
}
}
Решение 2: @Contended аннотация (Java 8+)
public class FalseSharingFixed_Contended {
static class ContendedCounters {
@jdk.internal.vm.annotation.Contended
volatile long counter1 = 0;
@jdk.internal.vm.annotation.Contended
volatile long counter2 = 0;
}
public static void main(String[] args) throws InterruptedException {
ContendedCounters counters = new ContendedCounters();
long start = System.nanoTime();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10_000_000; i++) {
counters.counter1++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10_000_000; i++) {
counters.counter2++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
long elapsed = (System.nanoTime() - start) / 1_000_000;
System.out.println("With @Contended: " + elapsed + "ms");
// Результат: ~1000ms — такой же как padding
// ВАЖНО: нужно запустить с флагом:
// java -XX:-RestrictContended ...
}
}
Практический совет
False sharing особенно критичен в:
- Высоконагруженных счётчиках (metrics, monitoring)
- Lock-free структурах данных (AtomicLong, AtomicReference)
- Очередях и очередях сообщений
CPU-bound vs I/O-bound задачи
Правильный размер thread pool зависит от типа задач, которые выполняются. Это не универсальная формула, а два совершенно разных сценария.
CPU-bound задачи
CPU-bound — это задачи, которые тратят почти всё время на вычисления в CPU (без ожидания I/O).
Примеры:
- Математические расчёты, шифрование
- Обработка изображений, компрессия
- Поиск, сортировка больших данных
public class CpuBoundExample {
// Это CPU-bound: почти весь процессорный цикл уходит на вычисления
public long complexCalculation(long input) {
long result = input;
for (int i = 0; i < 10_000_000; i++) {
result = (result * 1103515245 + 12345) % (2L << 31);
}
return result;
}
public static void main(String[] args) {
// Для CPU-bound: количество потоков = число CPU ядер
int cpuBoundPoolSize = Runtime.getRuntime().availableProcessors();
System.out.println("Optimal pool size for CPU-bound: " + cpuBoundPoolSize);
// На 4-ядерном CPU: 4
// На 16-ядерном CPU: 16
ExecutorService executor = Executors.newFixedThreadPool(cpuBoundPoolSize);
// Если запустить с большим числом потоков:
// - Будет много context switches (очень дорого!)
// - Производительность упадёт на 20-30%
for (int i = 0; i < 1000; i++) {
executor.submit(() -> complexCalculation(i));
}
executor.shutdown();
}
}
Почему ровно столько, сколько ядер?
- У вас есть 4 ядра → максимум 4 потока могут одновременно выполняться
- 5-й поток будет либо ждать, либо вызывать context switch
- Context switch — дорогостоящая операция на CPU-bound задачах (очищение кэша!)
I/O-bound задачи
I/O-bound — это задачи, которые тратят большую часть времени в ожидании I/O операций (database, network, file system).
Примеры:
- HTTP запросы к внешним API
- Запросы к базе данных
- Чтение/запись файлов
public class IoBoundExample {
// Это I/O-bound: большую часть времени ждём response от DB
public Data queryDatabase(String query) {
try {
// Представьте, что это запрос в БД на 100ms
Thread.sleep(100); // Wait for I/O response
} catch (InterruptedException e) {}
return new Data();
}
// Возвращает I/O время / CPU время (время обработки)
public static double calculateRatio() {
// Пример:
// CPU time (обработка запроса): 10ms
// I/O time (ожидание ответа): 100ms
// Ratio = 100 / 10 = 10
return 100.0 / 10.0; // 10
}
public static void main(String[] args) {
int cores = Runtime.getRuntime().availableProcessors();
double ioRatio = calculateRatio();
// Формула для I/O-bound:
// poolSize = cores × (1 + ioRatio)
int poolSize = (int) (cores * (1 + ioRatio));
System.out.println("Cores: " + cores);
System.out.println("I/O ratio: " + ioRatio);
System.out.println("Optimal pool size for I/O-bound: " + poolSize);
// На 4-ядерном CPU с ratio=10: poolSize = 4 × 11 = 44 потока
ExecutorService executor = Executors.newFixedThreadPool(poolSize);
// С 44 потоками и соотношением 10:1:
// - Пока один поток ждёт I/O (100ms), 10 других выполняют CPU работу (10ms каждый)
// - Все 44 потока будут полезно использованы
for (int i = 0; i < 1000; i++) {
executor.submit(() -> queryDatabase("SELECT ..."));
}
executor.shutdown();
}
}
Почему формула cores × (1 + ratio)?
Во время I/O операции (100ms):
-
CPU не занят (поток блокирован)
-
Другие потоки могут выполняться на этом CPU ядре
-
Если ratio = 10 (100ms I/O / 10ms CPU), то на одном ядре можно запустить ~11 потоков:
- 1 поток ждёт I/O
- 10 потоков выполняют CPU работу
Измерение wait/compute ratio
Как узнать, CPU-bound ли ваша задача или I/O-bound? Нужно измерить wall time (реальное время) и CPU time (только время, когда CPU был занят).
Использование ThreadMXBean
public class WaitComputeRatioMeasurement {
public static void main(String[] args) throws InterruptedException {
ThreadMXBean threadBean = ManagementFactory.getThreadMXBean();
// Убедитесь, что CPU time measurement включён
if (!threadBean.isThreadCpuTimeSupported()) {
System.out.println("Thread CPU time not supported!");
return;
}
Thread testThread = new Thread(() -> {
performMixedWork();
});
// Запомним начальные значения
long threadId = testThread.getId();
long startWall = System.nanoTime();
long startCpu = threadBean.getThreadCpuTime(threadId);
testThread.start();
testThread.join();
// Вычислим израсходованное время
long wallTime = System.nanoTime() - startWall;
long cpuTime = threadBean.getThreadCpuTime(threadId) - startCpu;
long waitTime = wallTime - cpuTime;
System.out.println("Wall time (реальное): " + wallTime / 1_000_000 + "ms");
System.out.println("CPU time: " + cpuTime / 1_000_000 + "ms");
System.out.println("Wait time (I/O): " + waitTime / 1_000_000 + "ms");
double ratio = (double) waitTime / cpuTime;
System.out.println("Wait/Compute ratio: " + String.format("%.2f", ratio));
// Интерпретация:
// ratio ≈ 0.1 → CPU-bound (pool size = cores)
// ratio ≈ 1.0 → Mixed (pool size = cores × 2)
// ratio ≈ 10.0 → I/O-bound (pool size = cores × 11)
if (ratio < 0.5) {
int optimalSize = Runtime.getRuntime().availableProcessors();
System.out.println("CPU-bound. Recommended pool size: " + optimalSize);
} else if (ratio < 2) {
int optimalSize = (int) (Runtime.getRuntime().availableProcessors() * 2.5);
System.out.println("Mixed. Recommended pool size: " + optimalSize);
} else {
int cores = Runtime.getRuntime().availableProcessors();
int optimalSize = (int) (cores * (1 + ratio));
System.out.println("I/O-bound. Recommended pool size: " + optimalSize);
}
}
// Имитация смешанной работы: CPU + I/O
static void performMixedWork() {
// 10% CPU работа
for (int i = 0; i < 1_000_000; i++) {
long x = i * i;
}
// 90% I/O ожидание
try {
Thread.sleep(100); // Имитация I/O
} catch (InterruptedException e) {}
}
}
Практический подход: экспериментирование
public class ThreadPoolSizingExperiment {
public static void runBenchmark(int poolSize, int taskCount) throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(poolSize);
long start = System.currentTimeMillis();
List<Future<?>> futures = new ArrayList<>();
for (int i = 0; i < taskCount; i++) {
futures.add(executor.submit(() -> {
// Ваша реальная задача
performYourTask();
}));
}
for (Future<?> f : futures) {
f.get();
}
executor.shutdown();
long elapsed = System.currentTimeMillis() - start;
System.out.println("Pool size: " + poolSize + ", Time: " + elapsed + "ms");
}
public static void main(String[] args) throws InterruptedException {
int cores = Runtime.getRuntime().availableProcessors();
// Пробуем разные размеры пула
for (int factor = 1; factor <= 20; factor++) {
runBenchmark(cores * factor, 1000);
}
// Найдёте, при каком факторе время достигает минимума
// Это и будет оптимальный размер
}
static void performYourTask() {
// Your actual workload
}
}
Benchmarking с JMH
JMH (Java Microbenchmark Harness) — это фреймворк, разработанный командой OpenJDK для написания точных микробенчмарков. Он учитывает множество факторов, которые влияют на производительность:
- JIT компиляцию (warmup фаза)
- Garbage collection (контролирует GC паузы)
- Dead code elimination (Blackhole)
- CPU турбобуст (изоляция результатов)
Установка JMH
<!-- pom.xml -->
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>1.36</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.36</version>
<scope>provided</scope>
</dependency>
Пример бенчмарка: ConcurrentHashMap vs synchronized Map
import org.openjdk.jmh.annotations.*;
import java.util.*;
import java.util.concurrent.*;
@State(Scope.Benchmark)
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
@Fork(1)
public class ConcurrentMapBenchmark {
private ConcurrentHashMap<Integer, Integer> concurrentMap;
private Map<Integer, Integer> synchronizedMap;
@Setup
public void setup() {
concurrentMap = new ConcurrentHashMap<>();
synchronizedMap = Collections.synchronizedMap(new HashMap<>());
// Инициализируем обе карты с 10000 элементов
for (int i = 0; i < 10000; i++) {
concurrentMap.put(i, i);
synchronizedMap.put(i, i);
}
}
@Benchmark
@Threads(4)
public Integer concurrentMapGet(Blackhole bh) {
// Читаем значение на ключу 5000 (в середине карты)
Integer result = concurrentMap.get(5000);
bh.consume(result); // Предотвращаем JIT оптимизацию
return result;
}
@Benchmark
@Threads(4)
public Integer synchronizedMapGet(Blackhole bh) {
Integer result = synchronizedMap.get(5000);
bh.consume(result);
return result;
}
@Benchmark
@Threads(4)
public void concurrentMapPut() {
for (int i = 0; i < 100; i++) {
concurrentMap.put(i, i);
}
}
@Benchmark
@Threads(4)
public void synchronizedMapPut() {
for (int i = 0; i < 100; i++) {
synchronizedMap.put(i, i);
}
}
}
Запуск и интерпретация
# Сборка (генерирует JMH код)
mvn clean install
# Запуск бенчмарка
java -jar target/benchmarks.jar ConcurrentMapBenchmark
# Результаты могут выглядеть так:
# Benchmark Mode Cnt Score Error Units
# ConcurrentMapBenchmark.concurrentMapGet thrpt 5 50000.000 ± 1000.000 ops/ms
# ConcurrentMapBenchmark.synchronizedMapGet thrpt 5 10000.000 ± 500.000 ops/ms
# ConcurrentMapBenchmark.concurrentMapPut thrpt 5 30000.000 ± 800.000 ops/ms
# ConcurrentMapBenchmark.synchronizedMapPut thrpt 5 6000.000 ± 400.000 ops/ms
Интерпретация:
- ConcurrentHashMap на операциях чтения в 5 раз быстрее
- ConcurrentHashMap на операциях записи в 5 раз быстрее
- Synchronized Map использует глобальную блокировку на всю структуру
- ConcurrentHashMap использует segment-level locking (намного лучше на многопоточности)
Аннотации JMH
@State(Scope.Benchmark) // Состояние доступно для всех потоков
@State(Scope.Thread) // Каждому потоку своё состояние
@BenchmarkMode(Mode.Throughput) // Ops в единицу времени
@BenchmarkMode(Mode.AverageTime) // Среднее время операции
@BenchmarkMode(Mode.SampleTime) // Распределение времён (percentiles)
@BenchmarkMode(Mode.SingleShotTime) // Одиночное выполнение (без warmup)
@Warmup(iterations = 3, time = 1) // 3 итерации по 1 секунде для разогрева JIT
@Measurement(iterations = 5, time = 1) // 5 итераций для измерения
@Fork(1) // Запустить в отдельном процессе
@Threads(4) // Использовать 4 потока
@OutputTimeUnit(TimeUnit.MILLISECONDS) // Единица вывода
Profiling: нахождение hotspots и bottlenecks
После того как вы написали многопоточное приложение, нужно понять, где оно медленное. Profiling помогает найти проблемные методы и узкие места.
Инструмент 1: JVisualVM Sampler
JVisualVM — это встроенный инструмент Java для профилирования и мониторинга.
// Запуск:
// jvisualvm (в $JAVA_HOME/bin/jvisualvm)
// Подключение к приложению:
// 1. Запустите приложение (обычно автоматически видно в JVisualVM)
// 2. Перейдите на вкладку "Sampler"
// 3. Нажмите "CPU" для профилирования
// 4. Запустите нагрузку
// 5. Остановите sampling
// Интерпретация результатов:
// Self time: время, потраченное в самом методе
// Total time: время с учётом вызовов из этого метода
// Пример вывода:
// Method Self Time (%) Total Time (%)
// processData() 45% 50% ← hotspot в этом методе!
// synchronize(lock) 30% 30% ← lock contention
// database.query() 15% 25% ← I/O ожидание
Инструмент 2: JProfiler Call Tree
JProfiler — это коммерческий профайлер с расширенными возможностями.
// JProfiler Call Tree показывает:
// - Вызовы методов в виде дерева
// - Сколько раз вызывался каждый метод
// - Сколько времени потрачено
// - Сколько памяти выделено
// Drill down по интересующему методу:
// processData() {
// → readInput() [100 calls, 25ms]
// → transform() [100 calls, 450ms] ← медленный метод!
// → writeOutput() [100 calls, 15ms]
// }
// Можно увидеть точно, какой вложенный метод тормозит
Инструмент 3: async-profiler
async-profiler — это высокопроизводительный профайлер, работающий на низком уровне (не останавливает приложение).
# Установка: https://github.com/async-profiler/async-profiler
# Базовое использование:
# Найти PID вашего Java приложения:
jps -l
# Запустить профилирование (30 секунд):
./profiler.sh start -d 30 <pid>
# Результат в HTML:
./profiler.sh dump -f profile.html <pid>
# Профайлер покажет:
# - Flame graph (графическое представление стека вызовов)
# - Горячие методы (на вершине графика)
# - Цепи вызовов
Поиск lock contention (проблемы с блокировками)
public class LockContentionDetection {
static class Counter {
private long value = 0;
// ПЛОХО: слишком часто вызывается synchronized
public synchronized void increment() {
value++;
}
public synchronized long getValue() {
return value;
}
}
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
// Создаём несколько потоков, которые конкурируют за блокировку
for (int t = 0; t < 10; t++) {
new Thread(() -> {
for (int i = 0; i < 1_000_000; i++) {
counter.increment();
}
}).start();
}
// Запустите profiler:
// Вы увидите, что много времени уходит на ожидание блокировки!
// Метод increment() будет показан как hotspot
}
}
Диагностика проблем производительности
| Проблема | Признаки | Решение |
|---|---|---|
| Lock contention | Методы с synchronized показывают высокий self time |
Используйте ConcurrentHashMap, AtomicInteger, ReentrantReadWriteLock |
| False sharing | Случайные потери производительности на многоядерных системах | Добавьте padding или @Contended |
| Неправильный размер thread pool | Либо высокий latency, либо недоиспользование CPU | Используйте закон Литтла для расчёта |
| Excessive context switching | Слишком много потоков создано | Снизьте число потоков до рекомендуемого |
| Memory allocation pressure | Высокая GC активность | Снизьте создание объектов (pooling, reuse) |
| I/O bottleneck | Thread pool слишком маленький для I/O-bound задач | Увеличьте размер пула по формуле cores × (1 + ratio) |
Практические рекомендации
Чек-лист оптимизации
- Применен закон Амдала
- Размер thread pool рассчитан по закону Литтла
- Избежано false sharing
- Используется thread pool вместо создания потоков — никаких
- Проведено profiling
- Написаны бенчмарки на JMH
- Выбрана правильная структура данных —
ConcurrentHashMap, не
Мониторинг в production
public class PerformanceMonitoring {
static class ThreadPoolMetrics {
private final ThreadPoolExecutor executor;
public ThreadPoolMetrics(ThreadPoolExecutor executor) {
this.executor = executor;
}
public void printMetrics() {
System.out.println("Active threads: " + executor.getActiveCount());
System.out.println("Core pool size: " + executor.getCorePoolSize());
System.out.println("Max pool size: " + executor.getMaximumPoolSize());
System.out.println("Queue size: " + executor.getQueue().size());
System.out.println("Completed tasks: " + executor.getCompletedTaskCount());
System.out.println("Total tasks: " + executor.getTaskCount());
}
}
public static void main(String[] args) {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
10, 20, 60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100)
);
ThreadPoolMetrics metrics = new ThreadPoolMetrics(executor);
// Выводить метрики каждые 10 секунд
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.scheduleAtFixedRate(
metrics::printMetrics,
0, 10, TimeUnit.SECONDS
);
}
}
Заключение
Производительность многопоточных приложений — это наука, а не искусство. Используйте:
- Закон Амдала для понимания максимально возможного ускорения
- Закон Литтла для правильной настройки размера пула потоков
- Понимание overhead (создание потоков, context switch, false sharing)
- Правильные типы для разных нагрузок (CPU-bound vs I/O-bound)
- Profiling и benchmarking для поиска узких мест
- Мониторинг в production для отслеживания проблем
Практические примеры
Эта статья содержит детальные примеры решения классических проблем конкурентного программирования с полным разбором техник синхронизации, правил безопасности потоков и архитектурных паттернов.
Producer-Consumer с BlockingQueue
Концепция
Паттерн Producer-Consumer решает задачу асинхронной обработки данных между несколькими потребителями и производителями. BlockingQueue предоставляет потокобезопасный способ передачи данных, автоматически управляя синхронизацией и блокировкой потоков при переполнении или пустоте очереди.
Почему BlockingQueue
- Потокобезопасность: внутренне использует блокировки, не требует явного
synchronized - Обратное давление: если очередь полна,
put()блокируется до освобождения места - Элегантный API:
put(),take(),poll()скрывают сложность ожидания
Полная реализация с объяснениями
class ProducerConsumerExample {
// Очередь максимум на 100 элементов
private final BlockingQueue<Task> queue = new ArrayBlockingQueue<>(100);
// Volatile гарантирует видимость флага между потоками
private volatile boolean running = true;
class Producer implements Runnable {
@Override
public void run() {
int taskId = 0;
while (running) {
try {
Task task = new Task(taskId++);
// put() блокируется если очередь полна (более 100 элементов)
queue.put(task);
System.out.println("Produced: " + task.id);
Thread.sleep(100); // Имитация времени создания задачи
} catch (InterruptedException e) {
// Восстанавливаем флаг прерывания перед выходом
Thread.currentThread().interrupt();
break;
}
}
}
}
class Consumer implements Runnable {
@Override
public void run() {
// Продолжаем работу пока есть новые задачи или очередь не пуста
while (running || !queue.isEmpty()) {
try {
// poll() ждёт максимум 1 секунду, затем возвращает null
Task task = queue.poll(1, TimeUnit.SECONDS);
if (task != null) {
processTask(task);
System.out.println("Consumed: " + task.id);
}
// Если задач нет в течение секунды и running=false, выход
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}
private void processTask(Task task) throws InterruptedException {
// Имитация долгой обработки
Thread.sleep(200);
}
}
public void start() {
// 2 производителя создают задачи с частотой 1 каждые 100ms
for (int i = 0; i < 2; i++) {
new Thread(new Producer()).start();
}
// 5 потребителей обрабатывают задачи параллельно
// Это обеспечивает параллельность, так как 5 потребителей могут
// обрабатывать 5 задач одновременно
for (int i = 0; i < 5; i++) {
new Thread(new Consumer()).start();
}
}
public void stop() {
// Сигнал потокам на завершение
running = false;
}
static class Task {
final int id;
Task(int id) { this.id = id; }
}
}
Ключевые моменты
- volatile boolean: гарантирует, что изменение
runningодним потоком видно другим потокам немедленно - poll() с timeout: предотвращает бесконечное ожидание; потребитель выходит если очередь пуста 1 сек и
running=false - InterruptedException: всегда восстанавливайте флаг прерывания с
Thread.currentThread().interrupt(), иначе потребитель может зависнуть
Dining Philosophers Problem
Классическая проблема deadlock'а
Это классическая задача синхронизации, демонстрирующая, как неправильное использование ресурсов приводит к deadlock (взаимной блокировке). Пять философов сидят за круглым столом, каждый должен взять две вилки (слева и справа) для еды.
Проблема без решения
Если каждый философ захватит левую вилку и ждёт правой — происходит deadlock. Все философы заморозятся в ожидании друг друга.
Решение через упорядочение захвата ресурсов
class DiningPhilosophers {
private final int philosopherCount = 5;
private final Semaphore[] forks = new Semaphore[philosopherCount];
// Дополнительный семафор: максимум 4 философа одновременно за столом
// Это гарантирует, что по крайней мере один философ сможет взять обе вилки
private final Semaphore maxDiners = new Semaphore(philosopherCount - 1);
public DiningPhilosophers() {
// Каждая вилка может быть захвачена только одним философом
for (int i = 0; i < philosopherCount; i++) {
forks[i] = new Semaphore(1);
}
}
class Philosopher implements Runnable {
private final int id;
Philosopher(int id) { this.id = id; }
@Override
public void run() {
try {
while (true) {
think();
eat();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private void think() throws InterruptedException {
System.out.println("Philosopher " + id + " is thinking");
// Случайное время размышления
Thread.sleep((long) (Math.random() * 1000));
}
private void eat() throws InterruptedException {
// Первая защита: не более 4 философов одновременно
// Это гарантирует прогресс системы
maxDiners.acquire();
try {
int leftFork = id;
int rightFork = (id + 1) % philosopherCount;
// КРИТИЧЕСКИ ВАЖНО: захватываем вилки в упорядоченном порядке
// Всегда сначала меньший индекс, потом больший
// Это предотвращает циклический deadlock
if (leftFork < rightFork) {
forks[leftFork].acquire();
forks[rightFork].acquire();
} else {
forks[rightFork].acquire();
forks[leftFork].acquire();
}
try {
System.out.println("Philosopher " + id + " is eating");
Thread.sleep((long) (Math.random() * 1000));
} finally {
// Гарантированный выход: вилки освобождаются даже при исключении
forks[leftFork].release();
forks[rightFork].release();
}
} finally {
// Освобождаем место за столом для следующего философа
maxDiners.release();
}
}
}
public void start() {
for (int i = 0; i < philosopherCount; i++) {
new Thread(new Philosopher(i)).start();
}
}
}
Почему это работает
-
Упорядочение вилок: захватываем в порядке возрастания индексов (0, 1, 2, 3, 4). Это разрывает циклическую зависимость, которая вызывает deadlock.
-
Ограничение философов:
maxDiners.acquire()позволяет только 4 из 5 философов одновременно попытаться взять вилки. Это гарантирует, что хотя бы один сможет захватить обе вилки и продолжить работу. -
Try-finally: даже если произойдёт исключение, вилки и место будут освобождены.
Thread-safe Singleton
Различные реализации с трейд-офами
Singleton должен быть доступен из нескольких потоков без race conditions. Вот 4 основных подхода:
1. Eager Initialization
class EagerSingleton {
// Создаётся при загрузке класса — гарантированно thread-safe
private static final EagerSingleton INSTANCE = new EagerSingleton();
private EagerSingleton() {}
public static EagerSingleton getInstance() {
return INSTANCE;
}
}
Плюсы: Простой, безопасный, нет overhead'а при доступе. Минусы: Инстанс создаётся всегда, даже если никогда не используется.
2. Lazy Initialization с Double-Checked Locking
class LazySingleton {
// volatile гарантирует, что другие потоки видят полностью инициализированный объект
private static volatile LazySingleton instance;
private LazySingleton() {}
public static LazySingleton getInstance() {
// Первая проверка без блокировки (быстро)
if (instance == null) {
synchronized (LazySingleton.class) {
// Вторая проверка внутри синхронизированного блока
// Нужна, так как между первой проверкой и захватом lock'а
// другой поток мог создать инстанс
if (instance == null) {
instance = new LazySingleton();
}
}
}
return instance;
}
}
Плюсы: Ленивая инициализация, после первого вызова практически нет overhead'а.
Минусы: Сложнее, требует понимания volatile и памяти. На некоторых CPU может быть slower.
3. Initialization-on-Demand Holder (Рекомендуется)
class HolderSingleton {
private HolderSingleton() {}
// Внутренний класс загружается только при первом обращении
private static class Holder {
// Инициализируется при загрузке класса Holder
static final HolderSingleton INSTANCE = new HolderSingleton();
}
public static HolderSingleton getInstance() {
// JVM гарантирует потокобезопасность при загрузке класса
// Это определено в JLS (Java Language Specification)
return Holder.INSTANCE;
}
}
Плюсы: Потокобезопасен по умолчанию, ленивая инициализация, просто и надёжно. Минусы: Требует понимания класслоадера.
Это лучший выбор для большинства случаев.
4. Enum Singleton (Самый безопасный)
public enum EnumSingleton {
INSTANCE;
public void doSomething() {
// ...
}
}
// Использование
EnumSingleton singleton = EnumSingleton.INSTANCE;
Плюсы: Защищен от рефлексии, сериализации, потокобезопасен. Наиболее простой синтаксис. Минусы: Нельзя наследовать от другого класса (только от Enum).
Это лучший выбор если Singleton может быть Enum.
Concurrent Cache Implementation
Задача
Кеш, который:
- Автоматически загружает значения при их отсутствии
- Истекает через TTL (time-to-live)
- Потокобезопасен без явных блокировок
Ключевые техники
class ConcurrentCache<K, V> {
private final ConcurrentHashMap<K, CacheEntry<V>> cache = new ConcurrentHashMap<>();
private final long ttlMillis;
public ConcurrentCache(long ttlMillis) {
this.ttlMillis = ttlMillis;
}
public V get(K key, Function<K, V> loader) {
CacheEntry<V> entry = cache.get(key);
// Быстрый путь: если кеш содержит невыроченный элемент
if (entry != null && !entry.isExpired()) {
return entry.value;
}
// Медленный путь: загружаем или обновляем значение
// computeIfAbsent() является АТОМАРНОЙ операцией:
// если ключ отсутствует, lambda вычисляется и результат вставляется
// Если ключ уже вставлен другим потоком между get() и computeIfAbsent(),
// его значение будет использовано вместо нашего
entry = cache.computeIfAbsent(key, k -> {
V value = loader.apply(k); // Может быть долгой операцией
return new CacheEntry<>(value, System.currentTimeMillis() + ttlMillis);
});
// После computeIfAbsent нужна повторная проверка на истечение
// Причина: между инвалидацией и computeIfAbsent другой поток мог
// вставить старое выроченное значение
if (entry.isExpired()) {
cache.remove(key);
// Нельзя просто вызвать loader.apply(), так как может быть race
// Вместо этого рекурсивно вызываем get() для переработки computeIfAbsent
return loader.apply(key);
}
return entry.value;
}
public void put(K key, V value) {
cache.put(key, new CacheEntry<>(value, System.currentTimeMillis() + ttlMillis));
}
// Периодически удаляем выроченные элементы
public void evictExpired() {
cache.entrySet().removeIf(entry -> entry.getValue().isExpired());
}
static class CacheEntry<V> {
final V value;
final long expiryTime;
CacheEntry(V value, long expiryTime) {
this.value = value;
this.expiryTime = expiryTime;
}
boolean isExpired() {
return System.currentTimeMillis() > expiryTime;
}
}
}
// Использование
ConcurrentCache<String, User> userCache = new ConcurrentCache<>(60000); // 60 сек TTL
User user = userCache.get("user123", userId -> {
// Вызывается если ключ отсутствует или выроченный
return userRepository.findById(userId);
});
Почему ConcurrentHashMap вместо synchronized HashMap
- ConcurrentHashMap использует bucket-level locking (каждый bucket имеет свой lock), а не table-level
- Несколько потоков могут одновременно работать с разными bucket'ами
- Более высокий throughput на многоядерных системах
Rate Limiter
Задача
Ограничить количество запросов до N в секунду, используя паттерн Token Bucket.
Алгоритм Token Bucket
- Начинаем с
capacityтокенов - Каждую секунду добавляем
refillRateтокенов (максимум доcapacity) - Для каждого запроса нужен 1 токен
- Если токенов нет — отклоняем запрос
Реализация
class TokenBucketRateLimiter {
private final long capacity;
private final long refillRate; // токенов в секунду
// AtomicLong для потокобезопасного доступа без блокировок
private final AtomicLong tokens;
private final AtomicLong lastRefillTimestamp;
public TokenBucketRateLimiter(long capacity, long refillRate) {
this.capacity = capacity;
this.refillRate = refillRate;
this.tokens = new AtomicLong(capacity);
this.lastRefillTimestamp = new AtomicLong(System.nanoTime());
}
public boolean tryAcquire() {
// Сначала пополняем токены на основе прошедшего времени
refill();
// Атомарная попытка уменьшить токены
// CAS (Compare-And-Swap) выполняется в цикле до успеха
while (true) {
long currentTokens = tokens.get();
if (currentTokens > 0) {
// Пытаемся уменьшить на 1
if (tokens.compareAndSet(currentTokens, currentTokens - 1)) {
return true;
}
// Если compareAndSet не сработал, цикл повторяется
} else {
return false;
}
}
}
private void refill() {
long now = System.nanoTime();
long lastRefill = lastRefillTimestamp.get();
long elapsedNanos = now - lastRefill;
// Вычисляем сколько токенов добавить: (нанос / наносек_в_сек) * refillRate
long tokensToAdd = (elapsedNanos * refillRate) / 1_000_000_000L;
if (tokensToAdd > 0) {
// CAS для обновления timestamps
if (lastRefillTimestamp.compareAndSet(lastRefill, now)) {
// Если успешно обновили время, добавляем токены
while (true) {
long currentTokens = tokens.get();
// Не превышаем capacity
long newTokens = Math.min(capacity, currentTokens + tokensToAdd);
if (tokens.compareAndSet(currentTokens, newTokens)) {
break;
}
}
}
}
}
}
// Использование
TokenBucketRateLimiter limiter = new TokenBucketRateLimiter(100, 10); // 100 макс, 10/сек
if (limiter.tryAcquire()) {
processRequest();
} else {
// Слишком много запросов
rejectRequest();
}
CAS вместо lock'ов
CAS (Compare-And-Swap) — это атомарная операция на уровне CPU. Вместо блокировки потока мы циклически пытаемся обновить значение. Это более эффективно при низкой контенции (когда мало конфликтов между потоками).
Batch Processing с ExecutorService
Задача
Обработать большой список элементов параллельно, разбивая на batch'и для оптимальной работы потоков.
Реализация
class BatchProcessor<T, R> {
private final ExecutorService executor;
private final int batchSize;
private final Function<List<T>, List<R>> processor;
public BatchProcessor(int threadCount, int batchSize, Function<List<T>, List<R>> processor) {
// FixedThreadPool создаёт пул из threadCount потоков
// Если больше threadCount задач, они ждут в очереди
this.executor = Executors.newFixedThreadPool(threadCount);
this.batchSize = batchSize;
this.processor = processor;
}
public List<R> process(List<T> items) throws ExecutionException, InterruptedException {
// Разбиваем на batch'и
List<List<T>> batches = partition(items, batchSize);
List<Future<List<R>>> futures = new ArrayList<>();
// Отправляем каждый batch в executor
for (List<T> batch : batches) {
// executor.submit() возвращает Future — обещание результата
Future<List<R>> future = executor.submit(() -> processor.apply(batch));
futures.add(future);
}
// Собираем результаты в порядке выполнения
List<R> results = new ArrayList<>();
for (Future<List<R>> future : futures) {
// get() блокируется до завершения задачи или исключения
results.addAll(future.get());
}
return results;
}
private List<List<T>> partition(List<T> list, int size) {
List<List<T>> partitions = new ArrayList<>();
for (int i = 0; i < list.size(); i += size) {
// subList() не копирует, а возвращает view — памяти экономнее
partitions.add(list.subList(i, Math.min(i + size, list.size())));
}
return partitions;
}
public void shutdown() {
executor.shutdown();
}
}
// Использование
BatchProcessor<Integer, Integer> processor = new BatchProcessor<>(
4, // 4 потока
100, // batch size 100
batch -> batch.stream().map(x -> x * 2).collect(Collectors.toList())
);
List<Integer> input = IntStream.range(0, 10000).boxed().collect(Collectors.toList());
List<Integer> results = processor.process(input);
processor.shutdown();
Почему batch'и
- Без batch'ей: для 10000 элементов создаём 10000 задач. Overhead на создание, управление, переключение контекста велик.
- С batch'ями: 10000 элементов / 100 = 100 batch'ей. 4 потока обрабатывают их параллельно. Гораздо эффективнее.
Spring @Async для email отправки
Задача
Отправлять emails асинхронно без блокировки HTTP response.
Реализация
@Service
public class EmailService {
private final JavaMailSender mailSender;
@Autowired
public EmailService(JavaMailSender mailSender) {
this.mailSender = mailSender;
}
// @Async отправляет метод в отдельный поток
@Async
public CompletableFuture<Void> sendEmailAsync(String to, String subject, String body) {
try {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true);
helper.setTo(to);
helper.setSubject(subject);
helper.setText(body, true);
// Это может занять 1-5 секунд (network I/O к SMTP серверу)
// Но это происходит в фоновом потоке, не блокируя REST controller
mailSender.send(message);
return CompletableFuture.completedFuture(null);
} catch (MessagingException e) {
return CompletableFuture.failedFuture(e);
}
}
@Async
public void sendBulkEmails(List<EmailRequest> requests) {
for (EmailRequest request : requests) {
// Отправляем каждый email асинхронно
sendEmailAsync(request.getTo(), request.getSubject(), request.getBody())
.exceptionally(ex -> {
// Если email не отправился, логируем и продолжаем
log.error("Failed to send email to {}: {}", request.getTo(), ex.getMessage());
return null;
});
}
}
}
// Конфигурация для активации @Async
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public Executor asyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5); // 5 потоков всегда активных
executor.setMaxPoolSize(10); // Максимум 10 потоков
executor.setQueueCapacity(100); // Очередь из 100 задач
executor.setThreadNamePrefix("async-email-");
executor.initialize();
return executor;
}
}
// Использование в controller
@Autowired
private EmailService emailService;
@PostMapping("/notify-users")
public ResponseEntity<String> notifyUsers(@RequestBody List<User> users) {
List<EmailRequest> requests = users.stream()
.map(user -> new EmailRequest(user.getEmail(), "Notification", "Hello " + user.getName()))
.collect(Collectors.toList());
// Отправляем emails в фоне, сразу возвращаем ответ
emailService.sendBulkEmails(requests);
return ResponseEntity.ok("Emails queued for sending");
}
Как работает @Async
- Spring обёртывает метод с
@Asyncв proxy - При вызове proxy отправляет выполнение в
Executor(пул потоков) - Метод, который вызвал
sendBulkEmails(), продолжает работу немедленно - Emails отправляются в фоновых потоках из executor'а
Обработка платежей с @Transactional
Задача
Обработать платёж в асинхронном потоке с гарантией транзакционности. Это критично: нельзя допустить, чтобы платёж был записан в БД, а далее платёжный gateway вернул ошибку.
Реализация
@Service
public class PaymentService {
@Autowired
private PaymentRepository paymentRepository;
@Autowired
private TransactionTemplate transactionTemplate;
@Autowired
private Executor paymentExecutor;
public CompletableFuture<PaymentResult> processPaymentAsync(PaymentRequest request) {
return CompletableFuture.supplyAsync(() -> {
// TransactionTemplate вручную управляет транзакциями
// Нужно, так как @Transactional на @Async методах имеет подводные камни
return transactionTemplate.execute(status -> {
try {
// 1. Валидация
validatePayment(request);
// 2. Создаём запись о платеже в БД со статусом PENDING
Payment payment = new Payment();
payment.setAmount(request.getAmount());
payment.setStatus(PaymentStatus.PENDING);
payment = paymentRepository.save(payment); // INSERT
// 3. Вызываем платёжный gateway (может быть долгим и может упасть)
GatewayResponse response = callPaymentGateway(request);
// 4. Обновляем статус платежа
payment.setStatus(response.isSuccess() ? PaymentStatus.COMPLETED : PaymentStatus.FAILED);
payment.setTransactionId(response.getTransactionId());
paymentRepository.save(payment); // UPDATE
return new PaymentResult(payment.getId(), payment.getStatus());
} catch (Exception e) {
// Откатываем всю транзакцию, включая INSERT и UPDATE
// Платёж останется несохранённым
status.setRollbackOnly();
throw new PaymentException("Payment failed", e);
}
});
}, paymentExecutor);
}
private void validatePayment(PaymentRequest request) {
if (request.getAmount() <= 0) {
throw new IllegalArgumentException("Amount must be positive");
}
}
private GatewayResponse callPaymentGateway(PaymentRequest request) {
// REST call к платёжной системе
// Если упадёт — исключение пройдёт до catch блока в execute()
return new GatewayResponse(true, "TXN123");
}
}
// Конфигурация Executor'а
@Configuration
public class AsyncConfig {
@Bean(name = "paymentExecutor")
public Executor paymentExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10); // Обработка платежей параллельно
executor.setMaxPoolSize(20); // Масштабируемость при пиках
executor.setQueueCapacity(100); // Буфер на очереди
executor.setThreadNamePrefix("payment-");
executor.setWaitForTasksToCompleteOnShutdown(true); // КРИТИЧНО: ждём завершения
executor.setAwaitTerminationSeconds(60); // Максимум 60 сек ожидания
executor.initialize();
return executor;
}
@Bean
public TransactionTemplate transactionTemplate(PlatformTransactionManager transactionManager) {
return new TransactionTemplate(transactionManager);
}
}
Ключевые моменты
-
TransactionTemplate вместо @Transactional: На
@Asyncметодах@Transactionalне работает как ожидается, так как proxy создаётся до того, как Spring узнает о@Async. -
Откат всей операции: Если платёжный gateway упадёт, исключение перейдёт в catch блок
execute(), где мы вызовемsetRollbackOnly(). Это откатит INSERT платежа. -
Graceful shutdown:
setWaitForTasksToCompleteOnShutdown(true)гарантирует, что при завершении приложения не потеряются обрабатываемые платежи.
Сравнение техник синхронизации
| Техника | Использование | Плюсы | Минусы |
|---|---|---|---|
synchronized |
Критические секции | Простой, понятный | Может быть bottleneck, нет timeout'ов |
ReentrantLock |
Когда нужны timeout'ы, условия | Больше контроля, tryLock() |
Нужно руками unlock'овать |
ConcurrentHashMap |
Потокобезопасные map'ы | Высокий throughput | Итерация не атомарна |
AtomicLong/Integer |
Счётчики, флаги | Без блокировок, CAS | Только для примитивов |
BlockingQueue |
Producer-consumer | Элегантный, управление обратным давлением | Может быть медленнее при high throughput |
Semaphore |
Ограничение доступа | Гибко, поддерживает многие разрешения | Может привести к deadlock'у |
Выводы
- Выбирайте правильный инструмент: не всегда нужен самый мощный lock, часто хватает
volatileилиConcurrentHashMap. - Избегайте deadlock'а: упорядочивайте захват ресурсов, ограничивайте одновременный доступ.
- Используйте higher-level abstractions:
ExecutorService,CompletableFuture, Spring@Asyncскрывают сложность thread management'а. - Тестируйте concurrent код: race conditions тяжело воспроизвести, используйте
Thread.sleep(), load testing'и, tools как JCStress. - Думайте о масштабировании: при большом количестве потоков overhead на context switching'и может быть больше, чем выигрыш от параллелизма.