17 апреля 2010 года в Москве, в офисе Яндекса, ул. Льва Толстого, 16, состоится встреча JUG.RU на которой выступит Евгений Кирпичёв с докладом “Многопоточное программирование и Java: корректность, паттерны, оптимизация”.

Цель доклада — расширить кругозор слушателей в области методик разработки многопоточных программ:

  1. формальные рассуждения о корректности
  2. способствующие ей приёмы проектирования
  3. способы тестирования
  4. вопросы эффективности
  5. инструментарий
  6. интересные средства многопоточного программирования из других языков

Для регистрации пришлите свою фамилию и имя на русском языке на yasha@telamon.ru?subject=17_April_2010. Регистрация мягкая, достаточно просто послать письмо, подтверждения не высылаются. После официальной части планируется совместное посещение бара для неформального общения.

Цель этого поста – вспомнить все про потоки в Java. С помощью этой статьи также можно подготовиться к сдаче экзамена SCJP 6 так как в ней рассматриваются все вопросы, которые могут возникнуть на экзамене по теме “потоки в Java”. Поехали!

Определение потока в Java

В Java термин “поток” может обозначать две разные вещи:

  1. Экземпляр класса java.lang.Thread
  2. Поток выполнения

Экземпляр Thread – это обычный объект. Подобно другим объектам в Java, он имеет переменные и методы, а живет и умирает в куче. Поток выполнения (thread of execution) – это отдельный процесс (“легковесный” процесс), имеющий свой собственный стэк вызовов. В Java для каждого потока существует один стэк вызовов. Даже если вы явно не создаете потоков в вашей программе, они там все равно есть.

Метод main(), например, выполняется в потоке, который называется “главным” (main thread), и если вы посмотрите на стэк вызовов, то увидите, что метод main() стоит первым в стеке, т.е. находится в самом низу.

Поток main

Поток main

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

JVM от Sun, начиная с версии 1.2 мапит Java-потоки на нативные потоки операционной системы один-к-одному, однако у JVM свой планировщик потоков, который не зависит от планировщика операционной системы под которой работает JVM. Одно следует знать точно: когда дело доходит до потоков, то здесь нельзя давать ни каких гарантий. Нельзя точно определить как будут выполняться параллельные потоки. Ответственность за это полностью лежит на планировщике JVM.

В Java есть два вида потоков: потоки-демоны (daemon threads) и пользовательские потоки (user threads). Здесь мы будем рассматривать в основном user threads. Разница между этими двумя типами потоков в том, что JVM завершает выполнение программы когда все пользовательские потоки завершат свое выполнение. Как только завершил свое выполнение последний пользовательский поток, JVM остановится не зависимо от того, в каком состоянии находятся потоки-демоны.

Создание потоков

Поточность в Java начинается с создания экземпляра java.lang.Thread. В этом классе мы найдем методы для управления потоками: для создания, запуска, и приостановки потоков. Следующие методы самые популярные:

  1. start()
  2. yield()
  3. sleep()
  4. run()

Весь экшн происходит в методе run(). В него помещается код, который требуется выполнить в отдельном потоке.

public void run() {
  // код для выполнения в отдельном потоке
}

Из метода run() конечно же можно вызывать другие методы, а так как для отдельного потока создался новый стэк вызовов, то в этом новом стеке, первым методом будет метод run(). А где нам разместить метод run()? Определить и инстанциировать поток можно двумя способами:

  1. Расширить класс java.lang.Thread
  2. Реализовать интерфейс Runnable

Предпочтительным способом является реализация интерфейса Runnable. Расширить Thread – это просто, но этот способ не является хорошей практикой ООП. Почему? Потому что в Java нет множественного наследования и расширяя Thread, вы не можете сделать этот класс подклассом другого класса. Поэтому, лучше всего разработать класс, который реализует интерфейс Runnable.

Определение потока

Хоть в предыдущем разделе мы и выяснили что расширение Thread это не good OOP practice, но такой способ определения потока все же существует и заслуживает рассмотрения.

Расширение java.lang.Thread

Простейший путь определить поток – расширить Thread, переопределить метод run() и поместить в нем весь код, который нужно выполнить в отдельном потоке:

package ru.topcode.threadomaniac;
 
public class MyThread extends Thread {
 
    @Override
    public void run() {
        System.out.println("Очень важная работа выполняется в MyThread");
    }
}

Вы можете свободно использовать перегрузку метода run():

package ru.topcode.threadomaniac;
 
public class MyThread extends Thread {
 
    @Override
    public void run() {
        System.out.println("Очень важная работа выполняется в MyThread");
    }
 
    public void run(String s) {
        System.out.println("Строка из метода run: " + s);
    }
}

Однако, не забывайте, что перегруженные run() игнорируются классом Thread. Класс Thread ожидает запуска именно run() без аргументов. Если вы вызовете run(String s), то вызов метода попадет в тот же стек вызовов, в котором лежит вызывающий его метод, т.е. отдельный поток не создастся.

Реализация интерфейса java.lang.Runnable

Реализация интерфейса Runnable дает возможность расширить любой другой класс и в то же время определить поведение, которое будет выполняться в отдельном потоке.

package ru.topcode.threadomaniac;
 
class MyRunnable implements Runnable {
 
    @Override
    public void run() {
        System.out.println("Очень важная работа выполняется в MyRunnable");
    }
}

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

Инстанцирование потока

Каждый поток начинается с создания экземпляра Thread. Независимо от того, расширяли вы Thread или реализовывали Runnable, для выполнения работы, нужно иметь экземпляр Thread.

Если вы расширили класс Thread, то инстанцирование выполняется также как инстанцирование обычного объекта:

MyThread t = new MyThread();

Если вы реализовывали Runnable, вам все равно придется создать экземпляр класса Thread.

MyRunnable r = new MyRunnable();
Thread t = new Thread(r);

Объяснить это можно так: вместо того, чтобы комбинировать в одном классе и поток и выполняемую работу (код), мы разделяем логику на два класса – класс Thread для потоко-зависимого кода и реализация Runnable для описания работы, которая должна выполняться в отдельном потоке. Другими словами: Thread – это работник, а Runnable – это работа.

Один экземпляр Runnable можно передать нескольким объектам Thread:

MyRunnable r = new MyRunnable();
Thread foo = new Thread(r);
Thread bar = new Thread(r);
Thread bat = new Thread(r);

Это означает что несколько потоков будут делать одну и ту же работу. Значит эта работа будет выполнена несколько раз.

Класс Thread тоже реализует Runnable, а значит в конструктор Thread можно передать экземпляр Thread:

Thread t = new Thread(new MyThread());

Выглядит глупо, но сделать так можно.

Кроме конструктора по умолчанию и конструктора, принимающего экземпляр Thread, в классе Thread есть и другие перегруженные конструкторы. Вот список всех конструкторов Thread:

  1. Thread()
  2. Thread(String name)
  3. Thread(Runnable runnable)
  4. Thread(Runnable runnable, String name)
  5. Thread(ThreadGroup g, Runnable runnable)
  6. Thread(ThreadGroup g, Runnable runnable, String name)
  7. Thread(ThreadGroup g, String name)

Мы рассмотрим каждый конструктор немного позже.

Итак, вы сделали себе экземпляр Thread и он знает что нужно выполнять метод run(). Но почему ничего не происходит? На данный момент мы имеем всего лишь обычный Java-объект типа Thread. Это еще не поток исполнения. Для того чтобы создать новый поток исполнения и новый стэк вызовов, нам нужно запустить поток.

Когда поток инстанцирован, но не запущен, говорят что он находится в состоянии new (новый). На этом этапе поток еще не считается живым (alive). После вызова метода start() экземпляра потока, поток переходит в состояние alive (живой). То что поток живой не означает что метод run() уже начал выполняться! Поток считается мертвым (dead) после того, как метод run() закончит выполняться. Метод isAlive() – лучший способ определить запущен ли поток и не завершил ли он уже выполняться. Часто при отладке используется метод getState().

Запуск потока

Пришло время запустить поток. Это настолько просто, что врядли заслуживает отдельного раздела:

t.start();

Что происходит после старта потока? А происходит следующее:

  • Стартует новый поток выполнения (с новым стэком вызовов).
  • Поток переходит из состояния new (новый) в состояние работоспособный (runnable).
  • Когда поток получает шанс выполниться, он вызывает метод run().

Запомните, что мы вызываем метод start() экземпляра класса Thread. Следующий пример продемонстрирует все что мы рассмотрели выше:

package ru.topcode.threadomaniac;
 
public class Starter {
    public static void main(String[] args) {
        FooRunnable r = new FooRunnable();
        Thread t = new Thread(r);
        t.start();
    }
}
 
class FooRunnable implements Runnable {
    public void run() {
        for (int x = 1; x < 6; x++) {
            System.out.println("Runnable running");
        }
    }
}

Еще раз заметьте, что для старта потока, нужно вызывать метод start() экземпляра класса Thread. Если вы вызовете метод run() из вашего класса, который реализует интерфейс Runnable или даже если вы вызовете run из класса, расширяющего Thread, то ничего страшного не произойдет. Не возникнет ни каких исключительных ситуаций относящихся к потокам. Метод просто выполнится, но новый поток не создастся! Метод выполнится в том же потоке, из которого был запущен! Следующий код не запускает новый поток выполнения:

Thread t = new Thread();
t.run(); // Так можно, но новый поток не создастся.

Так что же произойдет если запустить несколько потоков? Давайте рассмотрим пример запуска нескольких потоков. В следующем примере инстанцируются новые именованные потоки (потоки, которым мы явно присвоили имена). Имена здесь мы присвоили потокам чтобы проследить какой поток выполняется в данный момент времени.

package ru.topcode.threadomaniac;
 
public class Starter {
    public static void main(String[] args) {
        NameRunnable nr = new NameRunnable();
        Thread t = new Thread(nr);
        t.setName("Поток1");
        t.start();
    }
}
 
class NameRunnable implements Runnable {
    public void run() {
        System.out.println("NameRunnable запущен");
        System.out.println("Выполняется "
                + Thread.currentThread().getName());
    }
}

В результате получим следующий вывод в консоль:

NameRunnable запущен
Выполняется Поток1

Здесь, чтобы получить имя выполняющегося потока, мы использовали метод getName() экземпляра класса Thread. Статический метод Thread.currentThread() возвращает ссылку на текущий выполняемый поток.

Даже если вы явно не указываете имя потока, оно у него все равно будет. Закомментируйте строку

t.setName("Поток1");

и запустите программу. Вы получите следующий вывод в консоль:

NameRunnable запущен
Выполняется Thread-0

Так как мы получаем имя текущего потока с помощью статического метода Thread.currentThread(), то можно также получить имя главного потока – main.

Вот наглядное представление того, что происходит со стеками во время работы многопоточного приложения:

Выполнение многопоточного приложения

Выполнение многопоточного приложения

Картинка из книги SCJP Sun Certified Programmer for Java 6 Study Guide (Exam 310-065).

Запуск и выполнение нескольких потоков

Мы поиграли с двумя потоками (main и Thread-0). Давайте теперь запустим побольше потоков и посмотрим как они будут работать:

package ru.topcode.threadomaniac;
 
public class Starter {
    public static void main(String[] args) {
        NameRunnable nr = new NameRunnable();
        Thread one = new Thread(nr);
        Thread two = new Thread(nr);
        Thread three = new Thread(nr);
 
        one.setName("Первый");
        two.setName("Второй");
        three.setName("Третий");
 
        one.start();
        two.start();
        three.start();
    }
}
 
class NameRunnable implements Runnable {
    public void run() {
        for (int x = 1; x <= 3; x++) {
            System.out.println("Запущен "
                    + Thread.currentThread().getName()
                    + ", x равен " + x);
        }
    }
}

Я получил следующий вывод в консоль:

Запущен Третий, x равен 1
Запущен Третий, x равен 2
Запущен Третий, x равен 3
Запущен Первый, x равен 1
Запущен Первый, x равен 2
Запущен Первый, x равен 3
Запущен Второй, x равен 1
Запущен Второй, x равен 2
Запущен Второй, x равен 3

В такой последовательности выполнялись потоки у меня в этот раз. Однако, такое же поведение не гарантируется при следующем запуске программы. Также не гарантируется что когда поток начал выполняться, он будет продолжать выполняться пока не закончится его метод run(). В моем случае произошло именно так: каждый цикл успел полностью выполниться без прерываний. Я запустил эту же программу еще раз и получил следующий вывод:

Запущен Первый, x равен 1
Запущен Третий, x равен 1
Запущен Третий, x равен 2
Запущен Третий, x равен 3
Запущен Первый, x равен 2
Запущен Первый, x равен 3
Запущен Второй, x равен 1
Запущен Второй, x равен 2
Запущен Второй, x равен 3

В каждом отдельном потоке, порядок выполнения предсказуем, т.е., например, наши циклы всегда будут инкрементировать x и выводить в консоль сообщение, но порядок выполнения потоков не предсказуем. Велика вероятность того, что такой маленький и простой цикл всегда будет успевать выполняться до переключения на другой поток, но если увеличить длину цикла хотя бы до 400, можно увидеть, что потоки прерываются.

Так же не гарантируется что если поток1 начал выполняться первым, то он и закончит выполняться первым. Он может закончить свою работу и самым последним. Мы не контролируем планировщик потоков, поэтому не можем предсказать порядок выполнения, а соответственно, и порядок завершения потоков. Однако, существует способ сказать потоку чтобы не запускался пока какой-нибудь другой поток не закончился. Это можно сделать с помощью метода join(), который мы рассмотрим немного позже.

Когда метод run() потока завершился, поток перестает быть потоком исполнения, стек вызовов удаляется, а поток считается мертвым (dead). С этого момента поток – это обычный объект Thread. А можем ли мы снова запустить run() этого объекта? Нет! Если поток был запущен, то он никогда не может быть запущен повторно! Если у вас есть ссылка на поток, и вы вызовете его метод start() повторно, то получите IllegalThreadStateException. Запустить поток можно только из состояния new (новый), а состояние new он имеет только перед первым запуском. Если попытаться запустить поток из состояния runnable или dead, то получим IllegalThreadStateException.

До сих пор мы узнали о трех состояниях потока: новый (new), runnable (работоспособный), и dead (мертвый).

В дополнение к методам setName() и getName(), для идентификации потока можно еще использовать getId(). getId() возвращает положительное, уникальное число типа long и это число однозначно идентифицирует поток на протяжении всей его жизни.

Планировщик потоков

Планировщик потоков является частью JVM (хотя некоторые JVM мапят Java-потоки на нативные потоки ОС) и решает какой поток будет работать в определенный момент.

Предположим, что на нашем компьютере установлен только один процессор. Тогда реально, в определенный момент времени может выполняться только один поток, а решает какой именно поток – планировщик.

Любой поток, имеющий состояние runnable (работоспособный), может быть выбран планировщиком для выполнения. Если поток не в состоянии runnable, то он не может быть выбран планировщиком. Когда поток переходит в состояние runnable, он помещается в runnable pool из которого планировщик выбирает какой поток выполнять.

Хоть мы и не контролируем планировщик потоков (т.е. явно нельзя сказать какой поток запустить), мы можем влиять на него с помощью нескольких методов:

  • public static void sleep(long millis) throws InterruptedException
  • public static void yield()
  • public final void join() throws InterruptedException
  • public final void setPriority(int newPriority)

Методы sleep() и join() имеют также перегруженные версии, которые здесь не показаны.

Приведенные выше методы находятся в классе java.lang.Thread, но в классе java.lang.Object для работы с потоками также есть несколько методов:

  • public final void wait() throws InterruptedException
  • public final void notify()
  • public final void notifyAll()

Метод wait() имеет три перегруженные версии.

Состояния потоков и переходы между состояниями

Мы уже знаем три состояния потоков – new, runnable и dead, но это еще не все. Планировщик потоков может переводить потоки в состояние running (работающий) и “отбирать” у них это состояние, т.е., например, переводить их обратно в состояние runnable или в какое-нибудь другое. Ниже мы рассмотрим все состояния, в которых может находиться поток и переходы между этими состояниями.

Состояния потоков

Поток может находиться в одном из пяти состояний:

  1. Новый (new). После создания экземпляра потока, он находится в состоянии Новый до тех пор, пока не вызван метод start(). В этом состоянии поток не считается живым.
  2. Работоспособный (runnable). Поток переходит в состояние Работоспособный, когда вызывается метод start(). Поток может перейти в это состояние также из состояния Работающий или из состояния Блокирован. Когда поток находится в этом состоянии, он считается живым.
  3. Работающий (running). Поток переходит из состояния Работоспособный в состояние Работающий, когда Планировщик потоков выбирает его из runnable pool как работающий в данный момент.
  4. Ожидающий (waiting)/Заблокированный (blocked)/Спящий(sleeping). Эти состояния характеризуют поток как не готовый к работе. Я объединил эти состояния т.к. все они имеют общую черту – поток еще жив (alive), но в настоящее время не может быть выполнен. Другими словами, поток уже не runnable, но он может вернуться в это состояние. Поток может быть заблокирован – это может означать, например, что он ждет освобождения каких-нибудь ресурсов, и разблокируется когда эти ресурсы освободятся. Поток может спать потому, что в его методе run() встретился метод sleep(). Просыпается он тогда, когда время его сна истекло (таймер). Поток может находиться в состоянии ожидания если в его методе run() встретится метод wait(). Вызов notify() или notifyAll() может перевести поток из состояния Ожидания в состояние Работоспособный.

    Важно отметить, что один поток не может попросить другой поток заблокироваться. Если в потоке у вас есть ссылка на экземпляр другого потока, то можно сделать следующее:

    t.sleep(); 
     
    или 
     
    t.yield()

    Но эффекта от этого будет ноль, т.к. это статические методы класса Thread, а значит они ни коим образом не влияют на какой-то конкретный экземпляр. Вместо этого они влияют на текущий поток. Это хороший пример того, почему использование экземпляра для доступа к статическим методам является плохой практикой.

  5. Мёртвый (dead). Поток считается мёртвым, когда его метод run() полностью выполнен. Мёртвый поток не может перейти ни в какое другое состояние, даже если для него вызван метод start(). Не нужно быть ученым чтобы понять что мёртвый не может ожить :-)

Весь этот раздел можно представить одной картинкой:

Диаграмма переходов между состояниями потока

Диаграмма переходов между состояниями потока

Продолжение читайте в статье “Потоки в Java. Часть 2“.