Java запустить новый поток. Используйте блоки синхронизации вместо блокированных методов. Листинги классов Producer и Consumer

Java запустить новый поток. Используйте блоки синхронизации вместо блокированных методов. Листинги классов Producer и Consumer

26.04.2019

Немного о принципе многопоточности

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

Возникнет странная ситуация. Для того чтобы такого не происходило, для этого метода необходимо установить так называемый признак блокировки. Если какой-либо объект начинает выполнять метод, то происходит блокировка этого метода, и никто больше не может его выполнить, пока данный объект не закончит работу с ним. Все другие объекты, которые должны выполнить этот метод, будут находиться в состоянии ожидания.

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

Чтобы создать свой поток, необходимо создать дочерний класс класса Thread. Давайте создадим такой класс и затем разберем весь текст программы (листинг 5.7).

Листинг 5.7.
Создание потока на основе класса Thread

Public class MyThread extends Thread{ private int seconds; public MyThread(int seconds) { this.seconds = seconds; } public void run() { try { for (int i = 0; i < this.seconds; i++) { Thread.sleep(1000); System.out.println("Идет секунда: " + i); } } catch (InterruptedException e) { System.out.println("У нас проблемы с потоком"); } } public static void main(String args) { MyThread myThread = new MyThread(5); myThread.start(); new MyThread(15); } }

Вначале мы создаем дочерний класс для класса Thread, затем - конструктор с параметром, который установит количество секунд выполнения потока. После этого мы определяем метод run. который начинает действовать при запуске потока.
Чтобы его вызвать, необходимо вызвать метод start () - «стартовать поток».

У класса Thread есть метод sleep, который позволяет приостановить выполнение потока на заданное количество миллисекунд (внимание: отсчет идет в миллисекундах). Чтобы использовать данный метод, необходимо поместить его в блок try, а блок catch сделать обрабатывающим исключение InterruptedException. Затем мы запускаем два потока, которые будут выполняться одновременно. Чтобы их различить, можно переделать код так (листинг 5.8).

Листинг 5.8.
Создание двух потоков, которые будут выполняться в классе Thread одновременно

Public class MyThread extends Thread{ private int seconds; private static int numbers = 0; private int number; public static int setNumber() { return ++numbers; } public MyThread(int seconds) { this.seconds = seconds; number = MyThread.setNumber(); } public void run() { try { for (int i = 0; i < this.seconds; i++) { Thread.sleep(1000); System.out.println("Идет секунда: " + i + " выполнения потока под номером " + this.number); } } catch (InterruptedException e) { System.out.println("У нас проблемы с потоком"); } } public static void main(String args) { MyThread myThread = new MyThread(5); myThread.start(); new MyThread(15).start(); } }

Теперь мы каждому потоку присвоили свой уникальный номер.

Использование интерфейса Runnable

Можно создавать классы потоков, реализуя интерфейс Runnable. Это бывает порой значительно удобнее. Например, код из предыдущего примера можно переписать так (листинг 5.9).

Листинг 5.9.
Создание классов потоков с использованием интерфейса Runnable

В некоторых случаях так представляется удобней: обычно у подобного создания потоков более короткая запись.
И в том и в другом случае потоку можно присвоить имя. Обычно оно задается в конструкторе в качестве последнего параметра.

Написание параллельного кода – непростая задача, а проверка его корректности – задача еще сложнее. Несмотря на то, что Java предоставляет обширную поддержку многопоточности и синхронизации на уровне языка и API, на деле же оказывается, что написание корректного многопоточного Java-кода зависит от опыта и усердности конкретного программиста. Ниже изложен набор советов, которые помогут вам качественно повысить уровень вашего многопоточного кода на Java. Некоторые из вас, возможно, уже знакомы с этими советами, но никогда не помешает освежать их в памяти раз в пару лет.

Многие из этих советов появились в процессе обучения и практического программирования, а также после прочтения книг «Java concurrency in practice» и «Effective Java» . Я советую прочитать первую каждому Java-программисту два раза; да, всё правильно, ДВА раза. Параллелизм – запутанная и сложная для понимания тема (как, например, для некоторых – рекурсия), и после однократного прочтения вы можете не до конца всё понять.

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

1. Используйте локальные переменные

Всегда старайтесь использовать локальные переменные вместо полей класса или статических полей. Иногда разработчики используют поля класса, чтобы сэкономить память и переиспользовать переменные, полагая, что создание локальной переменной при каждом вызове метода может потребовать большого количества дополнительной памяти. Одним из примеров такого использования может послужить коллекция (Collection), объявленная как статическое поле и переиспользуемая с помощью метода clear(). Это переводит класс в общее состояние, которого он иметь не должен, т.к. изначально создавался для параллельного использования в нескольких потоках. В коде ниже метод execute() вызывается из разных потоков, а для реализации нового функционала потребовалась временная коллекция. В оригинальном коде была использована статическая коллекция (List), и намерение разработчика были ясны - очищать коллекцию в конце метода execute() , чтобы потом можно было её заново использовать. Разработчик полагал, что его код потокобезопасен, потому что CopyOnWriteArrayList потокобезопасен. Но это не так - метод execute() вызывается из разных потоков, и один из потоков может получить доступ к данным, записанным другим потоком в общий список. Синхронизация, предоставляемая CopyOnWriteArrayList в данном случае недостаточна для обеспечения инвариантности метода execute() .

Public static class ConcurrentTask { private static List temp = Collections.synchronizedList(new ArrayList()); @Override public void execute(Message message) { // Используем локальный временный список // List temp = new ArrayList(); // Добавим в список что-нибудь из сообщения temp.add("message.getId()"); temp.add("message.getCode()"); temp.clear(); // теперь можно переиспользовать } }

Проблема: Данные одного сообщения попадут в другое, если два вызова execute() «пересекаются», т.е. первый поток добавит id из первого сообщения, затем второй поток добавит id из второго сообщения (это произойдёт ещё до очистки списка), таким образом данные одного из сообщений будут повреждены.

Варианты решений:

  1. Добавить блок синхронизации в ту часть кода, где поток добавляет что-то во временный список и очищает его. Таким образом, другой поток не сможет получить доступ к списку, пока первый не закончит работу с ним. В таком случае эта часть кода будет однопоточной, что уменьшит производительность приложения в целом.
  2. Использовать локальный список в место поля класса. Да, это увеличит затраты памяти, но избавит от блока синхронизации и сделает код более читаемым. Также вам не придётся беспокоиться о временных объектах – о них позаботится сборщик мусора.

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

2. Предпочитайте неизменяемые классы изменяемым

Самая широко известная практика в многопоточном программировании на Java – использование неизменяемых (immutable) классов. Неизменяемые классы, такие как String, Integer и другие упрощают написание параллельного кода в Java, т.к. вам не придётся беспокоиться о состоянии объектов данных классов. Неизменяемые классы уменьшают количество элементов синхронизации в коде . Объект неизменяемого класса, будучи однажды созданным, не может быть изменён. Самый лучший пример такого класса – строка (java.lang.String). Любая операция изменения строки в Java (перевод в верхний регистр, взятие подстроки и пр.) приведёт к созданию нового объекта String для результата операции, оставив исходную строку нетронутой.

3. Сокращайте области синхронизации

Любой код внутри области синхронизации не может быть исполнен параллельно, и если в вашей программе 5% кода находится в блоках синхронизации, то, согласно закону Амдала, производительность всего приложения не может быть улучшена более, чем в 20 раз. Главная причина этого в том, что 5% кода всегда выполняется последовательно. Вы можете уменьшить это количество, сокращая области синхронизации – попробуйте использовать их только для критических секций. Лучший пример сокращения областей синхронизации – блокировка с двойной проверкой, которую можно реализовать в Java 1.5 и выше с помощью volatile переменных.

4. Используйте пул потоков

Создание потока (Thread) - дорогая операция. Если вы хотите создать масштабируемое Java-приложение, вам нужно использовать пул потоков. Помимо тяжеловесности операции создания, управление потокам вручную порождает много повторяющегося кода, который, перемешиваясь с бизнес-логикой, уменьшает читаемость кода в целом. Управление потоками – задача фреймворка, будь то инструмент Java или любой другой, который вы захотите использовать. В JDK есть хорошо организованный, богатый и полностью протестированный фреймворк, известный как Executor framework , который можно использовать везде, где потребуется пул потоков.

5. Используйте утилиты синхронизации вместо wait() и notify()

В Java 1.5 появилось множество утилит синхронизации, таких как CyclicBarrier, CountDownLatch и Semaphore. Вам всегда следует сначала изучить, что есть в JDK для синхронизации, прежде чем использовать wait() и notify() . Будет намного проще реализовать шаблон читатель-писатель с помощью BlockingQueue, чем через wait() и notify() . Также намного проще будет подождать 5 потоков для завершения вычислений, используя CountDownLatch, чем реализовывать то же самое через wait() и notify() . Изучите пакет java.util.concurrent , чтобы писать параллельный код на Java лучшим образом.

6. Используйте BlockingQueue для реализации Producer-Consumer

Этот совет следует из предыдущего, но я выделил его отдельно ввиду его важности для параллельных приложений, используемых в реальном мире. Решение многих проблем многопоточности основано на шаблоне Producer-Consumer, и BlockingQueue – лучший способ реализации его в Java. В отличие от Exchanger, который может быть использовать в случае одного писателя и читателя, BlockingQueue может быть использована для правильной обработки нескольких писателей и читателей.

7. Используйте потокобезопасные коллекции вместо коллекций с блокированием доступа

Потокобезопасные коллекции предоставляют большую масштабируемость и производительность, чем их аналоги с блокированием доступа (Collections.synchronizedCollection и пр.). СoncurrentHashMap, которая, по моему мнению, является самой популярной потокобезопасной коллекцией, демострирует лучшую производителность, чем блокировочные HashMap или Hashtable, в случае, когда количество читателей превосходит количество писателей. Другое преимущество потокобезопасных коллекций состоит в том, что они реализованы с помощью нового механизма блокировки (java.util.concurrent.locks.Lock) и используют нативные механизмы сихнронизации, предоставленные низлежащим аппаратным обеспечением и JVM. Вдобавок используйте CopyOnWriteArrayList вместо Collections.synchronizedList , если чтение из списка происходит чаще, чем его изменение.

8. Используйте семафоры для создания ограничений

Чтобы создать надёжную и стабильную систему, у вас должны быть ограничения на ресурсы (базы данных, файловую систему, сокеты и т.д.). Ваш код ни в коем случае не должен создавать и/или использовать бесконечное количество ресурсов. Семафоры (java.util.concurrent.Semaphore) - хороший выбор для создания ограничений на использование дорогих ресурсов, таких как подключения к базе данных (кстати, в этом случае можно использовать пул подключений). Семафоры помогут создать ограничения и заблокируют потоки в случае недоступности ресурса.

9. Используйте блоки синхронизации вместо блокированных методов

Данный совет расширяет совет по сокращению областей синхрониации. Использование блоков синхронизации – один из методов сокращения области синхронизации, что также позволяет выполнить блокировку на объекте, отличном от текущего, представленного указателем this . Первым кандитатом должна быть атомарная переменная, затем volatile переменная, если они удовлетворяют ваши требования к синхронизации. Если вам требуется взаимное исключение, используйте в первую очередь ReentrantLock, либо блок synchronized . Если вы новичок в параллельном программировании, и не разрабатываете какое-либо жизненно важное приложение, можете просто использовать блок synchronized - так будет безопаснее и проще.

10. Избегайте использования статических переменных

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

11. Используйте Lock вместо synchronized

Последний, бонусный совет, следует использовать с осторожностью. Интерфейс Lock - мощный инструмент, но его сила влечёт и большую ответственность. Различные объекты Lock на операции чтения и записи позволяют реализовывать масштабируемые структуры данных, такие как ConcurrentHashMap, но при этом требуют большой осторожности при своём программировании. В отличие от блока synchronized, поток не освобождает блокировку автоматически. Вам придётся явно вызывать unlock() , чтобы снять блокировку. Хорошей практикой является вызов этого метода в блоке finally, чтобы блокировка завершалась при любых условиях:

Lock.lock(); try { //do something ... } finally { lock.unlock(); }

Заключение

Вам были представлены советы по написанию многопоточного кода на Java. Ещё раз повторюсь, никогда не помешает перечитывать «Java concurrency in practice» и «Effective Java» время от времени. Также можно вырабатывать нужный для параллельного програмирования способ мышления, просто читая чужой код и пытаясь визуализировать проблемы во время разработки. В завершение спросите себя, каких правил вы придерживаетесь, когда разрабатываете многопоточные приложения на Java?

должны воспользоваться классом java.lang.Thread. В этом классе определены все методы, необходимые для создания потоков, управления их состоянием и синхронизации.

Как пользоваться классом Thread?

Есть две возможности.

  • Во-первых, вы можете создать свой дочерний класс на базе класса Thread. При этом вы должны переопределить метод run. Ваша реализация этого метода будет работать в рамках отдельного потока.
  • Во-вторых, ваш класс может реализовать интерфейс Runnable. При этом в рамках вашего класса необходимо определить метод run, который будет работать как отдельный поток.

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

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

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

С помощью конструкторов вы можете создавать потоки различными способами, указывая при необходимости для них имя и группу. Имя предназначено для идентификации потока и является необязательным атрибутом. Что же касается групп, то они предназначены для организации защиты потоков друг от друга в рамках одного приложения.

Методы класса Thread предоставляют все необходимые возможности для управления потоками, в том числе для их синхронизации.

Поля

Три статических поля предназначены для назначения приоритетов потокам.

  • NORM_PRIORITY

Нормальный

public final static int NORM_PRIORITY;
  • MAX_PRIORITY

Максимальный

public final static int MAX_PRIORITY;
  • MIN_PRIORITY

Минимальный

public final static int MIN_PRIORITY;

Конструкторы

Создание нового объекта Thread

public Thread();

Создвание нового объекта Thread с указанием объекта, для которого будет вызываться метод run

public Thread(Runnable target); public Thread(Runnable target, String name);

Создание объекта Thread с указанием его имени

public Thread(String name);

Создание нового объекта Thread с указанием группы потока и объекта, для которого вызывается метод run

public Thread(ThreadGroup group, Runnable target);

Аналогично предыдущему, но дополнительно задается имя нового объекта Thread

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

Создание нового объекта Thread с указанием группы потока и имени объекта

public Thread(ThreadGroup group, String name);

Методы

  • activeCount

Текущее количество активных потоков в группе, к которой принадлежит поток

public static int activeCount();
  • checkAccess

Текущему потоку разрешается изменять объект Thread

public void checkAccesss();
  • countStackFrames

Определение количества фреймов в стеке

public int countStackFrames();
  • currentThread

Определение текущего работающего потока

public static Thread currentThread();
  • destroy

Принудительное завершение работы потока

public void destroy();
  • dumpStack

Вывод текущего содержимого стека для отладки

public static void dumpStack();
  • enumerate

Получение всех объектов Tread данной группы

public static int enumerate(Thread tarray);
  • getName

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

public final String getName();
  • getPriority

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

public final int getPriority();
  • getThreadGroup

Определение группы, к которой принадлежит поток

public final ThreadGroup getThreadGroup();
  • interrupt

Прерывание потока

public void interrupt();
  • interrupted
public static boolean interrupted();
  • isAlive

Определение, выполняется поток или нет

public final boolean isAlive();
  • isDaemon

Определение, является ли поток демоном

public final boolean isDaemon();
  • isInterrupted

Определение, является ли поток прерванным

public boolean isInterrupted();
  • join

Ожидание завершения потока

public final void join();

Ожидание завершения потока в течение заданного времени. Время задается в миллисекундах

public final void join(long millis);

Ожидание завершения потока в течение заданного времени. Время задается в миллисекундах и наносекундах

public final void join(long millis, int nanos);
  • resume

Запуск временно приостановленного потока

public final void resume();

Метод вызывается в том случае, если поток был создан как объект с интерфейсом Runnable

public void run();
  • setDaemon

Установка для потока режима демона

public final void setDaemon(boolean on);
  • setName

Устаовка имени потока

public final void setName(String name);
  • setPriority

Установка приоритета потока

public final void setPriority(int newPriority);
  • sleep
public static void sleep(long millis);

Задержка потока на заднное время. Время задается в миллисекундах и наносекундах

public static void sleep(long millis, int nanos);
  • start

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

public void start();
  • stop

Остановка выполнения потока

public final void stop();

Аварийная остановка выполнения потока с заданным исключением

public final void stop(Throwable obj);
  • suspend

Приостановка потока

public final void suspend();
  • toString

Строка, представляющая объект-поток

public String toString();
  • yield

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

public static void yield();

Создание дочернего класса на базе класса Thread

Рассмотрим первый способ реализации многопоточности, основанный на наследовании от класса Thread. При использовании этого способа вы определяете для потока отдельный класс, например, так:

class DrawRectangles extends Thread { . . . public void run() { . . . } }

Здесь определен класс DrawRectangles, который является дочерним по отношению к классу Thread.

Обратите внимание на метод run. Создавая свой класс на базе класса Thread, вы должны всегда определять этот метод, который и будет выполняться в рамках отдельного потока.

Заметим, что метод run не вызывается напрямую никакими другими методами. Он получает управление при запуске потока методом start.

Как это происходит?

Рассмотрим процедуру запуска потока на примере некоторого класса DrawRectangles.

Вначале ваше приложение должно создать объект класса Thread:

public class MultiTask2 extends Applet { Thread m_DrawRectThread = null; . . . public void start() { if (m_DrawRectThread == null) { m_DrawRectThread = new DrawRectangles(this); m_DrawRectThread.start(); } } }

Создание объекта выполняется оператором new в методе start, который получает управление, когда пользователь открывает документ HTML с аплетом. Сразу после создания поток запускается на выполнение, для чего вызывается метод start.

Что касается метода run, то если поток используется для выполнения какой либо периодической работы, то этот метод содержит внутри себя бесконечный цикл. Когда цикл завершается и метод run возвращает управление, поток прекращает свою работу нормальным, не аварийным образом. Для аварийного завершения потока можно использовать метод interrupt.

Остановка работающего потока выполняется методом stop. Обычно остановка всех работающих потоков, созданных аплетом, выполняется методом stop класса аплета:

public void stop() { if (m_DrawRectThread != null) { m_DrawRectThread.stop(); m_DrawRectThread = null; } }

Напомним, что этот метод вызывается, когда пользователь покидает страницу сервера Web, содержащую аплет.

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

Описанный выше способ создания потоков как объектов класса Thread или унаследованных от него классов кажется достаточнао естественным. Однако этот способ не единственный. Если вам нужно создать только один поток, работающую одновременно с кодом аплета, проще выбрать второй способ с использованием интерфейса Runnable.

Идея заключается в том, что основной класс аплета, который является дочерним по отношению к классу Applet, дополнительно реализует интерфейс Runnable, как это показано ниже:

public class MultiTask extends Applet implements Runnable { Thread m_MultiTask = null; . . . public void run() { . . . } public void start() { if (m_MultiTask == null) { m_MultiTask = new Thread(this); m_MultiTask.start(); } } public void stop() { if (m_MultiTask != null) { m_MultiTask.stop(); m_MultiTask = null; } } }

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

Для создания потока используется оператор new. Поток создается как объект класса Thread, причем конструктору передается ссылка на класс аплета:

m_MultiTask = new Thread(this);

При этом, когда поток запустится, управление получит метод run, определенный в классе аплета.

Как запустить поток?

Запуск выполняется, как и раньше, методом start. Обычно поток запускается из метода start аплета, когда пользователь отображает страницу сервера Web, содержащую аплет. Остановка потока выполняется методом stop.

Последнее обновление: 27.04.2018

Большинство языков программирования поддерживают такую важную функциональность как многопоточность, и Java в этом плане не исключение. При помощи многопоточности мы можем выделить в приложении несколько потоков, которые будут выполнять различные задачи одновременно. Если у нас, допустим, графическое приложение, которое посылает запрос к какому-нибудь серверу или считывает и обрабатывает огромный файл, то без многопоточности у нас бы блокировался графический интерфейс на время выполнения задачи. А благодаря потокам мы можем выделить отправку запроса или любую другую задачу, которая может долго обрабатываться, в отдельный поток. Поэтому большинство реальных приложений, которые многим из нас приходится использовать, практически не мыслимы без многопоточности.

Класс Thread

В Java функциональность отдельного потока заключается в классе Thread . И чтобы создать новый поток, нам надо создать объект этого класса. Но все потоки не создаются сами по себе. Когда запускается программа, начинает работать главный поток этой программы. От этого главного потока порождаются все остальные дочерние потоки.

С помощью статического метода Thread.currentThread() мы можем получить текущий поток выполнения:

Public static void main(String args) { Thread t = Thread.currentThread(); // получаем главный поток System.out.println(t.getName()); // main }

По умолчанию именем главного потока будет main .

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

    getName() : возвращает имя потока

    setName(String name) : устанавливает имя потока

    getPriority() : возвращает приоритет потока

    setPriority(int proirity) : устанавливает приоритет потока. Приоритет является одним из ключевых факторов для выбора системой потока из кучи потоков для выполнения. В этот метод в качестве параметра передается числовое значение приоритета - от 1 до 10. По умолчанию главному потоку выставляется средний приоритет - 5.

    isAlive() : возвращает true, если поток активен

    isInterrupted() : возвращает true, если поток был прерван

    join() : ожидает завершение потока

    run() : определяет точку входа в поток

    sleep() : приостанавливает поток на заданное количество миллисекунд

    start() : запускает поток, вызывая его метод run()

Мы можем вывести всю информацию о потоке:

Public static void main(String args) { Thread t = Thread.currentThread(); // получаем главный поток System.out.println(t); // main }

Консольный вывод:

Thread

Первое main будет представлять имя потока (что можно получить через t.getName()), второе значение 5 предоставляет приоритет потока (также можно получить через t.getPriority()), и последнее main представляет имя группы потоков, к которому относится текущий - по умолчанию также main (также можно получить через t.getThreadGroup().getName())

Недостатки при использовании потоков

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

На некоторых платформах запуск новых потоков может замедлить работу приложения. Что может иметь большое значение, если нам критичная производительность приложения.

Для каждого потока создается свой собственный стек в памяти, куда помещаются все локальные переменные и ряд других данных, связанных с выполнением потока. Соответственно, чем больше потоков создается, тем больше памяти используется. При этом надо помнить, в любой системе размеры используемой памяти ограничены. Кроме того, во многих системах может быть ограничение на количество потоков. Но даже если такого ограничения нет, то в любом случае имеется естественное ограничение в виде максимальной скорости процессора.

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

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

Давайте начнем. Сначала о процессах.

Процессы

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

Для каждого процесса ОС создает так называемое «виртуальное адресное пространство», к которому процесс имеет прямой доступ. Это пространство принадлежит процессу, содержит только его данные и находится в полном его распоряжении. Операционная система же отвечает за то, как виртуальное пространство процесса проецируется на физическую память.

Схема этого взаимодействия представлена на картинке. Операционная система оперирует так называемыми страницами памяти, которые представляют собой просто область определенного фиксированного размера. Если процессу становится недостаточно памяти, система выделяет ему дополнительные страницы из физической памяти. Страницы виртуальной памяти могут проецироваться на физическую память в произвольном порядке.

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

Потоки

Один поток – это одна единица исполнения кода. Каждый поток последовательно выполняет инструкции процесса, которому он принадлежит, параллельно с другими потоками этого процесса.

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

Проще говоря, при псевдопараллельном выполнении потоков процессор мечется между выполнением нескольких потоков, выполняя по очереди часть каждого из них.

Вот как это выглядит:

Цветные квадраты на рисунке – это инструкции процессора (зеленые – инструкции главного потока, синие – побочного). Выполнение идет слева направо. После запуска побочного потока его инструкции начинают выполняться вперемешку с инструкциями главного потока. Кол-во выполняемых инструкций за каждый подход не определено.

То, что инструкции параллельных потоков выполняются вперемешку, в некоторых случаях может привести к конфликтам доступа к данным. Проблемам взаимодействия потоков будет посвящена следующая статья, а пока о том, как запускаются потоки в Java…

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

Каждый процесс имеет хотя бы один выполняющийся поток. Тот поток, с которого начинается выполнение программы, называется главным. В языке Java, после создания процесса, выполнение главного потока начинается с метода main(). Затем, по мере необходимости, в заданных программистом местах, и при выполнении заданных им же условий, запускаются другие, побочные потоки.

В языке Java поток представляется в виде объекта-потомка класса Thread. Этот класс инкапсулирует стандартные механизмы работы с потоком.

Запустить новый поток можно двумя способами:

Способ 1
Создать объект класса Thread, передав ему в конструкторе нечто, реализующее интерфейс Runnable. Этот интерфейс содержит метод run(), который будет выполняться в новом потоке. Поток закончит выполнение, когда завершится его метод run().

Выглядит это так:

Class SomeThing //Нечто, реализующее интерфейс Runnable implements Runnable //(содержащее метод run()) { public void run() //Этот метод будет выполняться в побочном потоке { System.out.println("Привет из побочного потока!"); } } public class Program //Класс с методом main() { static SomeThing mThing; //mThing - объект класса, реализующего интерфейс Runnable public static void main(String args) { mThing = new SomeThing(); Thread myThready = new Thread(mThing); //Создание потока "myThready" myThready.start(); //Запуск потока System.out.println("Главный поток завершён..."); } }

Для пущего укорочения кода можно передать в конструктор класса Thread объект безымянного внутреннего класса, реализующего интерфейс Runnable:

Public class Program //Класс с методом main(). { public static void main(String args) { //Создание потока Thread myThready = new Thread(new Runnable() { public void run() //Этот метод будет выполняться в побочном потоке { System.out.println("Привет из побочного потока!"); } }); myThready.start(); //Запуск потока System.out.println("Главный поток завершён..."); } }

Способ 2
Создать потомка класса Thread и переопределить его метод run():

Class AffableThread extends Thread { @Override public void run() //Этот метод будет выполнен в побочном потоке { System.out.println("Привет из побочного потока!"); } } public class Program { static AffableThread mSecondThread; public static void main(String args) { mSecondThread = new AffableThread(); //Создание потока mSecondThread.start(); //Запуск потока System.out.println("Главный поток завершён..."); } }

В приведённом выше примере в методе main() создается и запускается еще один поток. Важно отметить, что после вызова метода mSecondThread.start() главный поток продолжает своё выполнение, не дожидаясь пока порожденный им поток завершится. И те инструкции, которые идут после вызова метода start(), будут выполнены параллельно с инструкциями потока mSecondThread.

Для демонстрации параллельной работы потоков давайте рассмотрим программу, в которой два потока спорят на предмет философского вопроса «что было раньше, яйцо или курица?». Главный поток уверен, что первой была курица, о чем он и будет сообщать каждую секунду. Второй же поток раз в секунду будет опровергать своего оппонента. Всего спор продлится 5 секунд. Победит тот поток, который последним изречет свой ответ на этот, без сомнения, животрепещущий философский вопрос. В примере используются средства, о которых пока не было сказано (isAlive() sleep() и join()). К ним даны комментарии, а более подробно они будут разобраны дальше.

Class EggVoice extends Thread { @Override public void run() { for(int i = 0; i < 5; i++) { try{ sleep(1000); //Приостанавливает поток на 1 секунду }catch(InterruptedException e){} System.out.println("яйцо!"); } //Слово «яйцо» сказано 5 раз } } public class ChickenVoice //Класс с методом main() { static EggVoice mAnotherOpinion; //Побочный поток public static void main(String args) { mAnotherOpinion = new EggVoice(); //Создание потока System.out.println("Спор начат..."); mAnotherOpinion.start(); //Запуск потока for(int i = 0; i < 5; i++) { try{ Thread.sleep(1000); //Приостанавливает поток на 1 секунду }catch(InterruptedException e){} System.out.println("курица!"); } //Слово «курица» сказано 5 раз if(mAnotherOpinion.isAlive()) //Если оппонент еще не сказал последнее слово { try{ mAnotherOpinion.join(); //Подождать пока оппонент закончит высказываться. }catch(InterruptedException e){} System.out.println("Первым появилось яйцо!"); } else //если оппонент уже закончил высказываться { System.out.println("Первой появилась курица!"); } System.out.println("Спор закончен!"); } } Консоль: Спор начат... курица! яйцо! яйцо! курица! яйцо! курица! яйцо! курица! яйцо! курица! Первой появилась курица! Спор закончен!

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

Теперь немного о завершении процессов…

Завершение процесса и демоны

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

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

Объявить поток демоном достаточно просто - нужно перед запуском потока вызвать его метод setDaemon(true) ;
Проверить, является ли поток демоном, можно вызвав его метод boolean isDaemon() ;

Завершение потоков

В Java существуют (существовали) средства для принудительного завершения потока. В частности метод Thread.stop() завершает поток незамедлительно после своего выполнения. Однако этот метод, а также Thread.suspend(), приостанавливающий поток, и Thread.resume(), продолжающий выполнение потока, были объявлены устаревшими и их использование отныне крайне нежелательно. Дело в том что поток может быть «убит» во время выполнения операции, обрыв которой на полуслове оставит некоторый объект в неправильном состоянии, что приведет к появлению трудноотлавливаемой и случайным образом возникающей ошибке.

Вместо принудительного завершения потока применяется схема, в которой каждый поток сам ответственен за своё завершение. Поток может остановиться либо тогда, когда он закончит выполнение метода run(), (main() - для главного потока) либо по сигналу из другого потока. Причем как реагировать на такой сигнал - дело, опять же, самого потока. Получив его, поток может выполнить некоторые операции и завершить выполнение, а может и вовсе его проигнорировать и продолжить выполняться. Описание реакции на сигнал завершения потока лежит на плечах программиста.

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

Incremenator - поток, который каждую секунду прибавляет или вычитает единицу из значения статической переменной Program.mValue. Incremenator содержит два закрытых поля – mIsIncrement и mFinish. То, какое действие выполняется, определяется булевой переменной mIsIncrement - если оно равно true, то выполняется прибавление единицы, иначе - вычитание. А завершение потока происходит, когда значение mFinish становится равно true.

Class Incremenator extends Thread { //О ключевом слове volatile - чуть ниже private volatile boolean mIsIncrement = true; private volatile boolean mFinish = false; public void changeAction() //Меняет действие на противоположное { mIsIncrement = !mIsIncrement; } public void finish() //Инициирует завершение потока { mFinish = true; } @Override public void run() { do { if(!mFinish) //Проверка на необходимость завершения { if(mIsIncrement) Program.mValue++; //Инкремент else Program.mValue--; //Декремент //Вывод текущего значения переменной System.out.print(Program.mValue + " "); } else return; //Завершение потока try{ Thread.sleep(1000); //Приостановка потока на 1 сек. }catch(InterruptedException e){} } while(true); } } public class Program { //Переменая, которой оперирует инкременатор public static int mValue = 0; static Incremenator mInc; //Объект побочного потока public static void main(String args) { mInc = new Incremenator(); //Создание потока System.out.print("Значение = "); mInc.start(); //Запуск потока //Троекратное изменение действия инкременатора //с интервалом в i*2 секунд for(int i = 1; i <= 3; i++) { try{ Thread.sleep(i*2*1000); //Ожидание в течении i*2 сек. }catch(InterruptedException e){} mInc.changeAction(); //Переключение действия } mInc.finish(); //Инициация завершения побочного потока } } Консоль: Значение = 1 2 1 0 -1 -2 -1 0 1 2 3 4

Взаимодействовать с потоком можно с помощью метода changeAction() (для смены вычитания на сложение и наоборот) и метода finish() (для завершения потока).

В объявлении переменных mIsIncrement и mFinish было использовано ключевое слово volatile (изменчивый, не постоянный). Его необходимо использовать для переменных, которые используются разными потоками. Это связано с тем, что значение переменной, объявленной без volatile, может кэшироваться отдельно для каждого потока, и значение из этого кэша может различаться для каждого из них. Объявление переменной с ключевым словом volatile отключает для неё такое кэширование и все запросы к переменной будут направляться непосредственно в память.

В этом примере показано, каким образом можно организовать взаимодействие между потоками. Однако есть одна проблема при таком подходе к завершению потока - Incremenator проверяет значение поля mFinish раз в секунду, поэтому может пройти до секунды времени между тем, когда будет выполнен метод finish(), и фактическим завершения потока. Было бы замечательно, если бы при получении сигнала извне, метод sleep() возвращал выполнение и поток незамедлительно начинал своё завершение. Для выполнения такого сценария существует встроенное средство оповещения потока, которое называется Interruption (прерывание, вмешательство).

Interruption

Класс Thread содержит в себе скрытое булево поле, подобное полю mFinish в программе Incremenator, которое называется флагом прерывания. Установить этот флаг можно вызвав метод interrupt() потока. Проверить же, установлен ли этот флаг, можно двумя способами. Первый способ - вызвать метод bool isInterrupted() объекта потока, второй - вызвать статический метод bool Thread.interrupted(). Первый метод возвращает состояние флага прерывания и оставляет этот флаг нетронутым. Второй метод возвращает состояние флага и сбрасывает его. Заметьте что Thread.interrupted() - статический метод класса Thread, и его вызов возвращает значение флага прерывания того потока, из которого он был вызван. Поэтому этот метод вызывается только изнутри потока и позволяет потоку проверить своё состояние прерывания.

Итак, вернемся к нашей программе. Механизм прерывания позволит нам решить проблему с засыпанием потока. У методов, приостанавливающих выполнение потока, таких как sleep(), wait() и join() есть одна особенность - если во время их выполнения будет вызван метод interrupt() этого потока, они, не дожидаясь конца времени ожидания, сгенерируют исключение InterruptedException.

Переделаем программу Incremenator – теперь вместо завершения потока с помощью метода finish() будем использовать стандартный метод interrupt(). А вместо проверки флага mFinish будем вызывать метод bool Thread.interrupted();
Так будет выглядеть класс Incremenator после добавления поддержки прерываний:

Class Incremenator extends Thread { private volatile boolean mIsIncrement = true; public void changeAction() //Меняет действие на противоположное { mIsIncrement = !mIsIncrement; } @Override public void run() { do { if(!Thread.interrupted()) //Проверка прерывания { if(mIsIncrement) Program.mValue++; //Инкремент else Program.mValue--; //Декремент //Вывод текущего значения переменной System.out.print(Program.mValue + " "); } else return; //Завершение потока try{ Thread.sleep(1000); //Приостановка потока на 1 сек. }catch(InterruptedException e){ return; //Завершение потока после прерывания } } while(true); } } class Program { //Переменая, которой оперирует инкременатор public static int mValue = 0; static Incremenator mInc; //Объект побочного потока public static void main(String args) { mInc = new Incremenator(); //Создание потока System.out.print("Значение = "); mInc.start(); //Запуск потока //Троекратное изменение действия инкременатора //с интервалом в i*2 секунд for(int i = 1; i <= 3; i++) { try{ Thread.sleep(i*2*1000); //Ожидание в течении i*2 сек. }catch(InterruptedException e){} mInc.changeAction(); //Переключение действия } mInc.interrupt(); //Прерывание побочного потока } } Консоль: Значение = 1 2 1 0 -1 -2 -1 0 1 2 3 4

Как видите, мы избавились от метода finish() и реализовали тот же механизм завершения потока с помощью встроенной системы прерываний. В этой реализации мы получили одно преимущество - метод sleep() вернет управление (сгенерирует исключение) незамедлительно после прерывания потока.

Заметьте что методы sleep() и join() обёрнуты в конструкции try-catch. Это необходимое условие работы этих методов. Вызывающий их код должен перехватывать исключение InterruptedException, которое они бросают при прерывании во время ожидания.

С запуском и завершением потоков разобрались, дальше я расскажу о методах, использующихся при работе с потоками.

Метод Thread.sleep()

Thread.sleep() - статический метод класса Thread, который приостанавливает выполнение потока, в котором он был вызван. Во время выполнения метода sleep() система перестает выделять потоку процессорное время, распределяя его между другими потоками. Метод sleep() может выполняться либо заданное кол-во времени (миллисекунды или наносекунды) либо до тех пор пока он не будет остановлен прерыванием (в этом случае он сгенерирует исключение InterruptedException).

Thread.sleep(1500); //Ждет полторы секунды Thread.sleep(2000, 100); //Ждет 2 секунды и 100 наносекунд

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

Метод yield()

Статический метод Thread.yield() заставляет процессор переключиться на обработку других потоков системы. Метод может быть полезным, например, когда поток ожидает наступления какого-либо события и необходимо чтобы проверка его наступления происходила как можно чаще. В этом случае можно поместить проверку события и метод Thread.yield() в цикл:

//Ожидание поступления сообщения while(!msgQueue.hasMessages()) //Пока в очереди нет сообщений { Thread.yield(); //Передать управление другим потокам }

Метод join()

В Java предусмотрен механизм, позволяющий одному потоку ждать завершения выполнения другого. Для этого используется метод join(). Например, чтобы главный поток подождал завершения побочного потока myThready, необходимо выполнить инструкцию myThready.join() в главном потоке. Как только поток myThready завершится, метод join() вернет управление, и главный поток сможет продолжить выполнение.

Метод join() имеет перегруженную версию, которая получает в качестве параметра время ожидания. В этом случае join() возвращает управление либо когда завершится ожидаемый поток, либо когда закончится время ожидания. Подобно методу Thread.sleep() метод join может ждать в течение миллисекунд и наносекунд – аргументы те же.

С помощью задания времени ожидания потока можно, например, выполнять обновление анимированной картинки пока главный (или любой другой) поток ждёт завершения побочного потока, выполняющего ресурсоёмкие операции:

Thinker brain = new Thinker(); //Thinker - потомок класса Thread. brain.start(); //Начать "обдумывание". do { mThinkIndicator.refresh(); //mThinkIndicator - анимированная картинка. try{ brain.join(250); //Подождать окончания мысли четверть секунды. }catch(InterruptedException e){} } while(brain.isAlive()); //Пока brain думает... //brain закончил думать (звучат овации).

В этом примере поток brain (мозг) думает над чем-то, и предполагается, что это занимает у него длительное время. Главный поток ждет его четверть секунды и, в случае, если этого времени на раздумье не хватило, обновляет «индикатор раздумий» (некоторая анимированная картинка). В итоге, во время раздумий, пользователь наблюдает на экране индикатор мыслительного процесса, что дает ему знать, что электронные мозги чем то заняты.

Приоритеты потоков

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

Работать с приоритетами потока можно с помощью двух функций:

void setPriority(int priority) – устанавливает приоритет потока.
Возможные значения priority - MIN_PRIORITY, NORM_PRIORITY и MAX_PRIORITY.

int getPriority() – получает приоритет потока.

Некоторые полезные методы класса Thread

Это практически всё. Напоследок приведу несколько полезных методов работы с потоками.

boolean isAlive() - возвращает true если myThready() выполняется и false если поток еще не был запущен или был завершен.

setName(String threadName) – Задает имя потока.
String getName() – Получает имя потока.
Имя потока – ассоциированная с ним строка, которая в некоторых случаях помогает понять, какой поток выполняет некоторое действие. Иногда это бывает полезным.

static Thread Thread.currentThread() - статический метод, возвращающий объект потока, в котором он был вызван.

long getId() – возвращает идентификатор потока. Идентификатор – уникальное число, присвоенное потоку.

Заключение

Отмечу, что в статье рассказано далеко не про все нюансы многопоточного программирования. И коду, приведенному в примерах, для полной корректности не хватает некоторых нюансов. В частности, в примерах не используется синхронизация. Синхронизация потоков - тема, не изучив которую, программировать правильные многопоточные приложения не получится. Почитать о ней вы можете, например, в книге «Java Concurrency in Practice» или

© 2024 beasthackerz.ru - Браузеры. Аудио. Жесткий диск. Программы. Локальная сеть. Windows