Многопоточность в Java на практике: от основ до реальных примеров

Многопоточность в Java на практике: от основ до реальных примеров

Многопоточность в Java — это мощный инструмент, позволяющий вашим программам выполнять несколько задач одновременно, подобно шеф-повару, который одновременно варит суп, жарит стейк и нарезает салат. Освоив этот механизм, вы сможете создавать отзывчивые, быстрые и эффективные приложения, которые максимально используют возможности современных многоядерных процессоров. В этой статье мы не только разберем теорию, но и погрузимся в живые, рабочие примеры, которые вы сможете сразу применить в своих проектах.

Что такое поток и зачем он нужен?

Поток (Thread) — это наименьшая единица выполнения внутри процесса. Одна Java-программа (процесс) может запускать множество потоков, которые выполняются параллельно или псевдопараллельно, разделяя ресурсы этого процесса. Представьте банк с одним кассиром (однопоточность) и банк с несколькими кассирами (многопоточность). Во втором случае клиенты обслуживаются быстрее, а система в целом — эффективнее.

Ключевая идея: Многопоточность не всегда делает программу быстрее для одной задачи. Её сила — в отзывчивости (интерфейс не "зависает" при долгой операции) и в параллельной обработке множества независимых задач (например, запросов к серверу).

Способы создания потоков в Java

В Java есть два основных способа создать и запустить поток.

1. Наследование от класса Thread

Это классический, но менее гибкий способ. Вы создаете свой класс, который расширяет Thread, и переопределяете метод run().

public class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("Поток работает: " + Thread.currentThread().getName());
    }
    public static void main(String[] args) {
        MyThread thread = new MyThread();
        thread.start(); // Важно: запускаем через start(), а не run()!
    }
}

2. Реализация интерфейса Runnable

Более предпочтительный и гибкий подход, так как ваш класс может наследовать что-то ещё. Вы реализуете единственный метод run().

public class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("Задача выполняется в потоке: " + Thread.currentThread().getName());
    }
    public static void main(String[] args) {
        Thread thread = new Thread(new MyRunnable());
        thread.start();
        // Или компактно с лямбдой (Java 8+):
        new Thread(() -> System.out.println("Лямбда-поток!")).start();
    }
}

Практические примеры многопоточности

Пример 1: Параллельная обработка элементов списка

Допустим, нам нужно применить тяжелую операцию (например, сложное вычисление или обращение к API) к каждому элементу большого списка.

import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class ParallelListProcessing {
    public static void main(String[] args) throws InterruptedException {
        List data = List.of("A", "B", "C", "D", "E");
        ExecutorService executor = Executors.newFixedThreadPool(3); // Пул из 3 потоков

        for (String item : data) {
            executor.submit(() -> {
                // Имитация долгой операции
                try { Thread.sleep(1000); } catch (InterruptedException e) {}
                System.out.println("Обработано: " + item + " в " + Thread.currentThread().getName());
            });
        }

        executor.shutdown(); // Прекращаем прием новых задач
        executor.awaitTermination(1, TimeUnit.MINUTES); // Ждем завершения всех задач
        System.out.println("Все задачи завершены!");
    }
}

Используйте пулы потоков (ExecutorService)! Создавать новый Thread для каждой задачи — дорого и неэффективно. Пул потоков переиспользует существующие потоки, управляя их жизненным циклом. Это лучшая практика в промышленной разработке.

Пример 2: Синхронизация и блокировки

Когда несколько потоков работают с общими данными, возникает состояние гонки (race condition). Для защиты используйте ключевое слово synchronized или объекты из пакета java.util.concurrent.locks.

public class SynchronizedCounter {
    private int count = 0;

    // Синхронизированный метод: только один поток может выполнять его за раз
    public synchronized void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }

    public static void main(String[] args) throws InterruptedException {
        SynchronizedCounter counter = new SynchronizedCounter();
        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                counter.increment();
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);
        t1.start();
        t2.start();
        t1.join(); // Ждем завершения t1
        t2.join(); // Ждем завершения t2

        System.out.println("Итоговый счетчик: " + counter.getCount()); // Всегда 2000
    }
}

Пример 3: Асинхронные вычисления с Future и CompletableFuture

Современный API (Java 8+) для работы с асинхронными операциями, возвращающими результат.

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;

public class AsyncExample {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        // Запускаем долгую задачу асинхронно
        CompletableFuture future = CompletableFuture.supplyAsync(() -> {
            try { Thread.sleep(2000); } catch (InterruptedException e) {}
            return "Результат из потока";
        });

        System.out.println("Делаем что-то ещё в основном потоке...");

        // Блокируемся и получаем результат (или обрабатываем его callback'ами)
        String result = future.get();
        System.out.println("Получено: " + result);
    }
}

Типичные проблемы и лучшие практики

  • Взаимная блокировка (Deadlock): Два или более потока бесконечно ждут друг друга. Избегайте: всегда захватывайте несколько блокировок в одном порядке.
  • Гонка данных (Race Condition): Непредсказуемый результат из-за порядка выполнения. Используйте синхронизацию или атомарные классы (AtomicInteger и др.).
  • Голодание (Starvation): Поток не может получить доступ к ресурсам. Настройте приоритеты и используйте честные блокировки.
  • Потокобезопасные коллекции: Вместо ручной синхронизации используйте ConcurrentHashMap, CopyOnWriteArrayList из пакета java.util.concurrent.

FAQ: Часто задаваемые вопросы

В чем разница между start() и run()?

Метод run() — это просто обычный метод, который выполнит код в текущем потоке. Метод start() создает новый поток и внутри него вызывает run(). Всегда используйте start() для запуска нового потока.

Что такое демон-потоки?

Демон-потоки (фоновые потоки) автоматически завершаются, когда все обычные (пользовательские) потоки завершили работу. Установить: thread.setDaemon(true) до вызова start(). Подходят для фоновых задач, например, сборщика мусора.

Как правильно останавливать потоки?

Методы stop() и suspend() устарели и опасны. Используйте флаги или метод interrupt(), который выставляет статус прерывания. Поток должен периодически проверять Thread.interrupted() и корректно завершаться.

Когда использовать многопоточность, а когда асинхронность?

Многопоточность (Thread) — для параллельных CPU-задач (вычисления, обработка данных). Асинхронность (CompletableFuture, реактивные стеки) — для работы с I/O (сеть, файлы, БД), где потоки часто простаивают в ожидании. В реальности подходы комбинируются.