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

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

Многопоточность в 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 проходит через несколько состояний:

  1. NEW: создан, но ещё не запущен (start() не вызван).
  2. RUNNABLE: выполняется или готов к выполнению.
  3. BLOCKED/WAITING/TIMED_WAITING: ожидает ресурс, уведомление или таймаут.
  4. 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) возникает, когда два или более потока бесконечно ждут друг друга, удерживая нужные друг другу ресурсы. Для предотвращения:

  1. Упорядочивайте захват блокировок (всегда в одном порядке).
  2. Используйте tryLock() с таймаутом из пакета java.util.concurrent.locks.
  3. Избегайте вложенной синхронизации.

Что лучше: synchronized или Lock из java.util.concurrent.locks?

synchronized проще, но менее гибок. ReentrantLock (реализация Lock) предлагает:

  • Возможность попытаться захватить блокировку (tryLock()).
  • Честное распределение блокировок.
  • Возможность привязать несколько Condition к одному Lock.
Выбирайте synchronized для простых случаев, 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) заранее создаёт и управляет набором потоков, переиспользуя их для выполнения множества задач. Это повышает производительность и упрощает управление ресурсами.