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

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

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

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

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

Главный класс для работы с потоками в Java — java.lang.Thread. Поток можно создать двумя основными способами: наследованием от класса Thread или реализацией интерфейса Runnable.

Создание потоков: два классических способа

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

Это более простой, но менее гибкий способ, так как Java не поддерживает множественное наследование.

class MyThread extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println("Поток " + getName() + ": " + i);
            try {
                Thread.sleep(500); // Имитация работы
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

// Запуск:
public class Main {
    public static void main(String[] args) {
        MyThread thread1 = new MyThread();
        MyThread thread2 = new MyThread();
        thread1.start(); // Важно: запускать start(), а не run()!
        thread2.start();
    }
}

2. Реализация интерфейса Runnable (рекомендуется)

Это более предпочтительный подход, так как позволяет вашему классу наследоваться от другого класса.

class MyRunnable implements Runnable {
    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println("Поток " + Thread.currentThread().getName() + ": " + i);
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

// Запуск:
public class Main {
    public static void main(String[] args) {
        Thread thread1 = new Thread(new MyRunnable(), "Поток-1");
        Thread thread2 = new Thread(new MyRunnable(), "Поток-2");
        thread1.start();
        thread2.start();
    }
}

Проблемы многопоточности и их решение

Когда потоки начинают работать с общими ресурсами (например, одной переменной), возникает состояние гонки (race condition).

// Проблемный пример
class Counter {
    private int count = 0;
    public void increment() { count++; } // Небезопасно!
    public int getCount() { return count; }
}

// Потоки одновременно вызывают increment() -> результат непредсказуем.

Синхронизация с помощью synchronized

Ключевое слово synchronized гарантирует, что только один поток в один момент времени может выполнить синхронизированный метод или блок кода.

class SafeCounter {
    private int count = 0;
    public synchronized void increment() { count++; } // Теперь безопасно!
    public synchronized int getCount() { return count; }
}

// Или синхронизация блока:
public void increment() {
    synchronized(this) { // Синхронизация по объекту this
        count++;
    }
}

Синхронизация решает проблему согласованности, но может привести к взаимным блокировкам (deadlock) или снижению производительности. Всегда старайтесь минимизировать область синхронизированного кода.

Современный подход: пакет java.util.concurrent

Для сложных задач используйте высокоуровневые инструменты из этого пакета.

Пример с ExecutorService и Callable

import java.util.concurrent.*;

public class ConcurrentExample {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ExecutorService executor = Executors.newFixedThreadPool(2); // Пул из 2 потоков

        // Задача, возвращающая результат
        Callable task = () -> {
            Thread.sleep(1000);
            return 42;
        };

        Future future = executor.submit(task);
        System.out.println("Результат задачи: " + future.get()); // Будет ждать завершения

        executor.shutdown(); // Важно: завершаем работу пула
    }
}

Атомарные операции (AtomicInteger)

Для простых операций над общими переменными используйте атомарные классы. Они быстрее и безопаснее synchronized в многих сценариях.

import java.util.concurrent.atomic.AtomicInteger;

class AtomicCounter {
    private AtomicInteger count = new AtomicInteger(0);
    public void increment() { count.incrementAndGet(); } // Атомарная операция
    public int getCount() { return count.get(); }
}

Практический пример: параллельная обработка списка

Допустим, нам нужно быстро обработать большой список чисел.

import java.util.Arrays;
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 numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
        ExecutorService executor = Executors.newFixedThreadPool(4);

        for (Integer num : numbers) {
            executor.submit(() -> {
                int result = num * num; // Какая-то "тяжелая" операция
                System.out.println(Thread.currentThread().getName() + ": " + num + "^2 = " + result);
            });
        }

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

FAQ: Часто задаваемые вопросы о многопоточности в Java

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

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

Что такое deadlock и как его избежать?

Взаимная блокировка (deadlock) возникает, когда два или более потока бесконечно ждут друг друга, удерживая нужные друг другу ресурсы. Для предотвращения:

  • Устанавливайте порядок захвата блокировок (всегда в одной последовательности).
  • Используйте tryLock() из ReentrantLock с таймаутом.
  • Избегайте вложенной синхронизации.

Когда использовать synchronized, а когда java.util.concurrent?

Используйте synchronized для простых случаев с низкой конкуренцией. Для сложных, высоконагруженных систем с большим количеством потоков предпочтительнее классы из java.util.concurrent (например, ConcurrentHashMap, AtomicInteger, ReentrantLock), так как они часто обеспечивают лучшую производительность и более гибкое управление.

Что такое пул потоков (ThreadPool) и зачем он нужен?

Создание потока — дорогая операция. Пул потоков (например, через Executors.newFixedThreadPool()) создает заранее заданное количество потоков и переиспользует их для выполнения множества задач. Это экономит ресурсы и позволяет контролировать максимальную нагрузку на систему.