Java. Concurrency
Thread vs Runnable/Callable

Cоздание и управление потоками
Введение в потоки Java
В предыдущих лекциях мы говорили о том, что делать в многопоточной среде, но не о том, как создавать и управлять самими потоками. Давайте вернёмся к нашей аналогии с рестораном. Мы знаем, что нам нужно несколько «поваров» (потоков), но как их нанять, дать им задание и уволить, когда работа сделана?
В Java есть несколько способов создать поток. Сегодня мы разберём три основных подхода:
  • Наследование класса Thread.
  • Реализация интерфейса Runnable.
  • Реализация интерфейса Callable.
Мы также изучим жизненный цикл потока — от его рождения до завершения работы.
Способ 1: Наследование класса Thread
Самый простой, но и самый ограниченный способ — это унаследовать свой класс от класса Thread и переопределить его метод run().
Что такое класс Thread?
Thread — это базовый класс в Java, который представляет собой поток выполнения. Когда вы создаёте экземпляр своего класса, унаследованного от Thread, вы по сути создаёте нового «повара» со своей собственной инструкцией (методом run).
// 1. Создаём класс, наследующийся от Thread
class MyWorker extends Thread {
@Override
public void run() {
// 2. Переопределяем метод run() с нашей логикой
System.out.println("Поток " + getName() + " начал работу.");
try {
	Thread.sleep(2000); // Имитация работы
} catch (InterruptedException e) {
	e.printStackTrace();
	}
	System.out.println("Поток " + getName() + " завершил работу.");
	}
}

public class ThreadExample {
public static void main(String[] args) {
MyWorker worker1 = new MyWorker();
MyWorker worker2 = new MyWorker();
    // 3. Запускаем потоки с помощью метода start()
    worker1.start(); // ← Не run(), а именно start()!
    worker2.start();
	}
}
Что здесь происходит?
  • Мы создаём класс MyWorker, который является «потоком».
  • В методе run() мы описываем, что именно должен делать этот поток.
  • Ключевой момент — мы вызываем метод start(), а не run(). Метод start() даёт команду JVM выделить ресурсы и запустить новый поток, который затем вызовет метод run(). Если вызвать run() напрямую, код выполнится в текущем потоке, а не в новом.
Плюсы и минусы наследования Thread
Преимущества:
  • Простота для самых базовых задач
Недостатки:
  • Главный недостаток: Java не поддерживает множественное наследование классов. Если ваш класс уже наследует что-то другое, вы не можете унаследовать его ещё и от Thread.
  • Плохая практика с точки зрения дизайна ООП. Вы смешиваете задачу (что делать) и механизм выполнения (как выполнять) в одном классе.
Способ 2: Реализация интерфейса Runnable
Этот подход является более гибким и предпочтительным. Вместо того чтобы создавать «повара», который умеет делать только одно блюдо, мы создаём «рецепт» (задачу), который можно отдать любому свободному «повару» (потоку).
Что такое интерфейс Runnable?
Runnable — это функциональный интерфейс с единственным методом run(). Он представляет собой абстрактную задачу, которая может быть выполнена.
// 1. Создаём класс, реализующий интерфейс Runnable
class MyTask implements Runnable {
@Override
public void run() {
// 2. Описываем логику задачи
System.out.println("Задача выполняется в потоке: " + Thread.currentThread().getName());
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
	e.printStackTrace();
}
	System.out.println("Задача завершена.");
	}
}

public class RunnableExample {
public static void main(String[] args) {
MyTask task = new MyTask(); // Создаём задачу (рецепт)
    // 3. Создаём потоки и передаём им нашу задачу
    Thread thread1 = new Thread(task, "Поток-Алиса"); // ← Можно дать имя потоку
    Thread thread2 = new Thread(task, "Поток-Боб");

    // 4. Запускаем потоки
    thread1.start();
    thread2.start();
	}
}
Что здесь происходит?
  • Класс MyTask — это просто описание работы. Он сам по себе не является потоком.
  • Мы создаём объекты Thread и «скармливаем» им наш объект MyTask через конструктор.
  • Поток thread1 и thread2 будут выполнять одну и ту же задачу task, но независимо друг от друга.
Используйте Runnable вместо наследования от Thread всегда, когда это возможно. Это позволяет отделить задачу от исполнителя и сохраняет возможность наследования от других классов.
Способ 3: Реализация интерфейса Callable
Интерфейс Runnable хорош, но у него есть два ограничения:
  • Метод run() не может возвращать результат.
  • Метод run() не может бросать проверяемые (checked) исключения.
Для решения этих проблем был введён интерфейс Callable.
Что такое интерфейс Callable?
Callable<V> — это обобщение, которое похоже на Runnable, но его метод call() возвращает результат типа V и может бросать исключения.
import java.util.concurrent.*;

class MyCallableTask implements Callable<String> {
@Override
public String call() throws Exception { // ← Возвращает String и может бросить Exception
System.out.println("Callable задача выполняется в потоке: " + Thread.currentThread().getName());
Thread.sleep(3000);
return "Результат выполнения задачи"; // ← Возвращаем результат
}
}

public class CallableExample {
public static void main(String[] args) throws ExecutionException, InterruptedException {
MyCallableTask task = new MyCallableTask();    
    // Для работы с Callable нужен ExecutorService
    ExecutorService executor = Executors.newSingleThreadExecutor();

    // submit() возвращает объект Future, который будет содержать результат в будущем
    Future<String> future = executor.submit(task); // ← Отправляем задачу на выполнение

    System.out.println("Задача отправлена. Ждём результат...");

    // Метод get() блокирует выполнение до получения результата
    String result = future.get(); // ← Блокирующий вызов

    System.out.println("Результат получен: " + result);

    executor.shutdown(); // ← Не забываем关闭ть ExecutorService
	}
}
Что здесь происходит?
  • Задача MyCallableTask обещает вернуть String.
  • Мы используем ExecutorService для управления потоками. Это современный и рекомендуемый подход.
  • Метод submit() немедленно возвращает объект Future<String>. Это как чек в химчистке: вы отдаёте вещь и получаете чек, по которому потом сможете её забрать.
  • future.get() — это тот самый момент, когда мы приходим по чеку и ждём, пока нашу вещь постирают. Этот метод заблокирует основной поток до тех пор, пока задача не завершится и не вернёт результат.
Сравнение: Thread vs Runnable vs Callable
Чтобы окончательно разобраться, давайте сведём всё в одну таблицу:
Жизненный цикл потока
Поток в Java не просто появляется и исчезает. Он проходит через несколько состояний. Понимание этого цикла критически важно для отладки многопоточных приложений.
					  start()
				┌───────────┐
				│    NEW    │
				└─────┬─────┘
				      │
				      ▼
				┌───────────┐   Выполнение завершено
				│ RUNNABLE  │ ───────────────────────► TERMINATED
				└────┬──────┘
				     │
			┌──────┴───────┐
			▼              ▼
┌────────┐   ┌─────────────┐
│BLOCKED │   │ WAITING/    │
└────┬───┘   │TIMED_WAITING│
		 │       └──────┬──────┘
		 │              │
     └───────┬──────┘
             ▼
				┌───────────┐
				│ RUNNABLE  │
				└───────────┘
Основные состояния потока:
  • NEW
    Поток создан (new Thread(…)), но ещё не запущен (start() не вызван).
  • RUNNABLE
    Поток запущен и готов к выполнению. Он либо выполняется прямо сейчас, либо ждёт, когда процессорное время будет выделено ему планировщиком ОС.
  • BLOCKED
    Поток пытается захватить монитор (войти в synchronized блок), который уже занят другим потоком. Он «заблокирован» до тех пор, пока монитор не освободится.
  • WAITING
    Поток бесконечно ждёт, пока другой поток выполнит определённое действие. Например, вызвав wait() или join().
  • TIMED_WAITING
    Поток ждёт в течение определённого времени. Например, после вызова Thread.sleep() или join(timeout).

  • TERMINATED
    Поток завершил свою работу (метод run() или call() завершился).
Управление потоками: основные методы
Давайте разберём несколько ключевых методов для управления жизненным циклом потока.
  • start()
    Запускает поток. Вызывается только один раз для каждого экземпляра Thread.
  • sleep(long millis)
    Приостанавливает выполнение текущего потока на указанное количество миллисекунд. Поток переходит в состояние TIMED_WAITING.
  • join()
    Позволяет одному потоку дождаться завершения другого.
Thread t = new Thread(() -> { /* ... */ });
t.start();
t.join(); // ← Основной поток остановится здесь и будет ждать, пока 't' не завершится
System.out.println("Поток 't' завершился, продолжаем работу.");
interrupt(): «Вежливый» способ попросить поток остановиться. Он устанавливает специальный флаг «прерывания». Если поток находится в состоянии ожидания (sleep, wait, join), он проснётся и бросит InterruptedException.
Thread sleeper = new Thread(() -> {
try {
	Thread.sleep(5000);
} catch (InterruptedException e) {
	System.out.println("Меня разбудили! Нужно завершаться.");
	return; // Корректно завершаем работу
}
});
	sleeper.start();
// ...
	sleeper.interrupt(); // ← Посылаем сигнал прерывания
	}
}
stop(), suspend(), resume(): эти методы устарели и небезопасны! Не используйте их. Они могут оставить объекты в неконсистентном состоянии. Всегда используйте interrupt() для корректной остановки потока.
Заключение
Поздравляю, вы теперь владеете полным набором инструментов для создания и управления потоками в Java! Давайте подведём итоги:
Ключевые моменты:
  • Избегайте наследования от Thread. Это ограничивает ваш дизайн.
  • Используйте Runnable для определения задач, которые не возвращают результат. Это самый гибкий и распространённый подход.
  • Используйте Callable вместе с ExecutorService и Future, когда задача должна вернуть результат или бросить проверяемое исключение.
  • Понимайте жизненный цикл потока. Это поможет вам диагностировать проблемы, такие как дедлоки или зависшие потоки.
  • Для остановки потоков всегда используйте interrupt(), а не устаревшие и опасные методы.
Теперь вы готовы не просто запускать потоки, а управлять ими как опытный дирижёр управляет оркестром, заставляя каждую партию звучать в нужный момент и создавая слаженную и производительную многопоточную программу. Удачи в ваших проектах