Последнее изменение: 28 июля 2011г.

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

...Клетчатый специалист-хормейстер проорал:
– До-ми-соль-до! – вытащил наиболее застенчивых из-за шкафов, где они пытались спастись от пения, Косарчуку сказал, что у него абсолютный слух, заныл, заскулил, просил уважить старого регента-певуна, стучал камертоном по пальцам, умоляя грянуть "Славное море".

Грянули. И славно грянули. Клетчатый, действительно, понимал свое дело...

М.А. Булгаков, "Мастер и Маргарита"

Вам приходилось когда-нибудь дирижировать оркестром? Или хотя бы хором? Второе мне довелось. Тридцать человек, два голоса. И пусть каждый знает свою партию наизусть – сделать так, чтобы пятнадцать человек первого голоса и пятнадцать человек второго пели в унисон, очень сложно. А уж чтобы не сбивались и каждый голос вел свою партию – и вовсе задача запредельная. Вот так я впервые в жизни столкнулся с синхронизацией. :)

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

Мы коснемся следующих тем:

Итак, поехали.

Введение

Для начала простейший вопрос – что такое потоки и для чего они вообще нужны? Жили ведь без них в течение долгого времени? Жили. Но плохо и недолго. :)

Сразу хочу сделать одно замечание. Так получилось, что при переводе на русский ДВА английских термина имеют одинаковое значение – поток. Это "stream" и "thread". У второго из них есть еще один перевод – нить. Однако мне как-то привычнее использовать именно термин "поток". Потому, в этой статье, если я говорю о потоке, то имею в виду именно thread.

А что такое вообще поток как явление? Это набор исполняемых инструкций. Практически все современные ОС позволяют исполнять несколько таких наборов одновременно. Ну, одновременно – понятие, конечно, относительное. Правильно было бы сказать – одновременно с точки зрения пользователя. Ибо процессор один и исполнять он может только один набор инструкций. Потому, для создания видимости одновременной работы процессор исполняет определенное количество инструкций одного потока, после чего переключается на другой. Это происходит очень часто, в результате чего создается иллюзия одновременной работы.

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

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

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

С введением закончили, переходим к следующей теме –

Потоки в Java

Итак, потоки в Java. Основа их – класс java.lang.Thread (http://java.sun.com/j2se/1.5.0/docs/api/java/lang/Thread.html). Этот класс позволяет создать поток и запустить его на выполнение.

Существует два пути создания потока. Первый – наследование от класса java.lang.Thread и переопределение его метода run. Второй – реализация интерфейса java.lang.Runnable и создание потока на основе этой реализации. В принципе это методы эквивалентны, разница в деталях. Наследование от java.lang.Thread делает его единственным родителем класса, что не всегда удобно. Я лично предпочитаю реализовывать java.lang.Runnable.

Таким образом, простейший поток может быть реализован так:

package ru.skipy.juga_ru.tests.threads;

public class SampleThread extends Thread{

    public SampleThread(){
        super();
    }

    public void run(){
        System.out.println("Hello, threads world!");
    }

    public static void main(String[] args){
        Thread t = new SampleThread();
        t.start();
    }

}

Или так:

package ru.skipy.juga_ru.tests.threads;

public class SampleRunnable implements Runnable{

    public SampleRunnable(){
        super();
    }

    public void run(){
        System.out.println("Hello, threads world!");
    }

    public static void main(String[] args){
        Runnable r = new SampleRunnable();
        Thread t = new Thread(r);
        t.start();
    }

}

Как я уже говорил, эти методы практически эквивалентны.

В приведенных примерах в методах main создаются потоки, после чего они запускаются на выполнение. Поток завершается, когда произошел выход из метода run, либо если в методе run было выброшено исключение, которое не было обработано. Чаще всего это RuntimeException или наследник, реже – Error или наследник. После того, как поток завершил работу, перестартовать его НЕЛЬЗЯ. Попытка вторичного вызова start приведет к исключению IllegalThreadStateException.

Для того, чтобы уже закончить с классом Thread, коснемся еще нескольких его возможностей.

Во-первых, у потоков могут быть различные приоритеты. Существует несколько констант – Thread.MIN_PRIORITY == 1, Thread.NORM_PRIORITY == 5 и Thread.MAX_PRIORITY == 10. Значения эти внутренние и с реальными приоритетами потоков в операционной системе соотносятся слабо.

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

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

И последнее, чего бы я тут хотел коснуться – статический метод yield. Он служит для передачи управления другим потокам. Т.е. в результате его вызова происходит переключение контекста и процессор начинает исполнять код другого потока. Это нужно как в ситуациях, когда работа на текущий момент завершена и можно дать поработать другим (например, поток обрабатывает данные, все обработал, а новые еще не пришли), так и в ситуациях, когда поток занимается какими-нибудь интенсивными действиями, съедает большую часть процессора и не дает другим потокам работать. Правда, последний подход я встречал только в программировании под Java ME, в условиях ограниченных ресурсов. Обратите внимание, что этот метод статический и действует только на текущий поток. Заставить таким образом чужой поток поделиться своим временем нельзя!

Следующая тема –

Управление выполнением потоков

На данный момент мы знаем, как запустить поток на выполнение. Естественный вопрос – а как его остановить? Ответ может оказаться обескураживающим. В Java НЕТ средств для принудительной остановки потока. Вернее, они есть, но использовать их не стоит – метод stop объявлен deprecated. А вместе с ним – и suspend c resume.

Почему это так сделано? Причины, несомненно, есть. Дело в том, что при принудительной остановке (приостановке) потока совершенно непонятно, что делать с ресурсами. Поток может открыть сетевое соединение, например. Что делать с данными, которые еще не вычитаны? Где гарантия, что после дальнейшего запуска потока (в случае приостановки) он сможет их дочитать? То же самое и про соединение с базой данных. И еще много про что. А если поток остановят посередине транзакции? Кто ее будет закрывать? Кто будет разблокировать ресурсы? В общем, проблем тут существенно больше, чем преимуществ.

Потому – в Java принят уведомительный порядок остановки потока. Вариантов реализации два: либо воспользоваться существующими механизмами, либо создавать такие механизмы вручную. Мы рассмотрим оба варианта и проблемы, связаные с ними.

Вариант первый – использовать существующий механизм. У класса Thread есть такой метод – interrupt. Этот метод выставляет некоторый флаг в дебрях native-кода. В дальнейшем состояние этого флага можно проверить с помощью метода isInterrupted. Существует также статический метод interrupted, который производит проверку для текущего потока. Обратите внимание – вызов этого метода сбрасывает флаг, что подразумевает ответственность разработчика по обработке этой ситуации.

Кстати, если вам придется прерывать поток извне – это один из немногих случаев когда удобнее унаследоваться от Thread. Если у вас будет ссылка на экземпляр Thread, вызвать у него interrupt не составит труда. Если же вы будете держать ссылку на экземпляр Runnable – придется в начале метода run сохранять ссылку на связаный с этим Runnable поток, реализовывать метод для получения этой ссылки и т.п. В общем, много ненужной возни.

Что хорошо в методе interrupt? Он способен вывести поток из состояния ожидания. Т.е. если у потока были вызваны методы sleep или wait (мы поговорим о нем ниже) – ожидание прервется и будет выброшено исключение InterruptedException. Флаг в этом случае не выставляется, т.к. на брошеное исключение разработчик отреагирует незамедлительно.

Таким образом, действовать нужно так. Реализуете поток (удобнее через наследование от Thread). В потоке периодически вставляете проверки isInterrupted. Если проверка сработала или выброшено исключение во время ожидания – поток пытаются остановить извне. Обращаю внимание – именно остановить, а не ПРИостановить. Вам надо принять решение – либо вы продолжаете работу (если по каким-то причинам не можете остановиться), либо вы освобождаете ресурсы и выходите из метода run. Что вы выберете – зависит от ситуации. Главное – у вас есть возможность маневрировать и делать то, что сочтете нужным. В том числе и продолжать работу. В случае вызова stop такой возможности нет.

Возможная проблема, которую я сейчас вижу в этом подходе – блокировки на потоковом вводе-выводе. Если поток заблокирован на чтении данных (из какого-либо наследника InputStream или Reader) – вызов interrupt его из этого состояния не выведет. Решение тут различается в зависимости от типа источника данных. Если чтение идет из файла – долговременная блокировка крайне маловероятна. И тогда можно просто дождаться выхода из метода read. Если же чтение каким-то образом связано с сетью – можно использовать неблокирующий ввод-вывод (Java NIO).

Пара слов по поводу NIO. Официальной информации по этой технологии сейчас немного, во всяком случае внятного руководства я не нашел. Есть какое-то количество примеров вот тут – http://java.sun.com/j2se/1.4/nio/index.html. Есть также очень хорошая книга – Ron Hitchens. Java NIO. ISBN: 0-596-00288-2. Правда, на английском, для кого-то это может быть минусом. Книга эта есть у меня в формате PDF, желающим могу послать по почте.

Второй вариант реализации метода остановки (а также и приостановки) – сделать аналог interrupt, но руками. Т.е. организовать в собственной реализации потока флаги – на остановку и приостановку, – и выставлять их путем вызова методов извне. Методика действия та же – проверять установку флагов.

Недостатки такого подхода. Во-первых, потоки в состоянии ожидания таким способом не расшевелить. И это серьезный минус по сравнению с первым методом. А во-вторых, выставление флага одним потоком совсем не означает, что второй поток тут же его увидит. Для увеличения производительности виртуальная машина использует кеш данных потока, в результате чего обновление переменной у второго потока может произойти через неопределенный промежуток времени. Можно, конечно, объявить эту переменную volatile, но это поможет только в случае примитивных типов данных. Подробнее этого вопроса мы коснемся ниже.

В связи со всем вышесказаным, я рекомендую использовать существующий механизм, основаный на выставлении native-флага и использовании метода interrupt. Он намного удобнее и функциональнее.

Пойдем дальше. На очереди ...

Мониторы

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

Итак, что такое монитор. Я бы сказал так – это средство обеспечения контроля за доступом к ресурсу. У монитора может быть максимум один владелец в каждый текущий момент времени. Следовательно, если кто-то использует ресурс и захватил монитор для обеспечения единоличного доступа, то другой, желающий использовать тот же ресурс, должен подождать освобождения монитора, захватить его и только потом начать использовать ресурс.

Удобно представлять монитор как id захватившего его объекта. Если этот id равен 0 – ресурс свободен. Если не 0 – ресурс занят. Можно встать в очередь и ждать его освобождения.

Это была общая теория. Перейдем к Java. Здесь у каждого экземпляра объекта есть монитор. Реализован он где-то в недрах native-кода и контролируется виртуальной машиной. Используется он так: любой нестатический synchronized-метод при своем вызове прежде всего пытается захватить монитор того объекта, у которого он вызван (на который он может сослаться как на this). Если это удалось – метод исполняется. Если нет – поток останавливается и ждет, пока монитор будет отпущен.

Как работает synchronized-блок. Пусть у нас есть следующий код:

Object sync = new Object();
...
synchronized(sync){

}

В этом случае (синхронизация блока) захватывается монитор у объекта sync. Таким образом, объявление synchronized-метода ...

public synchronized void someMethod(){
    // code
}

... полностью эквивалентно следующей конструкции:

public void someMethod(){
    synchronized(this){
        // code
    }
}

Выше я неслучайно акцентировал внимание на том, что это верно для нестатического метода. Действительно, у статического метода нет ссылки this. А синхронизированый статический метод – реальность. Чей же монитор он захватывает? Все просто. Пусть у него нет ссылки this, но есть класс. В смысле, объект класса Class. И есть он в одном экземпляре. Идеальный кандидат на использование его монитора для синхронизации статических методов. Собственно, именно так и делается. Таким образом, следующая конструкция:

public class SomeClass{

    public static synchronized void someMethod(){
        //code
    }

}

... эквивалентна такой:

public class SomeClass{

    public static void someMethod(){
        synchronized(SomeClass.class){
            //code
        }
    }

}

Итак, резюме. Если два нестатических метода объявлены как synchronized, то в каждый момент времени из разных потоков на одном объекте может быть вызван только один из них. Поток, который вызывает метод первым, захватит монитор, и второму потоку придется ждать. Заостряю ваше внимание на трех моментах:

  • Это верно только для разных потоков. Один и тот же поток может вызвать синхронизированный метод, внутри него – другой синхронизированный метод на том же экземпляре. Поскольку этот поток владеет монитором, проблем второй вызов не создаст.
  • Это верно только для вызовов методов одного экземпляра. У разных экземпляров разные мониторы, потому одновременный вызов нестатических методов проблем не создаст
  • В случае статических методов имеет значение только одно – разные ли потоки, вызывающие синхронизированные методы, или нет. Об экземпляре тут речи не идет, его роль исполняет объект класса.

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

Сразу же возникает вопрос о взаимодействии синхронизированных методов с несинхронизированными. Т.е. – возможен ли одновременный их вызов из двух потоков. Да, возможен. Ибо несинхронизированный метод при вызове не пытается захватить монитор, и, следовательно, ничто ему не может помешать. А потому – надо очень аккуратно подходить к синхронизации методов.

Для чего же все-таки нужно два различных варианта синхронизации – на уровне метода и на уровне блока? Почему нельзя обойтись одним?

Синхронизация на уровне метода имеет очевидный недостаток – метод может быть долгим. Если, скажем, нужно менять данные в некой реализации Runnable (например, выставлять флаг окончания работы потока, как я описывал выше) и при этом синхронизировать метод run – ничего хорошего не получится. Ибо run захватит монитор при старте и любой вызов любого синхронизированного метода из любого другого потока будет блокирован.

Можно было бы, конечно, ограничиться только синхронизацией на уровне блока. Функционально это то же самое. Но менее удобно. У синхронизированного метода ключевое слово synchronized фигурирует в сигнатуре, что сразу дает разработчику много информации о поведении данного метода. Если же синхронизация будет внутри, блоком от начала до конца метода – о том, что она есть, нужно будет упоминать в комментариях к методу. Учитывая любовь разработчиков к написанию комментариев, а также к их чтению (а тем паче – к чтению документации!) – лучше включить подобное указание в сигнатуру.

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

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

Что такое взаимная блокировка по своей сути? Все достаточно просто. Предположим, что один поток уже захватил монитор на некотором объекте x и для продолжения работы ему нужно захватить монитор на объекте y. В другом же потоке ситуация ровно обратная – он уже захватил монитор на объекте y и ему нужен монитор объекта x. В результате оба потока будут ждать, пока нужный монитор освободится. Как вы сами прекрасно понимаете, ждать они будут до бесконечности. Эта ситуация и называется взаимной блокировкойdeadlock.

Рассмотрим вполне невинный пример:

package ru.skipy.juga_ru.tests.threads;

/**
 * DeadLockTest
 *
 * @author Eugene Matyushkin
 * @version 1.0
 */
public class DeadLockTest{

    public static void main(String[] args){
        A a1 = new A();
        A a2 = new A();
        Thread t1 = new Thread(new Tester(a1,a2));
        Thread t2 = new Thread(new Tester(a2,a1));
        t1.start();
        t2.start();
    }

    public static class Tester implements Runnable{

        static int nextId = 1;

        private A obj1;
        private A obj2;
        private int id = 0;

        public Tester(A obj1, A obj2){
            this.obj1 = obj1;
            this.obj2 = obj2;
            id = nextId++;
        }

        public void run(){
            print("Setting value to obj1... ");
            obj1.setValue(id);
            print("done.");
            print("Comparing objects... ");
            print("Done. Result: "+((obj1.equals(obj2)) ? "equal" : "not equal"));
        }

        private void print(String msg){
            System.out.println("Thread #"+id+": "+msg);
        }
    }

    public static class A{

        private int value = 0;

        synchronized void setValue(int value){
            this.value = value;
        }

        synchronized int getValue(){
            return value;
        }

        public synchronized boolean equals(Object o){
            A a = (A) o;
            try{
                Thread.sleep(1000);
            }catch(InterruptedException ex){
                System.err.println("Interrupted!");
            }
            return value == a.getValue();
        }
    }
}

В центре примера – класс A. Тривиальный контейнер для единственного числа, с синхронизированными методами установки значения, его чтения и сравнения. (Разумеется, было бы нравственно вместе с equals переопределить еще и hashCode, но для данного примера это абсолютно некритично.) Задержка в методе equals нужна как имитация долгой работы.

Далее, поток Tester, реализация Runnable. Тоже ничего криминального – установка значения одному из объектов и сравнение с другим объектом. Ну и метод main – создание двух объектов, двух потоков и запуск всего этого хозяйства.

Можете попробовать запустить этот тест. У меня он гарантированно виснет. Оба потока останавливаются на стадии "Comparing objects... ".

Что же тут происходит? Рассмотрим все по шагам. Для определенности будем предполагать, что поток t1 имеет id=1, а t2id=2.

Итак, создание экземпляров класса Tester. Для потока t1 obj1=a1, obj2=a2. Для t2 наоборот – obj1 = a2, obj2=a1.

Исполнение. В потоке t1 объекту a1 выставляется значение 1, в потоке t2 объекту a2 – значение 2. При этом на время вызова setValue захватываются мониторы на соответствующих объектах.

А вот дальше начинается самое интересное. Вызов equals. Поток t1 захватывает монитор на объекте a1. И ждет секунду. Приблизительно в то же самое время поток t2 захватывает монитор на объекте a2. И тоже ждет. А после ожидания каждый поток пытается захватить монитор другого объекта – a2 для t1 и a1 для t2. Поскольку мониторы уже захвачены другим потоком – происходит взаимная блокировка: потоки входят в состояние бесконечного ожидания.

В данном случае определить источник проблем сравнительно просто – не так много мест, где используется синхронизация. Можно найти методом исключения. Как только вы уберете ключевое слово synchronized у equals или у getValue – проблема уйдет. В случае же сложной логики поиск источника взаимной блокировки – страшная вещь. Не поможет даже отладчик – пока вы будете по шагам идти по одной линии исполнения, другая уже может закончиться. Возможна, кстати, и обратная ситуация – спонтанные блокировки при работе в отладчике, а при исполнении все будет нормально. Просто потоки пересеклись в нужное время в нужном месте.

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

На самом деле, утверждение, что взаимные блокировки не отслеживаются, не совсем верно. Если попросить виртуальную машину выдать дамп всех потоков (для Windows-систем это Ctrl+Break, для *NIX – kill -3, если я правильно помню), то взаимно заблокированные потоки определяются и соответствующим образом помечаются. Другое дело, что это чистая информация – предпринять какие-либо действия по этому поводу все равно не получится.

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

Собственный опыт

Случилось мне с полгода назад реализовывать библиотеку для распределенной блокировки ресурсов. Фактически, монитор, доступный в нескольких виртуальных машинах, в виде Java-объекта. Ну и полный сопутствующий набор методов – захватить (lock), отпустить (release). Естественно, методы синхронизированы. Логика при этом была следующей – если поток, вызывающий lock, не может в действительности захватить монитор (он уже захвачен другим потоком), то он входит в состояние ожидания, вызывая wait на объекте монитора. Когда владелец монитора отпускает его, он вызывает у него notify, в результате чего ожидающий поток может продолжить работу и захватить монитор. Подробнее о механизме синхронизации через wait/notify мы поговорим ниже.

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

Сделали. Пока поток один – все в порядке. Как только появляется поток, ждущий захвата монитора – владелец монитора отпустить его не может. Останавливается на входе в release. В чем дело?

Как обычно – в синхронизации. Методы были синхронизированы? Были. Что означает, что на входе в них захватывался системный монитор. При вызове wait в методе lock системный монитор отпускался, что давало возможность другому потоку войти в release. После рефакторинга метод wait стал вызываться на внутреннем объекте синхронизации, следовательно, системный монитор продолжал быть захваченым! И вызов release из другого потока закономерно блокировался до отпускания системного монитора. А он не может быть отпущен, пока ожидающий поток не выйдет из lock, что не может произойти, пока на внутреннем объекте синхронизации не будет вызван notify, что и должно произойти в release, в который невозможно войти. Взаимная блокировка в действии!

Ошибка заключалась в том, что методы lock и release остались синхронизированными, хотя синхронизация уже осуществлялась на внутреннем объекте.

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

Следующий немаловажный момент в понимании синхронизации как явления –

Синхронизация данных

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

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

Действительным спасением является обычная синхронизация. При входе в синхронизированный метод или блок все данные, используемые в них, синхронизируются автоматически. И проблем с чтением актуальных значений не возникает.

Проблемы могут проявиться в другом. А именно – в производительности. Синхронизация – это все-таки некоторые дополнительные издержки. При большой интенсивности использования синхронизированных методов исключительно для доступа к данным эти издержки могут дать отрицательный эффект и вызвать желание избавиться от синхронизации. Я об этом говорил в статье о реализации шаблона Singleton, в разделе инициализация и синхронизация. В некоторых случаях от синхронизации действительно можно отказаться. Если рассматривать пример реализации того же Singleton-а, то при его интенсивном использовании можно отложенную инициализацию заменить обычной, тогда метод для получения экземпляра можно не синхронизировать. В общем, варианты найти можно. Однако я не уверен, что синхронизация может дать ощутимое падение производительности. И потому, как я и говорил неоднократно – сначала синхронизируйте. Потом, когда выяснится (если выяснится!), что именно синхронизация является причиной падения производительности и ее отсутствие поможет делу – только тогда думайте о том, как бы ее убрать. Да, и обязательно напишите мне об этом случае!

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

Вот мы и подошли к самому, пожалуй, интересному разделу. А именно –

Приемы синхронизации

По уровню, на котором производится синхронизация потоков, ее можно разделить на две области – системная и программная. Как, наверное, понятно из названия, системная осуществляется с использованием средств (мониторов и пр.) виртуальной машины, в то время как в программной роль объектов, на которых происходит синхронизация, играют Java-объекты. Начнем с системной. Советую всем еще раз просмотреть API классов java.lang.Object (методы wait/notify) и java.lang.Thread.

Системная синхронизация с использованием wait/notify

Пожалуй, это наиболее часто использующийся тип синхронизации. Суть его проста. Берется некий объект. Поток, который ждет выполнения каких-либо условий, вызывает у этого объекта метод wait, предварительно захватив его монитор. На этом его работа приостанавливается. Другой поток может вызвать на этом же самом объекте метод notify (опять же, предварительно захватив монитор объекта), в результате чего, ждущий на объекте поток "просыпается" и продолжает свое выполнение. Подчеркиваю, в обоих случаях монитор надо захватывать в явном виде, через synchronized-блок, ибо методы семейства wait/notify не синхронизированы! Для меня загадка, почему это так, но примем как данность.

Вот простой пример.

package ru.skipy.juga_ru.tests.threads;

public class SyncTest {

    public static void main(String[] args) {
        Object sync = new Object();
        Data data = new Data();
        Thread t = new Thread(new WaitingThread(sync, data));
        t.start();
        try{
            System.out.println("main::Sleeping");
            Thread.sleep(500);
        }catch(InterruptedException ex){
            System.err.println("main::Interrupted: "+ex.getMessage());
        }
        synchronized (sync){
            System.out.println("main::setting value to 1");
            data.value = 1;
            System.out.println("main::notifying thread");
            sync.notify();
            System.out.println("main::Thread notified");
        }
    }

    static class Data {
        public int value = 0;
    }

    static class WaitingThread implements Runnable {

        private Object sync;
        private Data data;

        public WaitingThread(Object sync, Data data) {
            this.sync = sync;
            this.data = data;
        }

        public void run() {
            System.out.println("own:: Thread started");
            synchronized(sync) {
                if (data.value == 0) {
                    try {
                        System.out.println("own:: Waiting");
                        sync.wait();
                        System.out.println("own:: Running again");
                    } catch (InterruptedException ex) {
                        System.err.println("own:: Interrupted: "+ex.getMessage());
                    }
                }
                System.out.println("own:: data.value = "+data.value);
            }
        }
    }
}

Как вы видите, условием для продолжения работы потока в WaitingThread является data.value!=0. Если это условие не выполняется – поток ждет. Причем СНАЧАЛА он получает монитор объекта, на котором идет синхронизация – sync, – а уж потом проверяет условие. Это сделано для синхронизации данных, а именно – значения data.value. Далее вызывается sync.wait(). Основной же поток задерживается на небольшое время, чтобы дать второму потоку стартовать, после чего выставляет нужное значение (перед этим захватив монитор!) и вызывает sync.notify(). После этого второй поток продолжает работу и, как мы видим, у него значение data.value = 1.

Самые внимательные, вероятно, обратили внимание на один тонкий момент. Каждый поток перед вызовом wait или notify захватывает монитор на объекте sync. Причем в потоке main это происходит в то время, когда поток own ждет в точке вызова wait, т.е. – еще не вышел из синхронизированного блока! Нас где-то кидают?

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

Зададимся вот каким вопросом. Вызов wait происходит в синхронизированном блоке. Следовательно, прямо после вызова монитор должен быть захвачен, ибо выйти из блока мы еще не успели. Внутри же метода wait монитор отпускается и захватывается потоком, вызывающим notify. Внимание, вопрос: в какой момент происходит обратный захват монитора потоком, ждущим в wait? Ответить на него поможет следующий несложный тест:

package ru.skipy.juga_ru.tests.threads;

public class MonitorCaptureTest {

    public static void main(String[] args) {
        Object sync = new Object();
        Thread t = new Thread(new WaitingThread(sync));
        t.start();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException ex) {
            System.err.println("main::Interrupted: " + ex.getMessage());
        }
        synchronized (sync) {
            System.out.println("main::Calling notify");
            sync.notify();
            System.out.println("main::Sleeping for 5 seconds");
            try {
                Thread.sleep(5000);
            } catch (InterruptedException ex) {
                System.err.println("main::Interrupted: " + ex.getMessage());
            }
            System.out.println("main::Exiting synchronized block");
        }
    }

    static class WaitingThread implements Runnable {

        private Object sync;

        public WaitingThread(Object sync) {
            this.sync = sync;
        }

        public void run() {
            synchronized (sync) {
                System.out.println("own:: Waiting");
                try {
                    sync.wait();
                } catch (InterruptedException ex) {
                    System.err.println("own:: Interrupted: " + ex.getMessage());
                }
                System.out.println("own:: Running again");
            }
        }
    }
}

В общем-то тест прозрачен. Мы запускаем поток, ждем немного, чтобы он добрался до вызова wait, потом вызываем notify в основном потоке и... ждем. И смотрим, что будет. Результат, в общем-то предсказуем:

own:: Waiting
main::Calling notify
main::Sleeping for 5 seconds
main::Exiting synchronized block
own:: Running again

Итак, несмотря на то, что notify уже вызван, ждущий поток все еще стоит до того момента, пока не будет отпущен монитор. Это происходит при выходе из синхронизированного блока в потоке, вызвавшем notify. Сразу после этого ждущий поток захватывает монитор обратно и продолжает работу.

То же самое происходит и при вызове wait с параметром – временем ожидания в миллисекундах. Выполним следующий код:

package ru.skipy.juga_ru.tests.threads;

import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Date;

public class WaitWithTOTest {

    static final DateFormat FORMAT = new SimpleDateFormat("HH:mm:ss");

    public static void main(String[] args) {
        Object sync = new Object();
        Thread t = new Thread(new WaitingThread(sync));
        t.start();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException ex) {
            log("main::Interrupted: " + ex.getMessage());
        }
        synchronized (sync) {
            log("main::Sleeping for 5 seconds");
            try {
                Thread.sleep(5000);
            } catch (InterruptedException ex) {
                log("main::Interrupted: " + ex.getMessage());
            }
            log("main::Exiting synchronized block");
        }
    }

    static void log(String msg){
        System.out.println(FORMAT.format(new Date())+": "+msg);
    }

    static class WaitingThread implements Runnable {

        private Object sync;

        public WaitingThread(Object sync) {
            this.sync = sync;
        }

        public void run() {
            synchronized (sync) {
                log("own:: Waiting for 2 seconds");
                try {
                    sync.wait(2000);
                } catch (InterruptedException ex) {
                    log("own:: Interrupted: " + ex.getMessage());
                }
                log("own:: Running again");
            }
        }
    }
}

Этот код не менее прозрачен, нежели предыдущий. Мы запускаем поток, ждем, чтобы он добрался до вызова wait с двухсекундным временем ожидания. Затем захватываем монитор и засыпаем, на 5 секунд. Это время гарантированно больше того, которое еще осталось ждать другому потоку. Вот результат:

12:32:09: own:: Waiting for 2 seconds
12:32:10: main::Sleeping for 5 seconds
12:32:15: main::Exiting synchronized block
12:32:15: own:: Running again

Для удобства в этом тесте я добавил временные засечки1. Итак, наш поток останавливается в 12:32:09 на две секунды, т.е. продолжить работу он должен в 12:32:11. Монитор он при этом, естественно, отпускает. Далее, в 12:32:10 в основном потоке монитор захватывается и отпускается только через 5 секунд, в 12:32:15. Соответственно, пока это не произошло, ждущий поток не может захватить монитор обратно и продолжить работу. Кстати, этот тест дает еще немножко информации по поводу метода Thread.sleep. При его вызове никакие мониторы не отпускаются!

Теперь совершим краткий экскурс в API java.lang.Object. Помимо трех wait и одного notify, с которыми мы уже знакомы, там есть еще и метод notifyAll. Что это за зверь и с чем его едят?

Представьте себе, что один поток вызвал wait на каком-либо объекте и ждет. Внимание, вопрос: мешает ли кто-нибудь другому потоку вызвать на этом же объекте wait? Учитывая, что монитор отпущен – вряд ли. И вот уже ДВА потока ждут у моря погоды. Мешает ли кто-нибудь третьему потоку присоединиться к ним? А четвертому? Пятому?

notifyAll служит для одной цели – отправить в плаванье ВСЕ ждущие потоки. В то время как вызов notify подействует только на один.

Кстати, хороший вопрос. А на какой именно из пяти ждущих потоков подействует вызов notify? Знаете, какой правильный ответ? А черт его знает! :) Это действительно так – notify выводит из состояния ожидания произвольный поток.

Еще один хороший вопрос. А в каком порядке выводит из состояния ожидания потоки вызов notifyAll? Догадались, какой правильный ответ?.. :)

Еще один тест, суммирующий все вышесказаное. На этот раз у нас ПЯТЬ ждущих потоков:

package ru.skipy.juga_ru.tests.threads;

import java.util.Date;
import java.text.DateFormat;
import java.text.SimpleDateFormat;

public class NotifyAllTest {

    static final DateFormat FORMAT = new SimpleDateFormat("HH:mm:ss");

    public static void main(String[] args) {
        Object sync = new Object();
        for(int i=0; i<5; i++){
            Thread t = new Thread(new WaitingThread(sync));
            t.start();
        }
        try{
            Thread.sleep(1000);
        }catch(InterruptedException ex){
            System.err.println("main::  Interrupted: "+ex.getMessage());
        }
        synchronized(sync){
            log("main::  Calling notifyAll");
            sync.notifyAll();
            log("main::  Sleeping for 3 seconds");
            try{
                Thread.sleep(3000);
            }catch(InterruptedException ex){
                System.err.println("main::  Interrupted: "+ex.getMessage());
            }
            log("main::  Exiting synchronized block");
        }
    }

    static void log(String msg){
        System.out.println(FORMAT.format(new Date())+": "+msg);
    }

    static class WaitingThread implements Runnable{

        static int nextId = 1;

        private Object sync;
        private int id;

        public WaitingThread(Object sync) {
            this.sync = sync;
            id = nextId++;
        }

        public void run() {
            synchronized (sync) {
                log("own("+id+")::Waiting");
                try {
                    sync.wait();
                } catch (InterruptedException ex) {
                    log("own("+id+")::Interrupted: " + ex.getMessage());
                }
                log("own("+id+")::Running again");
                log("own("+id+")::Sleeping for 1 second");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException ex) {
                    log("own("+id+")::Interrupted: " + ex.getMessage());
                }
                log("own("+id+")::Finishing");
            }
        }
    }
}

Результат:

13:20:37: own(1)::Waiting
13:20:37: own(2)::Waiting
13:20:37: own(4)::Waiting
13:20:37: own(3)::Waiting
13:20:37: own(5)::Waiting
13:20:38: main::  Calling notifyAll
13:20:38: main::  Sleeping for 3 seconds
13:20:41: main::  Exiting synchronized block
13:20:41: own(2)::Running again
13:20:41: own(2)::Sleeping for 1 second
13:20:42: own(2)::Finishing
13:20:42: own(1)::Running again
13:20:42: own(1)::Sleeping for 1 second
13:20:43: own(1)::Finishing
13:20:43: own(4)::Running again
13:20:43: own(4)::Sleeping for 1 second
13:20:44: own(4)::Finishing
13:20:44: own(3)::Running again
13:20:44: own(3)::Sleeping for 1 second
13:20:45: own(3)::Finishing
13:20:45: own(5)::Running again
13:20:45: own(5)::Sleeping for 1 second
13:20:46: own(5)::Finishing

Итак, что мы видим. Первое. Несмотря на то, что потоки создавались с id по порядку, к вызову wait они могут придти в другом порядке. И зависит это буквально от малейшего чиха. Если в конструктор WaitingThread добавить синхронизацию на присваивание id:

public WaitingThread(Object sync) {
    this.sync = sync;
    synchronized(sync){
        id = nextId++;
    }
}

..., то порядок может измениться. У меня, во всяком случае, он меняется. (Кстати, а нужна ли тут такая синхронизация? Ответ в конце статьи, №1.)

Что еще мы видим? Порядок выхода из состояния ожидания вообще произволен. Запустите тест несколько раз, скорее всего порядок будет меняться от вызова к вызову. Ну и наконец, то, что мы уже обсуждали – из состояния ожидания потоки выходят строго по очереди. Каждый из них захватывает монитор, отрабатывает до конца, выходит из синхронизированного блока, после чего монитор захватывает следующий поток и т.д. Кстати, еще вопрос – можно ли в этом примере синхронизированный блок в методе run заменить синхронизацией самого метода run? Ответ в конце статьи, №2.

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

package ru.skipy.juga_ru.tests.threads;


import java.util.Date;
import java.text.DateFormat;
import java.text.SimpleDateFormat;

public class ReentranceTest {

    static final DateFormat FORMAT = new SimpleDateFormat("HH:mm:ss.SSS");

    public static void main(String[] args) {
        SynchronizedObject object = new SynchronizedObject();
        object.setValue(1);
        Thread t = new Thread(new ProcessingThread(object));
        t.start();
        try{
            Thread.sleep(1000);
        }catch(InterruptedException ex){
            System.err.println("main::Interrupted: "+ex.getMessage());
        }
        log("main::Setting value");
        object.setValue(2);
        log("main::Value set");
    }

    static void log(String msg){
        System.out.println(FORMAT.format(new Date())+": "+msg);
    }

    static class SynchronizedObject{

        private int value;

        public synchronized void setValue(int value){
            this.value = value;
        }

        public synchronized void process(){
            log("own:: value: "+value);
            try{
                log("own:: Sleeping");
                Thread.sleep(2000);
                log("own:: Waiting");
                wait(1000);
            }catch(InterruptedException ex){
                System.err.println("own:: Interrupted: "+ex.getMessage());
            }
            log("own:: value: "+value);
        }
    }

    static class ProcessingThread implements Runnable{

        private SynchronizedObject object;

        public ProcessingThread(SynchronizedObject object){
            this.object = object;
        }

        public void run() {
            object.process();
        }
    }
}

Итак, у нас два потока: основной, и тот, в котором вызывается синхронизированный метод process. Мы запускаем его, он засыпает на две секунды, а мы тем временем пытаемся вызвать синхронизированный метод setValue. Дальше второй поток уходит в состояние ожидания на секунду и продолжает работу. Посмотрим на результат:

14:43:55.763: own:: value: 1
14:43:55.763: own:: Sleeping
14:43:56.762: main::Setting value
14:43:57.761: own:: Waiting
14:43:57.761: main::Value set
14:43:58.760: own:: value: 2

В начале исполнения синхронизированного метода значение value = 1. Далее этот поток засыпает, в 14:43:55, на 2 секунды. В 14:43:56 основной поток пытается установить новое значение, но у него ничего не выходит – монитор объекта захвачен другим потоком. Остается только ждать... до 14:43:57, когда второй поток просыпается и уходит в состояние ожидания. Как только это происходит, монитор отпущен и новое значение установлено. Вы можете видеть по засечкам времени, что между уходом второго потока в состояние ожидания и установкой основным потоком нового значения прошло менее одной миллисекунды.

Таким образом, в начале синхронизированного метода значение поля было 1, а в конце – 2. Причем менялось это значение с помощью другого синхронизированного метода из другого потока. Забавно, правда? На самом деле, это зависит от того, по какую сторону баррикад находишься. Если приходится искать ошибку, связаную с такой синхронизацией... Приятного мало. Но у вас есть большое преимущество – теперь вы знаете, что такое тоже возможно.

Еще пара слов по поводу вызова метода wait. Это уже из разряда чистой техники. Рекомендуется вызывать wait изнутри цикла while. Т.е., писать не

if (some condition){
    obj.wait()
}

..., а

while (some condition){
    obj.wait()
}

Зачем это надо. Дело в том, что notify может вызвать кто угодно. Просто по ошибке, от которой никто не застрахован. В том случае из моего опыта, о котором я рассказывал выше, мы взялись за переделку именно для того, чтобы избежать такой возможности. Просто спрятали объект, на котором происходит синхронизация. И доступ к нему имел только наш код. Это хорошая практика, но не всегда возможно, к сожалению. Так вот, если поток ждет выполнения некоторого условия – вариант с while надежнее. Если поток пустили по ошибке – он опять проверит условие и, если надо, будет ждать дальше.

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

Уф! Похоже, с wait/notify мы покончили. Следующий вариант синхронизации –

Системная синхронизация с использованием join

Тут все намного проще. Метод join, вызванный у экземпляра класса Thread, позволяет текущему потоку остановиться до того момента, как поток, связаный с этим экземпляром, закончит работу. Это удобно во многих случаях. Например, запустили мы чтение данных в отдельном потоке и занялись подготовкой инфраструктуры для их обработки. Закончили подготовку – а данные еще не готовы. Вот тогда можно подождать окончания потока, в котором они читаются. Подводных камней я тут не вижу, честно сказать.

Кстати, с этим методом связан очень любопытный прием. К сожалению, напрочь не помню авторства, я его видел в форуме juga.ru. Если автор мне напишет – обязательно напишу, что это его творение.

Так вот, при работе с сетевыми соединениями не всегда можно указать таймаут. В частности, при использовании java.net.URL. С версии 1.5 у java.net.URLConnection уже появились методы setConnectTimeout и setReadTimeout, но до нее такого способа не было. И, соответственно, бывали случаи, когда вызов connect у URLConnection блокировал поток всерьез и надолго.

Предложеное решение оказалось весьма оригинальным.

URL url = new URL(...)
final URLConnection connection = url.openConnection();
// connection initialization
long timeout = 5000;
Thread t = new Thread(new Runnable(){
    public void run(){
        connection.connect();
    }
});
t.start();
t.join(timeout);

Т.е. соединение осуществляется в отдельном потоке, а текущий поток ждет его окончания в течение 5 секунд. Если соединение не произошло – текущий поток во всяком случае не блокируется.

С системной синхронизацией вроде разобрались. Поговорим теперь о программной.

Программная синхронизация – шаблоны и библиотеки

Так получилось, что практически вся программная синхронизация неразрывно связана с одним единственным именем – Doug Lea (Дуг Ли). В 1999 году вышла его книга Concurrent Programming in Java™: Design Principles and Pattern, в которой собрано множество различных шаблонов программной синхронизации. Более того, у него на сайте (http://g.oswego.edu/) есть полная реализация этих шаблонов – библиотека util.concurrent (http://gee.cs.oswego.edu/dl/classes/EDU/oswego/cs/dl/util/concurrent/intro.html). Эта реализация оказалась настолько удачной, что с версии Java 1.5 она включена в ядро в виде пакетов java.util.concurrent, java.util.concurrent.atomic и java.util.concurrent.locks. Пожалуй, я не буду подробно останавливаться на этих пакетах. Для того, чтобы их использовать, достаточно почитать документацию. Ее у Sun немало. Вот, например, хорошая статья: http://java.sun.com/developer/technicalArticles/J2SE/concurrency/. Вот еще: http://java.sun.com/developer/JDCTechTips/2005/tt0816.html#2.

Программная синхронизация хороша тем, что она гораздо более гибкая. Можно реализовать блокирующие очереди, барьеры, замки, семафоры и много другого. Все это уже есть в Java 1.5. Остается только изучить API.

Пожалуй, с теорией можно закончить. Обратимся теперь к практике.

Синхронизация на практике

Рассмотрим применение синхронизации в Java SE и Java EE.

Синхронизация коллекций

Начнем мы, пожалуй, с коллекций. С классов java.util.Vector и java.util.ArrayList. Оба они хранят данные в массивах. Оба они реализуют интерфейс java.util.List. Различия между ними минимальны. Вопрос. Зачем их два?

Ответы я слышал самые разнообразные. От того, что Vector оставлен для совместимости (он был с версии 1.0, а ArrayList появился в 1.2), до того, что у них разные алгоритмы работы и ArrayList существенно быстрее (что с версии 1.3.1 не соответствует действительности – по производительности они уже сравнимы).

На самом же деле различие между ними вплотную касается обсуждаемой темы. Дело в том, что Vector синхронизирован, а ArrayList – нет. Практически все методы класса Vector объявлены как synchronized, за исключением разве что iterator(). Однако, возвращаемый итератор опирается на те же синхронизированные методы вектора. Т.е. можно сказать, что класс Vector – thread-safe.

В случае с ArrayList применен кардинально другой подход. Для него поддерживается счетчик обновлений. Если в каком-нибудь критичном к обновлениям методе (вроде next итератора) ожидаемое значение счетчика не совпадает с реальным – инициируется исключение java.util.ConcurrentModificationException.

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

Точно такая же разница и между ассоциативными коллекциями – java.util.Hashtable и java.util.HashMap. Hashtable синхронизирован (thread-safe), HashMap – нет. Преимущества и недостатки абсолютно те же.

Ну ладно, у ArrayList есть синхронизированный аналог, у HashMap тоже. А как быть с остальными? Есть еще, например, java.util.LinkedList. Есть java.util.LinkedHashMap и java.util.TreeMap. Есть, наконец, целый класс множеств, унаследованных от java.util.Setjava.util.HashSet, java.util.TreeSet, java.util.LinkedHashSet. Как быть с ними, если очень хочется? Писать самостоятельно?

К счастью, этого не требуется. Ввиду наличия присутствия такого класса как java.util.Collections. Класс этот сервисный. Он содержит множество статических методов, из которых нам интересно шесть:

static java.util.Collection synchronizedCollection(Collection)
static java.util.List       synchronizedList(List)
static java.util.Map        synchronizedMap(Map)
static java.util.Set        synchronizedSet(Set)
static java.util.SortedMap  synchronizedSortedMap(SortedMap)
static java.util.SortedSet  synchronizedSortedSet(SortedSet)

Каждый из этих методов возвращает синхронизированную оболочку над переданным параметром. Единственное "но": возвращаемые оболочками итераторы не синхронизированы! Их надо синхронизировать вручную. Как – подробно описано в API, в описании самих методов.

Те же из вас, кто используют версию 1.5, имеют в своем распоряжении несколько классов, появившихся вместе с пакетом java.util.concurrentConcurrentHashMap, ConcurrentLinkedQueue, CopyOnWriteArrayList и CopyOnWriteArraySet. Все эти классы являются синхронизированными. А в версии 1.6 к ним прибавилось еще несколько (вот тут можно найти описание: http://java.sun.com/javase/6/docs/technotes/guides/collections/changes6.html).

Синхронизация GUI

Теперь обсудим синхронизацию GUI. Здесь тоже есть о чем поговорить.

Прежде всего, существует отдельный поток, обслуживающий GUI, с именем AWT-EventQueue. Этот поток отвечает, во-первых, за отрисовку всех компонентов, во-вторых, за реакцию на все события – нажатия клавиш на клавиатуре, движение мыши, ввод текста, нажатие кнопок, – в общем, за всю интерактивную часть. С другой стороны, некоторые действия, потенциально затрагивающие GUI, могут производиться и в других потоках. Классический пример – отображения статуса длительной операции, progress bar. Вопрос, собственно, заключается в следующем – как обеспечить корректное взаимодействие между такими потоками и компонентами пользовательского интерфейса, которыми заведует другой поток – AWT-EventQueue.

В документации по этому поводу говорится следующее. Компоненты UI, как Swing, так и AWT, должны рассматриваться как не-thread-safe. Иначе говоря, вызов всех их методов должен производится в GUI-потоке. Исключение составляет repaint(), который можно вызывать из любого потока. Создавать компоненты можно тоже в любом потоке. И даже вызывать их методы, если компоненты не видны. Но с того момента, как в первый раз компонент был показан на экране – работать с ним можно только в GUI-потоке.

Нет, можно, конечно, и в любом другом. Я видел кучу приложений, написаных именно так. Но мы сейчас говорим о том, как это делать ПРАВИЛЬНО. В противном случае корректную работу UI гарантировать никто не может.

Итак, работать только в GUI-потоке. Легко сказать! Нет, конечно, подавляющее большинство действий в визуальном приложении происходит именно после действий пользователя, которые обрабатываются именно в GUI-потоке. Но запустить в нем по нажатию кнопки длительный рассчетный процесс, загрузку файла по сети или что-нибудь еще – значит, похоронить приложение. Потому как до того момента, как управление выйдет из обработчика события, GUI-поток не сможет делать ничего другого, вплоть до отрисовки интерфейса. Следовательно, длительные процессы просто НЕОБХОДИМО запускать в другом потоке. Как быть?

Как обычно, "все уже украдено до нас"! В смысле, об этом тоже позаботились разработчики Sun. Существует такой класс как javax.swing.SwingUtilities. Помимо всего прочего он содержит два интересующих нас статических метода – invokeAndWait(Runnable) и invokeLater(Runnable). Оба они выполняют метод run переданного им Runnable в GUI-потоке. Разница в том, что первый из них синхронный – т.е. вызывающий его поток ждет завершения выполнения метода run, – а второй асинхронный – т.е. этот метод run выполнится гарантированно, но тогда, когда это будет удобно виртуальной машине. Последнее звучит страшнее, чем есть на самом деле – я не замечал задержек в выполнении. Так что "когда будет удобно" на практике не превышает долей секунды. И при прочих равных стоит использовать именно метод invokeLater.

Кроме этих двух методов класс SwingUtilities содержит полезный метод – isEventDispatchThread, – позволяющий определить, в каком потоке выполняется текущий код – в GUI или нет. Соответственно, если это GUI-поток, то в использовании методов invokeAndWait(Runnable) и invokeLater(Runnable) нет необходимости.

Таким образом, правильный вызов методов компонента выглядит где-то так (для примера – установим текст какому-либо полю):

package ru.skipy.juga_ru.tests.threads;

import javax.swing.*;

public class FileEditor extends JFrame{

    private JTextArea dataField;
    private String fileName;

    public FileEditor(String fileName){
        super();
        this.fileName = fileName;
        dataField = new JTextArea(40,20);

        // ... some other initialisation

        Thread dataLoader = new Thread(new FileLoader());
        dataLoader.start();
    }

    private class FileLoader implements Runnable{

        public void run(){
            final StringBuffer content = new StringBuffer();

            //... loading file data here into buffer

            if (SwingUtilities.isEventDispatchThread()){
                dataField.setText(content.toString());
            }else{
                SwingUtilities.invokeLater(new Runnable(){
                    public void run(){
                        dataField.setText(content.toString());
                    }
                });
            }
        }
    }
}

Это пример гипотетического редактора файла, вернее, его фрагмент. В конструкторе мы запускаем поток, который загрузит содержимое этого файла. После окончания загрузки содержимое устанавливается в качестве текста компоненты, причем с гарантией выполнения этого действия в GUI-потоке. Замечу, что проверка if (SwingUtilities.isEventDispatchThread()) в данном случае всегда даст false, т.к. код всегда будет выполняться в одном, не-GUI, потоке. Однако, если есть вероятность выполнения кода как в GUI-, так и в обычном потоке, – стоит применять этот прием.

Еще один момент. Если вы посмотрите на класс java.awt.EventQueue, то найдете там те же самые три метода. С той только разницей, что isEventDispatchThread там называется isDispatchThread. На самом деле, методы класса SwingUtilities просто являются оболочками для этих. И какие использовать – дело вкуса. Один лишний вызов в стеке.

И последний вопрос, которого я бы хотел коснуться –

Синхронизация сервлетов

Изначально в Java EE API существовал такой интерфейс как javax.servlet.SingleThreadModel. Реализация сервлетом этого интерфейса гарантировала, что на одном экземпляре сервлета метод service исполняется только в одном потоке. Это достигалось либо использованием единственного экземпляра сервлета (что влияло на производительность), либо использованием пула сервлетов (что вело к большему потреблению ресурсов).

В какой-то момент в Sun сочли такой подход неудачным. В самом деле, гораздо эффективнее отдать синхронизацию разработчику. Он во всяком случае знает, какие блоки действительно нужно синхронизировать вместо синхронизации всего метода service, который может работать очень и очень долго. Соответственно, в версии Java Servlet API 2.4 этот интерфейс был объявлен как deprecated, без какой-либо замены ему.

Это приводит нас к следующему выводу. При проектировании сервлетов нужно уделить особое внимание тому, чтобы сервлет был thread-safe. Не использовать переменные экземпляра, например. Либо синхронизировать к ним доступ. В общем, рассчитывайте на то, что методы сервлета будут вызываться из разных потоков.

* * *

Итак, мы дошли до финиша. Ура! Это действительно очень важная тема, вызывающая много вопросов и проблем. Надеюсь, теперь их станет меньше. Всем спасибо!


1. Хочу напомнить, что класс java.text.SimpleDateFormat не является потоконезависимым. При использовании одного экземпляра в нескольких потоках возможны нарушения внутреннего состояния, вследствие чего результат форматирования будет непредсказуемым. В примерах я использую этот класс именно так, но только по той причине, что это всего лишь примеры. Правильность форматирования тут некритична. В реальных приложениях необходимо либо создавать экземпляр этого класса непосредственно в том месте, где он будет применяться, либо использовать класс java.lang.ThreadLocal, реализуя его метод initialValue для создания нужного экземпляра java.text.SimpleDateFormat. Спасибо читателю Даниле Галимову за напоминание!

Ответ №1. Вопрос на засыпку – в каком потоке вызывается конструктор WaitingThread? ТОЛЬКО в основном, в методе main. Соответственно, о доступе из разных потоков к статическому полю nextId говорить не приходится. Следовательно, синхронизация не нужна!

Ответ №2. Аналогом синхронизации нестатического метода является блок synchronized(this). В нашем же случае синхронизация производится не на объекте this, а на объекте sync. Следовательно, заменить блок синхронизации на синхронизацию метода в данном случае нельзя!