Теория и практика Java: Будьте хорошим подписчиком (событий)

Рекомендации по написанию и поддержке подписчиков событий

Шаблон Observer, наиболее часто встречаемый в разработке Swing, также очень полезен для развязывания компонентов в ситуациях, отличных от GUI-приложений. Однако, существует несколько общих для всех ловушек, связанных с регистрацией и активацией подписчиков. В этом очередном выпуске Теории и практики Java Брайан Гетц предлагает несколько полезных советов, как быть хорошим подписчиком и как самому быть хорошим для своих "подписчиков".

Брайан Гетц, главный консультант, Quiotix

Брайан Гетц (Brian Goetz) - консультант по ПО и последние 15 лет работал профессиональными разработчиком ПО. Сейчас он является главным консультантом в фирме Quiotix, занимающейся разработкой ПО и консалтингом и находящейся в Лос-Альтос, Калифорния. Следите за публикациями Брайана в популярных промышленных изданиях. Вы можете связаться с Брайаном по адресу brian@quiotix.com



26.02.2007

Среда Swing активно использует шаблон Observer (также известный как шаблон публикации-подписки) в форме приёмников событий. Компоненты Swing, являющиеся целью пользовательского взаимодействия, генерируют события, когда пользователь взаимодействует с ними; классы модели данных генерируют события, когда данные изменяются. Такое использование Observer позволяет отделить контроллер от модели, а модель отделить от представления, упрощая разработку GUI-приложений.

Книга "Банды четырех" (Gang of Four) Шаблоны для Проектирования (см. Ресурсы) описывает шаблон Observer, как определение "зависимости "один ко многим" между объектами, так что при изменении состояния одного объекта, все зависимые от него объекты оповещаются и обновляются автоматически." Шаблон Observer делает возможной слабую связь между компонентами; компоненты могут сохранять своё синхронизированное состояние при необязательном непосредственном знании личности и устройства друг друга, облегчая повторное использование компонентов.

Компоненты AWT и Swing, такие как JButton или JTable, используют шаблон Observer для отделения генерации событий GUI от их семантики внутри данного приложения. Аналогичным образом, классы моделей Swing, такие как TableModel и TreeModel, используют Observer чтобы разделить отображение модели данных от процесса генерации представлений, что делает возможными множественные независимые представления для одних и тех же данных. Swing определяет иерархии объектов Event и EventListener; компоненты, которые могут генерировать события, такие как JButton (визуальный компонент) или TableModel (модель данных), предоставляют методы addXxxListener() и removeXxxListener() для регистрации и разрегистрации подписчиков. Эти классы решают, когда им необходимо генерировать события, и когда они генерируют их, то вызывают все подписчики, которые зарегистрированы.

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

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

Зачастую подписичики вызываются в другом потоке, отличном от того, из которого они были зарегистрированы. Для поддержки регистрации подписчиков из различных потоков, какой бы механизм не использовался для хранения и поддержания списка активных подписчиков, он должен быть безопасным для потоков. Многие примеры в документации Sun используют Vector для хранения списка подписчиков, что решает проблему частично, но не решает всей проблемы. Когда событие генерируется, компонент, его генерирующий, будет перебирать список подписчиков и вызывать каждый из них, что вносит риск одновременной модификации, если какой-нибудь поток захочет добавить или удалить подписчик в то время, когда список подписчиков перебирается.

Управление списком подписчиков

Предположим, вы используете Vector<Listener> для хранения вашего списка подписчиков. Хотя класс Vector является потокобезопасным, что означает, что его методы могут быть вызваны без дополнительной синхронизации безо всякого риска повредить структуры данных Vector, перебор коллекции включает в себя последовательности вида "проверка-затем-действие", которые могут работать некорректно, если коллекция модифицируется во время перебора. Допустим, к началу перебора в вашем списке находятся три подписчика. Когда вы перебираете Vector, вы многократно вызываете size() и get(), пока не останется больше элементов для извлечения, как в Листинге 1:

Листинг 1. Небезопасный перебор Vector
Vector<Listener> v;
for (int i=0; i<v.size(); i++)
  v.get(i).eventHappened(event);

Но что случится, если сразу после последнего вызова Vector.size() кто-то удаляет подписчик из списка? Теперь Vector.get() возвратит null (что совершенно верно, поскольку состояние вектора изменилось с момента вашей последней проверки), и вы выбросите NullPointerException, когда попытаетесь вызвать eventHappened(). Это пример последовательности проверка-затем-действие - вы проверяете, существуют ли еще элементы, и если да, то вы получаете следующий элемент, но в присутствии одновременной модификации состояние могло измениться с момента проверки. Рисунок 1 иллюстрирует проблему:

Рисунок 1. Одновременный перебор и модификация, приводящие к неожиданному сбою

Одновременный перебор и модификация, приводящие к неожиданному сбою

Одно из решений данной проблемы - установить блокировку на Vector на время перебора; другое - клонировать Vector или вызывать его метод toArray() для извлечения его содержимого каждый раз при возникновении события. Оба этих подхода отражаются на производительности; в первом случае вы рискует заблокировать на время перебора другие потоки, которым может потребоваться доступ к списку подписчиков, тогда как во втором случае происходит создание временного объекта и копирование списка каждый раз при возникновении события.

Если вы используете Iterator для обхода списка подписчиков, у вас будет та же проблема, но в слегка измененном виде; вместо NullPointerException реализация iterator() выбросит ConcurrentModificationException, если обнаружит, что коллекция была модифицирована с момента начала перебора. Опять же, этого можно избежать, заблокировав коллекцию на время перебора.

Класс CopyOnWriteArrayList в java.util.concurrent может помочь предотвратить эту проблему. Он реализует List и является потокобезопасным, но его итераторы не выбросят ConcurrentModificationException и не требуют никаких дополнительных блокировок во время обхода. Эта комбинация характеристик достигается путем внутреннего перераспределения и копирования содержимого списка каждый раз, когда список модифицируется, так что потокам, перебирающим содержимое, не приходится иметь дело с изменениями - с их точки зрения содержимое списка остается неизменным во время перебора. Хотя это, может быть, звучит неэффективно, помните, что в большинстве ситуаций с Observer каждый компонент имеет небольшое количество подписчиков, а количество обходов намного превосходит по численности вставки и удаления. Тем самым, более быстрый перебор компенсирует более медленную мутацию и обеспечивает лучшую параллельность, потому что множественные потоки могут перебирать список одновременно.

Угрозы безопасности при инициализации

Весьма заманчиво регистрировать подписчик из его конструктора, но следует избегать этого соблазна. Это не только связано с проблемой "недействительного подписчика" (о которой я говорю в данный момент), но и создаёт несколько проблем безопасности потоков. В Листинге 2 показана на первый взгляд безвредная попытка сконструировать и зарегистрировать подписчик в одно и то же время. Проблема заключается в том, что указатель "this" на объект здесь может быть потерян до того, как объект будет полностью сконструирован. Это может выглядеть безвредно, поскольку регистрация - это последнее, что делает конструктор, но внешний вид может быть обманчивым:

Листинг 2. Подписчик событий, позволяющий потерять указатель "this", что приводит к проблемам
public class EventListener { 

  public EventListener(EventSource eventSource) {
    // do our initialization
    ...

    // register ourselves with the event source
    eventSource.registerListener(this);
  }

  public onEvent(Event e) { 
    // handle the event
  }
}

Один из рисков данного подхода проявляется, когда создаётся подкласс подписчика событий: Теперь всё, что делается конструктором подкласса, происходит после запуска конструктора EventListener, а, значит и после публикации EventListener, создавая условия для конкуренции. При неудачном стечении обстоятельств метод onEvent в Листинге 3 может быть вызван до инициализации поля списка, вызывая сильно запутывающее NullPointerException при разыменовании финального поля:

Листинг 3. Проблема, вызванная созданием подкласса для EventListener в Листинге 2
public class RecordingEventListener extends EventListener {
  private final ArrayList<Event> list;

  public RecordingEventListener(EventSource eventSource) {
    super(eventSource);
    list = Collections.synchronizedList(new ArrayList<Event>());
  }

  public onEvent(Event e) { 
    list.add(e);
    super.onEvent(e);
  }
}

Даже если класс вашего подписчика является финальным, и потому из него нельзя создавать подклассы, вы, всё равно, не должны допускать потери указателя "this" в конструкторе - это подрывает некоторые гарантии безопасности, которые даёт Java Memory Model. Существует возможность указателю "this" ускользнуть, даже без использования слова "this" в вашей программе; публикация экземпляра нестатического внутреннего класса оказывает тот же эффект, потому что внутренний класс содержит ссылку на указатель "this" вмещающего его в себя объекта. Одной из наиболее типичных причин, позволяющих ссылке "this" случайно потеряться, является регистрация подписчиков, как показано в Листинге 4. Подписчики событий не должны регистрироваться из конструкторов!

Листинг 4. Неявное позволение указателю "this" потеряться за счёт опубликования экземпляра внутреннего класса
public class EventListener2 {
  public EventListener2(EventSource eventSource) {

    eventSource.registerListener(
      new EventListener() {
        public void onEvent(Event e) { 
          eventReceived(e);
        }
      });
  }

  public void eventReceived(Event e) {
  }
}

Потокобезопасность подписчика

Третья проблема безопасности потоков, возникающая в связи с использованием подписчиков, является следствием того факта, что подписчикам может потребоваться доступ к данным приложения, а подписчик обычно вызывается в потоке, который не контролируется непосредственно приложением. Если вы регистрируете подписчик с помощью JButton или другого компонента Swing, то он будет вызван из EDT. Код подписчика может безопасно вызывать методы компонентов Swing из EDT, но доступ к объектам приложения из подписчика может добавить новые требования безопасности потоков в вашу программу, если эти объекты не являются уже потокобезопасными.

Компоненты Swing генерируют события в результате воздействий пользователя, но классы модели Swing генерируют события в случае вызова методов fireXxxEvent(). Эти методы в свою очередь вызовут подписчики, в каком бы потоке они не вызывались. Поскольку классы модели Swing не являются потокобезопасными и предположительно ограничены EDT, любые вызовы fireXxxEvent() должны также выполняться из EDT. Если вы хотите запустить событие из другого потока, вы должны использовать функцию Swing invokeLater(), чтобы вызов метода сработал, но из EDT. В общем, учитывайте, из какого потока будут вызваны подписчики событий, и убедитесь, что любые затрагиваемые ими объекты являются либо потокобезопасными, либо защищёнными соответствующей синхронизацией (или ограничением потока, как классы модели Swing) везде, где к ним имеется доступ.


Недействительные подписчики

Всякий раз, используя шаблон Observer, вы связываете два отдельных компонента - обозреватель и обозреваемого, которые обычно имеют свои жизненные циклы. Одним из следствий регистрации подписчика является то, что создаётся жёсткая ссылка от обозреваемого объекта к подписчику, что мешает подписчику (и другим объектам, на которые он ссылается) быть утилизированными до тех пор, пока подписчик не будет разрегистрирован. Во многих случаях предполагается жизненный цикл подписчика такой же по длительности, как и у обозреваемого компонента - многие подписчики сохраняются на протяжении всей работы приложения. Но в некоторых случаях подписчики, которые предполагались как кратковременные, оказываются постоянными, и единственными признаками их внеплановой задержки является медленная работа приложения и чрезмерное потребление памяти.

Проблема "недействительного подписчика" может быть вызвана небрежностью на уровне проектирования: неадекватным расчётом времени жизни задействованных объектов или просто неряшливым кодированием. Регистрация и разрегистрирование подписчиков должны всегда делаться в согласованной паре. Но даже если вы делаете именно так, вы должны удостовериться, что разрегистрирование действительно выполняется в нужный момент. В Листинге 5 показан пример идиомы кодирования с риском появления недействительных подписчиков. В нём подписчик регистрируется с компонентом, выполняет некоторые действия, а затем подписчик разрегистрируется:

Листинг 5. Код с риском появления недействительных подписчиков
  public void processFile(String filename) throws IOException {
    cancelButton.registerListener(this);
    // open file, read it, process it
    // might throw IOException
    cancelButton.unregisterListener(this);
  }

Проблема в Листинге 5 заключается в том, что если код обработки файлов выбрасывает IOException (что вполне вероятно), то подписчик никогда не будет разрегистрирован, а это означает, что он никогда не будет утилизирован. Операция разрегистрирования должна проводиться в блоке finally, так чтобы она выполнялась при любом маршруте выхода из метода processFile().

Один из предлагаемых подходов для работы с недействительными подписчиками - это использование слабых ссылок. Хотя данный подход и возможен, его реализация довольно сложна. Для того чтобы он работал, вам необходимо найти другой объект, чей жизненный цикл точно такой, как у вашего подписчика, и настроить для него жёсткие ссылки на ваш подписчик, что не всегда легко.

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


Другие дефекты подписчика

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

Другой способ, которым подписчики могут создавать неприятности для неосторожных источников событий, это генерирование неотмеченного исключения. Хотя чаще всего мы не собираемся генерировать неотмеченные исключения, это иногда происходит. Если вы использовали идиому из Листинга 1 для активации подписчиков, а второй подписчик в списке выбросил неотмеченное исключение, то не только не будут вызваны последующие подписчики (что потенциально оставляет приложение в неопределенном состоянии), но это даже может убить поток, в котором он выполняется, что вызывает частичный сбой в приложении.

При активации неизвестного кода (который подписчики конечно квалифицируют именно таким образом), разумно выполнять его в блоке try-catch, чтобы неблагополучные подписчики не нанесли больше вреда, чем необходимо. Вы даже, может быть, захотите автоматически разрегистрировать подписчик, генерирующий неотмеченное исключение; в конце концов, это является признаком того, что подписчик испорчен. (Вы также захотите записать это в журнал или как-нибудь ещё обратить на это внимание пользователя, чтобы пользователь смог разобраться, почему программа перестала выполняться, как ожидалось.) Листинг 6 демонстрирует пример такого подхода, при котором блок try-catch помещается внутри цикла прокрутки:

Листинг 6. Вызов отказоустойчивого подписчика
List<Listener> list;
for (Iterator<Listener> i=list.iterator; i.hasNext(); ) {
    Listener l = i.next();
    try {
        l.eventHappened(event);
    }
    catch (RuntimeException e) {
        log("Unexpected exception in listener", e);
        i.remove();
    }
}

Резюме

Шаблон Observer весьма полезен для создания слабосвязанных компонентов и стимулирования повторного использования компонентов, но при этом существуют некоторые риски, которых должны остерегаться разработчики подписчиков и разработчики компонентов. При регистрации подписчика, всегда учитывайте его жизненный цикл. Если он рассчитан на более короткий жизненный цикл, чем приложение, убедитесь, что он разрегистрируется, чтобы он мог быть утилизирован. При написании подписчиков и компонентов учитывайте затрагиваемые проблемы безопасности потоков. Любые объекты, затронутые подписчиками, должны быть или потокобезопасными, или предназначаться для ограниченных потоком объектов, таких как модели Swing, подписчик должен быть уверен, что он выполняется в правильном потоке.

Ресурсы

Комментарии

developerWorks: Войти

Обязательные поля отмечены звездочкой (*).


Нужен IBM ID?
Забыли Ваш IBM ID?


Забыли Ваш пароль?
Изменить пароль

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Профиль создается, когда вы первый раз заходите в developerWorks. Информация в вашем профиле (имя, страна / регион, название компании) отображается для всех пользователей и будет сопровождать любой опубликованный вами контент пока вы специально не укажите скрыть название вашей компании. Вы можете обновить ваш IBM аккаунт в любое время.

Вся введенная информация защищена.

Выберите имя, которое будет отображаться на экране



При первом входе в developerWorks для Вас будет создан профиль и Вам нужно будет выбрать Отображаемое имя. Оно будет выводиться рядом с контентом, опубликованным Вами в developerWorks.

Отображаемое имя должно иметь длину от 3 символов до 31 символа. Ваше Имя в системе должно быть уникальным. В качестве имени по соображениям приватности нельзя использовать контактный e-mail.

Обязательные поля отмечены звездочкой (*).

(Отображаемое имя должно иметь длину от 3 символов до 31 символа.)

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Вся введенная информация защищена.


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=Технология Java
ArticleID=197819
ArticleTitle=Теория и практика Java: Будьте хорошим подписчиком (событий)
publish-date=02262007