Санкт-Петербургская группа тестирования JVM


« jdksearch.com | Main | Новая лицензия Java... »
20070813 понедельник Август 13, 2007

Организация многопоточных приложений

Многие книги, посвящённые Java, содержат крайне мало информации, относящейся к такой фундаментальной части платформы Java, как потоки. В связи с этим хочется порекомендовать для чтения вышедшую в прошлом году книгу "Java Concurrency in Practice", последовательно и подробно рассказывающую о создании многопоточных приложений. Эта книга затрагивает множество проблем, начиная с базовых принципов разработки потокобезопасных (thread-safe) классов и заканчивая сложностями, возникающими при тестировании многопоточных программ. В этой статье мы хотим рассказать о некоторых интересных рассмотренных в этой книге вопросах, связанных с использованием потоков.

Необходимость синхронизации

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

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

class NumberRange {
private int lower;
private int upper;

public NumberRange(int lower, int upper) {
setLower(lower);
setUpper(upper);
}

public int getLower() {
return lower;
}

public int getUpper() {
return upper;
}

public void setLower(int lower) {
if (lower > upper)
throw new IllegalArgumentException();
this.lower = lower;
}

public void setUpper(int upper) {
if (upper < lower)
throw new IllegalArgumentException();
this.upper = upper;
}
}

Для начала сформулируем определение термина потокобезопасный:

Класс является потокобезопасным, если он ведёт себя корректно (то есть работает согласно спецификации класса) в случае его использования в нескольких потоках.

Является ли класс NumberRange потокобезопасным?

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

Одним из аспектов межпоточного взаимодействия, описываемых моделью памяти, является видимость. В каком случае поток увидит изменения переменных, сделанные другими потоками? Для этого, модель памяти вводит отношение между изменением данных одними потоками и чтением этих данных другими потоками. В английском варианте оно называется happens before. Согласно модели памяти, если один поток изменит значение поля класса NumberRange при помощи метода setLower, то не гарантируется, что другой поток, используя метод getLower, увидит это изменение (поскольку ни изменение, ни чтение поля lower не использует синхронизацию). Очевидно, что в этом случае класс не является потокобезопасным.

Для того чтобы изменения, сделанные одним потоком, были гарантированно видны во всех других потоках, можно, например, объявить поля lower и upper с модификатором volatile. Однако даже после того, как проблема видимости будет решена таким способом, класс не будет являться потокобезопасным, поскольку может быть нарушено ещё одно требование контракта класса: "значение нижней границы не может быть больше верхней". Это возможно в следующем случае: пусть в объекте NumberRange записаны значения lower и upper равные, соответственно, 0 и 10. Поток A вызывает метод setLower с аргументом 5, успешно выполняет проверку if (lower > upper) throw new IllegalArgumentException(); и готов сохранить новое значение lower. В это время начинает работу поток Б, который пытается установить значение upper равное 4, и, поскольку поток А ещё не успел записать значение 5 в поле lower, то поток Б также успешно проходит проверку if (upper < lower) throw new IllegalArgumentException();. После этого потоки сохраняют новые значения в объекте NumberRange. В результате получается, что lower = 5 и upper = 4.

Таким образом, для того, чтобы класс NumberRange работал в многопоточном приложении корректно, необходимо обеспечить атомарность выполнения методов setUpper и setLower. Если эти методы выполняются атомарно, то поток, модифицирующий объект NumberRange, не может быть прерван другим потоком в середине операции изменения. В этом случае будет невозможна описанная выше ситуация, при которой в поля upper и lower могут быть записаны некорректные значения. Традиционным подходом для решения проблемы атомарности операций в Java является объявление методов setUpper и setLower с модификатором synchronized.

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

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

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

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

Для начала обозначим проблему, которую решают все способы организации взаимодействия потоков. Эта проблема хорошо видна на примере класса NumberRange: объект, который должен использоваться несколькими потоками, содержит переменные, являющиеся его состоянием, а методы класса объекта предоставляют возможность это состояние менять, не обеспечивая при этом синхронизации. Таким образом, главная задача при написании многопоточного приложения—правильно обеспечить доступ к состоянию класса, которое может быть изменено более чем одним потоком. Можно также сформулировать следующее правило: если несколько потоков могут прочитать состояние объекта, и хотя бы один поток может это состояние изменить, то все потоки должны осуществлять доступ к этому состоянию с использованием механизмов синхронизации. Какие же существуют способы, позволяющие решить эту проблему?

Привязка к потоку

Как отмечено выше, приложение может работать некорректно в том случае, если несколько потоков используют класс, не обеспечивающий синхронизацию. Но если этот класс используется только одним потоком, то никакой дополнительной синхронизации не требуется. Поэтому самый простой способ организовать многопоточное приложение—ввести требование использовать классы, не являющиеся потокобезопасными только в одном потоке. Эта техника на������ывается привязкой к потоку (thread confinement). Она используется, например, в подавляющем большинстве широко распространённых сред для создания графического пользовательского интерфейса. Подобные системы чрезвычайно подвержены взаимоблокировкам, и создание среды, в котором элементы пользовательского интерфейса могли бы безопасно использоваться сразу несколькими потоками является очень сложной задачей.  Поэтому, в данном случае, привязка к потоку, пожалуй, является наилучшим решением.

Пакет Swing устроен именно таким образом: практически все классы, представляющие элементы пользовательского интерфейса (например, JButton или JTable) не являются потокобезопасными и должны использоваться только из одного потока—потока обработки сообщений. Если есть необходимость модифицировать эти классы из других потоков, то необходимо использовать специальные методы, такие как SwingUtilities.invokeLater(Runnable runnable), которые позволяют запустить заданный объект runnable в потоке обработки сообщений.

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

Отсутствие языковой поддержки частично компенсировано библиотеками Java, а именно классом ThreadLocal. Использование этого класса позволяет ассоциировать с каждым потоком собственную копию какого-либо ресурса. В частности, ThreadLocal удобно использовать при работе с JDBC: поскольку классы, реализующие интерфейс Connection, не являются потокобезопасными (по крайней мере, спецификация этого интерфейса не содержит такого требования), то каждому потоку, осуществляющему доступ к базе данных, необходимо предоставить отдельный экземпляр Connection. Класс, предоставляющий подобную функциональность и основанный на ThreadLocal, мог бы быть написан следующим образом:

class ConnectionAccess {
private static ThreadLocal<Connection> connectionHolder = new ThreadLocal<Connection>() {
public Connection initialValue() {
return DriverManager.getConnection(DB_URL);
}
};

public static Connection getConnection() {
return connectionHolder.get();
}
}

Неизменяемые объекты

Рассмотренная техника привязки к потоку решает сформулированную нами проблему необходимости использования синхронизации ("если несколько потоков могут прочитать состояние объекта, и хотя бы один поток может это состояние изменить, то все потоки должны осуществлять доступ к этому состоянию с использованием механизмов синхронизации"), запрещая использование из нескольких потоков объекты, не являющиеся потокобезопасными. Другой способ избежать связанных с синхронизацией сложностей—запретить изменять состояние объектов, которые используются несколькими потоками. Основная сложность при написании потокобезопасного класса—обеспечение непротиворечивости его состояния. Но как только мы запретим модифицировать объект, то сразу избавимся от непротиворечивости состояния.

Язык Java и модель памяти Java позволяют создать неизменяемые (immutable) объекты, которые могут использоваться одновременно несколькими потоками без дополнительных механизмов синхронизации. Объект является неизменяемым, если выполняются следующие условия:

Очень важно отметить, что простое объявление всех полей объекта с использованием final не делает объект потокобезопасным. Главное условие—состояние объекта не должно меняться после создания. Так, класс SetHolder из следующего примера является изменяемым, так как хоть его единственное поле set и объявлено final, но  доступ к этому полю не ограничен и состояние объекта SetHolder может меняться. Это произойдёт, например, если в коллекцию set будут добавлены другие элементы.

class SetHolder {

public final Set<String> set = new HashSet<String>();

public SetHolder() {
set.add("One");
set.add("Two");
set.add("Three");
}

public boolean containsString(String string) {
return set.contains(string);
}
}

Чтобы превратить данный класс в неизменяемый, достаточно объявить поле set как private вместо public.

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

Попытаемся создать потокобезопасный класс, чьё состояние представлено объектом типа NumberRange из нашего первого примера. Вот версия класса без использования неизменяемых объектов:

class NumberRangeHolder {

private NumberRange numberRange;

public synchronized NumberRange getNumberRange() {
return numberRange;
}

public synchronized void setNumberRange(int lower, int upper) {
numberRange.setLower(lower);
numberRange.setUpper(upper);
}
}

Даже если на первый взгляд кажется, что данный класс можно абсолютно безопасно использовать в многопоточном приложении, на самом деле это не так. Класс NumberRangeHolder посредством метода getNumberRange предоставляет доступ к полю numberRange, и, следовательно, состояние класса может быть изменено в обход правильного способа модификации, то есть без вызова синхронизированного метода setNumberRange, например, с помощью NumberRange.setUpper. А как мы уже отметили, класс NumberRange.setUpper не является потокобезопасным. Есть как минимум два способа исправить это и без использования неизменяемых объектов: объявить все методы класса NumberRange synchronized (то есть сделать класс NumberRange потокобезопасным) или возвращать в методе getNumberRange вместо самого поля numberRange его копию. Однако есть и более простое решение: сделать класс NumberRange неизменяемым:

class ImmutableNumberRange {
private final int lower;
private final int upper;

public ImmutableNumberRange(int lower, int upper) {
if (lower > upper)
throw new IllegalArgumentException();
this.lower = lower;
this.upper = upper;
}

public int getLower() {
return lower;
}

public int getUpper() {
return upper;
}
}

class NumberRangeHolder {

private volatile ImmutableNumberRange numberRange;

public ImmutableNumberRange getNumberRange() {
return numberRange;
}

public void setNumberRange(int lower, int upper) {
numberRange = new ImmutableNumberRange(lower, upper);
}
}

Как видите, используя неизменяемый объект ImmutableNumberRange и volatile поле, мы смогли совсем избавиться от synchronized методов.

Возможно, кого-то может напугать, что каждый раз при вызове метода setNumberRange создаётся новый объект, и это может стать причиной снижения производительности. Эти опасения безосновательны. Во-первых, мы получаем выигрыш в производительности за счёт того, что не используем синхронизацию. Во-вторых, в современных виртуальных машинах цена создания нового объекта очень мала, и даже если новая копия NumberRange создаётся очень часто, время жизни создаваемых объектов невелико, и поэтому у современных сборщиков мусора, основанных на поколениях (generational garbage collectors), не возникнет серьёзных проблем при очистке памяти.

Корректная инициализация разделяемых объектов

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

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

class NumberRangeHolder {
public NumberRange numberRange = new NumberRange(1, 10);
}

Если объект класса NumberRangeHolder был создан в одном потоке, то другой поток, пытающийся работать с полем numberRange, может столкнуться с двумя проблемами: во-первых, он может увидеть неверное, устаревшее значение этого поля (null), и, во-вторых, даже если поток видит, что поле numberRange содержит корректную ссылку, то сам объект NumberRange может находиться в некорректном состоянии. Поля lower и upper могут также содержать устаревшие значения (нули), отличные от ожидаемых 1 и 10. Такой результат возможен, так как в отсутствие синхронизации, JIT-компилятор и процессор не ограничены в наборе используемых оптимизаций (таких как, например, переупорядочивание операций или сохранение данных в локальном кэше процессора вместо основной памяти), в результате чего действия, выполняемые одним потоком, с точки зрения другого потока могут происходить в порядке, не соответствующем порядку, записанном в программе.

Для обеспечения корректной инициализация объекта, используемого несколькими потоками, требуется, чтобы поток, создающий объект, и потоки, обращающиеся к этому объекту, обязательно использовали синхронизацию. Чтобы избежать упомянутых проблем, связанных с инициализацией поля numberRange, класс NumberRangeHolder мог бы быть изменён следующим образом (хочу обратить внимание, что данный пример решает только проблему, связанную с инициализацией объекта, и этот вариант класса NumberRangeHolder не предоставляет возможностей для последующего корректного изменения состояния объекта numberRange):

class NumberRangeHolder {

private NumberRange numberRange;

synchronized public NumberRange getNumberRange() {
if (numberRange == null)
numberRange = new NumberRange(1, 10);

return numberRange;
}
}

Помимо явного использования синхронизации есть несколько других способов гарантировать корректную инициализацию объекта перед тем, как к этому объекту станет возможен доступ:

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

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

Хотите знать больше об использовании потоков? Тогда обязательно читайте "Java Concurrency in Practice".

Семён Бойков

опубликовал vmrobot ( авг 13 2007, 03:58:28 AM MSD ) Permalink Комментарии [2]

Trackback URL: http://blogs.sun.com/vmrobot/entry/%D0%BE%D1%80%D0%B3%D0%B0%D0%BD%D0%B8%D0%B7%D0%B0%D1%86%D0%B8%D1%8F_%D0%BC%D0%BD%D0%BE%D0%B3%D0%BE%D0%BF%D0%BE%D1%82%D0%BE%D1%87%D0%BD%D1%8B%D1%85_%D0%BF%D1%80%D0%B8%D0%BB%D0%BE%D0%B6%D0%B5%D0%BD%D0%B8%D0%B9
Комментарии:

# для создания объекта и доступа к нему не требуется использовать синхронизацию, если объект создаётся в статическом инициализаторе. В этом случае синхронизация используется неявно: согласно спецификации (JVM Spec 2.17.5) на время инициализации класса (то есть при выполнении его статических инициализаторов) виртуальная машина получает блокировку для объекта Class, представляющий данный класс, и каждый поток по крайней мере один раз получает эту же блокировку, для того, чтобы убедиться, что класс был инициализирован.

Всё, что говорит спецификация на этот счёт:
# If the execution of the initializers completes normally, then lock this Class object, label it fully initialized, notify all waiting threads, release the lock, and complete this procedure normally.

Не нашёл, если честно, никаких сведений, что КАЖДЫЙ поток проверяет, был ли правильно инициализирован класс, используя для этого синхронизацию.

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

опубликовал Дмитрий Июль 13, 2008 at 11:36 AM MSD #

В конструкторе класса NumberRange стоит заменить setLower(lower) на this.lower = lower, иначе нет возможности устновить нижнюю границу больше 0. ))

опубликовал Федор Август 20, 2008 at 04:49 PM MSD #

Опубликовать комментарий:

Имя
E-Mail:
URL:

Ваш комментарий:

HTML Syntax: Отключен

Хиты страниц за сегодня: 27