При участии Тима Перлса, Джошуа Блоха, Джозева Боубира, Дэвида Холмса и Дага Ли Параллельное программирование в java на
Скачать 4.97 Mb.
|
Глава 4 Компоновка объектов До сих пор мы рассмотрели низкоуровневые основы потокобезопасности и синхронизации. Но мы не хотим анализировать каждое обращение к памяти, чтобы гарантировать, что наша программа является потокобезопасной; мы хотим иметь возможность создавать потокобезопасные компоненты и безопасно составлять из них более крупные компоненты или программы. В этой главе рассматриваются шаблоны для структурирования классов, которые могут облегчить обеспечение их потокобезопасности и поддержки, без риска случайного нарушения гарантий безопасности. 4.1 Проектирование потокобезопасных классов Хотя можно написать потокобезопасную программу, которая хранит все свое состояние в общедоступных статических полях, гораздо сложнее проверить ее на потокобезопасность или изменить ее так, чтобы она оставалась потокобезопасной, в противоположность той, что использует инкапсуляцию соответствующим образом. Инкапсуляция позволяет прийти к выводу о потокобезопасности класса без необходимости изучения всей программы. Процесс проектирования потокобезопасного класса должен включать три базовых элемента: • Определение переменных, формирующих состояние объекта; • Определение инвариантов, ограничивающих переменные состояния; • Установление политики для управления параллельным доступом к состоянию объекта. Определение состояния объекта начинается с его полей. Если все они примитивных типов, поля содержат в себе всё состояние. Класс Counter в листинге 4.1 имеет только одно поле, поэтому в поле value содержится всё его состояние. Состояние объекта с n примитивными полями - это всего лишь n-кортеж 50 значений его полей; состояние класса 2D Point определяется значением (x, y). Если у объекта имеются поля, содержащие ссылки на другие объекты, его состояние также будет включать поля из объектов, на которые имеются ссылки. Например, состояние класса LinkedList включает в себя состояние всех ссылок на объекты узлов, принадлежащих списку. @ThreadSafe public final class Counter { @GuardedBy("this") private long value = 0; public synchronized long getValue() { return value; } 50 Кортеж представляет собой вектор или одномерный массив значений. public synchronized long increment() { if (value == Long.MAX_VALUE) throw new IllegalStateException("counter overflow"); return ++value; } } Листинг 4.1 Простой потокобезопасный счётчик, использующий Java шаблон “монитор” Политика синхронизации определяет, каким образом объект координирует доступ к своему состоянию, для того, чтобы избежать нарушения своих инвариантов или постусловий. Она определяет, какая комбинация из неизменяемости, ограничения потока и блокировки используется для обеспечения потокобезопасности и какие переменные защищены какими блокировками. Чтобы убедиться, что класс можно анализировать и поддерживать, документируйте политику синхронизации. 4.1.1 Сбор требований к синхронизации Сделать класс потокобезопасным – это значит гарантировать, что его инварианты будут “удержаны” 51 при параллельном доступе; это требует рассуждений о его состоянии. Объекты и переменные имеют пространство состояний: диапазон возможных состояний, которые они могут принимать. Чем меньше это пространство состояний, тем легче о нём рассуждать. Используя поля типа final везде, где это практически возможно, вы упрощаете анализ возможных состояний объекта. (В крайнем случае, неизменяемые объекты могут находиться только в одном состоянии.) Многие классы имеют инварианты, которые определяют некоторые состояния как допустимые (valid) или недопустимые (invalid). Поле value в классе Counter типа long . Пространство состояний типа long составляет диапазон от Long.MIN_VALUE до Long.MAX_VALUE , но класс Counter накладывает собственное ограничение на поле value; отрицательные значения не допустимы. Аналогичным образом операции могут иметь постусловия, которые идентифицируют определенные переходы состояний как недопустимые. Если текущее состояние объекта Counter равно 17, единственным допустимым следующим значением состояния является 18. Когда следующее состояние выводится из текущего состояния, операция обязательно является составным действием. Не все операции налагают ограничения на переходы состояний; при обновлении переменной, которая содержит текущую температуру, ее предыдущее состояние не оказывает влияние на вычисления. Ограничения, накладываемые на состояния или переходы состояний инвариантов и постусловий, создают дополнительные требования к использованию синхронизации или инкапсуляции. Если некоторые состояния недопустимы, то базовые переменные состояния должны быть инкапсулированы, иначе клиентский код может перевести объект в недопустимое состояние. Если операция имеет недопустимые переходы состояний, она должна быть атомарной. С другой стороны, если класс не накладывает таких ограничений, мы можем ослабить требования к инкапсуляции или сериализации, чтобы получить большую гибкость или лучшую производительность. 51 Состояние инвариантов не будет изменяться при параллельном доступе. Класс также может иметь инварианты, ограничивающие несколько переменных состояния. Класс с числовым диапазоном, например, такой как NumberRange в листинге 4.10, обычно поддерживает сохранение нижней и верхней границ диапазона в переменных состояния. Эти переменные должны подчиняться ограничению, заключающемуся в том, что нижняя граница диапазона была меньше или равна верхней границе. Многомерные инварианты, подобные этому, создают требования к атомарности: связанные переменные должны извлекаться или обновляться в одной атомарной операции. Вы не можете обновить переменную, снять и повторно захватить блокировку, а затем обновить другие переменные, так как это может повлечь за собой оставление объекта в недопустимом состоянии, когда блокировка будет снята. Когда в инварианте участвует несколько переменных, блокировка, которая их защищает, должна удерживаться в процессе выполнения любой операции, обращающейся к связанным переменным. Невозможно обеспечить потокобезопасность без понимания инвариантов и постусловий объекта. Ограничение допустимых значений или переходов состояний для переменных состояний может потребовать применения атомарности и инкапсуляции. 4.1.2 Операции, зависящие от состояния Инварианты классов и постусловия методов ограничивают допустимые состояния и переходы состояний объекта. Некоторые объекты также имеют методы с предварительными условиями на основе состояния. Например, нельзя удалить элемент из пустой очереди; очередь должна находиться в состоянии “не пусто”, прежде чем можно будет удалить элемент. Операции с предварительными условиями на основе состояния называются зависимыми от состояния [CPJ 3]. В однопоточной программе, если предварительное условие не выполняется, операция не имеет выбора, кроме как завершиться c ошибкой. Но в параллельной программе предварительное условие может быть выполнено позже из-за действия другого потока. Параллельные программы добавляют возможность ожидания до тех пор, пока условие не станет истинным, а затем продолжат операцию. Встроенные механизмы эффективного ожидания выполнения условия - wait и notify - тесно связаны со встроенными блокировками и могут быть сложны в правильном использовании. Для создания операций, ожидающих выполнения предварительных условий перед выполнением, часто проще использовать существующие классы библиотек, такие как блокирующие очереди или семафоры, чтобы обеспечить требуемое поведение, зависящее от состояния. Блокирующие библиотечные классы, такие как BlockingQueue , Semaphore и другие синхронизаторы, рассматриваются в главе 5; создание зависимых от состояния классов с использованием низкоуровневых механизмов, предоставляемых платформой и библиотекой классов, рассматривается в главе 14. 4.1.3 Владелец состояния В разделе 4.1 мы подразумевали, что состояние объекта может быть подмножеством полей в графе объектов, внедренных в этот объект. Почему это может быть подмножество? При каких условиях поля, достижимые из данного объекта, не являются частью состояния этого объекта? Определяя, какие переменные формируют состояние объекта, мы хотим рассматривать только те данные, которыми объект владеет. Владелец явно в языке не реализован, но является элементом дизайна класса. Если вы размещаете и заполняете объект HashMap , вы создаете несколько объектов: объект HashMap , несколько объектов Map.Entry , используемых реализацией HashMap , и, возможно, другие внутренние объекты. Логическое состояние объекта HashMap включает состояние всех объектов Map.Entry и внутренних объектов, даже если они реализованы как отдельные объекты. К лучшему или к худшему, сборка мусора позволяет нам не думать о владельце. При передаче объекта методу в C++, необходимо достаточно тщательно обдумывать вопрос о том, передаете ли вы право собственности, занимаетесь ли вы краткосрочным кредитом или предполагаете долгосрочное совместное владение. В языке Java возможны все те же модели владения, но сборщик мусора снижает стоимость многих распространенных ошибок при совместном использовании ссылок, позволяя размышлять о владении без учёта мелких деталей. Во многих случаях владение и инкапсуляция следуют рука об руку - объект инкапсулирует состояние, которым он владеет, и владеет состоянием, которое он инкапсулирует. Владелец переменной состояния принимает решение о протоколе блокировки, используемом для поддержания целостности состояния этой переменной. Владение подразумевает контроль, но как только вы опубликуете ссылку на изменяемый объект, у вас больше не будет эксклюзивного права контроля; в лучшем случае у вас может быть “совместное владение”. Класс обычно не владеет объектами, переданными его методам или конструкторам, если только этот метод не предназначен для явного переноса права собственности на переданные объекты (например, фабричный метод обертки синхронизированной коллекции). Классы коллекций часто демонстрируют форму "раздельного владения", в которой коллекции принадлежит состояние инфраструктуры коллекции, а клиентскому коду принадлежат объекты, хранящиеся в коллекции. Например, класс ServletContext из фреймворка сервлетов. Класс ServletContext предоставляет для сервлетов Map-подобный контейнер объектов, с помощью которого они могут регистрировать и извлекать объекты приложения по имени, используя методы setAttribute и getAttribute . Объект ServletContext , реализованный контейнером сервлета, должен быть потокобезопасным, потому что к нему обязательно будут обращаться несколько потоков. Сервлетам нет необходимости использовать синхронизацию при вызове методов setAttribute и getAttribute , но им, возможно, придется использовать синхронизацию в моменты использовании объектов, хранящихся в ServletContext Эти объекты принадлежат приложению; они хранятся, для безопасного хранения, контейнером сервлетов от имени приложения. Как и прочие совместно используемые объекты, они должны использоваться безопасно; чтобы не допустить взаимного влияния от нескольких потоков обращающихся к одному и тому же объекту одновременно, они должны быть потокобезопасными, фактически неизменяемым или использовать явную блокировку 52 52 Интересно, что объект HttpSession , выполняющий аналогичную функцию в рамках сервлета, может иметь более строгие требования. Поскольку контейнер сервлета может обращаться к объектам в httpsession, чтобы их можно было сериализовать для репликации или пассивации, они должны быть потокобезопасными, так как контейнер будет обращаться к ним, а также к веб-приложению. (Мы говорим "может иметь", так как репликация и пассивация вне спецификации сервлета, но является общей особенностью контейнеров сервлета.) 4.2 Ограничение экземпляра Если объект не является потокобезопасным, несколько подходов могут позволить безопасно использовать его в многопоточной программе. Вы можете гарантировать, что доступ к объекту будет осуществляется только из одного потока (ограничение потока) или что весь доступ к объекту будет должным образом защищен блокировкой. Инкапсуляция упрощает создание потокобезопасных классов, способствуя ограничению экземпляра; часто это называется просто ограничением [CPJ2.3.3]. Когда объект инкапсулируется в другой объект, все ветки кода, имеющие доступ к инкапсулированному объекту, известны и поэтому могут быть проанализированы значительно легче, чем в том случае, если бы этот объект был доступен из всех частей программы. Сочетание ограничения с соответствующей правилам блокировкой гарантирует, что не потокобезопасные объекты используются потокобезопасным способом. Инкапсуляция данных в объекте ограничивает доступ к данным методами объекта, что упрощает обеспечение гарантии того, что доступ к данным будет всегда осуществляться с удержанием соответствующей блокировки. Ограниченные объекты не должны покидать заданной области видимости. Объект может быть ограничен экземпляром класса (например, закрытым членом класса), лексической областью действия (например, локальной переменной) или потоком (например, объектом, который передается из метода в метод в потоке, но он не должен совместно использоваться другими потоками). Конечно, объекты не могут сбегать сами по себе - им нужна помощь разработчика, который может способствовать, опубликовав объект за пределами его области видимости. Класс PersonSet в листинге 4.2 иллюстрирует, как ограничение и блокировка могут работать совместно, чтобы сделать класс потокобезопасным, даже если переменные состояния компонента таковыми не являются. Состояние класса PersonSet управляется классом HashSet , который не является потокобезопасным. Но, так как переменная mySet является приватной и не может сбежать, класс HashSet ограничен классом PersonSet . Е динственные ветки кода, которые могут получить доступ к переменной mySet, это методы addPerson и containsPerson , и каждая из них захватывает блокировку на экземпляре PersonSet Всё состояние класса защищено внутренней блокировкой, что делает класс PersonSet потокобезопасным. @ThreadSafe public class PersonSet { @GuardedBy("this") private final Set mySet = new HashSet (); public synchronized void addPerson(Person p) { mySet.add(p); } public synchronized boolean containsPerson(Person p) { return mySet.contains(p); } } Листинг 4.2 Использование ограничения для обеспечения потокобезопасности В этом примере не делается никаких предположений о потокобезопасности класса Person , но если класс изменяемый, при обращении к классу Person , полученному из PersonSet, будет необходимо использовать дополнительную синхронизацию. Самый надежный способ достигнуть этого – сделать класс Person потокобезопасным; менее надежным было бы защитить объекты Person с помощью блокировки и гарантировать, чтобы все клиенты будут следовать протоколу захвата соответствующей блокировки до осуществления доступа к экземпляру Person Ограничение экземпляра - один из простейших способов создания потокобезопасных классов. Оно позволяет проявить гибкость в выборе стратегии блокировки; класс PersonSet полагается на свою собственную внутреннюю блокировку, чтобы защитить свое состояние, но любая блокировка, используемая последовательно, будет работать же хорошо. Ограничение экземпляра также позволяет различным переменным состояния защищаться различными блокировками. (Пример класса, использующего несколько объектов блокировки для защиты своего состояния, см. в главе 11, в классе ServerStatus ). Существует множество примеров ограничений в библиотеках классов платформы, включая некоторые классы, которые существуют исключительно для того, чтобы превращать не потокобезопасные классы в потокобезопасные. Базовые классы коллекций, такие как ArrayList и HashMap , не являются потокобезопасными, но библиотека классов предоставляет фабричные методы- обертки ( Collections.synchronizedList и другие), чтобы их можно было безопасно использовать в многопоточном окружении. Эти фабрики используют шаблон Декоратор (Гамма и другие, 1995) 53 , чтобы обернуть коллекцию в синхронизированный объект-обёртку; обёртка реализует каждый метод соответствующего интерфейса как синхронизированный метод, который перенаправляет запрос базовому объекту коллекции. До тех пор, пока объект- обертка содержит единственную доступную ссылку на базовую коллекцию (то есть базовая коллекция ограничена обёрткой), объект-обёртка также является потокобезопасным. В Javadoc для таких методов указано предупреждение, что весь доступ к базовой коллекции должен выполняться через объект-обёртку. Конечно, по-прежнему возможно нарушить ограничение, опубликовав предположительно ограниченный объект; если объект должен быть ограничен определенной областью видимости, то позволение покинуть эту область видимости является ошибкой. Ограниченные объекты также могут сбегать после публикации другими объектами, такими как итераторы или внутренние экземпляры классов, которые могут косвенно публиковать ограниченные объекты. Ограничение упрощает создание потокобезопасных классов, поскольку класс, ограничивающий своё состояние, может быть проанализирован на потокобезопасность без необходимости проверки всей программы. 53 Речь о книге “Шаблоны проектирования” банды четырёх. 4.2.1 Шаблон Java “Монитор” Следуя от принципа ограничения экземпляра к его логическому завершению, вы придёте к шаблону Java “монитор” 54 . Объект, следующий шаблону Java “монитор”, инкапсулирует все своё изменяемое состояние и защищает его с помощью собственной внутренней блокировки. Класс Counter в листинге 4.1 демонстрирует типичный пример использования этого шаблона. Он инкапсулирует одну переменную состояния, value , и весь доступ к этой переменной состояния осуществляется через методы класса Counter , которые являются синхронизированными. Шаблон Java “монитор” используется многими библиотечными классами, такими как Vector и Hashtable . Иногда требуется более тонкая политика синхронизации; в главе 11 показано, как повысить масштабируемость за счет более утончённых стратегий блокировки. Основным преимуществом шаблона Java “монитор” является его простота. Шаблон Java “монитор” - это просто соглашение; любой объект блокировки может использоваться для защиты состояния объекта, если он используется согласованно. В Листинге 4.3 иллюстрируется класс, который использует приватную блокировку для защиты своего состояния. public class PrivateLock { private final Object myLock = new Object(); @GuardedBy("myLock") Widget widget; void someMethod() { synchronized(myLock) { // Access or modify the state of widget } } } |