Многопоточность в Java — это не просто модное слово из собеседований, а мощный инструмент, который позволяет вашим программам выполнять несколько задач одновременно, эффективно используя ресурсы процессора. Представьте себе веб-сервер, обрабатывающий тысячи запросов, или приложение, которое загружает данные и при этом остаётся отзывчивым. Всё это становится возможным благодаря грамотной работе с потоками. Но эта сила требует понимания: без чётких правил вы столкнётесь с гонками данных, взаимными блокировками и непредсказуемым поведением. Давайте разберёмся, как заставить потоки работать на вас, а не против вас.
Что такое поток (Thread) и зачем он нужен?
Поток — это наименьшая единица выполнения в Java. Каждая Java-программа запускается в главном потоке (main thread). Создавая дополнительные потоки, мы можем выполнять код параллельно или псевдопараллельно (если ядер процессора меньше, чем потоков). Основные причины использования:
- Повышение производительности на многоядерных процессорах.
- Отзывчивость GUI: чтобы интерфейс не "зависал" при выполнении долгих операций.
- Асинхронные задачи: обработка сетевых запросов, чтение файлов.
- Моделирование параллельных процессов.
Важно! Параллелизм (concurrency) и многопоточность (multithreading) — не синонимы. Параллелизм — это более общая концепция выполнения нескольких задач за определённый период времени, в то время как многопоточность — конкретный способ достижения параллелизма с помощью потоков.
Способы создания потоков в Java
В Java есть два основных способа создать и запустить поток.
1. Наследование от класса Thread
Самый простой, но менее гибкий способ, так как Java не поддерживает множественное наследование.
class MyThread extends Thread {
@Override
public void run() {
System.out.println(\"Поток работает: \" + Thread.currentThread().getName());
}
}
// Запуск
public class Main {
public static void main(String[] args) {
MyThread thread = new MyThread();
thread.start(); // Не вызывайте run() напрямую!
}
}
2. Реализация интерфейса Runnable
Более предпочтительный и гибкий подход. Позволяет разделить задачу (Runnable) и механизм выполнения (Thread).
class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println(\"Задача выполняется в потоке: \" + Thread.currentThread().getName());
}
}
public class Main {
public static void main(String[] args) {
Thread thread = new Thread(new MyRunnable());
thread.start();
// Или с лямбдой (Java 8+)
Thread lambdaThread = new Thread(() -> {
System.out.println(\"Лямбда-поток!\");
});
lambdaThread.start();
}
}
Жизненный цикл потока
Поток в Java проходит через несколько состояний:
- NEW: создан, но ещё не запущен (start() не вызван).
- RUNNABLE: выполняется или готов к выполнению.
- BLOCKED/WAITING/TIMED_WAITING: ожидает ресурс, уведомление или таймаут.
- TERMINATED: завершил выполнение.
Синхронизация и проблемы параллелизма
Когда несколько потоков обращаются к общим данным, возникает состояние гонки (race condition). Для защиты используется синхронизация.
Ключевое слово synchronized
class Counter {
private int count = 0;
// Синхронизированный метод
public synchronized void increment() {
count++; // Атомарная операция
}
public int getCount() { return count; }
}
// Или синхронизированный блок
public void safeMethod() {
synchronized(this) {
// Критическая секция
}
}
Используйте синхронизацию минимально, только для критических секций. Избыточная синхронизация убивает производительность и может привести к взаимным блокировкам (deadlock).
Практический пример: параллельная обработка списка
Допустим, нам нужно обработать большой список чисел. Последовательно это займёт много времени. Распараллелим задачу!
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
public class ParallelProcessor {
public static void main(String[] args) throws InterruptedException, ExecutionException {
List numbers = new ArrayList<>();
for (int i = 1; i <= 1000; i++) numbers.add(i);
int threadCount = 4;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
List> futures = new ArrayList<>();
int chunkSize = numbers.size() / threadCount;
for (int i = 0; i < threadCount; i++) {
int start = i * chunkSize;
int end = (i == threadCount - 1) ? numbers.size() : start + chunkSize;
List subList = numbers.subList(start, end);
Callable task = () -> {
long sum = 0;
for (int num : subList) {
sum += num;
// Имитация сложной обработки
Thread.sleep(1);
}
return sum;
};
futures.add(executor.submit(task));
}
long totalSum = 0;
for (Future future : futures) {
totalSum += future.get(); // get() блокирует, пока результат не готов
}
executor.shutdown(); // Важно: завершаем работу пула!
System.out.println(\"Общая сумма: \" + totalSum);
}
}
В этом примере мы используем ExecutorService и Future — современные и безопасные инструменты из пакета java.util.concurrent, которые предпочтительнее "ручного" управления потоками.
Коллекции для многопоточных сред
Обычные ArrayList и HashMap не потокобезопасны. Используйте:
- CopyOnWriteArrayList — для часто читаемых, редко изменяемых списков.
- ConcurrentHashMap — высокопроизводительная потокобезопасная карта.
- BlockingQueue (например, ArrayBlockingQueue) — для паттерна "производитель-потребитель".
FAQ: Часто задаваемые вопросы о многопоточности в Java
В чём разница между start() и run()?
Вызов start() создаёт новый поток и внутри него вызывает метод run(). Прямой вызов run() выполнит код в том же потоке, без создания нового, что лишает смысла использование Thread.
Что такое deadlock и как его избежать?
Взаимная блокировка (deadlock) возникает, когда два или более потока бесконечно ждут друг друга, удерживая нужные друг другу ресурсы. Для предотвращения:
- Упорядочивайте захват блокировок (всегда в одном порядке).
- Используйте tryLock() с таймаутом из пакета java.util.concurrent.locks.
- Избегайте вложенной синхронизации.
Что лучше: synchronized или Lock из java.util.concurrent.locks?
synchronized проще, но менее гибок. ReentrantLock (реализация Lock) предлагает:
- Возможность попытаться захватить блокировку (tryLock()).
- Честное распределение блокировок.
- Возможность привязать несколько Condition к одному Lock.
Как правильно остановить поток?
Не используйте устаревшие методы stop() или suspend(). Корректный способ — реализовать флаг завершения:
class StoppableTask implements Runnable {
private volatile boolean running = true;
public void stop() { running = false; }
@Override
public void run() {
while (running) {
// Работа...
}
// Корректное завершение
}
}
Ключевое слово volatile гарантирует видимость изменения флага между потоками.
Что такое пул потоков (ThreadPool) и зачем он нужен?
Создание потока — дорогая операция. Пул потоков (например, ExecutorService) заранее создаёт и управляет набором потоков, переиспользуя их для выполнения множества задач. Это повышает производительность и упрощает управление ресурсами.