Теория и практика Java: Методы безопасного конструирования

Не позволяйте указателю "this" пропадать во время конструирования

Язык Java предлагает гибкое и кажущееся простым средство организации поточности, которое облегчает внедрение элементов многопоточности в ваши приложения. Однако параллельное программирование в Java-приложениях гораздо сложнее, чем кажется: в Java-программах существует несколько неочевидных (и не очень) способов создания ситуации соперничества за данные (data races) и других опасностей, связанных с параллелизмом. В этом выпуске по теории и практике Java Брайан Гетц рассматривает распространенную опасность, связанную с поточностью: допущение ситуации, при которой указатель this исчезает во время конструирования. Эта безвредная на вид практика может вызвать непредсказуемые и нежелательные результаты в работе ваших Java-программ.

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

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



12.01.2007

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

Следование методам "безопасного конструирования"

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

Опасные условия соперничества

Большинство опасностей, связанных с параллелизмом сводятся к своего рода соперничеству за данные. Соперничество за данные, или условие соперничества, происходит, когда множественные потоки или процессы читают или записывают разделяемый объект данных, и конечный результат зависит от порядка, в котором потоки работают. Листинг 1 показывает пример простого соперничества за данные, в котором программа может выдать либо 0, либо 1, в зависимости от планирования потоков.

Листинг 1. Простое соперничество за данные
public class DataRace {
  static int a = 0;

  public static void main() {
    new MyThread().start();
    a = 1;
  }

  public static class MyThread extends Thread {
    public void run() { 
      System.out.println(a);
    }
  }
}

Второй поток мог бы быть запущен сразу же, печатая для a начальное значение, равное 0. Как вариант, второй поток мог и не запуститься сразу, в результате чего на печать вывелось бы значение 1. Результаты этой программы могут зависеть от используемого вами пакета разработки (JDK), от планировщика вашей операционной системы или от случайных временных артефактов. Повторный запуск программы может выдавать различные результаты.

Опасности, связанные с видимостью

Фактически в Листинге 1 есть еще одно соперничество за данные, кроме очевидного соперничества, заключающегося в том, начнет второй поток выполняться до или после того, как первый поток установит a в значение 1. Второе соперничество - это соперничество, связанное с видимостью: два потока не используют синхронизации, которая обеспечила бы видимость изменений данных для всех потоков. Поскольку синхронизация отсутствует, если второй поток запускается после того, как первый поток завершит присвоение a значения, то изменения, внесенные первым потоком могут, а могут и не быть сразу же видны для второго потока. Возможно, что второй поток еще будет видеть значение a, равное 0, хотя первый поток уже присвоил переменной значение 1. Этот второй класс соперничества за данные, в котором оба потока получают доступ к одной и той же переменной в связи с отсутствием должной синхронизации, - вещь сложная, но, к счастью, вы можете избежать этот класс соперничества за данные, используя синхронизацию каждый раз, когда вы читаете переменную, которая могла быть последний раз написана другим потоком, или пишите переменную, которую в следующий раз может прочитать другой поток. Далее в этой статье мы не будем рассматривать этот тип соперничества за данные, поэтому просмотрите колонку "Синхронизация с помощью Java Memory Model" и раздел Ресурсы, чтобы получить дополнительную информацию по этому сложному вопросу.

Синхронизация с помощью Java Memory Model

Ресурсы

Не публикуйте указатель "this" во время конструирования

Одна из ошибок, которая может внести соперничество за данные в ваш класс, - показывать указатель this другому потоку до того, как завершит работу конструктор. Иногда указатель явный, как при прямом сохранении this в статическом поле или коллекции, но иногда он может быть и неявным, как, например, когда вы публикуете указатель на экземпляр нестатического внутреннего класса в конструкторе. Конструкторы не являются обычными методами - у них специальная семантика для безопасной инициализации. Предполагается, что объект находится в предсказуемом, логически непротиворечивом состоянии после того, как конструктор завершит работу, а публикация указателя на не полностью сконструированный объект просто опасна. Листинг 2 показывает пример введения такого рода условия соперничества в конструктор. Это может выглядеть безвредным, но все же содержит зачатки серьезных проблем, связанных с параллелизмом.

Листинг 2. Внесение условия соперничества в конструктор
public class EventListener { 

  public EventListener(EventSource eventSource) {
    // выполняем инициализацию
    ...

    // регистрируемся в источнике событий
    eventSource.registerListener(this);
  }

  public onEvent(Event e) { 
    // обрабатываем событие
  }
}

На первый взгляд, класс EventListener выглядит безобидно. Регистрация приёмника, который публикует указатель на новый объект, где другие потоки могли бы его увидеть, - это последнее, что делает конструктор. Но даже если игнорировать все моменты, связанные с Java Memory Model (JMM), такие как различия в видимости по всем потокам и переупорядочение доступа к памяти, этот код все еще подвержен опасности показать не полностью сконструированный объект EventListener другим потокам. Посмотрите, что происходит, когда в EventListener создаётся подкласс, как в Листинге 3:

Листинг 3. Создание подкласса для EventListener
public class RecordingEventListener extends EventListener {
  private final ArrayList list;

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

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

  public Event[] getEvents() {
    return (Event[]) list.toArray(new Event[0]);
  }
}

Поскольку спецификация Java требует, чтобы вызов super() был первым оператором в конструкторе подкласса, наш недоконструированный приёмник событий уже зарегистрирован в источнике событий до того, как мы сможем закончить инициализацию полей подкласса. Теперь мы имеем соперничество за данные в поле list. Если приёмник событий решает послать событие из регистрационного вызова, или нам просто не везёт и событие приходит в неподходящий момент, то RecordingEventListener.onEvent() может быть вызван, тогда как поле list все еще содержит значение по умолчанию null и выбросит затем исключение NullPointerException. Методы классов, такие как onEvent(), не могут быть вызваны до того, как последние поля будут проинициализированы.

Проблема Листинга 2 заключается в том, что EventListener опубликовал ссылку на конструируемый объект до того, как завершилась работа конструктора. Хотя может показаться, что объект почти полностью сконструирован, и, значит, передача указателя this источнику событий выглядит безопасной, поверхностный взгляд может быть обманчивым. Публикация указателя this из тела конструктора, как это сделано в Листинге 2, представляет собой бомбу замедленного действия, ждущую своего момента.


Не показывайте в неявном виде указатель "this"

Проблему с исчезнувшим указателем можно создать, и вовсе не используя указатель this. Не статические внутренние классы содержат неявную копию указателя this от своего родительского объекта, поэтому создание анонимного экземпляра внутреннего класса и передача его объекту, видимому из вне текущего потока, несёт такие же риски, как и показ самого указателя this. Посмотрите Листинг 4, в котором имеется та же основная проблема, как и в Листинге 2, но отсутствует явное использование указателя this:

Листинг 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) {
  }
}

Класс EventListener2 болен тем же, чем и его брат EventListener из Листинга 2: указатель на конструируемый объект публикуется (в данном случае косвенным образом) и это делает его видимым для другого потока. Если нам понадобится создать подкласс для EventListener2, мы столкнёмся с той же самой проблемой - метод подкласса может быть вызван ещё до завершения работы конструктора.


Не запускайте потоков из тела конструкторов

Специальный случай данной проблемы, представленный в Листинге 4, - запуск потока из тела конструктора, так как часто, когда объект владеет потоком, этот поток является либо внутренним классом, либо мы передаём указатель this его конструктору (либо сам класс является расширением класса Thread). Если объект планирует владеть потоком, лучше всего, если он же предоставляет метод start(), как это делает Thread, и запускает поток из метода start(), а не из конструктора. И хотя при этом через интерфейс становятся видны некоторые детали реализации класса (как, например, возможное наличие у него принадлежащего ему потока), что чаще всего является нежелательным, в данном случае риск от запуска потока из конструктора перевешивает выигрыш от сокрытия деталей реализации.


Что вы подразумеваете под словом "публиковать"?

Не все ссылки на указатель this во время работы конструктора являются вредными, а лишь те, которые публикуют указатель таким образом, что его могут видеть другие потоки. Определение, является ли безопасным совместное с другим объектом использование указателя this, требует детального понимания видимости объекта и того, что данный объект будет делать с указателем. Листинг 5 содержит несколько примеров безопасной и небезопасной методики в плане возможности указателю this исчезать при работе конструктора:

Листинг 5. Безопасная и небезопасная методики обращения с this
public class Safe { 

  private Object me;
  private Set set = new HashSet();
  private Thread thread;

  public Safe() { 
    // Безопасно, потому что "me" невидимо из любого другого потока
    me = this;

    // Безопасно, потому что "set" невидимо из любого другого потока
    set.add(this);

    // Безопасно, потому что MyThread не запустится, пока не будет завершена работа конструктора
    // и конструктор не опубликует указатель
    thread = new MyThread(this);
  }

  public void start() {
    thread.start();
  }

  private class MyThread(Object o) {
    private Object theObject;

    public MyThread(Object o) { 
      this.theObject = o;
    }

    ...
  }
}

public class Unsafe {
  public static Unsafe anInstance;
  public static Set set = new HashSet();
  private Set mySet = new HashSet();

  public Unsafe() {
    // Небезопасно, потому что anInstance виден всем
    anInstance = this;

    // Небезопасно, потому что SomeOtherClass.anInstance виден всем
    SomeOtherClass.anInstance = this;

    // Небезопасно, потому что SomeOtherClass может сохранить указатель "this"
    // так, что он может быть виден другому потоку
    SomeOtherClass.registerObject(this);

    // Небезопасно, потому что set имеет глобальную видимость 
    set.add(this);

    // Небезопасно, потому что мы публикуем указатель на mySet
    mySet.add(this);
    SomeOtherClass.someMethod(mySet);

    // Небезопасно, потому что объект "this" будет виден из нового
    // потока до того, как конструктор завершит работу
    thread = new MyThread(this);
    thread.start();
  }

  public Unsafe(Collection c) {
    // Небезопасно, потому что "c" может быть видимо из других потоков
    c.add(this);
  }
}

Как видите, большинство небезопасных конструкций в классе Unsafe в значительной степени напоминают безопасные конструкции в классе Safe. Определить, может ли указатель this стать видимым другому потоку, может быть достаточно сложно. Самая лучшая стратегия - избегать использования в конструкторах указателя this вообще (прямо или косвенно). Тем не менее, в действительности это не всегда возможно. Просто не забывайте быть очень осторожными с указателями this, а также при создании экземпляров нестатических внутренних классов в конструкторах.


Другие причины, по которым нельзя допускать исчезновения указателей во время работы конструктора

Детально описанные выше методы потокобезопасного конструирования приобретают еще большую важность, когда мы рассматриваем эффекты синхронизации. Например, когда поток A запускает поток B, спецификация Java (JLS) гарантирует, что все переменные, которые были видимыми для потока A, становятся видимыми и для потока B, когда тот запускается потоком А и, фактически, как бы имеет неявную синхронизацию в Thread.start(). Если мы запускаем поток из конструктора, то конструируемый объект ещё не до конца сконструирован, и поэтому мы теряем гарантии видимости.

Из-за некоторых еще более запутанных аспектов, JMM сейчас дорабатывается в рамках Java Community Process JSR 133, который (кроме всего прочего) изменит семантику volatile и final, чтобы привести их в соответствие с общей интуицией. Например, в соответствии с сегодняшней семантикой JMM для потока допустимо видеть, что поле final имеет на протяжении своего жизненного цикла более одного значения. Семантика новой модели памяти не допустит этого, но лишь в том случае, если конструктор правильно описан - а это означает, что нельзя допускать исчезновения указателя this во время конструирования.


Заключение

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

Ресурсы

Комментарии

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=188200
ArticleTitle=Теория и практика Java: Методы безопасного конструирования
publish-date=01122007