Многопоточность в Java — это не просто модная функция, а фундаментальный подход к созданию отзывчивых и эффективных приложений. Она позволяет вашей программе выполнять несколько задач одновременно, максимально используя возможности современных многоядерных процессоров. Но за этой мощью скрывается сложность: гонки данных, взаимные блокировки и тонкие ошибки, которые трудно воспроизвести. В этой статье мы разберем многопоточность на практике, от классических примеров до современных подходов.
Что такое поток и зачем он нужен?
Поток (Thread) в Java — это наименьшая единица выполнения. Каждая Java-программа запускается как минимум с одним потоком — main. Создавая дополнительные потоки, мы можем, например, загружать данные из сети, не блокируя при этом пользовательский интерфейс, или обрабатывать несколько запросов к серверу параллельно.
Важно понимать разницу между многопоточностью и многозадачностью. Многопоточность — это параллельное выполнение задач в рамках одного процесса, с общим адресным пространством. Это делает обмен данными между потоками быстрым, но и опасным.
Способы создания потоков
В Java есть два основных способа создать поток.
1. Наследование от класса Thread
Самый простой, но менее гибкий способ. Вы создаете свой класс, который наследуется от Thread, и переопределяете метод run().
class MyThread extends Thread {
public void run() {
System.out.println(\"Поток запущен: \" + Thread.currentThread().getName());
}
}
// Запуск:
MyThread thread = new MyThread();
thread.start(); // Важно: запускать start(), а не run()!
2. Реализация интерфейса Runnable
Более предпочтительный и гибкий подход. Вы реализуете интерфейс Runnable с единственным методом run() и передаете его экземпляр в конструктор Thread.
class MyRunnable implements Runnable {
public void run() {
System.out.println(\"Задача выполняется в потоке: \" + Thread.currentThread().getName());
}
}
// Запуск:
Thread thread = new Thread(new MyRunnable());
thread.start();
Использование Runnable предпочтительнее, так как это позволяет отделить задачу от механизма выполнения. Ваш класс может также наследоваться от другого класса, что невозможно при наследовании от Thread.
Практический пример: параллельная обработка коллекции
Допустим, у нас есть список чисел, и мы хотим вычислить их квадраты параллельно.
import java.util.concurrent.*;
import java.util.*;
public class ParallelProcessingExample {
public static void main(String[] args) throws InterruptedException, ExecutionException {
List numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
ExecutorService executor = Executors.newFixedThreadPool(4); // Пул из 4 потоков
List> futures = new ArrayList<>();
for (Integer num : numbers) {
Callable task = () -> {
Thread.sleep(100); // Имитация сложной операции
return num * num;
};
futures.add(executor.submit(task));
}
executor.shutdown(); // Больше не принимаем задачи
executor.awaitTermination(1, TimeUnit.MINUTES); // Ждем завершения
for (Future future : futures) {
System.out.println(\"Результат: \" + future.get());
}
}
}
Здесь мы используем ExecutorService и пул потоков — современный и эффективный способ управления потоками. Мы не создаем потоки вручную, а отправляем задачи (Callable) в пул.
Синхронизация и безопасность
Когда потоки работают с общими данными, возникает проблема состояния гонки (race condition). Для ее решения используется синхронизация.
Ключевое слово synchronized
class Counter {
private int count = 0;
public synchronized void increment() { // Только один поток может выполнить этот метод
count++; // для данного экземпляра Counter одновременно
}
public int getCount() {
return count;
}
}
Использование атомарных классов
Более современная альтернатива — классы из пакета java.util.concurrent.atomic, например, AtomicInteger.
import java.util.concurrent.atomic.AtomicInteger;
class SafeCounter {
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // Атомарная операция
}
public int getCount() {
return count.get();
}
}
Современные инструменты: CompletableFuture
В Java 8 появился мощный инструмент CompletableFuture для асинхронного программирования.
CompletableFuture.supplyAsync(() -> {
// Долгая задача, например, запрос к API
return fetchDataFromRemoteService();
})
.thenApply(data -> processData(data)) // Обрабатываем результат асинхронно
.exceptionally(ex -> { // Обрабатываем ошибки
System.err.println(\"Ошибка: \" + ex.getMessage());
return getDefaultData();
})
.thenAccept(result -> System.out.println(\"Итог: \" + result));
FAQ: Часто задаваемые вопросы
В чем разница между start() и run()?
Метод start() создает новый поток и вызывает в нем метод run(). Вызов run() напрямую выполнит код в текущем потоке, не создавая новый.
Что такое deadlock (взаимная блокировка)?
Это ситуация, когда два или более потока бесконечно ждут друг друга, освободив заблокированные ресурсы. Например, Поток 1 заблокировал Ресурс A и ждет Ресурс B, а Поток 2 заблокировал Ресурс B и ждет Ресурс A.
Что лучше: synchronized или Lock из java.util.concurrent.locks?
synchronized проще в использовании, но менее гибок. ReentrantLock предоставляет больше возможностей: попытка захвата блокировки с таймаутом, честная очередь и т.д. Используйте synchronized для простых случаев, Lock — для сложной логики.
Зачем нужен пул потоков (ThreadPool)?
Создание потока — дорогая операция. Пул потоков создает заранее заданное количество потоков и переиспользует их для выполнения множества задач. Это экономит ресурсы и позволяет контролировать нагрузку на систему.
Что такое volatile переменная?
Ключевое слово volatile гарантирует, что чтение и запись переменной будут атомарными, а ее значение всегда будет читаться из основной памяти, а не из кэша потока. Это решает проблему видимости изменений между потоками, но не решает проблему атомарности составных операций (например, инкремента).