LR5
.pdfПодчеркнем, что перечисленные команды не являются методами каких-либо классов, они недоступны программисту. Сама виртуальная машина использует их для обеспечения корректной работы потоков исполнения.
Поток, работая с переменной, регулярно применяет команды use и assign для использования ее текущего значения и присвоения нового. Кроме того, должны осуществляться действия по передаче значений в основное хранилище и из него. Они выполняются в два этапа. При получении данных сначала основное хранилище считывает значение командой read, а затем поток сохраняет результат в своей рабочей памяти командой load. Эта пара команд всегда выполняется вместе именно в таком порядке, т.е. нельзя выполнить одну, не выполнив другую. При отправлении данных сначала поток считывает значение из рабочей памяти командой store, а затем основное хранилище сохраняет его командой write. Эта пара команд также всегда выполняется вместе именно в таком порядке, т.е. нельзя выполнить одну, не выполнив другую.
Набор этих правил составлялся с тем, чтобы операции с памятью были достаточно строги для точного анализа их результатов, а с другой стороны, правила должны оставлять достаточное пространство для различных технологий оптимизаций (регистры, очереди, кэш и т.д.).
Последовательность команд подчиняется следующим правилам:
все действия, выполняемые одним потоком, строго упорядочены, т.е. выполняются одно за другим;
все действия, выполняемые с одной переменной в основном хранилище памяти, строго упорядочены, т.е. следуют одно за другим.
За исключением некоторых дополнительных очевидных правил, больше никаких ограничений нет. Например, если поток изменил значение сначала одной, а затем другой переменной, то эти изменения могут быть переданы в основное хранилище в обратном порядке.
Поток создается с чистой рабочей памятью и должен перед использованием загрузить все необходимые переменные из основного хранилища. Любая переменная сначала создается в основном хранилище и лишь затем копируется в рабочую память потоков, которые будут ее применять.
Таким образом, потоки никогда не взаимодействуют друг с другом напрямую, только через главное хранилище.
Модификатор volatile
При объявлении полей объектов и классов может быть указан модификатор volatile. Он устанавливает более строгие правила работы со значениями переменных.
Если поток собирается выполнить команду use для volatile переменной, то требуется, чтобы предыдущим действием с этой переменной было обязательно load, и наоборот – операция load может выполняться только перед use. Таким образом, переменная и главное хранилище всегда имеют самое последнее значение этой переменной.
Аналогично, если поток собирается выполнить команду store для volatile переменной, то требуется, чтобы предыдущим действием над этой переменной было обязательно assign, и наоборот – операция assign может выполняться, только если следующей будет store. Таким образом, переменная и главное хранилище всегда имеют самое последнее значение этой переменной.
Наконец, если проводятся операции над несколькими volatile переменными, то передача соответствующих изменений в основное хранилище должна проводиться строго в том же порядке.
При работе с обычными переменными компилятор имеет больше пространства для маневра. Например, при благоприятных обстоятельствах может оказаться возможным предсказать значение переменной, заранее вычислить и сохранить его, а затем в нужный
момент использовать уже готовым.
Следует обратить внимание на два 64-разрядных типа, double и long. Поскольку многие платформы поддерживают лишь 32-битную память, величины этих типов рассматриваются как две переменные и все описанные действия выполняются независимо для двух половинок таких значений. Конечно, если производитель виртуальной машины считает возможным, он может обеспечить атомарность операций и над этими типами. Для volatile переменных это является обязательным требованием.
Блокировки
Восновном хранилище для каждого объекта поддерживается блокировка (lock), над которой можно произвести два действия – установить (lock) и снять (unlock). Только один поток в один момент времени может установить блокировку на некоторый объект. Если до того, как этот поток выполнит операцию unlock, другой поток попытается установить блокировку, его выполнение будет приостановлено до тех пор, пока первый поток не отпустит ее.
Операции lock и unlock накладывают жесткое ограничение на работу с переменными в рабочей памяти потока. После успешно выполненного lock рабочая память очищается и все переменные необходимо заново считывать из основного хранилища. Аналогично, перед операцией unlock необходимо все переменные сохранить в основном хранилище.
Важно подчеркнуть, что блокировка является чем-то вроде флага. Если блокировка на объект установлена, это не означает, что данным объектом нельзя пользоваться, что его поля
иметоды становятся недоступными,– это не так. Единственное действие, которое становится невозможным,– установка этой же блокировки другим потоком, до тех пор, пока первый поток не выполнит unlock.
ВJava-программе для того, чтобы воспользоваться механизмом блокировок, существует ключевое слово synchronized. Оно может быть применено в двух вариантах – для объявления synchronized-блока и как модификатор метода. В обоих случаях действие его примерно одинаковое.
Synchronized-блок записывается следующим образом:
synchronized (ref) {
...
}
Прежде, чем начать выполнять действия, описанные в этом блоке, поток обязан установить блокировку на объект, на который ссылается переменная ref (поэтому она не может быть null). Если другой поток уже установил блокировку на этот объект, то выполнение первого потока приостанавливается до тех пор, пока не удастся выполнить операцию lock.
После этого блок выполняется. При завершении исполнения (как успешном, так и в случае ошибок) производится операция unlock, чтобы освободить объект для других потоков.
Рассмотрим пример:
public class ThreadTest implements Runnable { private static ThreadTest
shared = new ThreadTest(); public void process() {
for (int i=0; i<3; i++) { System.out.println(
Thread.currentThread(). getName()+" "+i);
Thread.yield();
}
}
public void run() { shared.process();
}
public static void main(String s[]) { for (int i=0; i<3; i++) {
new Thread(new ThreadTest(), "Thread-"+i).start();
}
}
}
В этом простом примере три потока вызывают метод у одного объекта, чтобы тот распечатал три значения. Результатом будет:
Thread-0 0
Thread-1 0
Thread-2 0
Thread-0 1
Thread-2 1
Thread-0 2
Thread-1 1
Thread-2 2
Thread-1 2
То есть все потоки одновременно работают с одним методом одного объекта. Заключим обращение к методу в synchronized-блок:
public void run() { synchronized (shared) {
shared.process();
}
}
Теперь результат будет строго упорядочен:
Thread-0 0
Thread-0 1
Thread-0 2
Thread-1 0
Thread-1 1
Thread-1 2
Thread-2 0
Thread-2 1
Thread-2 2
Synchronized-методы работают аналогичным образом. Прежде, чем начать выполнять их, поток пытается заблокировать объект, у которого вызывается метод. После выполнения блокировка снимается. В предыдущем примере аналогичной упорядоченности можно было добиться, если использовать не synchronized-блок, а объявить метод process() синхронизированным.
Также допустимы методы static synchronized. При их вызове блокировка устанавливается на объект класса Class, отвечающего за тип, у которого вызывается этот метод.
При работе с блокировками всегда надо помнить о возможности появления deadlock – взаимных блокировок, которые приводят к зависанию программы. Если один поток заблокировал один ресурс и пытается заблокировать второй, а другой поток заблокировал второй и пытается заблокировать первый, то такие потоки уже никогда не выйдут из состояния ожидания.
Рассмотрим простейший пример:
public class DeadlockDemo {
// Два объекта-ресурса
public final static Object one=new Object(), two=new Object();
public static void main(String s[]) {
//Создаем два потока, которые будут
//конкурировать за доступ к объектам
//one и two
Thread t1 = new Thread() { public void run() {
// Блокировка первого объекта synchronized(one) { Thread.yield();
// Блокировка второго объекта synchronized (two) {
System.out.println("Success!");
}
}
}
};
Thread t2 = new Thread() { public void run() {
// Блокировка второго объекта synchronized(two) {
Thread.yield();
// Блокировка первого объекта synchronized (one) {
System.out.println("Success!");
}
}
}
};
// Запускаем потоки t1.start(); t2.start();
}
}
Если запустить такую программу, то она никогда не закончит свою работу. Обратите внимание на вызовы метода yield() в каждом потоке. Они гарантируют, что когда один поток выполнил первую блокировку и переходит к следующей, второй поток находится в таком же состоянии. Очевидно, что в результате оба потока "замрут", не смогут продолжить свое выполнение. Первый поток будет ждать освобождения второго объекта, и наоборот. Именно такая ситуация называется "мертвой блокировкой", или deadlock. Если один из потоков успел бы заблокировать оба объекта, то программа успешно бы выполнилась до конца. Однако многопоточная архитектура не дает никаких гарантий, как именно потоки будут выполняться друг относительно друга. Задержки (которые в примере моделируются вызовами yield()) могут возникать из логики программы (необходимость произвести вычисления), действий пользователя (не сразу нажал кнопку "ОК"), занятости ОС (из-за нехватки физической оперативной памяти пришлось воспользоваться виртуальной), значений приоритетов потоков
итак далее.
ВJava нет никаких средств распознавания или предотвращения ситуаций deadlock. Также нет способа перед вызовом синхронизированного метода узнать, заблокирован ли уже
объект другим потоком. Программист сам должен строить работу программы таким образом, чтобы неразрешимые блокировки не возникали. Например, в рассмотренном примере достаточно было организовать блокировки объектов в одном порядке (всегда сначала первый, затем второй) – и программа всегда выполнялась бы успешно.
Опасность возникновения взаимных блокировок заставляет с особенным вниманием относиться к работе с потоками. Например, важно помнить, что если у объекта потока был вызван метод sleep(..), то такой поток будет бездействовать определенное время, но при этом все заблокированные им объекты будут оставаться недоступными для блокировок со стороны других потоков, а это потенциальный deadlock. Такие ситуации крайне сложно выявить путем тестирования и отладки, поэтому вопросам синхронизации надо уделять много времени на этапе проектирования.
Методы wait(), notify(), notifyAll() класса Object
Каждый объект в Java имеет не только блокировку для synchronized блоков и методов, но и так называемый wait-set, набор потоков исполнения. Любой поток может вызвать метод wait() любого объекта и таким образом попасть в его wait-set. При этом выполнение такого потока приостанавливается до тех пор, пока другой поток не вызовет у этого же объекта метод notifyAll(), который пробуждает все потоки из wait-set. Метод notify() пробуждает один случайно выбранный поток из данного набора.
Однако применение этих методов связано с одним важным ограничением. Любой из них может быть вызван потоком у объекта только после установления блокировки на этот объект. То есть либо внутри synchronized-блока с ссылкой на этот объект в качестве аргумента, либо обращения к методам должны быть в синхронизированных методах класса самого объекта. Рассмотрим пример:
public class WaitThread implements Runnable { private Object shared;
public WaitThread(Object o) { shared=o;
}
public void run() { synchronized (shared) {
try { shared.wait();
} catch (InterruptedException e) {} System.out.println("after wait");
}
}
public static void main(String s[]) { Object o = new Object(); WaitThread w = new WaitThread(o); new Thread(w).start();
try { Thread.sleep(100);
} catch (InterruptedException e) {} System.out.println("before notify"); synchronized (o) {
o.notifyAll();
}
}
}
Результатом программы будет:
before notify
after wait
Обратите внимание, что метод wait(), как и sleep(), требует обработки InterruptedException, то есть его выполнение также можно прервать методом interrupt().
В заключение рассмотрим более сложный пример для трех потоков:
public class ThreadTest implements Runnable {
final static private Object shared=new Object(); private int type;
public ThreadTest(int i) { type=i;
}
public void run() {
if (type==1 || type==2) { synchronized (shared) {
try { shared.wait();
} catch (InterruptedException e) {} System.out.println("Thread "+type+" after wait()");
}
} else {
synchronized (shared) { shared.notifyAll();
System.out.println("Thread "+type+" after notifyAll()");
}
}
}
public static void main(String s[]) { ThreadTest w1 = new ThreadTest(1); new Thread(w1).start();
try { Thread.sleep(100);
}catch (InterruptedException e) {} ThreadTest w2 = new ThreadTest(2); new Thread(w2).start();
try { Thread.sleep(100);
}catch (InterruptedException e) {} ThreadTest w3 = new ThreadTest(3); new Thread(w3).start();
}
Результатом работы программы будет:
Thread 3 after notifyAll()
Thread 1 after wait()
Thread 2 after wait()
Рассмотрим, что произошло. Во-первых, был запущен поток 1, который тут же вызвал метод wait() и приостановил свое выполнение. Затем то же самое произошло с потоком 2. Далее начинает выполняться поток 3.
Сразу обращает на себя внимание следующий факт. Еще поток 1 вошел в synchronizedблок, а стало быть, установил блокировку на объект shared. Но, судя по результатам, это не помешало и потоку 2 зайти в synchronized-блок, а затем и потоку 3. Причем, для последнего это просто необходимо, иначе как можно "разбудить" потоки 1 и 2?
Можно сделать вывод, что потоки, прежде чем приостановить выполнение после
вызова метода wait(), отпускают все занятые блокировки. Итак, вызывается метод notifyAll(). Как уже было сказано, все потоки из wait-set возобновляют свою работу. Однако чтобы корректно продолжить исполнение, необходимо вернуть блокировку на объект, ведь следующая команда также находится внутри synchronized-блока!
Получается, что даже после вызова notifyAll() все потоки не могут сразу возобновить работу. Лишь один из них сможет вернуть себе блокировку и продолжить работу. Когда он покинет свой synchronized-блок и отпустит объект, второй поток возобновит свою работу, и так далее. Если по какой-то причине объект так и не будет освобожден, поток так никогда и не выйдет из метода wait(), даже если будет вызван метод notifyAll(). В рассмотренном примере потоки один за другим смогли возобновить свою работу.
Кроме того, определен метод wait() с параметром, который задает период тайм-аута, по истечении которого поток сам попытается возобновить свою работу. Но начать ему придется все равно с повторного получения блокировки.
Заключение
Вэтой лекции были рассмотрены принципы построения многопоточного приложения.
Вначале разбирались достоинства и недостатки такой архитектуры – как правило ОС не выделяет отдельный процессор под каждый процесс, а значит применяется процедура time slicing. Было выделено три признака, указывающие на целесообразность запуска нескольких потоков в рамках программы.
Основу работы с потоками в Java составляют интерфейс Runnable и класс Thread. С их помощью можно запускать и останавливать потоки, менять их свойства, среди которых основные: приоритет и свойство daemon. Главная проблема, возникающая в таких программах - одновременный доступ нескольких потоков к одним и тем же данным, в первую очередь -– к полям объектов. Для понимания, как в Java решается эта задача, был сделан краткий обзор по организации памяти в JVM, работы с переменными и блокировками. Блокировки, несмотря на название, сами по себе не ограничивают доступ к переменной. Программист использует их через ключевое слово synchronized, которое может быть указано в сигнатуре метода или в начале блока. В результате выполнение не будет продолжено, пока блокировка не освободится.
Новый механизм порождает новую проблему - взаимные блокировки (deadlock), к которой программист всегда должен быть готов, тем более, что Java не имеет встроенных средств для определения такой ситуации. В лекции разбирался пример, как организовать работу программы без "зависания" ожидающих потоков.
Взавершение рассматривались специализированные методы базового класса Object, которые также позволяют управлять последовательностью работы потоков.
3.Дополнительная литература
1.Вязовик Н.А. Программирование на JAVA. Курс лекций на intuit.ru
2.Хорстманн К., Корнелл Г. Java 2. Том 2. Тонкости программирования
3.Герберт Шилдт, Джеймс Холмс Искусство программирования на JAVA.
4.Патрик Нотон, Герберт Шилдт Полный справочник по Java.
Рекумендуется самостоятельно изучить современные средства многопоточного программирования на J2SE 6.0, которые храняться в пакете java.util.concurrent.
4.Порядок выполнения работы
Всоответствии с вариантом выполните следующее основное задание: 1. Создайте новый проект в среде Eclipse.
2. Создать классы и интерфесы в соответствии с заданием. 3. Поместить созданные классы и интерфейсы в пакет.
4. Создайте вне пакета управляющий класс, предусмотрев в нем точку входа
(main), который будет подключать пакет и осуществлять тестирование разработанных классов и интерфейса.
5. Подготовьте отчет о выполнении лабораторной работы:
Для успешной сдачи лабораторной работы необходимо:
1.представить преподавателю отлаженный код программы для указанного варианта задания;
2.подготовить отчет по работе.
5. Порядок оформления отчета
Отчет о выполнении лабораторной работы должен содержать:
1)титульный лист;
2)задание;
3)текст программы;
4)результаты работы программы.
6.Варианты заданий
1.Разработать класс, имеющий методы для установки и чтения значений переменной. Создать несколько потоков, которые сначала устанавливают значение этой переменной, а затем считывают его, выводя информацию об установленном и считанном значениях. Продемонстрировать работу этих потоков.
2.Создать несколько потоков, которые обращаются к единому для всех потоков списку объектов Integer, устанавливая и считывая значения из него и выводя результаты. Продемонстрировать работу потоков без использования и с использованием синхронизации.
3.На основе варианта предыдущего задания, в котором методы доступа к списку синхронизованы, продемонстрировать работу потоков, имеющих разные приоритеты. (Использовать вывод поток информации о себе).
4.Разработать программу, выполняющую сортировку массива методом «пузырька» и выводящую результаты промежуточных и конечных вычислений, а также время, затраченное на вычисления. Вычисления осуществляются в отдельном потоке, а вывод результатов – в потоке-демоне.
5.Разработать программу, выполняющую сортировку случайного одномерного массива различными методами (вставка, выбор, пузырек). Каждый алгоритм сортировки работает в отдельном потоке и выводит результаты сортировки при начале каждого нового прохода в виде строки <Название алгоритма> : <список значений>
6.Разработать программу, демонстрирующую ситуация взаимной блокировки при обращении двух потоков к массиву значений, приводящей к зависанию приложения (deadlock). Потоки пытаются отсортировать массив различными методами. Затем продемонстрировать, как такой ситуации можно избежать.
7.Продемонстрировать программу, в которой разные потоки обращаются к файлу, используя методы wait(), notify(), notifyAll(). Для разработки классов потоков использовать интерфейс Runnable.
8.Составить программу, обеспечивающую создание файла, запись данных в файл и чтение данных из файла. При выполнении операции записи программа должна запрашивать у пользователя позицию данных в файле и новое значение, при выполнении операции чтения - запрашивать позицию данных и вывести данные.
Необходимо обеспечить корректную работу нескольких экземпляров этой программы одновременно. Запись данных в файл в конкретную позицию должна разрешаться только одному экземпляру программы.
9.Разработать простое многопоточное приложение. Первый поток генерирует 100 случайных чисел и помещает их в массив. Второй поток, поочередно выбирая эти
числа, вычисляет значение синуса для каждого из них, и добавляет его в результирующий массив. При этом, по мере выборки чисел из первого массива, происходит добавление новых туда же первым потоком, так чтобы количество элементов держалось на уровне десяти. Обеспечить синхронизацию потоков.
