Лабораторная работа №7
Тема: Многопоточность и параллелизм.
Цели работы:
1. Изучить и освоить способы построения многопотоковых приложений.
2. Освоить базовые средства и возможности синхронизации потоков.
3. Изучить расширенные по сравнению с базовыми возможности пакета java. util. concurrent и его подпакетов:
- фреймворк Executor, пулы потоков;
- средства блокировки;
- атомарные операции;
- расширенные средства синхронизации потоков;
Порядок выполнения работы:
Ориентируясь на собственный вариант задания на курсовую работу и используя результаты предыдущих лабораторных работ:
1. Определить набор задач, которые должны исполняться параллельно при работе приложения, например:
- отрисовка элементов графического интерфейса пользователя;
- ввод/вывод данных о настройке приложения;
- сериализация/десериализация;
- основные расчеты;
- ….
2. Выбрать наиболее подходящий для каждой задачи способ ее создания и запуска.
3. Спланировать расписание запуска задач, разработать и реализовать создание соответствующих задач и запуск их на выполнение.
4. Определить перечень объектов приложения, через которые осуществляется передача данных между каждой парой взаимодействующих потоков.
5. Разработать и реализовать безопасные алгоритмы взаимодействия всех задач в приложении.
Требования к содержанию отчета:
Отчет готовится в электронном виде и должен содержать:
- цель работы;
- описание разработанной иерархии классов и интерфейсов заданной предметной области;
- листинги классов и интерфейсов;
- документацию, подготовленную с использованием утилиты javadoc;
- выводы и заключение.
Контрольные вопросы (примерный перечень):
1. Что такое процесс и поток? В чем разница между ними?
2. Чем определяется порядок передачи управления потокам?
3. Что такое приоритет потока, каков диапазон приоритетов?
4. Может ли быть изменен приоритет выполняющегося потока?
5. Будет ли низкоприоритетный поток исполняться, если существуют высокоприоритетные потоки, постоянно нуждающиеся в процессорном времени?
6. Какие есть способы реализации многозадачности в Java?
7. Что необходимо сделать для создания подкласса потоков (подкласса Thread)?
8. Когда запускается на выполнение метод run() подкласса Thread?
9. Что произойдет при запуске задачи, если метод run определить, например, так:
public void run( int argument ) { … } ?
10. Какими методами класса Thread необходимо запускать поток на выполнение и останавливать его?
11. Что необходимо сделать для реализации классом интерфейса Runnable?
12. Можно ли из родительского потока вызвать метод run порождаемой задачи? Как будет выполняться задача?
13. В каких состояниях может находиться поток?
14. Какой поток считается новым, выполняемым и завершенным?
15. В каких ситуациях поток является невыполняемым?
16. Что такое прерывание потока?
17. Когда могут возникать исключительные ситуации при работе с потоками?
18. Что такое группы потоков и чем они полезны?
19. Что такое родовая группа потоков и главная группа потоков?
20. Чем интерфейс Callable отличается от интерфейса Runnable?
21. Что входит в базовый набор средств синхронизации потоков?
22. В каком контексте должны выполняться вызовы методов wait(), notify() и notifyAll()?
23. Как рекомендуется вызывать методы синхронизации для безопасности и живучести приложения?
24. В чем состоят сравнительные преимущества и недостатки использования методов notify() и notifyAll()?
25. Какие возможности по управлению потоками и задачами предоставляет интерфейс ExecutorService?
26. Что такое объект Future и как его можно использовать для получения результата исполнения задачи?
27. Чем интерфейс SheduledExecutorService отличается от ExecutorService?
28. Что такое пул потоков? Как можно создать и настроить пул потоков?
29. В чем состоят выгоды от использования пулов потоков?
30. В чем состоят проблемы и трудности использования пулов потоков?
31. Что такое политика честности (справедливости) использования ресурсов потоками? Почему иногда не следует реализовывать справедливую политику?
32. Чем расширение ForkAndJoin отличается от возможностей базового интерфейса Executor?
33. Что такое атомарная операция? Для чего нужны атомарные операции? Приведите примеры неатомарных операций.
34. Каковы преимущества и в чем состоят недостатки использования классов подпакета java. util. concurrent. atomic, реализующих атомарные операции?
35. Какие средства синхронизации взаимодействующих потоков содержит пакет java. util. concurrent?
36. Что такое семафор? Как и для чего используются семафоры?
37. Что такое обменник? Как и для чего используются обменники?
38. Что такое барьерная синхронизация?
39. С помощью какого класса пакета java. util. concurrent осуществляется барьерная синхронизация?
40. Что такое щеколда (защелка)? Какие виды синхронизации потоков можно реализовывать с помощью щеколды?
41. На какие группы делятся все классы по отношению к безопасности использования в многопоточном режиме?
Краткие справочные сведения:
Процессы, потоки и приоритеты
Обычно в многозадачной операционной системе выделяют такие объекты, как процессы и потоки. Между этими понятиями существует большая разница, которую следует четко представлять.
Процесс (process) - это объект, который создается операционной системой, когда пользователь запускает приложение. Процессу выделяется отдельное адресное пространство, причем это пространство физически недоступно для других процессов. Процесс может работать с файлами или с каналами связи локальной или глобальной сети.
Для каждого процесса операционная система создает один главный поток (thread), который является потоком выполняющихся по очереди команд центрального процессора. При необходимости главный поток может создавать другие потоки, пользуясь для этого программным интерфейсом операционной системы.
Все потоки, созданные процессом, выполняются в адресном пространстве этого процесса и имеют доступ к ресурсам процесса. Однако поток одного процесса не имеет никакого доступа к ресурсам потока другого процесса, так как они работают в разных адресных пространствах. При необходимости организации взаимодействия между процессами или потоками, принадлежащим разным процессам, следует пользоваться системными средствами, специально предназначенными для этого.
Приоритеты потоков
Если процесс создал несколько потоков, то все они выполняются параллельно, причем время центрального процессора (или нескольких центральных процессоров в многопроцессорных системах) распределяется между этими потоками.
Распределением времени центрального процессора занимается специальный модуль операционной системы - планировщик. Планировщик по очереди передает управление отдельным потокам, так что даже в однопроцессорной системе создается полная иллюзия параллельной работы запущенных потоков.
Следует особо отметить, что распределение времени выполняется для потоков, а не для процессов. Потоки, созданные разными процессами, конкурируют между собой за получение процессорного времени. Каждому потоку задается приоритет его выполнения, уровень которого определяет очередность выполнения того или иного потока.
Реализация многозадачности в Java
Для создания многозадачных приложений Java необходимо воспользоваться классом java. lang. Thread. В этом классе определены все методы, необходимые для создания потоков, управления их состоянием и синхронизации.
Есть две возможности использования класса Thread.
Во-первых, можно создать собственный класс на базе класса Thread. При этом необходимо переопределить метод run(). Новая реализация этого метода будет работать в рамках отдельного потока.
Во-вторых, создаваемый класс, не являясь подклассом класса Thread, может реализовать интерфейс Runnable. При этом в рамках этого класса необходимо определить метод run(), который будет работать как отдельный поток.
Создание подкласса Thread
Первый способ реализации мультизадачности основан на наследовании от класса Thread. При использовании этого способа для потоков определяется отдельный класс, например:
class myThread extends Thread
{
public void run()
{
// здесь можно добавить код, который будет
// выполняться в рамках отдельного потока
}
// здесь можно добавить специализированный для класса код
}
Метод run() должен быть всегда переопределен в классе, наследованном от Thread. Именно он определяет действия, выполняемые в рамках отдельного потока. Если поток используется для выполнения циклической работы, этот метод содержит внутри себя бесконечный цикл.
Метод run() не вызывается напрямую никакими другими методами. Он получает управление при запуске потока методом start() класса Thread. В случае апплетов создание и запуск потоков обычно осуществляется в методе start() апплета.
Остановка работающего потока раньше выполнялась методом stop() класса Thread. Обычно остановка всех работающих потоков, созданных апплетом, выполняется в методе stop() апплета. Сейчас использование этого метода считается устаревшим и не рекомендуется. Завершение работы потока желательно проводить так, чтобы происходило естественное завершение метода run. Для этого используется управляющая переменная в потоке.
Пример 1. Многопоточное приложение с использованием наследников класса Thread
import java. awt. Color;
import java. awt. Graphics;
import javax. swing. JApplet;
import javax. swing. JFrame;
// Поток для расчета координат прямоугольника
class ComputeRects extends Thread
{
boolean going = true;
// конструктор, получающий ссылку на создателя объекта - апплет
public ComputeRects(MainApplet parentObj)
{
parent = parentObj;
}
public void run()
{
while(going)
{
int w = parent. size().width-1, h = parent. size().height-1;
parent. RectCoordinates
((int)(Math. random()*w),(int)(Math. random()*h));
}
}
MainApplet parent; // ссылка на создателя объекта
}
// Поток для расчета координат овала
class ComputeOvals extends Thread
{
boolean going = true;
// конструктор, получающий ссылку на создателя объекта - апплет
public ComputeOvals(MainApplet parentObj)
{
parent = parentObj;
}
public void run()
{
while(going)
{
int w = parent. size().width-1, h = parent. size().height-1;
parent. OvalCoordinates
((int)(Math. random()*w),(int)(Math. random()*h));
}
}
MainApplet parent; // ссылка на создателя объекта
}
public class MainApplet extends JApplet
{
ComputeRects m_rects = null;
ComputeOvals m_ovals = null;
int m_rectX = 0;
int m_rectY = 0;
int m_ovalX = 0;
int m_ovalY = 0;
// Синхронный метод для установки координат прямоугольника
// из другого потока
public synchronized void RectCoordinates(int x, int y)
{
m_rectX = x; m_rectY = y;
this.repaint();
}
// Синхронный метод для установки координат овала
// из другого потока
public synchronized void OvalCoordinates(int x, int y)
{
m_ovalX = x; m_ovalY = y;
this. repaint();
}
@Override
public void start()
{
super. start();
// Запускаем потоки
if (m_rects == null) { m_rects = new ComputeRects(this);
m_rects. start(); }
if (m_ovals == null) { m_ovals = new ComputeOvals(this);
m_ovals. start(); }
}
@Override
public void stop()
{
super. stop();
// Останавливаем потоки
if(m_rects!= null) m_rects. going = false;
if(m_ovals!= null) m_ovals. going = false;
}
public void paint(Graphics g)
{
int w = this. getWidth(), h = this. getHeight();
g. clearRect(0, 0, w, h);
g. setColor(Color. red);
g. fillRect(m_rectX, m_rectY, 20, 20);
g. setColor(Color. blue);
g. fillOval(m_ovalX, m_ovalY, 20, 20);
}
public static void main(String[] args)
{
}
}
В приведенном примере координаты прямоугольника и овала рассчитываются в отдельных потоках и передаются в основной поток, который осуществляет рисование фигур.
Обратите внимание на запуск и остановку потоков в методах start и stop соответственно, а также на объявление синхронных методов и использованием ключевого слова synchronized. Синхронизация крайне важна в многопоточных приложениях, так как потоки, работающие над одними и теми же данными одновременно, могут испортить эти данные. Вместе с тем не забывайте про здравый смысл – делайте синхронными только те методы, которые нужно синхронизовать, в противном случае может произойти неоправданное замедление работы программы.
Реализация интерфейса Runnable
Если нет необходимости или возможности расширять класс Thread подобно примерам, показанным выше, то можно применить второй способ реализации многозадачности. Допустим, уже существует класс MyClass, функциональные возможности которого удовлетворяют разработчика.
Необходимо, чтобы он выполнялся как отдельный поток. Для этого необходимо для этого класса реализовать интерфейс Runnable, создавая разделяемый метод run() интерфейса Runnable:
class myClass implements Runnable
{
// код класса - объявление его элементов и методов
// этот метод получает управление при запуске потока
public void run()
{
// здесь можно добавить код, который будет
// выполняться в рамках отдельного потока
}
}
Доработаем пример 1, добавив новый поток, реализованный через интерфейс Runnable.
Пример 2. Доработанное многопоточное приложение
import java.awt.Color;
import java. awt. Graphics;
import javax. swing. JApplet;
// Поток для расчета координат прямоугольника
class ComputeRects extends Thread
{
// Без изменений, скопируйте из предыдущего примера
}
// Поток для расчета координат овала
class ComputeOvals extends Thread
{
// Без изменений, скопируйте из предыдущего примера
}
// Поток для расчета координат линии
class ComputeLines implements Runnable
{
boolean going = true;
// конструктор, получающий ссылку на создателя объекта
public ComputeLines(MainApplet parentObj)
{
parent = parentObj;
}
public void compute()
{
int w = parent. size().width-1, h = parent. size().height-1;
parent. LineCoordinates
((int)(Math. random()*w),(int)(Math. random()*h),
(int)(Math. random()*w), (int)(Math. random()*h));
}
MainApplet parent; // ссылка на создателя объекта
// этот метод получает управление при запуске потока
public void run()
{
while(going) { compute(); }
}
}
public class MainApplet extends JApplet
{
ComputeRects m_rects = null;
ComputeOvals m_ovals = null;
ComputeLines m_lines = null;
int m_rectX = 0;
int m_rectY = 0;
int m_ovalX = 0;
int m_ovalY = 0;
int m_lineX1 = 0, m_lineX2 = 0, m_lineY1 = 0, m_lineY2 = 0;
// Синхронный метод для установки координат
// прямоугольника из другого потока
public synchronized void RectCoordinates(int x, int y)
{
m_rectX = x; m_rectY = y;
this. repaint();
}
// Синхронный метод для установки координат овала
// из другого потока
public synchronized void OvalCoordinates(int x, int y)
{
m_ovalX = x; m_ovalY = y;
this. repaint();
}
// Синхронный метод для установки координат линии
// из другого потока
public synchronized void LineCoordinates(int x1, int y1,
int x2, int y2)
{
m_lineX1 = x1; m_lineX2 = x2; m_lineY1 = y1; m_lineY2 = y2;
this. repaint();
}
@Override
public void start()
{
super. start();
// Запускаем потоки
if (m_rects == null) { m_rects = new ComputeRects(this);
m_rects. start(); }
if (m_ovals == null) { m_ovals = new ComputeOvals(this);
m_ovals. start(); }
if (m_lines == null) { m_lines = new ComputeLines(this);
new Thread(m_lines).start(); }
}
@Override
public void stop()
{
super. stop();
// Останавливаем потоки
if(m_rects!= null) m_rects. going = false;
if(m_ovals!= null) m_ovals. going = false;
if(m_lines!= null) m_lines. going = false;
}
public void paint(Graphics g)
{
int w = this. getWidth(), h = this. getHeight();
g. clearRect(0, 0, w, h);
g. setColor(Color. red);
g. fillRect(m_rectX, m_rectY, 20, 20);
g. setColor(Color. blue);
g. fillOval(m_ovalX, m_ovalY, 20, 20);
g. setColor(Color. green);
g. drawLine(m_lineX1, m_lineX2, m_lineY1, m_lineY2);
}
public static void main(String[] args)
{
}
}
Применение анимации для мультизадачности
Одним из наиболее распространенных применений апплетов является создание анимационных эффектов типа бегущей строки, мерцающих огней и других эффектов, привлекающих внимание пользователя. Для достижения таких эффектов необходим механизм, позволяющий выполнять перерисовку всего окна апплета или его части периодически с заданным интервалом. Перерисовка окна апплета выполняется методом paint(), который вызывается виртуальной машиной Java асинхронно по отношению к выполнению другого кода апплета, если содержимое окна было перекрыто другими окнами. Возникает вопрос, а можно ли воспользоваться методом paint() для периодической перерисовки окна апплета, организовав в нем, например, бесконечный цикл с задержкой. К сожалению, так поступать нельзя. Метод paint() после перерисовки апплета должен сразу возвратить управление, иначе работа апплета будет заблокирована.
Единственный выход из создавшейся ситуации - создание потока (или нескольких потоков), которые будут выполнять рисование в окне апплета асинхронно по отношению к коду апплета. Например, можно создать поток, который периодически обновляет окно апплета, вызывая для этого метод repaint(), или рисовать из потока непосредственно в окне апплета. Также можно использовать класс таймера (этот механизм уже использовался в предыдущих работах). Таймер, по сути, тоже является потоком, выполняющимся параллельно с основным.
Рассмотрим пример апплета, который умеет сам себя перерисовывать при помощи дополнительного потока.
Пример 3. Самоперерисовывающийся апплет
import java.awt.Color;
import java. awt. Graphics;
import javax. swing. JApplet;
public class MainApplet extends JApplet implements Runnable
{
boolean m_isGoing = false;
@Override
public void run()
{
while (m_isGoing)
{
repaint();
try{ Thread. sleep(500); }
catch(InterruptedException e) { stop(); }
}
}
@Override
public void start()
{
super. start();
// Запускаем поток
m_isGoing = true;
new Thread(this).start();
}
@Override
public void stop()
{
super. stop();
// Останавливаем потоки
m_isGoing = false;
// Дадим потоку время завершиться
try{ Thread. sleep(500); } catch(InterruptedException e) {}
}
public void paint(Graphics g)
{
int w = this. getWidth(), h = this. getHeight();
g. setColor(new Color((int)(Math. random() * 255),
(int)(Math. random() * 255), (int)(Math. random() * 255)));
g. fillRect(0, 0, w, h);
}
public static void main(String[] args)
{
}
}
Класс Thread
Класс Thread содержит несколько конструкторов и большое количество методов для управления потоков.
Некоторые методы класса Thread:
· currentThread - Возвращает ссылку на выполняемый в настоящий момент объект класса Thread
· sleep - Переводит выполняемый в данное время поток в режим ожидания в течение указанного промежутка времени
· start - Начинает выполнение потока. Это метод приводит к вызову соответствующего метода run()
· run - Фактическое тело потока. Этот метод вызывает после запуска потока
· stop - Останавливает поток // Устаревший метод
· isAlive - Определяет, является ли поток активным (запущенным и не остановленным)
· suspend - Приостанавливает выполнение потока // Устаревший метод
· resume - Возобновляет выполнение потока. Этот метод работает только после вызова метода suspend()
· setPriority - Устанавливает приоритет потока (принимает значение от MIN_PRIORITY до MAX_PRIORITY)
· getPriority - Возвращает приоритет потока
· wait() - Переводит поток в состояние ожидания выполнения условия, определяемого переменной условия
· join() - Ожидает, пока данный поток не завершит своего существования бесконечно долго или в течении некоторого времени
· setDaemon - Отмечает данный поток как поток-демон или пользовательский поток. Когда в системе останутся только потоки-демоны, программа на языке Java завершит свою работу
· isDaemon - Возвращает признак потока-демона
Для того чтобы эффективно использовать потоки, необходимо уяснить их различные аспекты, а также особенности работы исполняющей системы языка Java. Рассмотрим атрибуты потоков.
Состояние потока
Во время своего существования поток может переходить во многие состояния, находясь в одном из нижеперечисленных состояний:
· Новый поток
· Выполняемый поток
· Невыполняемый поток
· Завершенный поток
Новый поток
При создании экземпляра потока этот поток приобретает состояние “Новый поток”:
Thread myThread=new Thread();
В этот момент для данного потока распределяются системные ресурсы; это всего лишь пустой объект. В результате все, что с ним можно делать - это запустить:
myThread.start();
Любой другой метод потока в таком состоянии вызвать нельзя, это приведет к возникновению исключительной ситуации.
Выполняемый поток
Когда поток получает метод start(), он переходит в состояние “Выполняемый поток”. Процессор разделяет время между всеми выполняемыми потоками согласно их приоритетам.
Невыполняемый поток
Если поток не находится в состоянии “Выполняемый поток”, то он может оказаться в состоянии “Невыполняемый поток”. Это состояние наступает тогда, когда выполняется одно из четырех условий:
· Поток был приостановлен. Это условие является результатом вызова метода suspend(). После вызова этого метода поток не находится в состоянии готовности к выполнению; его сначала нужно “разбудить” с помощью метода resume(). Это полезно в том случае, когда необходимо приостановить выполнение потока, не удаляя его. Поскольку метод suspend не рекомендуется к использованию, приостановка потока должна выполняться через управляющую переменную.
· Поток ожидает. Это условие является результатом вызова метода sleep(). После вызова этого метода поток переходит в состояние ожидания в течении некоторого определенного промежутка времени и не может выполняться до истечения этого промежутка. Даже если ожидающий поток имеет доступ к процессору, он его не получит. Когда указанный промежуток времени пройдет, поток переходит в состояние “Выполняемый поток”. Метод resume() не может повлиять на процесс ожидания потока, этот метод применяется только для приостановленных потоков.
· Поток ожидает извещения. Это условие является результатом вызова метода wait(). С помощью этого метода потоку можно указать перейти в состояние ожидания выполнения условия, определяемого переменной условия, вынуждая его тем самым приостановить свое выполнение до тех пор, пока данное условие удовлетворяется. Какой бы объект не управлял ожидаемым условием, изменение состояния ожидающих потоков должно осуществляться посредством одного из двух методов этого потока - notify() или notifyAll(). Если поток ожидает наступление какого-либо события, он может продолжить свое выполнение только в случае вызова для него этих методов.
· Поток заблокирован другим потоком. Это условие является результатом блокировки операцией ввода-вывода или другим потоком. В этом случае у потока нет другого выбора, как ожидать до тех пор, пока не завершится команда ввода-вывода или действия другого потока. В этом случае поток считается невыполняемым, даже если он полностью готов к выполнению.
Завершенный поток
Когда метод run() завершается, поток переходит в состояние “Завершенный поток”.
Исключительные ситуации для потоков
Исполняющая система языка Java будет возбуждать исключительную ситуацию IllegalThreadStateException всякий раз, когда будет вызываться метод, которым поток не может оперировать в своем текущем состоянии.
Например, ожидающий поток не может работать с методом resume(). В этом случае поток занят ожиданием, он просто не знает, как реагировать на этот метод.
То же самое справедливо и в том случае, когда происходит попытка вызвать метод suspend() для потока, который не находится в состоянии “Выполняемый поток”. Если он уже был приостановлен, просто ожидает, ожидает условия или заблокирован операцией ввода-вывода, поток не понимает как работать с этим методом.
Всякий раз при вызове метода потока, который потенциально может привести к возникновению исключительной ситуации, необходимо обеспечить и способ обработки исключительных ситуаций для того, чтобы перехватывать любые возбуждаемые ситуации, например:
try
{ // здесь вызываются методы для потоков
}
catch(InterruptedException e)
{ // потоку был послан метод, которым он
// не может оперировать в данном состоянии,
// можно остановить поток его методом stop()
}
Приоритеты потоков
В языке Java каждый поток обладает приоритетом, который оказывает влияние на порядок его выполнения. Потоки с высоким приоритетом выполняются до потоков с низким приоритетом. Это существенно, поскольку возникают моменты, когда их необходимо разделить подобным образом.
Поток наследует свой приоритет от потока, его создавшего. Если потоку не присвоен новый приоритет, он будет сохранять данный приоритет до своего завершения. Приоритет потока можно установить с помощью метода setPriority(), присваивая ему значение от MIN_PRIORITY до MAX_PRIORITY (константы класса Thread). По умолчанию потоку присваивается приоритет Thread. NORM_PRIORITY.
Порядок выполнения потоков и количество времени, которое они получат от процессора, - главные вопросы для разработчиков. Каждый поток должен разделять процессорное время с другими, не монополизируя систему.
Система, которая имеет дело с множеством выполняющихся потоков, может быть или приоритетная, или неприоритетная. Приоритетные системы гарантируют, что в любое время будет выполняться поток с самым высоким приоритетом. Виртуальная машина Java является приоритетной, то есть выполняться всегда будет поток с самым высоким приоритетом.
Потоки в языке Java планируются с использованием алгоритма планирования с фиксированными приоритетами. Этот алгоритм, по существу, управляет потоками на основе их взаимных приоритетов, кратко его можно изложить в виде следующего правила: в любой момент времени будет выполняться “Выполняемый поток” с наивысшим приоритетом.
Как выполняются потоки одного и того же приоритета, в спецификации Java не описано. Казалось бы, потоки должны использовать процессор совместно, но это не всегда так. Порядок их выполнения определен основной операционной системой и аппаратными средствами. Операционная система обслуживает потоки при помощи планировщика, который и определяет порядок выполнения.
Группы потоков
Все потоки в языке Java должны входить в состав группы потоков. В классе Thread имеется три конструктора, которые дают возможность указывать, в состав какой группы должен входить данный создаваемый поток.
Группы потоков особенно полезны, поскольку внутри их можно запустить или приостановить все потоки, а это значит, что при этом не потребуется иметь дело с каждым потоком отдельно. Группы потоков предоставляют общий способ одновременной работы с рядом потоков, что позволяет значительно сэкономить время и усилия, затрачиваемые на работу с каждым потоком в отдельности.
В приведенном ниже фрагменте программы создается группа потоков под названием genericGroup (родовая группа). Когда группа создана, создаются несколько потоков, входящих в ее состав:
ThreadGroup genericGroup=new ThreadGroup("My generic group");
Thread t1=new Thread(genericGroup, this);
Thread t2=new Thread(genericGroup, this);
Thread t3=new Thread(genericGroup, this);
Если при создании нового потока не указать, к какой конкретной группе он принадлежит, этот поток войдет в состав группы потоков main (главная группа) языка Java. Иногда ее еще называют текущей группой потоков. В случае апплета main может и не быть главной группой. Право присвоения имени принадлежит WWW-браузеру. Для того чтобы определить имя группы потоков, можно воспользоваться методом getName() класса ThreadGroup.
Для того чтобы определить к какой группе принадлежит данный поток, используется метод getThreadGroup(), определенный в классе Thread. Этот метод возвращает имя группы потоков, в которую можно послать множество методов, которые будут применяться к каждому члену этой группы.
Выбор между интерфейсом java.lang.Runnable и классом java.lang.Thread
Как было показано ранее, при необходимости обеспечить параллельное выполнение нескольких задач у программиста есть возможность выбрать, как именно реализовать эти задачи: с помощью класса Thread или интерфейса Runnable. У каждого подхода есть свои преимущества и недостатки.
В качестве основного преимущества при наследовании класса Thread заявляется полный доступ ко всем функциональным возможностям потока на платформе Java. Главным недостатком же считается как раз сам факт наследования, так как в силу того, что в Java применяется только одиночное наследование, то наследование классу Thread автоматически закрывает возможность наследовать другим классам. Для классов, отвечающих за доменную область или бизнес-логику, это может стать серьезной проблемой. Правда негативное воздействие, возникающее из-за невозможности наследования, можно ослабить, если вместо него применить прием делегирования или соответствующие шаблоны проектирования.
Использование интерфейса Runnable по умолчанию лишено этого недостатка, но если реализовать задачу таким способом, то придется потратить дополнительные усилия на ее запуск. Как было показано в листинге 2, для запуска Runnable-задачи все равно потребуется объект Thread, также в этом случае исчезнет возможность прямого управления потоком из задачи. Хотя последнее ограничение можно обойти с помощью статических методов класса Thread (например, метод currentThread() возвращает ссылку на текущий поток).
Поэтому сделать однозначный вывод о превосходстве какого-либо подхода довольно сложно, и чаще всего в приложениях одновременно используются оба варианта, но для решения задач различной направленности. Считается, что наследование класса Thread следует применять только тогда, когда действительно необходимо создать «новый вид потока, который должен дополнить функциональность класса java. lang. Thread», и подобное решение применяется при разработке системного ПО, например, серверов приложений или инфраструктур. Использование интерфейса Runnable показано в случаях, когда просто «необходимо одновременно выполнить несколько задач» и не требуется вносить изменений в сам механизм многопоточности, поэтому в бизнес-ориентированных приложениях в основном используется вариант с интерфейсом Runnable.
Ограничения классического подхода
Когда программист только начинает работать с многопоточными возможностями Java-платформы, то на первых порах он может даже впасть в состояние «эйфории», особенно, если у него уже был негативный опыт по созданию многопоточных приложений в других языках программирования. Действительно, система управления потоками в Java организованна крайне удачно, так как этот компонент платформы был детально проработан еще на стадии проектирования самых первых версий виртуальной Java-машины и языка программирования Java, и сейчас ему по-прежнему уделяется большое внимание. Однако со временем «розовые очки» спадают, и программист начинает замечать «раздражающие» моменты, которые усложняют организацию параллельного исполнения задач в Java.
Первым, что бросается в глаза, оказывается слияние низкоуровневого кода, отвечающего за многопоточное исполнение, и высокоуровневого кода, отвечающего за основную функциональность приложения (так называемый «спагетти-код»). В листинге 1 показано, что бизнес—код и поточный код вообще находятся в одном классе, но даже в более удачном варианте из листинга 2 для выполнения задачи все равно требуется создать объект Thread и запустить его. Подобное перемешивание снижает качество архитектуры приложения и может затруднить его последующее сопровождение.
Но даже если удалось отделить поточный код от основного, то возникает проблема, связанная уже с управлением самими потоками. Потоки в Java запускаются только путем вызова метода start и останавливаются после вызова соответствующих методов или самостоятельно после завершения работы метода run. Также после того, как поток остановился, его нельзя запустить второй раз, что и приводит к следующим негативным моментам:
- поток занимает относительно много места в куче, так что после его завершения необходимо проследить, чтобы память, занимаемая им, была освобождена (например, присвоить ссылке на поток значение null); для выполнения новой задачи потребуется запустить новый поток, что приведет к увеличению «накладных расходов» на виртуальную машину, так как запуск потока – это одна из самых требовательных к ресурсам операций.
Если же удалось превратить поток в «многоразовый», то программист сталкивается с проблемой, как понять, что поток уже закончил выполнение задачи и ему можно выдавать следующую. Необходимо еще учитывать тот факт, что выполнение задачи может завершиться неудачно, например, возникновением исключительной ситуации, и подобная ситуация не должна повлиять на выполнение других задач.
Важно сказать, что «среднестатистический» программист будет отнюдь не первым, кто сталкивается с подобными проблемами в многозадачных приложениях. Все эти проблемы были давно проанализированы Java-сообществом и нашли решение в признанных шаблонах проектирования (например, ThreadPool (пул потоков) и WorkerThread (рабочий поток)). Но скорее всего, у рядового программиста, ограниченного временными рамками проекта, просто не будет времени или ресурсов, чтобы подготовить и самое главное протестировать полноценную реализацию данных шаблонов. А неточное или неполное внедрение этих шаблонов (да и вообще любых шаблонов проектирования) может в будущем негативно сказаться на этапе сопровождения продукта.
Новые возможности пакета java. uti. concurrent
Платформа Java постоянно развивается, и поэтому к существующей функциональности все время добавляются новые возможности. Иногда новая функциональность берется из уже существующих сторонних библиотек, при этом речь не идет о банальном копировании, а скорее о переосмыслении и доработке уже существующих решений. Подобным способом в версию Java 5 был добавлен пакет java. util. concurrent, включающий в себя множество уже проверенных и хорошо зарекомендовавших себя приемов для параллельного выполнения задач (этот пакет - только одно из множества важных нововведений, представленных в Java 5).
Интерес представляют уже готовые к использованию реализации шаблонов WorkerThread и ThreadPool, а также еще один способ реализации задач для параллельного выполнения, кроме упоминавшихся класса Thread и интерфейса Runnable. Ещё в пакете java. util. concurrent находятся два подпакета: java. util. concurrent. locks и java. util. concurrent. atomic, с которыми тоже стоит ознакомиться, так как они значительно упрощают организацию взаимодействия между потоками и параллельного доступа к данным.
Создание задачи с помощью интерфейса java. util. concurrent. Callable
Интерфейс Callable гораздо больше подходит для создания задач, предназначенных для параллельного выполнения, нежели интерфейс Runnable или тем более класс Thread. При этом стоит отметить, что возможность добавить подобный интерфейс появилась только начиная с версии Java 5, так как ключевая особенность интерфейса Callable – это использование параметризованных типов (generics), как показано в листинге:
Создание задачи с помощью интерфейса Callable
import java. util. concurrent. Callable;
public class CallableSample implements Callable<String>{
public String call() throws Exception {
if(какое-то условие) {
throw new IOException("error during task processing");
}
System. out. println("task is processing");
return "result ";
}
}
Сразу необходимо обратить внимание на строку 2, где указано, что интерфейс Callable является параметризованным, и его конкретная реализация – класс CallableSample, зависит от типа String. В строке 3 приведена сигнатура основного метода call в уже параметризованном варианте, так как в качестве типа возвращаемого значения также указан тип String. Фактически это означает, что была создана задача, результатом выполнения которой будет объект типа String (см. строку 8). Точно также можно создать задачу, в результате работы которой в методе call будет создаваться и возвращаться объект любого требуемого типа. Такое решение значительно удобнее по сравнению с методом run в интерфейсе Runnable, который не возвращает ничего (его возвращаемый тип – void) и поэтому приходится изобретать обходные пути, чтобы извлечь результат работы задачи.
Еще одно преимущество интерфейса Callable – это возможность «выбрасывать» исключительные ситуации, не оказывая влияния на другие выполняющиеся задачи. В строке 3 указано, что из метода может быть «выброшена» исключительная ситуация типа Exception, что фактически означает любую исключительную ситуацию, так как все исключения являются потомками java. lang. Exception. На строке 5 эта возможность используется для создания контролируемой (checked) исключительной ситуации типа IOException. Метод run интерфейса Runnable вообще не допускал выбрасывания контролируемых исключительных ситуаций, а выброс неконтролируемой (runtime) исключительной ситуации приводил к остановке потока и всего приложения.
Интерфейс Executor
При использовании любого из вышеописанных способов запуска задач определенной трудностью является то, что в них необходимо явно создавать объекты класса Thread. В некоторых JVM создание потока является дорогостоящей операцией, поэтому хотелось бы повторно использовать уже существующие потоки а не создавать новые. В то же время в других JVM все наоборот: потоки довольно легковесны и поэтому гораздо лучше при необходимости создать новый поток. Конечно Мёрфи (как обычно) окажется прав, и какой бы подход ни был предпринят, он окажется неправильным для платформы, на которой в итоге будет разворачиваться приложение…
Тем не менее с появлением пакета java. util. concurrent был введен интерфейс Executor – абстракция для получения новых потоков. Executor позволяет создавать потоки без самостоятельного использования оператора new для объекта Thread:
Executor exec = getAnExecutorFromSomeplace();
exec. execute(new Runnable() { ... });
Использование Executor имеет основной недостаток всех фабрик: фабрика должна откуда-то брать объекты. К сожалению, в отличие от CLR, в спецификации JVM нет стандартного пула потоков, доступного в любой виртуальной машине.
Класс Executors является средством получения объектов, реализующих интерфейс Executor, однако он предлагает только new-методы (например, для создания нового пула потоков); у него нет заранее созданных экземпляров. Поэтому, если нужно создавать и использовать в коде экземпляры Executor, то придется делать это самостоятельно. Этого недостатка лишен интерфейс ExecutorService/
Запуск задач с помощью java. util. concurrent. ExecutorService
Облегчив с помощью интерфейса Callable создание задач для параллельного выполнения, пакет java. util. concurrent также берет на себя работу по запуску и остановке потоков. Вместо объекта Thread предлагается использовать объект типа ExecutorService, с помощью которого пользователь может просто поместить задачу в очередь на выполнение и ждать получения результата. Можно сказать, что ExecutorService – это значительно усовершенствованная реализация шаблона WorkerThread.
ExecutorService – это интерфейс, поэтому для выполнения задач используются его конкретные потомки, адаптированные под требования разрабатываемого приложения. Однако программисту нет необходимости создавать собственную реализацию ExecutorService, так как в пакете java. util. concurrent уже присутствуют различные варианты реализации ExecutorService. Доступ к ним можно получить через статические методы служебного класса Executors, метод которого newFixedThreadPool возвращает объект типа ExecutorService со встроенной поддержкой шаблона ThreadPool. Также в классе Executors есть и другие методы для создания объектов ExecutorService с различными свойствами.
Наибольший интерес в ExecutorService представляет метод submit, через который задача ставится в очередь на выполнение. На вход этот метод принимает объект типа Callable или Runnable, а возвращает некий параметризованный объект типа Future. Этот объект можно использовать для доступа к результату выполнения задачи, который будет возвращен из метода call соответствующего Callable-объекта. При этом через объект Future можно проверить, закончено ли уже выполнение задачи – с помощью метода isDone и через метод get получить доступ к результату или исключительной ситуации, если в процессе выполнения задачи произошла ошибка.
Таким образом, при запуске задач с помощью классов из пакета java. util. concurrent не требуется прибегать к низкоуровневой поточной функциональности класса Thread, достаточно создать объект типа ExecutorService с нужными свойствами и передать ему на исполнение задачу типа Callable. Впоследствии можно легко просмотреть результат выполнения этой задачи с помощью объекта Future, как показано в листинге:
Запуск задачи с помощью классов пакета java. util. concurrent
public class ExecutorServiceSample {
public static void main(String[] args) {
//создать ExecutorService на базе пула из пяти потоков
ExecutorService es1 = Executors. newFixedThreadPool(5);
//поместить задачу в очередь на выполнение
Future<String> f1 = es1.submit(new CallableSample());
while(!f1.isDone()) {
//подождать пока задача не выполнится
}
try {
//получить результат выполнения задачи
System. out. println("task has been completed : " + f1.get());
} catch (InterruptedException ie) {
ie. printStackTrace(System. err);
} catch (ExecutionException ee) {
ee. printStackTrace(System. err);
}
es1.shutdown();
}
}
Стоит обратить внимание на строку 18, где происходит остановка объекта ExecutorService с помощью метода shutdown. Дело в том, что потоки в объекте ExecutorService не останавливаются сами, как обычно, поэтому их необходимо явно остановить с помощью этого метода, при этом если в ExecutorService находятся невыполненные задачи, то потоки будут остановлены только, когда завершится последняя задача.
Класс ScheduledExecutorServices
Интерфейс ExecutorService хорош, однако он не подходит для случая, когда некоторые задачи необходимо делать по расписанию, например выполнять что-то через определенные интервалы времени или в заданное время. Здесь пригодится класс ScheduledExecutorService, расширяющий класс ExecutorService.
Если бы было нужно написать команду, отображающую "сердцебиение" программы, которая бы выполнялась каждые пять секунд, с помощью ScheduledExecutorService это можно было бы сделать очень просто, например так, как показано в листинге:
//ScheduledExecutorService выполняет 'пинг' по расписанию
import java. util. concurrent.*;
public class Ping
{
public static void main(String[] args)
{
ScheduledExecutorService ses =
Executors. newScheduledThreadPool(1);
Runnable pinger = new Runnable() {
public void run() {
System. out. println("PING!");
}
};
ses. scheduleAtFixedRate(pinger, 5, 5, TimeUnit. SECONDS);
}
}
Здесь не нужно заботиться ни о потоках, ни о том, что делать, если пользователь захочет остановить "сердцебиение", не нужно явно обозначать потоки как средство реализации; всеми этими деталями планирования занимается ScheduledExecutorService.
Кстати, если пользователь захочет отменить "сердцебиение", то это можно сделать с помощью экземпляра класса ScheduledFuture, возвращаемого вызовом метода scheduleAtFixedRate. Этот класс не только оборачивает в себя результат (если таковой имеется), но и имеет метод cancel для остановки запланированной операции.
О пулах потоков
Работа многих серверных приложений, таких как Web-серверы, серверы базы данных, серверы файлов или почтовые серверы, связана с совершением большого количества коротких задач, поступающих от какого-либо удаленного источника. Запрос прибывает на сервер определенным образом, например, через сетевые протоколы (такие как HTTP, FTP или POP), через очередь JMS, или, возможно, путем опроса базы данных. Независимо от того, как запрос поступает, в серверных приложениях часто бывает, что обработка каждой индивидуальной задачи кратковременна, а количество запросов большое.
Одной из упрощенных моделей для построения серверных приложений является создание нового потока каждый раз, когда запрос прибывает и обслуживание запроса в этом новом потоке. Этот подход в действительности хорош для разработки прототипа, но имеет значительные недостатки, что стало бы очевидным, если бы вам понадобилось развернуть серверное приложение, работающее таким образом. Один из недостатков подхода "поток-на-запрос" состоит в том, что системные издержки создания нового потока для каждого запроса значительны; a сервер, создавший новый поток для каждого запроса, будет тратить больше времени и потреблять больше системных ресурсов, создавая и разрушая потоки, чем он бы тратил, обрабатывая фактические пользовательские запросы.
В дополнение к издержкам создания и разрушения потоков, активные потоки потребляют системные ресурсы. Создание слишком большого количества потоков в одной JVM (виртуальной Java-машине) может привести к нехватке системной памяти или пробуксовке из-за чрезмерного потребления памяти. Для предотвращения пробуксовки ресурсов, серверным приложениям нужны некоторые меры по ограничению количества запросов, обрабатываемых в заданное время.
Пул потоков предлагает решение и проблемы издержек жизненного цикла потока, и проблемы пробуксовки ресурсов. При многократном использовании потоков для решения многочисленных задач, издержки создания потока распространяются на многие задачи. В качестве бонуса, поскольку поток уже существует, когда прибывает запрос, задержка, произошедшая из-за создания потока, устраняется. Таким образом, запрос может быть обработан немедленно, что делает приложение более быстрореагирующим. Более того, правильно настроив количество потоков в пуле потоков, вы можете предотвратить пробуксовку ресурсов, заставив любые запросы, если их количество выходит за определенные пределы, ждать до тех пор, пока поток не станет доступным, чтобы его обработать.
Альтернативы пулам потоков
Пулы потоков - это далеко не единственный способ использовать множественные потоки в серверном приложении. Как упоминалось ранее, иногда довольно разумно генерировать новый поток для каждой новой задачи. Однако, если частота создания задач высока, а их средняя продолжительность низка, порождение нового потока для каждой задачи приведет к проблемам с производительностью.
Другая распространенная модель организации поточной обработки - наличие единого фонового потока и очереди задач для задач определенного типа. AWT (набор инструментальных средств для абстрактных окон) и Swing используют эту модель, в которой есть поток событий GUI (графического интерфейса пользователя), и вся работа, вызывающая изменения в пользовательском интерфейсе, должна выполняться в этом потоке. Однако, поскольку существует только один AWT-поток, нежелательно выполнять задачи в потоке AWT, завершение которого может занять значительное количество времени. В результате, приложения Swing часто требуют дополнительных потоков "исполнителя" для решения долгосрочных, связанных с пользовательским интерфейсом (UI) задач.
Подходы "поток-на-задачу" и "единый фоновый поток" могут довольно хорошо функционировать в определенных ситуациях. Подход "поток-на-задачу" хорошо работаeт с небольшим количеством долгосрочных задач. Подход "единый фоновый поток" функционирует довольно хорошо, если не важна предсказуемость распределения (scheduling predictability), как в случае низкоприоритетных фоновых (low-priority background) задач. Однако большая часть серверных приложений ориентированы на обработку большого количества краткосрочных задач или подзадач, и нужно иметь механизм для эффективного осуществления этих задач с небольшими издержками, а также какую-либо меру управления ресурсами и предсказуемостью времени выполнения. Пулы потоков дают следующие преимущества.
Очереди действий
Конечно, мы могли легко применять класс пула потоков, в котором класс клиентов ожидал бы доступного потока, передавал бы задачу этому потоку для исполнения и затем возвращал бы поток к пулу, когда все окончено; но этот подход имеет несколько потенциально нежелательных эффектов. Что, например, если пул пуст? Любая вызывающая сторона, которая предприняла бы попытку передать задачу потоку пулов, обнаружила бы, что пул пуст, и ее поток заблокировался бы, ожидая доступного потока пулов. Часто одной из причин, по которой нам бы хотелось использовать фоновые потоки, является необходимость предотвращения блокировки подающего (submitting) потока. Проталкивание блокировки к вызывающей стороне, что происходит в случае с "очевидным" применением пула потоков, может закончиться возникновением таких же проблем, какие мы пытались решить.
То, что нам обычно нужно - это рабочая очередь в сочетании с фиксированной группой рабочих потоков, в которой используются wait() и notify(), чтобы сигнализировать ожидающим потокам, что прибыла новая работа. Очередь действий главным образом применяется как связанный список с присоединенным объектом монитора. Листинг 1 показывает пример простой, объединенной в пул очереди. Эта модель, используя очередь Runnable (работоспособных) объектов, является обычной для планировщиков и очередей действий, хотя нет особой необходимости из-за Thread API использовать интерфейс Runnable.
public class WorkQueue
{
private final int nThreads;
private final PoolWorker[] threads;
private final LinkedList queue;
public WorkQueue(int nThreads)
{
this. nThreads = nThreads;
queue = new LinkedList();
threads = new PoolWorker[nThreads];
for (int i=0; i<nThreads; i++) {
threads[i] = new PoolWorker();
threads[i].start();
}
}
public void execute(Runnable r) {
synchronized(queue) {
queue. addLast(r);
queue. notify();
}
}
private class PoolWorker extends Thread {
public void run() {
Runnable r;
while (true) {
synchronized(queue) {
while (queue. isEmpty()) {
try
{
queue. wait();
}
catch (InterruptedException ignored)
{
}
}
r = (Runnable) queue. removeFirst();
}
// If we don't catch RuntimeException,
// the pool could leak threads
try {
*****n();
}
catch (RuntimeException e) {
// You might want to log something here
}
}
}
}
}
Возможно, вы заметили, что в реализации задач используется notify() вместо notifyAll(). Большинство экспертов советуют использовать notifyAll() вместо notify(), и не напрасно: есть некоторые моменты риска, ассоциирующиеся с notify(), его использование является верным в определенных специфических условиях. С другой стороны, в случае надлежащего использования, notify() имеет более желательные рабочие характеристики, чем notifyAll(). Метод notify() вызывает гораздо меньше переключений процессов, что является важным в работе серверных приложений.
Возможный риск при использовании пулов потоков
Хотя пул потоков - мощный механизм для структурирования многопоточных приложений, он связан с определенным риском. Приложения, построенные при помощи пулов потоков, подвержены всем тем параллельным рискам, что и любое другое многопоточное приложение, как, например, ошибки синхронизации и взаимоблокировка, и также нескольким другим рискам, специфических также для пулов потоков, таких, как зависимая от пулов взаимоблокировка, пробуксовка ресурсов и рассеяние потока.
Взаимоблокировка
В любом многопоточном приложении есть риск взаимоблокировки. Говорят, что набор процессов или потоков взаимоблокирован, когда каждый ожидает события, которое может быть вызвано другим процессом. Простейший случай взаимоблокировки - когда поток A полностью блокирует объект X и ожидает блокировки объекта Y, в то время как поток B полностью блокирует объект Y и ожидает блокировки объекта X. И если нет какого-либо способа вырваться из ожидания блокирования (что блокирующее устройство Java не поддерживает), взаимоблокированные потоки будут ожидать вечно.
Поскольку взаимоблокировка - риск в любой многопоточной программе, пулы потоков предполагают другую возможность взаимоблокировки, где все потоки пула осуществляют задачи, которые блокируются в ожидании результатов другой задачи в очереди, но эта задача не может запуститься, поскольку нет доступного незанятого потока. Это может случиться, когда пулы потоков используются для проведения имитационных экспериментов, включающих большое количество взаимодействующих объектов, имитационные объекты могут посылать запросы друг другу, которые потом выполняются как задачи из очереди, и запрашиваемый объект синхронно ожидает ответа.
Пробуксовка ресурсов
Одно из преимуществ пулов потоков состоит в том, что они обычно хорошо выполняют операции, имеющие отношение к альтернативным распределяющим механизмам, некоторые из которых мы уже обсудили. Но это верно только в том случае, если размер пула потоков настроен правильно. Потоки потребляют многочисленные ресурсы, включая память и другие системные ресурсы. Кроме памяти, требующейся для объекта Thread, каждый поток требует двух списков вызовов выполнения, которые могут быть большими. В дополнение к этому, JVM, возможно, создаст "родной" поток для каждого Java-потока, что связано с потреблением дополнительных системных ресурсов. Наконец, поскольку распределяющиеся издержки переключения между потоками малы, для многих потоков переключение процессов может стать значительным замедлением в работе программы.
Если пул потоков слишком велик, ресурсы, потребляемые этими потоками, могут в значительной степени повлиять на работу системы. Время будет напрасно потрачено на переключение между потоками, и если у вас потоков больше, чем необходимо, это может вызвать проблемы ресурсного голодания, так кака потоки пулов потребляют ресурсы, которые могли бы быть более эффективно использованы другими задачами. В дополнение к ресурсам, использующимися самими потоками, работа, выполняемая по обслуживанию запросов, может также требовать дополнительных ресурсов, таких как соединения JDBC, сокеты, или файлы. Эти ресурсы также ограничены, и слишком много параллельных запросов могут вызвать сбои, такие как невозможность определении места JDBC-соединения.
Параллельные ошибки
Пулы потоков и другие механизмы ведения очередей опираются на методы wait() и notify(), которые могут быть коварны. Уведомления, если их неправильно закодировать, могут быть утеряны, в результате потоки остаются в состоянии бездействия, даже если в очереди есть работа, которая должна быть выполнена. При использовании этих средств необходимо соблюдать большую осторожность; даже эксперты делают ошибки при работе с ними. Еще лучше использовать проверенную в работе реализацию, например пакет java. util. concurrent.
Утечка потока
Существенный риск в самых разных пулах потоков заключается в утечке потока, которая случается, когда поток удаляется из пула для выполнения задачи, но не возвращается в пул, когда задача выполнена. Во-первых, это происходит, когда задача выдает RuntimeException или Error. Если класс пула их не воспринимает, тогда поток просто прекращается и размер пула потоков сокращается на один. Когда это произойдет достаточное количество раз, пул потоков окажется пустым, и система заблокируется, потому, что нет потоков, доступных для осуществления задач.
Задачи, которые постоянно блокируются, например, те, что потенциально ждут ресурсов, которые могут и не стать доступными, или ждут ввода со стороны пользователя, который, возможно, ушел домой, могут также вызвать эффект, эквивалентный утечке потока. Если поток постоянно занимается такой задачей, он фактически удален из пула. Таким задачам следует либо выделять собственный поток, либо ограничить время ожидания.
Перегрузка запросами
Бывает, что сервер просто переполнен запросами. В этом случае мы, возможно, не захотим, чтобы каждый входящий запрос помещался в нашу рабочую очередь, потому, что задачи, ожидающие выполнения, могут потреблять слишком много системных ресурсов и вызвать ресурсное голодание. Вам решать, что делать в таком случае; в некоторых ситуациях вы, возможно, можете просто выбросить запрос, надеясь, что протоколы более высокого уровня повторят запрос позже, или, возможно, вы захотите отказаться от запроса, сообщив, что сервер временно занят.
Руководство по эффективному использованию пулов потоков
Пулы потоков могут быть чрезвычайно эффективным способом структурирования серверных приложений, при условии, что вы следуете некоторым простым правилам:
· Не ставьте в очередь задачи, которые одновременно ожидают результатов других задач. Это может вызвать взаимоблокировку описанной выше формы, где все потоки заняты задачами, которые в свою очередь ожидают результатов от задач в очереди, не выполняющихся, поскольку все потоки заняты.
· Будьте осторожны, используя объединенные в пулы потоки для потенциально продолжительных операций. Если программа должна ждать ресурс, такой как осуществление I/O (ввода - вывода), укажите максимальное время ожидания, а затем выведите или возвратите в очередь задачу для выполнения в более позднее время. Это гарантирует, что некоторый прогресс будет достигнут путем освобождения потока для задач, которые могли бы успешно осуществиться.
· Разберитесь в своих задачах. Чтобы эффективно настроить размер пула потоков, вам нужно понять, что за задачи в очереди, и что они выполняют. Ограничены ли они возможностями процессора? Есть ли ограничения ввода/вывода? От ваших ответов зависит, как вы настроите свое приложение. Если у вас есть разные классы задач с радикально отличающимися характеристиками, возможно, имеет смысл иметь несколько рабочих очередей для разных типов задач, так, чтобы каждый пул можно было соответственно настроить.
Настройка размера пула
Настраивая размер пула потоков, важно избежать двух ошибок: слишком мало потоков или слишком много потоков. К счастью, для большинства приложений спектр между слишком большим и слишком малым количеством потоков довольно широк.
Если вы помните, есть два основных преимущества в организации поточной обработки сообщений в приложениях: возможность продолжения процесса во время ожидания медленных операций, таких, как I/O (ввод - вывод), и использование возможностей нескольких процессоров. В приложениях с ограничением по скорости вычислений, функционирующих на N-процессорной машине, добавление дополнительных потоков может улучшить пропускную способность, по мере того как количество потоков подходит к N, но добавление дополнительных потоков свыше N не оправдано. Действительно, слишком много потоков разрушают качество функционирования из-за дополнительных издержек переключения процессов
Оптимальный размер пула потоков зависит от количества доступных процессоров и природы задач в рабочей очереди. На N-процессорной системе для рабочей очереди, которая будет выполнять исключительно задачи с ограничением по скорости вычислений, вы достигните максимального использования CPU с пулом потоков, в котором содержится N или N+1 поток.
Для задач, которые могут ждать осуществления I/O (ввода - вывода) -- например, задачи, считывающей HTTP-запрос из сокета - вам может понадобиться увеличение размера пула свыше количества доступных процессоров, потому, что не все потоки будут работать все время. Используя профилирование, вы можете оценить отношение времени ожидания (WT) ко времени обработки (ST) для типичного запроса. Если назвать это соотношение WT/ST, для N-процессорной системе вам понадобится примерно N*(1+WT/ST) потоков для полной загруженности процессоров.
Использование процессора - не единственный фактор, важный при настройке размера пула потоков. По мере возрастания пула потоков, можно столкнуться с ограничениями планировщика, доступной памяти, или других системных ресурсов, таких, как количество сокетов, дескрипторы открытого файла, или каналы связи базы данных
Пакет java. util. concurrent кроме Фреймворк Executor содержит еще много полезных возможностей в области многопоточных вычислений:
· Атомарные классы
· Lock объекты
· Синхронизаторы
· Параллельные коллекции
Атомарные классы
Атомарные классы предоставляют возможность атомарного выполнения операций основных примитивных и ссылочных типов.
В качестве примера рассмотрим класс AtomicInteger и его основные методы. Как понятно из названия, данный класс является оберткой вокруг примитивного типа int, предоставляющей возможность атомарно обновлять его значения.
Основные методы данного класса:
· public final int get() – получение текущего значения
· public final void set(int newValue) – установка нового значения
· public final int incrementAndGet() – атомарный аналог операции “++x”.
· public final long getAndIncrement() – атомарный аналог операции “x++”.
· public final int addAndGet(int delta) – атомарно увеличивает текущее значение на delta и возвращает новое значение. public final int getAndAdd(int delta) – атомарно увеличивает текущее значение на delta и возвращает предыдущее значение.
· public final long getAndSet(long newValue) – атомарная установка нового значения и возвращение предыдущего. public final boolean compareAndSet(int expect, int update) – атомарная установка нового значения в случае, если текущее равно значению параметра expect. В случае успешной операции метод возвращаем булево значение true.
Пример использования 1
private AtomicInteger someValue;
//…
int previousBits, newBits;
do {
previousValue = someValue. get();
newValue = changeValue(previousValue);
} while (!pareAndSet(previousValue, newValue));
В приведенном примере совершается попытка обновления некоторого значения до тех пор, пока обновление не будет выполнено успешно. Благодаря использованию атомарной переменной, мы избежали необходимости использовать синхронизацию.
Довольно часто возникают ситуации, когда атомарное обновление необходимо только в одном из многих случаев использования объекта. Безусловно, в такой ситуации не хочется обременять себя работой с атомарными типами и не иметь возможности работать с объектом напрямую. Для этих целей прекрасно подойдет AtomicReferenceFieldUpdater. Само поле класса, которое будет использовано совместно с AtomicReferenceFieldUpdater, должно быть объявлено с ключевым словом volatile. Из рассмотрено ниже примера станет ясно, каким образом это можно сделать.
Пример использования 2
public class Node {
private volatile InnerNode next;
//…
}
//…
private static final AtomicReferenceFieldUpdater nextUpdater = AtomicReferenceFieldUpdater. newUpdater(Node. class, InnerNode. class, “next»);
//…
nextUpdater.compareAndSet(currentNode, expectedInnerNode, newInnerNode);
Синхронизаторы
К синхронизаторам можно отнести различного рода структуры, которые отвечают за координацию деятельности потоков. К ним относятся
· Семафоры
· Барьеры
· Обменники
· Щеколда
Считающим семафором называется целочисленная переменнуя, выполняющую те же функции, что и монитор блокировки. Однако в отличие от последнего она может принимать целые положительные значения в заданном диапазоне. Семафоры используются для ограничения числа потоков, которые используют некий ресурс.
Основные методы java. util. concurrent. Semaphore:
· acquire – пытается получить доступ к ресурсу и блокирует текущий поток до тех пор, пока ресурс не будет доступен.
· tryAcquire — пытается получить доступ к ресурсу в момент вызова без блокировки текущего потока.
· release – освобождение ресурса.
Следует отметить, что у семафора может быть различной сама стратегия получения освободившегося ресурса.
Пример использования
private final Semaphore available = new Semaphore(MAX_AVAILABLE, true);
public Object getItem() throws InterruptedException {
available. acquire();
return getNextAvailableItem();
}
public void putItem(Object x) {
if (markAsUnused(x))
available. release();
}
Барьер это средство синхронизации, которое используется для того, чтобы некоторое множество потоков ожидало окончания друг друга в некотором месте, являющемся барьером или точкой синхронизации. После того, как все потоки достигли точки синхронизации, они разблокируются и могут продолжать выполнение.
На практике барьеры используются для сбора результатов выполнения некоторой распараллеленной задачи. В качестве примера можно рассмотреть задачу умножения матриц. При распараллеливании данной задачи каждому потоку будет поручено умножения определенных строк на определенные столбцы. В точке синхронизации же полученные результаты собираются из всех потоков, и строится результирующая матрица.
В пакете java. util. concurrent класс CyclicBarrier является реализацией барьера.
Пример использования
class Worker extends Thread {
//…
@Override
public void run() {
// Некоторое действие
try {
barrier.await();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
}
}
//…
barrier = new CyclicBarrier(N, new Runnable() {
public void run() {
// Действия, которые выполняются при достижении барьера всеми потоками
}
});
for (int i = 0; i < N; i++) {
new Worker(barrier).start();
}
Обменники – это средства синхронизации, которые используются для обмена информацией между двумя потоками. Это делается посредством вызова потоками, желающими обменяться информацией метода exchange класса Exchanger. В качестве параметра выступает значение, которое должно быть отдано другому потоку.
Пример использования
Exchanger exchanger = new Exchanger();
class Loop1 implements Runnable {
public void run() {
MyClass loop1Value = …
loop1Value = exchanger. exchange(loop1Value );
//…
}
}
class Loop2 implements Runnable {
public void run() {
MyClass loop2Value = …
loop2Value = exchanger.exchange(loop2Value );
//…
}
}
Щеколда (защелка) – средство синхронизации, которое используются для того, чтобы один или несколько потоков могли дождаться выполнения определенного числа операций в других потоках.
Класс CountDownLatch пакета java. util. concurrent является средством синхронизации, обладающим вышеперечисленными свойствами. Данный класс работает по принципу таймера. Происходит инициализация его некоторым начальным значением и обратный отсчет. При вызове метода await данного класса каким-либо потоком, он переходит в состояние ожидания момента достижения счетчиком таймера значения 0.
На практике данный класс удобно использовать для координации момента начала и окончания определенного числа потоков. Это означает следующее:
- можно сделать так, чтобы определенное число потоков начиналось в один и тот же момент времени; можно отследить момент окончания нескольких потоков.
Пример использования 1
В первом примере рассмотрим первый случай из перечисленных выше, т. е. начало выполнения нескольких потоков в один момент времени.
public class LatchedThread extends Thread {
private final CountDownLatch startLatch;
public LatchedThread(CountDownLatch startLatch) {
this. startLatch = startLatch;
}
public void run() {
System. out. println( «PreRun»);
try {
startLatch. await();
System. out. println( «Run»);
} catch (InterruptedException iex) {
}
}
}
//…
CountDownLatch startLatch = new CountDownLatch(1);
for (int threadNo = 0; threadNo < 4; threadNo++) {
Thread t = new LatchedThread(startLatch);
t. start();
}
Thread. sleep(200);
startLatch. countDown();
В данном примере счетчик инициализируется значением «1», и все потоки ожидают момента перехода счетчика в состояние «0», прежде чем начать выполнение.
Пример использования 2
Во втором примере рассмотрим второй случай из перечисленных выше, т. е. отслеживания момента окончания работы нескольких потоков.
public class StopLatchedThread extends Thread {
private final CountDownLatch stopLatch;
public StopLatchedThread(CountDownLatch stopLatch) {
this. stopLatch = stopLatch;
}
public void run() {
try {
// Некоторые действия
} finally {
stopLatch. countDown();
}
}
}
CountDownLatch cdl = new CountDownLatch(10);
for (int i = 0; i < 10; i++) {
Thread t = new StopLatchedThread(cdl);
t. start();
}
cdl. await();
System. out. println(«Stopped»);
В данном примере каждый поток уменьшает значение счетчика на «1». При его достижении «0» работу продолжает основной поток.
Наверняка если вас спросить о средствах синхронизации потоков в Java 1.4, каждый сразу же вспомнит synchronized блоки или целые методы. Безусловно, они достаточно просты в использовании, но тем не менее обладают рядом недостатков, которые мы рассмотрим чуть ниже.
synchronized (object) {
//действия внутри синхронизованного блока
}
Среди основных недостатков synchronized блоков можно выделить следующие:
· Не существует способа отказаться от попытки захватить какой-либо объект, если он занят, отсутствует возможность отказаться от попытки захвата объекта через какой-то интервал времени. Имея все эти возможности, проблема появления deadlock при синхронизации потоков была бы не так актуальна.
· Не существует способа осуществлять отдельные блокировки для чтения или записи, что местами бывает весьма полезно. При освобождении некоторого захваченного ресурса (того, который выступает параметром у вызова synchronized блока) нет возможности специально дать доступ к этому блоку самому первому потоку, который раньше других начал пытаться его захватить.
· Если существует несколько вложенных synchronized блоков, то освобождены ресурсы должны быть строго в обратном порядке по сравнению с тем, в котором они были захвачены.
Lock объекты
В Java 1.5 были введены так называемые внешние блокировки. Т. е. программист сам может выбирать моменты начала и окончания каждой блокировки. Базовым интерфейсом для таких блокировок является java. util. concurrent. locks. Lock.
Lock l = …;
l.lock();
try {
// доступ к защищенным ресурсам
} finally {
l.unlock();
}
Среди основных методов, использующихся для начала защищенного блока, можно выделить следующие:
· void lock() — начало защищенного блока. В случае занятости ресурса происходит ожидание его освобождения.
· boolean tryLock() — попытка получить блокировку, если она свободна на момент вызова метода. Если нет, то возвращается значение false и программа продолжает свое выполнение.
· boolean tryLock(long time, TimeUnit unit) — попытка получить блокировку в течение некоторого интервала времени. Если она не удачна, то возвращается значение false и программа продолжает свое выполнение.
Реализацией интерфейса Lock является класс ReentrantLock. Конструктор данного класса может принимать так называемое значение честности (иногда говорят – справедливости), т. е. индикатор того, должен ли первый совершивший попытку захвата поток первым получить доступ к освобожденному ресурсу.
Следует отметить, что использование этой возможности может негативно сказаться на производительности приложения. Рекомендуется создавать «честный» ReentrantLock только в том случае, если это действительно необходимо.
Lock объекты для чтения и записи
В Java 1.5 стало возможным отдельно управляться блокировкой для чтения и записи данных, что, безусловно, является весьма удобным. Блокировка для чтения может одновременно быть захвачена несколькими потока, в то время как блокировка на запись является эксклюзивной только для одного конкретного потока. Такое разделение положительным образом влияет на производительность приложения, т. к. чтением информации могут заниматься несколько потоков одновременно. В частности данный вид блокировок можно применить для работы с коллекциями, к которым основной процент обращений связан с чтением информации, и только изредка присутствуют её обновления.
Реализацией интерфейса ReadWriteLock является ReentrantReadWriteLock. Выделим основные его особенности:
· Как и ReentrantLock поддерживает «честный» и «нечестный» режим работы.
· Не отдает какого-либо предпочтения потокам для чтения или записи при получении блокировки.
· Поддерживается прерывание процесса получения блокировки.
· Если для текущего потока блокировка захвачена читателем, то писатель не может её получить.
Пример использования
public void writeData(String str) {
lock. writeLock().lock();
//Write data
lock. writeLock().unlock();
}
public void readData() {
lock. readLock().lock();
//Read data
lock. readLock().unlock();
}
Аналоги методов wait, notify, notifyAll, используемые совместно с Lock объектами были вынесены в отдельный интерфейс – java. util. concurrent. locks. Condition. Определение интерфейса Condition:
public interface Condition {
void await() throws InterruptedException;
void awaitUninterruptibly();
long awaitNanos(long nanosTimeout) throws InterruptedException;
boolean await(long time, TimeUnit unit) throws InterruptedException;
boolean awaitUntil(Date deadline) throws InterruptedException;
void signal();
void signalAll();
}
Удобство его использования также заключается в том, что возможно использовать несколько независимых «ожиданий» для одного объекта:
final Condition notFull = lock. newCondition();
final Condition notEmpty = lock. newCondition();
Следует заметить, что с условиями можно оперировать только из блоков кода, заблокированных родительским Lock объектом.
В дополнение к уже вышеперечисленным возможностям пакет java. util. concurrent содержит высокопроизводительные thread-safe (потокобезопасные) реализации интерфейсов List и Map.
ConcurrentHashMap
ConcurrentHashMap является thread-safe реализацией интерфейса Map, которая предоставляет значительно лучшую поддержку параллелизма, нежели synchronizedMap. Рассмотрим, в чем же преимущество. Множество чтений из объекта ConcurrentHashMap могут выполняться параллельно, одновременные чтения и записи могут выполняться параллельно, одновременные записи часто могут выполняться параллельно. В первую очередь это достигается благодаря тому, что при выполнении какой-либо операции блокируется не вся таблица, а только определенная порция данных (ввиду соответствующей реализации хэш-таблиц в языке Java). При создании объекта типа ConcurrentHashMap в качестве параметра конструктора может быть передано число одновременно работающих потоков, которые записывают данные. Таблица данных, лежащая в основе Map, будет разделена на соответствующее число сегментов.
CopyOnWriteArrayList
Поведение CopyOnWriteArrayList аналогично поведению классов, реализующих интерфейс List за тем исключением, что параллельно может выполняться несколько операций чтения и записи (операция записи может быть только одна в один момент времени). Это достигается за счет того, что при каждом изменении списка создается его новая копия, с которой и идет работа. Более того, важным отличием от ConcurrentHashMap является то, что операции, работающие с множеством объектом (addAll, retailAll) , являются атомарными.
CopyOnWriteArrayList предназначен для тех случаев, когда:
· Число чтений во много раз превосходит число записей.
· Список небольшого размера.
· Использование обычного массива по какой-то причине не подходит, и нужны возможности List.
Потокобезопасность классов
· Неизменяемый (immutable). Экземпляры такого класса выглядят для своих клиентов как константы. Никакой внешней синхронизации не требуется. Примером являются String, Integer и BigInteger. Вся информация, содержащаяся в любом его экземпляре, записывается в момент создания экземпляра и остается неизменной в течение всего времени существования этого объекта. В библиотеке платформы Java имеется целый ряд неизменяемых классов, в том числе String, простые классы-оболочки, BigInteger и BigDecimal. Неизменяемые классы легче разрабатывать и использовать, они менее подвержены ошибкам и более надежны. Единственный настоящий недостаток неизменяемых классов заключается в том, что для каждого нового значения нужно создавать отдельный объект.
· С поддержкой многопоточности (thread-safe). Экземпляры такого класса могут изменяться, однако все методы имеют довольно надежную внутреннюю синхронизацию, чтобы эти экземпляры могли параллельно использовать несколько потоков безо всякой внешней синхронизации. Примеры: Random и java. util. Timer.
· С условной поддержкой многопоточности (conditionally thread-safe). То же, что и с поддержкой многопоточности, за исключением того, что класс содержит методы, которые должны вызываться последовательно один за другим без взаимного влияния со стороны других потоков. Для исключения такого влияния клиент должен установить соответствующую блокировку на время выполнения этой последовательности. Примеры: HashTable и Vector, чьи итераторы требуют внешней синхронизации.
· Совместимый с многопоточностью (thread-compatible). Экземпляры такого класса можно безопасно использовать при работе с потоками
· Несовместимый с многопоточностью (thread-hostile). Этот класс небезопасен при параллельной работе с несколькими потоками, даже если вызовы всех методов окружены внешней синхронизацией. Обычно несовместимость связана с тем обстоятельством, что эти методы меняют некие статические данные, которые оказывают влияние на другие потоки. Пример: метод *****nFinalizerOnExit несовместим с многопоточностью и признан устаревшим.


