sonyps4.ru

Что такое многопоточность. Работа с потоками в java

Написание параллельного кода – непростая задача, а проверка его корректности – задача еще сложнее. Несмотря на то, что 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?

Последнее обновление: 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. Перед тем как рассмотреть детали программирования на Java, давайте приступим к обзору о потоках в общем.

Если сказать просто, то поток(thread) – это путь программного выполнения. Большинство программ, написанных сегодня, запускаются одним потоком, проблемы начинают возникать, когда несколько событий или действий должны произойти в одно время. Допустим, например, программа не способна рисовать картинку пока выполняет чтение нажатия клавиш. Программа должна уделять всё своё внимание клавиатуре, вследствие чего отсутствует возможность обрабатывать более одного события одновременно. Идеальным решением для этой проблемы может служить возможность выполнения двух или более разделов программы в одно время. Потоки позволяют нам это сделать.

Многопоточные приложения предоставляют мощь при запуске многих потоков в рамках одной программы. С логической точки зрения, многопоточность означает, что несколько строк из одной и той же программы могут быть выполнены в одно и то же время, однако, это не то же самое, что запустить программу дважды и сказать, что несколько строк кода выполняются в одно время. В этом случае, операционная система обрабатывает две программы раздельно и как отдельные процессы. В Unix, разветвляющий(forking) процесс создаёт дочерний процесс с разным адресным пространством для кода и данных. Вместе с тем, fork() создаёт много накладок для операционной системы, это влечёт за собой интенсивную нагрузку на процессор. При запуске потока, эффективный путь выполнения создаётся за счёт распределения исходного пространство данных родителя. Идея совместного использования данных очень выгодна, но вызывает некоторые вопросы, которые мы обсудим позже.

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

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

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

Есть некоторые отличия между классом и интерфейсом. Во-первых, интерфейс может только содержать абстрактные методы и/или static final переменные (константы). Классы, с другой стороны, могут реализовывать методы и содержать переменные, которые не выступают в качестве констант. Во-вторых, интерфейс не может реализовывать никаких методов. Класс, который реализовывает интерфейс, должен реализовать все методы, которые описаны в интерфейсе. У интерфейса есть возможность расширяться за счёт других интерфейсов, и (в отличие от классов) могут расширяться от нескольких интерфейсов. К тому же, экземпляр интерфейса не может быть создан, используя, оператор new; например, Runnable a = new Runnable(); не разрешается .

Для первого способа создания потока необходимо просто наследоваться от класса Thread. Делайте так, только если классу нужно только выполниться как отдельному потоку и никогда не понадобиться наследоваться от другого класса. Класс Thread определён в пакете java.lang, который необходимо импортировать, что бы наши классы знали о его описании:


import java.lang.*;
public class Counter extends Thread {
public void run() {
....
}
}

Пример выше создаёт новый класс Counter, который расширяет класс Thread и подменяет метод Thread.run() для своей реализации. В методе run() происходит вся работа класса Counter как потока. Такой же класс можно создать, реализуя интерфейс Runnable:


import java.lang.*;
public class Counter implements Runnable {
Thread T;
public void run() {
....
}
}

Здесь осуществляется абстрактный метод run(), который описан в интерфейсе Runnable. Отметим, что у нас есть экземпляр класса Thread, переменная класса Counter. Единственное отличие этих двух методов заключается в том, что реализация Runnable является более гибкой для создания класса Counter. В примере, который описан выше, есть возможность расширения класса Counter, если в этом есть такая необходимость. Большинство классов, которые должны выполняться как потоки, реализуют Runnable, поскольку они, вероятно, могут расширить свою функциональность за счёт другого класса.

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


package java.lang;
public interface Runnable {
public abstract void run();
}

Это и всё, что есть в интерфейсе Runnable. Интерфейс – это всего лишь описание, которое классы должны реализовать. Итак, Runnable, заставляет только запустить метод run(). Вследствие этого, большая часть работы полагается на класс Thread. Более пристальный взгляд на класс Thread даст представление о том, что на самом деле происходит:


public class Thread implements Runnable {
...
public void run() {
if (target != null) {
target.run();
}
}
...
}

Во фрагменте кода, который представлен выше видно, что класс Thread, так же реализует интерфейс Runnable. Thread.run() выполняет проверку, что бы удостовериться в том, что этот класс (класс, который выполняется как поток) не равен null, и только потом выполняет метод run(). Когда это произойдёт, то метод run() запустит поток.

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

Различные способы создания объектов потока сейчас очевидны, мы продолжим дискуссию о реализации потоков, начиная с путей запуска и остановки их, используя, маленький applet, который содержит поток для иллюстрации этих механизмов:

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

В этом случае, класс CounterThread был вынужден реализовать интерфейс Runnnable, что бы дальше была возможность расширить класс Applet. Все апплеты начинают свою работу с метода init(), переменная Cout инициализируется нулём и создаётся новый объект класса Thread. Передавая this в конструктор класса Thread, таким образом, новый поток будет знать какой объект запускается. В этом случает this это ссылка на CounterThread. После того как поток создан его нужно запустить. Вызываем метод start(), который в свою очередь вызывает метод run() объекта CounterThread, то есть CounterThread.run(). Сразу выполниться метод start() и в это же время начнёт свою работу поток. Заметим, что в методе run() бесконечный цикл. Он бесконечен, потому что, как только выполниться метод run(), то поток закончит работу. Метод run() будет инкрементировать переменную Count, ожидать(sleep) 10 секунд и посылать запрос на обновление экрана апплета.

Заметим, что вызова метода sleep именно в потоке является очень важным. Если это не так, то программа займёт всё процессорное время для своего процесса и не даст возможности любым другим методам, например методам, выполниться. Другой способ остановить выполнение потока это вызвать метод stop(). В данном примере, поток останавливается, когда происходит нажатие мыши в пределах апплета. В зависимости от скорости компьютера, на котором запущен апплет, не все числа будут отображены, потому что инкрементирование происходит независимо от прорисовки апплета. Апплет может не обновляться после каждого запроса на прорисовку, так как ОС может поставить запрос в очередь запросов и последующие запросы на обновление будут удовлетворены с одним запросом. Пока запросы на перерисовку собираются в очередь, переменная Count продолжает увеличиваться, но не отображается.

Приостановка и возобновление

Когда поток остановлен с использованием метода stop() он уже не может быть возобновлён с использованием метода start(), сразу после вызова метода stop() происходит уничтожение выполняющегося потока. Вместо этого вы можете приостановить выполнение потока, используя метод sleep() на определённый отрезок времени и потом выполнение потока продолжится, когда выйдет время. Но это не самое лучшее решение, если поток необходимо запустить, когда произойдёт определённое условие. Для этого, используется метод suspend(), который даёт возможность временно прекратить выполнение потока и метод resume(), который позволяет продолжить выполнение потока. Следующий апплет является изменением апплета, который был дан выше, но с использованием методов suspend() и resume():


public class CounterThread2 extends Applet implements Runnable
{
Thread t;
int Count;
boolean suspended;
public boolean mouseDown(Event e,int x, int y)
{
if(suspended)
t.resume();
else
t.suspend();
suspended = !suspended;
return true;
}
...
}

Для того чтобы сохранить текущее состояние апплета используется логическая(boolean) переменная suspended. Характеристики разных состояний апплета является важной частью, потому что некоторые методы могут выкидывать исключения, если они вызываются не из того состояния. Например, если поток запущен и остановлен, вызов метода start() приведёт к исключению IllegalThreadStateException .

Планирование

В Java есть Планировщик Потоков(Thread Scheduler), который контролирует все запущенные потоки во всех программах и решает, какие потоки должны быть запущены, и какая строка кода выполняться. Существует две характеристики потока, по которым планировщик идентифицирует процесс. Первая, более важная, это приоритет потока, другая, является-ли поток демоном(daemon flag). Простейшее правило планировщика, это если запущены только daemon потоки, то Java Virtual Machine (JVM) вызгрузиться. Новые потоки наследуют приоритет и daemon flag от потока, который его создал. Планировщик определяет какой поток должен быть запущен, анализируя приоритет всех потоков. Потоку с наивысшим приоритетом позволяется выполниться раньше, нежели потокам с более низкими приоритетами.

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

Приоритеты

Планировщик определяет, какой поток должен запуститься, основываясь на номер приоритета, назначенный каждому потоку. Приоритет потока может принимать значения от 1 до 10. По умолчанию, значение приоритета для потока является Thread.NORM_PRIORITY, которому соответствует значение 5. Так же доступны две других static переменных: Thread.MIN_PRIORITY, значение 1, и Thread.MAX_PRIORITY – 10. Метод getPriority() может использоваться для получения текущего значения приоритета соответствующего потока.

Daemon потоки

Такие потоки иногда ещё называются “службами”, которые обычно запускаются с наименьшим приоритетом и обеспечивают основные услуги для программы или программ, когда деятельность компьютера понижается. Примером такого потока может служить сборщик мусора. Этот поток, предусмотрен JVM, сканирует программы на наличие переменных, к которым больше никогда не придется обращаться, и освобождает их ресурсы, возвращая их системе. Поток может стать daemon потоком, передав булево значение true в метод setDaemon(). Если принято значение false, то поток становится обычным пользовательским потоком. Тем не менее, это необходимо сделать до того как поток запустится.

Пример планирования

Представленный апплет демонстрирует выполнение двух потоков с разными приоритетами. Один поток запущен с наименьшим приоритетом, а другой с наивысшим. Потоки увеличивают значение счетчика, пока счетчик потока с большим приоритетом не догонит счетчик потока с меньшим приоритетом.

Заключение

Использование потоков в Java позволит программистам более гибко использовать преимущества Java в их программах. Простота создания, настраивания и запуска потоков даст Java программистам возможность разрабатывать переносимые и мощные апплеты/приложения, которые невозможно выполнить в других языках третьего поколения. Потоки позволяют любым программам выполнять несколько задач как одну. В интернет ориентированных(Internet-aware) языках, таких как Java, это очень важный инструмент.

Об авторе

Donald G. Drake программирует на Java начиная с альфа релиза(alpha release). Drake написал апплеты для специальных веб-сайтов. Наиболее популярна его версия TickerTape (http://www.netobjective.com/java/TickerTapeInfo.html), которая может быть широко сконфигурирована для внедрения в любые веб-страницы. Drake имеет степень бакалавра наук в области компьютерных наук в John Carroll University. В настоящее время он получает степень магистра наук в DePaul University.

Многопоточное программирование позволяет разделить представление и обработку информации на несколько «легковесных» процессов (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; 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 в конструкторе, т.к. приведет к ошибке компиляции.

Примечание: для синхронизации потоков можно использовать объекты синхронизации Synchroniser"s пакета java.util.concurrent .

Взаимная блокировка

С использованием блокировок необходимо быть очень внимательным, чтобы не создать «взаимоблокировку», которая хорошо известна разработчикам. Этот термин означает, что один из потоков ждет от другого освобождения заблокированного им ресурса, в то время как сам также заблокировал один из ресурсов, доступа к которому ждёт второй поток. В данном процессе могут участвовать два и более потоков.

Основные условия возникновения взаимоблокировок в многопотоковом приложении:

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

Взаимодействие между потоками в 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) будет вести себя.

Thread и Runnable, что выбрать?

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

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

Приоритеты выполнения и голодание

Иногда разработчики используют приоритеты выполнения потока. В Java есть планировщик потоков (Thread Scheduler ), который контролирует все запущенные потоки и решает, какие потоки должны быть запущены и какая строка кода должна выполняться. Решение основывается на приоритете потока. Поэтому потоки с меньшим приоритетом получают меньше процессорного времени по сравнению с потоками с бо́льшим приоритет. Данное разумное решением может стать причиной проблем при злоупотреблении. То есть, если бо́льшую часть времени исполняются потоки с высоким приоритетом, то низкоприоритетные потоки начинают «голодать», поскольку не получают достаточно времени для того, чтобы выполнить свою работу должным образом. Поэтому рекомендуется задавать приоритет потока только тогда, когда для этого имеются веские основания.

Неочевидный пример «голодания» потока даёт метод finalize() , предоставляющий возможность выполнить код перед тем, как объект будет удалён сборщиком мусора. Однако приоритет финализирующего потока невысокий. Следовательно, возникают предпосылки для потокового голодания, когда методы finalize() объекта тратят слишком много времени (большие задержки) по сравнению с остальным кодом.

Другая проблема со временем исполнения может возникнуть от того, что не был определен порядок прохождения потоком блока synchronized . Когда несколько параллельных потоков должны выполнить некоторый код, оформленный блоком synchronized , может получиться так, что одним потокам придётся ждать дольше других, прежде чем войти в блок. Теоретически они могут вообще туда не попасть.

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

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



Загрузка...