Многопоточность Thread, Runnable

Многопоточное программирование позволяет разделить представление и обработку информации на несколько «легковесных» процессов (light-weight processes), имеющих общий доступ как к методам различных объектов приложения, так и к их полям. Многопоточность незаменима в тех случаях, когда графический интерфейс должен реагировать на действия пользователя при выполнении определенной обработки информации. Потоки могут взаимодействовать друг с другом через основной «родительский» поток, из которого они стартованы.

В качестве примера можно привести некоторый поток, отвечающий за представление информации в интерфейсе, который ожидает завершения работы другого потока, загружающего файл, и одновременно отображает некоторую анимацию или обновляет прогресс-бар. Кроме того этот поток может остановить загружающий файл поток при нажатии кнопки «Отмена».

Создатели Java предоставили две возможности создания потоков: реализация (implementing) интерфейса Runnable и расширение(extending) класса Thread. Расширение класса - это путь наследования методов и переменных класса родителя. В этом случае можно наследоваться только от одного родительского класса Thread. Данное ограничение внутри Java можно преодолеть реализацией интерфейса Runnable, который является наиболее распространённым способом создания потоков.

Преимущества потоков перед процессами

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

Главный поток

Каждое java приложение имеет хотя бы один выполняющийся поток. Тот поток, с которого начинается выполнение программы, называется главным. После создания процесса, как правило, JVM начинает выполнение главного потока с метода main(). Затем, по мере необходимости, могут быть запущены дополнительные потоки. Многопоточность — это два и больше потоков, выполняющихся одновременно в одной программе. Компьютер с одноядерным процессором может выполнять только один поток, деля процессорное время между различными процессами и потоками.

Класс Thread

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

Конструкторы класса Thread

Thread();
Thread(Runnable target);
Thread(Runnable target, String name);
Thread(String name);
Thread(ThreadGroup group, Runnable target);
Thread(ThreadGroup group, Runnable target, String name);
Thread(ThreadGroup group, String name);

где :

  • target – экземпляр класса реализующего интерфейс Runnable;
  • name – имя создаваемого потока;
  • group – группа к которой относится поток.

Пример создания потока, который входит в группу, реализует интерфейс Runnable и имеет свое уникальное название :

Runnable    r  = new MyClassRunnable();
ThreadGroup tg = new ThreadGroup();
Thread      t  = new Thread(tg, r, "myThread");

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

Несмотря на то, что главный поток создаётся автоматически, им можно управлять. Для этого необходимо создать объект класса Thread вызовом метода currentThread().

Методы класса Thread

Наиболее часто используемые методы класса Thread для управления потоками :

  • long getId() - получение идентификатора потока;
  • String getName() - получение имени потока;
  • int getPriority() - получение приоритета потока;
  • State getState() - определение состояния потока;
  • void interrupt() - прерывание выполнения потока;
  • boolean isAlive() - проверка, выполняется ли поток;
  • boolean isDaemon() - проверка, является ли поток «Daemon»;
  • void join() - ожидание завершения потока;
  • void join(millis) - ожидание millis милисекунд завершения потока;
  • void notify() - «пробуждение» отдельного потока, ожидающего «сигнала»;
  • void notifyAll() - «пробуждение» всех потоков, ожидающих «сигнала»;
  • void run() - запуск потока, если поток был создан с использованием интерфейса Runnable;
  • void setDaemon(bool) - определение потока пользовательским или «Daemon»;
  • void setPriority(int) - определение приоритета потока;
  • void sleep(int) - приостановка потока на заданное время;
  • void start() - запуск потока.
  • void wait() - приостановка потока, пока другой поток не вызовет метод notify();
  • void wait(millis) - приостановка потока на millis милисекунд или пока другой поток не вызовет метод notify();

Жизненный цикл потока

При выполнении программы объект Thread может находиться в одном из четырех основных состояний: «новый», «работоспособный», «неработоспособный» и «пассивный». При создании потока он получает состояние «новый» (NEW) и не выполняется. Для перевода потока из состояния «новый» в «работоспособный» (RUNNABLE) следует выполнить метод start(), вызывающий метод run().

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

NEW — поток создан, но еще не запущен;
RUNNABLE — поток выполняется;
BLOCKED — поток блокирован;
WAITING — поток ждет окончания работы другого потока;
TIMED_WAITING — поток некоторое время ждет окончания другого потока;
TERMINATED — поток завершен.

Пример использования Thread

В примере ChickenEgg рассматривается параллельная работа двух потоков (главный поток и поток Egg), в которых идет спор, «что было раньше, яйцо или курица?». Каждый поток высказывает свое мнение после небольшой задержки, формируемой методом ChickenEgg.getTimeSleep(). Побеждает тот поток, который последним говорит свое слово.

package example;

import java.util.Random;

class Egg extends Thread
{
    @Override
    public void run()
    {
        for(int i = 0; i < 5; i++) {
            try {
                // Приостанавливаем поток
                sleep(ChickenEgg.getTimeSleep());
                System.out.println("Яйцо");
            }catch(InterruptedException e){}
        }
    }
}
public class ChickenEgg
{
    public static int getTimeSleep()
    {
        final Random random = new Random();
        int tm = random.nextInt(1000);
        if (tm < 10)
            tm *= 100;
        else if (tm < 100)
            tm *= 10;
        return tm;
    }
    public static void main(String[] args)
    {
        Egg egg = new Egg (); // Создание потока
        System.out.println("Начинаем спор : кто появился первым ?");

        egg.start(); // Запуск потока
        for(int i = 0; i < 5; i++) {
            try {
                // Приостанавливаем поток
                Thread.sleep(ChickenEgg.getTimeSleep());
                System.out.println("Курица");	
            }catch(InterruptedException e){}
        }
        if(egg.isAlive()) { // Cказало ли яйцо последнее слово?
            try{
                egg.join(); // Ждем, пока яйцо закончит высказываться
            }catch(InterruptedException e){}

            System.out.println("Первым появилось яйцо !!!");
        } else {
            //если оппонент уже закончил высказываться
            System.out.println("Первой появилась курица !!!");
        }
        System.out.println("Спор закончен");
    }
}

При выполнении программы в консоль было выведено следующее сообщение.


Начинаем спор : кто появился первым ?
Курица
Курица
Яйцо
Курица
Яйцо
Яйцо
Курица
Курица
Яйцо
Яйцо
Первым появилось яйцо !!!
Спор закончен
 

Невозможно точно предсказать, какой поток закончит высказываться последним. При следующем запуске «победитель» может измениться. Это происходит вследствии так называемого «асинхронного выполнения кода». Асинхронность обеспечивает независимость выполнения потоков. Или, другими словами, параллельные потоки независимы друг от друга, за исключением случаев, когда бизнес-логика зависимости выполнения потоков определяется предусмотренными для этого средств языка.

Интерфейс Runnable

Интерфейс Runnable содержит только один метод run() :

interface Runnable
{
    void run();
}

Метод run() выполняется при запуске потока. После определения объекта Runnable он передается в один из конструкторов класса Thread.

Пример класса RunnableExample, реализующего интерфейс Runnable

package example;

class MyThread implements Runnable
{
    Thread thread;
    MyThread() {
        thread = new Thread(this, "Дополнительный поток");
        System.out.println("Создан дополнительный поток " + thread);
        thread.start();
    }
    @Override
    public void run() {
        try {
            for (int i = 5; i > 0; i--) {
                System.out.println("\tдополнительный поток: " + i);
                Thread.sleep(500);
            }
        } catch (InterruptedException e) {
            System.out.println("\tдополнительный поток прерван");
        }
        System.out.println("\tдополнительный поток завершён");
    }
}
public class RunnableExample
{
    public static void main(String[] args)
    {
        new MyThread();
        try {
            for (int i = 5; i > 0; i--) {
                System.out.println("Главный поток: " + i);
                Thread.sleep(1000);
            }
        } catch (InterruptedException e) {
            System.out.println("Главный поток прерван");
        }
        System.out.println("Главный поток завершён");
    }
}

При выполнении программы в консоль было выведено следующее сообщение.


Создан дополнительный поток Thread[Дополнительный поток,5,main]
Главный поток: 5
	дополнительный поток: 5
	дополнительный поток: 4
Главный поток: 4
	дополнительный поток: 3
	дополнительный поток: 2
Главный поток: 3
	дополнительный поток: 1
	дополнительный поток завершён
Главный поток: 2
Главный поток: 1
Главный поток завершён
 

Синхронизация потоков, synchronized

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

package example;

class CommonObject
{
    int counter = 0;
}

class CounterThread implements Runnable
{
    CommonObject res;
    CounterThread(CommonObject res)
    {
        this.res = res;
    }
    @Override
    public void run()
    {
//      synchronized(res) {
            res.counter = 1;
            for (int i = 1; i < 5; i++){
                System.out.printf("'%s' - %d\n", 
                        Thread.currentThread().getName(), res.counter);
                res.counter++;
                try {
                    Thread.sleep(100);
                }
                catch(InterruptedException e){}
            }
//      }
    }
}
public class SynchronizedThread
{
    public static void main(String[] args) {
        CommonObject commonObject= new CommonObject();
        for (int i = 1; i < 6; i++) {
            Thread t = new Thread(new CounterThread(commonObject));
            t.setName("Поток " + i);
            t.start();
        }
    }
}

В примере определен общий ресурс в виде класса CommonObject, в котором имеется целочисленное поле counter. Данный ресурс используется внутренним классом, создающим поток CounterThread для увеличения в цикле значения counter на единицу. При старте потока полю counter присваивается значение 1. После завершения работы потока значение res.counter должно быть равно 4.

Две строчки кода класса CounterThread закомментированы. О них речь пойдет ниже.

В главном классе программы SynchronizedThread.main запускается пять потоков. То есть, каждый поток должен в цикле увеличить значение res.counter с единицы до четырех; и так пять раз. Но результат работы программы, отображаемый в консоли, будет иным :


'Поток 4' - 1
'Поток 2' - 1
'Поток 1' - 1
'Поток 5' - 1
'Поток 3' - 1
'Поток 2' - 6
'Поток 4' - 7
'Поток 3' - 8
'Поток 5' - 9
'Поток 1' - 10
'Поток 2' - 11
'Поток 4' - 12
'Поток 5' - 13
'Поток 3' - 13
'Поток 1' - 15
'Поток 4' - 16
'Поток 2' - 16
'Поток 3' - 18
'Поток 5' - 18
'Поток 1' - 20
 

То есть, с общим ресурсов res.counter работают все потоки одновременно, поочередно изменяя значение.

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

Блокировка на уровне объекта

Блокировать общий ресурс можно на уровне объекта, но нельзя использовать для этих целей примитивные типы. В примере следует удалить строчные комментарии в классе CounterThread, после чего общий ресурс будет блокироваться как только его захватит один из потоков; остальные потоки будут ждать в очереди освобождения ресурса. Результат работы программы при синхронизации доступа к общему ресурсу резко изменится :


'Поток 1' - 1
'Поток 1' - 2
'Поток 1' - 3
'Поток 1' - 4
'Поток 5' - 1
'Поток 5' - 2
'Поток 5' - 3
'Поток 5' - 4
'Поток 4' - 1
'Поток 4' - 2
'Поток 4' - 3
'Поток 4' - 4
'Поток 3' - 1
'Поток 3' - 2
'Поток 3' - 3
'Поток 3' - 4
'Поток 2' - 1
'Поток 2' - 2
'Поток 2' - 3
'Поток 2' - 4
 

Следующий код демонстрирует порядок использования оператора synchronized для блокирования доступа к объекту.

synchronized (оbject) {
    // other thread safe code
}

Блокировка на уровне метода и класса

Блокировать доступ к ресурсам можно на уровне метода и класса. Следующий код показывает, что если во время выполнения программы имеется несколько экземпляров класса DemoClass, то только один поток может выполнить метод demoMethod(), для других потоков доступ к методу будет заблокирован. Это необходимо когда требуется сделать определенные ресурсы потокобезопасными.

public class DemoClass
{
    public synchronized static void demoMethod(){
        // ...
    }
}
// или
public class DemoClass
{
    public void demoMethod(){
        synchronized (DemoClass.class) {
            // ...
        }
    }
}

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

Некоторые важные замечания использования synchronized

  1. Синхронизация в Java гарантирует, что два потока не могут выполнить синхронизированный метод одновременно.
  2. Оператор synchronized можно использовать только с методами и блоками кода, которые могут быть как статическими, так и не статическими.
  3. Если один из потоков начинает выполнять синхронизированный метод или блок, то этот метод/блок блокируются. Когда поток выходит из синхронизированного метода или блока JVM снимает блокировку. Блокировка снимается, даже если поток покидает синхронизированный метод после завершения из-за каких-либо ошибок или исключений.
  4. Синхронизация в Java вызывает исключение NullPointerException, если объект, используемый в синхронизированном блоке, не определен, т.е. равен null.
  5. Синхронизированные методы в Java вносят дополнительные затраты на производительность приложения. Поэтому следует использовать синхронизацию, когда она абсолютно необходима.
  6. В соответствии со спецификацией языка нельзя использовать synchronized в конструкторе, т.к. приведет к ошибке компиляции.

Взаимодействие между потоками в Java, wait и notify

При взаимодействии потоков часто возникает необходимость приостановки одних потоков и их последующего извещения о завершении определенных действий в других потоков. Так например, действия первого потока зависят от результата действий второго потока, и надо каким-то образом известить первый поток, что второй поток произвел/завершил определенную работу. Для подобных ситуаций используются методы :

  • wait() - освобождает монитор и переводит вызывающий поток в состояние ожидания до тех пор, пока другой поток не вызовет метод notify();
  • notify() - продолжает работу потока, у которого ранее был вызван метод wait();
  • notifyAll() - возобновляет работу всех потоков, у которых ранее был вызван метод wait().

Все эти методы вызываются только из синхронизированного контекста (синхронизированного блока или метода).

Рассмотрим пример «Производитель-Склад-Потребитель» (Producer-Store-Consumer). Пока производитель не поставит на склад продукт, потребитель не может его забрать. Допустим производитель должен поставить 5 единиц определенного товара. Соответственно потребитель должен весь товар получить. Но, при этом, одновременно на складе может находиться не более 3 единиц товара. При реализации данного примера используем методы wait() и notify().

Листинг класса Store

package example;

public class Store
{
    private int counter = 0;
    public synchronized void get()
    {
        while (counter < 1) {
            try {
                wait();
            } catch (InterruptedException e) {}
        }
        counter--;
        System.out.println("-1 : товар забрали");
        System.out.println("\tколичество товара на складе : " + counter);
        notify();
    }
    public synchronized void put() {
        while (counter >= 3) {
            try {
                wait();
            }catch (InterruptedException e) {} 
        }
        counter++;
        System.out.println("+1 : товар добавили");
        System.out.println("\tколичество товара на складе : " + counter);
        notify();
    }
}

Класс Store содержит два синхронизированных метода для получения товара get() и для добавления товара put(). При получении товара выполняется проверка счетчика counter. Если на складе товара нет, то есть counter < 1, то вызывается метод wait(), который освобождает монитор объекта Store и блокирует выполнение метода get(), пока для этого монитора не будет вызван метод notify().

При добавлении товара также выполняется проверка количества товара на складе. Если на складе больше 3 единиц товара, то поставка товара приостанавливается и вызывается метод notify(), который передает управление методу get() для завершения цикла while().

Листинги классов Producer и Consumer

Классы Producer и Consumer реализуют интерфейс Runnable, методы run() у них переопределены. Конструкторы этих классов в качестве параметра получают объект склад Store. При старте данных объектов в виде отдельных потоков в цикле вызываются методы put() и get() класса Store для «добавления» и «получения» товара.

package example;

public class Producer implements Runnable
{
    Store store;    
    Producer(Store store) {
       this.store=store; 
    }
    @Override
    public void run() {
        for (int i = 1; i < 6; i++) {
            store.put();
        }
    }
}

public class Consumer implements Runnable
{
    Store store;
    Consumer(Store store) {
        this.store=store; 
    }
    @Override
    public void run(){
        for (int i = 1; i < 6; i++) {
            store.get();
        }
    }
}

Листинг класса Trade

В главном потоке класса Trade (в методе main) создаются объекты Producer-Store-Consumer и стартуются потоки производителя и потребителя.

package example;

public class Trade
{
    public static void main(String[] args) 
    {
        Store     store    = new Store();
        Producer  producer = new Producer(store);
        Consumer  consumer = new Consumer(store);
        new Thread(producer).start();
        new Thread(consumer).start();
    }
}

При выполнении программы в консоль будут выведены следующие сообщения :


+1 : товар добавили
	количество товара на складе : 1
+1 : товар добавили
	количество товара на складе : 2
+1 : товар добавили
	количество товара на складе : 3
-1 : товар забрали
	количество товара на складе : 2
-1 : товар забрали
	количество товара на складе : 1
-1 : товар забрали
	количество товара на складе : 0
+1 : товар добавили
	количество товара на складе : 1
+1 : товар добавили
	количество товара на складе : 2
-1 : товар забрали
	количество товара на складе : 1
-1 : товар забрали
	количество товара на складе : 0
 	

Поток-демон, daemon

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

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

Объявить поток демоном достаточно просто. Для этого нужно перед запуском потока вызвать его метод setDaemon(true). Проверить, является ли поток daemon'ом можно вызовом метода isDaemon(). В качестве примера использования daemon-потока можно рассмотреть класс Trade, который принял бы следующий вид :

package example;

public class Trade
{
    public static void main(String[] args) 
    {
        Producer  producer = new Producer(store);
        Consumer  consumer = new Consumer(store);
		
//		new Thread(producer).start();
//		new Thread(consumer).start();
		
        Thread  tp = new Thread(producer);
        Thread  tc = new Thread(consumer);

        tp.setDaemon(true);
        tc.setDaemon(true);
		
        tp.start();
        tc.start();

        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {}
		
        System.out.println("\nГлавный поток завершен\n");
        System.exit(0);
    }
}

Здесь можно самостоятельно поэкспериментировать с определением daemon-потока для одного из классов (producer, consumer) или обоих классов, и посмотреть, как система (JVM) будет вести себя.

Какую реализацию выбрать ?

Зачем нужно два вида реализации многопоточности, и какую из них и когда использовать? Ответ несложен. Реализация интерфейса Runnable используется в случаях, когда класс уже наследует какой-либо родительский класс и не позволяет расширить класс Thread. К тому же хорошим тоном программирования в java считается реализация интерфейсов. Это связано с тем, что в java может наследоваться только один родительский класс. Таким образом, унаследовав класс Thread, невозможно наследовать какой-либо другой класс.

Расширение класса Thread целесообразно использовать в случае необходимости переопределения других методов класса помимо метода run().

Скачать примеры

Рассмотренные на странице примеры многопоточности и синхронизации потоков в виде проекта Eclipse можно скачать здесь (14Кб).

  Рейтинг@Mail.ru