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

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

Многопоточность в 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 гарантирует, что чтение и запись переменной будут атомарными, а ее значение всегда будет читаться из основной памяти, а не из кэша потока. Это решает проблему видимости изменений между потоками, но не решает проблему атомарности составных операций (например, инкремента).