Если нужен короткий ответ: поток (thread) это отдельная последовательность команд, которая выполняется внутри процесса параллельно с другими. В Java поток представлен объектом
java.lang.Thread, код для него кладут в метод
run(), а запускают вызовом
start(). Любая Java-программа многопоточная с первой секунды: JVM создает главный поток
main и несколько служебных еще до вашего кода.
Кратко
- Поток это отдельная линия выполнения внутри процесса. Память процесса у потоков общая, а стек у каждого свой.
- Объект
java.lang.Thread сам по себе потоком не является: это API к настоящему потоку, которым управляют JVM и операционная система. - Программа стартует не на пустом месте: launcher поднимает JVM через вызовы JNI, а она создает главный поток
main и служебные потоки. - Потоки делятся на обычные и демоны. JVM завершает работу, когда отработали все не-демоны или вызван
Runtime.exit, поэтому демона могут оборвать на любой команде и целостность данных в нем не гарантирована. - Создать поток можно двумя способами: унаследоваться от
Thread или передать в него Runnable. Второй лучше: класс не тратит единственное наследование и отвечает только за задачу. - Поток запускает
start(), а не run(). Прямой вызов run() просто выполнит код в текущем потоке, нового не появится.
Вступление
Многопоточность в Java была заложена с самых первых дней. Поэтому давайте кратко ознакомимся с тем, что такое многопоточность.
![Заставка статьи: швейная игла, через ушко которой продеты разноцветные нити]()
Возьмем за точку отсчета официальный урок от Oracle: "
Lesson: The "Hello World!" Application".
Код нашего Hello World приложения немного изменим на следующий:
class HelloWorldApp {
public static void main(String[] args) {
System.out.println("Hello, " + args[0]);
}
}
args это массив входных параметров, передаваемых при запуске программы. Сохраним данный код в файл с именем, которое совпадает с именем класса и с расширением
.java. Скомпилируем при помощи утилиты
javac:
javac HelloWorldApp.java
После этого вызовем наш код с каким-нибудь параметром, например, Roger:
java HelloWorldApp Roger
![Снимок экрана: класс HelloWorldApp в редакторе и командная строка, где после javac и java HelloWorldApp Roger печатается Hello, Roger]()
У нашего кода сейчас есть серьезный изъян. Если не передать никакой аргумент (т.е. выполнить просто java HelloWorldApp), мы получим ошибку:
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: 0
at HelloWorldApp.main(HelloWorldApp.java:3)
Возникло
исключение (т.е. ошибка) в thread (в потоке) с именем
main.
Получается, в Java есть какие-то потоки? Отсюда начинается наш путь.
Java и потоки
Чтобы разобраться, что такое поток, надо понять, как происходит запуск Java приложения.
Давайте изменим наш код следующим образом:
class HelloWorldApp {
public static void main(String[] args) {
while (true) {
//Do nothing
}
}
}
Теперь давайте скомпилируем это снова при помощи javac.
Далее для удобства запустим наш Java код в отдельном окне. В Windows это можно сделать так:
start java HelloWorldApp.
Теперь при помощи утилиты
jps посмотрим, какую информацию нам сообщит Java:
![Снимок экрана командной строки: вывод команды jps со строками 7700 HelloWorldApp и 14444 Jps]()
Первое число это PID или Process ID, идентификатор процесса. Что такое процесс?
Процесс это совокупность кода и данных, разделяющих общее виртуальное адресное пространство.
При помощи процессов выполнение разных программ изолировано друг от друга: каждое приложение использует свою область памяти, не мешая другим программам. Более подробно советую ознакомиться в статье: "
https://habr.com/ru/articles/164487/".
Процесс не может существовать без потоков, поэтому если существует процесс, в нем существует хотя бы один поток.
Как же это происходит в Java? Когда мы запускаем Java программу, ее выполнение начинается с метода
main. Мы как бы входим в программу, поэтому этот особый метод
main называется точкой входа, или "entry point". Метод
main всегда должен быть
public static void, чтобы виртуальная машина Java (JVM) смогла начать выполнение нашей программы. Подробнее см. "
Why is the Java main method static?".
Получается, что java launcher (java.exe или javaw.exe) это простое приложение (simple C application): оно загружает различные DLL, которые на самом деле являются JVM.
Java launcher выполняет определенный набор Java Native Interface (JNI) вызовов. JNI это механизм, соединяющий мир виртуальной машины Java и мир C++. Получается, что launcher это не JVM, а ее загрузчик. Он знает, какие правильные команды нужно выполнить, чтобы запустилась JVM. Знает, как организовать все необходимое окружение при помощи JNI вызовов.
В эту организацию окружения входит и создание главного потока, который обычно называется
main.
Чтобы нагляднее рассмотреть, какие живут потоки в java процессе, используем программу
jvisualvm, которая входит в поставку JDK.
Зная pid процесса, мы можем открыть данные сразу по нему:
jvisualvm --openpid айдипроцесса
![Окно jvisualvm, вкладка Threads для процесса HelloWorldApp: девять живых потоков, из них восемь демоны, в списке Attach Listener, Finalizer, JMX server connection timeout и main]()
Интересно, что каждый поток имеет свою обособленную область в памяти, выделенной для процесса. Эту структуру памяти называют стеком.
Стек состоит из фреймов. Фрейм это точка вызова метода, execution point. Также фрейм может быть представлен как StackTraceElement (см. Java API для
StackTraceElement).
Подробнее про память, выделяемую каждому потоку, можно прочитать
тут.
Если посмотреть на
Java API и поискать там слово Thread, мы увидим, что есть класс
java.lang.Thread.
Именно этот класс представляет в Java поток, и с ним нам и предстоит работать.
![Фрагмент документации: public class Thread extends Object implements Runnable]()
java.lang.Thread
Поток в Java представлен в виде экземпляра класса
java.lang.Thread. Стоит сразу понимать, что экземпляры класса Thread в Java сами по себе не являются потоками. Это лишь своего рода API для низкоуровневых потоков, которыми управляет JVM и операционная система. Когда при помощи java launcher'а мы запускаем JVM, она создает главный поток с именем
main и еще несколько служебных потоков.
Как сказано в JavaDoc класса Thread:
When a Java Virtual Machine starts up, there is usually a single non-daemon thread
Демоны и не демоны
Существует 2 типа потоков: демоны и не демоны. Демон-потоки это фоновые потоки (служебные), выполняющие какую-то работу в фоне. Такой интересный термин отсылает к "демону Максвелла", о чем подробнее можно прочитать в википедии в статье про "
демонов".
Как сказано в документации, JVM продолжает выполнение программы (процесса), до тех пор, пока:
- Не вызван метод Runtime.exit
- Все НЕ демон-потоки завершили свою работу (как без ошибок, так и с выбрасыванием исключений)
Отсюда и важная деталь: демон-потоки могут быть завершены на любой выполняемой команде. Поэтому целостность данных в них не гарантируется. Поэтому демон-потоки подходят для каких-то служебных задач. Например, в Java есть поток, который отвечает за обработку методов finalize или потоки, относящиеся к сборщику мусора (Garbage Collector, GC).
Группы потоков
Каждый поток входит в какую-то группу (
ThreadGroup).
А группы могут входить друг в друга, образуя некоторую иерархию или структуру.
public static void main(String []args){
Thread currentThread = Thread.currentThread();
ThreadGroup threadGroup = currentThread.getThreadGroup();
System.out.println("Thread: " + currentThread.getName());
System.out.println("Thread Group: " + threadGroup.getName());
System.out.println("Parent Group: " + threadGroup.getParent().getName());
}
Группы позволяют упорядочить управление потоками и вести их учет.
Обработчик исключений и приоритет
Помимо групп, у потоков есть свой обработчик исключений. Взглянем на пример:
public static void main(String []args) {
Thread th = Thread.currentThread();
th.setUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
@Override
public void uncaughtException(Thread t, Throwable e) {
System.out.println("Возникла ошибка: " + e.getMessage());
}
});
System.out.println(2/0);
}
Деление на ноль вызовет ошибку, которая будет перехвачена обработчиком. Если обработчик не указывать самому, отработает реализация обработчика по умолчанию, которая будет в StdError выводить стек ошибки. Подробнее можно прочитать в обзоре
http://pro-java.ru/java-dlya-opytnyx/obrabotchik-neperexvachennyx-isklyuchenij-java/".
Кроме того, у потока есть приоритет. Подробнее про приоритеты можно прочитать в статье "
Java Thread Priority in Multithreading".
Создание потока
Как и сказано в документации, у нас 2 способа создать поток.
Способ 1: свой наследник Thread
Первый способ, это создать своего наследника. Например:
public class HelloWorld{
public static class MyThread extends Thread {
@Override
public void run() {
System.out.println("Hello, World!");
}
}
public static void main(String []args){
Thread thread = new MyThread();
thread.start();
}
}
Как видим, запуск задачи выполняется в методе
run, а запуск потока в методе
start. Не стоит их путать, т.к. если мы запустим метод
run напрямую, никакой новый поток не будет запущен. Именно метод
start просит JVM создать новый поток. Что умеют остальные методы этого класса, разобрано в отдельной статье про
методы класса Thread.
Вариант с потомком от Thread плох уже тем, что мы в иерархию классов включаем Thread. Второй минус: мы начинаем нарушать принцип "Единственной ответственности" SOLID, т.к. наш класс становится одновременно ответственным и за управление потоком и за некоторую задачу, которая должна выполняться в этом потоке.
Способ 2: задача в Runnable
Как же правильно? Ответ находится в том самом методе
run, который мы переопределяем:
public void run() {
if (target != null) {
target.run();
}
}
Здесь
target это некоторый
java.lang.Runnable, который мы можем передать для Thread при создании экземпляра класса. Поэтому, мы можем сделать так:
public class HelloWorld{
public static void main(String []args){
Runnable task = new Runnable() {
public void run() {
System.out.println("Hello, World!");
}
};
Thread thread = new Thread(task);
thread.start();
}
}
А еще
Runnable является
функциональным интерфейсом начиная с Java 1.8. Это позволяет писать код задач для потоков еще красивее, одной лямбдой:
public static void main(String []args){
Runnable task = () -> {
System.out.println("Hello, World!");
};
Thread thread = new Thread(task);
thread.start();
}
Если свести оба способа в таблицу, разница видна сразу.
| |
Наследник Thread |
Задача в Runnable |
| Как выглядит |
свой класс наследует Thread и переопределяет run() |
задача описывается отдельно и передается в конструктор Thread |
| Место в иерархии классов |
единственное наследование израсходовано на Thread |
класс свободен, наследовать можно что угодно |
| За что отвечает класс |
и за управление потоком, и за саму задачу |
только за задачу, потоком управляет Thread |
| Можно ли записать лямбдой |
нет, нужен полноценный класс |
да, Runnable это функциональный интерфейс |
| Когда уместно |
почти никогда в новом коде |
по умолчанию |
Итого
Итак, надеюсь, из сего повествования понятно, что такое поток, как они существуют и какие базовые операции с ними можно выполнять.
В
следующей части стоит разобраться, как потоки взаимодействуют друг с другом и какой у них жизненный цикл.
#Viacheslav
Вопросы и ответы
Чем поток отличается от процесса?
Процесс это совокупность кода и данных со своим виртуальным адресным пространством, он изолирован от других программ. Поток живет внутри процесса и делит с остальными потоками его память, но имеет собственный стек. Процесс без потоков существовать не может: если процесс есть, в нем работает как минимум один поток.
Почему поток запускают через start(), а не run()?
run() это обычный метод с телом задачи. Если вызвать его напрямую, код выполнится в том же потоке, откуда вы его позвали, и никакой параллельности не будет. Новый поток создает только
start(): он просит об этом JVM, а та уже вызывает
run() внутри нового потока.
Что такое поток-демон?
Это фоновый поток, который не удерживает программу от завершения. JVM работает, пока жив хотя бы один не-демон; как только они закончились, машина останавливается, не дожидаясь демонов. Демона могут прервать на любой команде, поэтому целостность данных в нем не гарантируется и годится он только для служебных задач вроде сборки мусора.
Что лучше: наследовать Thread или реализовать Runnable?
Почти всегда
Runnable. Наследование от
Thread тратит единственное наследование, которое есть у класса в Java, и смешивает две ответственности: управление потоком и саму задачу. С
Runnable задача остается отдельной сущностью, ее можно передать в пул потоков, а с Java 8 еще и записать одной лямбдой.
Сколько потоков работает в самой простой Java-программе?
Больше одного. Кроме вашего
main JVM поднимает служебные потоки: обработку ссылок, сборку мусора, обработку сигналов, слушателей для средств диагностики. На скриншоте jvisualvm выше у пустой программы видно девять живых потоков, из которых восемь демоны. Посмотреть свои можно через
jps и
jvisualvm.
Что произойдет с необработанным исключением внутри потока?
Оно не обрушит всю программу, а достанется обработчику этого потока. Если своего обработчика не задать через
setUncaughtExceptionHandler, сработает поведение по умолчанию: стек ошибки уйдет в поток ошибок, а сам поток завершится. Остальные потоки при этом продолжат работать.
Читайте также
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ