Effective Java tmprogramming Language GuideJ o s h u a b lo c h
Скачать 1.05 Mb.
|
// Запускаем систему // Дадим системе 10 сна прогрев try { Thread.sleep(10000); } catch (InterruptedException e) { } // Подсчитаем количество переходов за 10 с int count = q1.count; try { Thread.sleep(10000); } catch (InterruptedException e) { } System.out.println(q1.count - count); q1.stop(); q2.stop(); Приведенная реализация WorkQueue может показаться немного надуманной, тем не менее нередко можно встретить многопоточные системы, в которых безо всякой необходимости запускается один или несколько лишних потоков. И хотя результат этого может быть не таким экстремальным, как здесь продемонстрировано, производительность и переносимость приложения, по-видимому, все же страдают. 193 Столкнувшись стем, что программа едва работает из-за того, что некоторые потоки, по сравнению с остальными, не получают достаточно процессорного времени, не поддайтесь искушению исправить" программу, добавив в нее вызовы Thread.yield. Вы можете заставить программу работать, однако полученное приложение не будет переносимым сточки зрения производительности. Вызовы yield, улучшающие производительность водной реализации JVM, в другой ее ухудшают, а в третьей не оказывают никакого влияния. У Thread.yield нет строгой семантики. Лучше измените структуру приложения таким образом, чтобы сократить количество параллельно выполняемых потоков. Схожий прием состоит в регулировании приоритетов потоков. приоритеты потоков числятся среди наименее переносимых характеристик платформы Java. Нельзя отрицать, что быстроту реагирования приложения можно настроить, отрегулировав приоритеты нескольких потоков, но необходимость в этом возникает редко, а полученные результаты будут меняться от одной реализации JVM к другой. Серьезную проблему живучести не решить с помощью приоритетов потоков. Проблема скорее всего вернется, пока вы не найдете и не устраните основную причину. Метод Thread.yield следует использовать для того, чтобы искусственно увеличить степень распараллеливания программы на время тестирования. Благодаря просмотру большей части пространства состояний программы, это помогает найти ошибки и удостовериться в правильности системы. Этот прием доказал свою высокую эффективность в выявлении скрытых ошибок многопоточной обработки. Подведем итоги. Ваше приложение не должно зависеть от планировщика потоков. Иначе оно не будет ни устойчивым, ни переносимым. Как следствие, лучше не связывайтесь с методом Thread.yield и приоритетами. Эти функции предназначены единственно для планировщика. Их можно дозировано применять для улучшения качества сервиса в уже работающей реализации, но ими нельзя пользоваться для "исправления" программы, которая едва работает. При работе с потоками документируйте уровень безопасности То, как класс работает, когда его экземпляры и статические методы одновременно используются в нескольких потоках, является важной частью соглашений, устанавливаемых классом для своих клиентов. Если вы не отразите эту сторону поведения класса в документации, использующие его программисты будут вынуждены делать Допущения. И если эти допущения окажутся неверными, полученная программа может иметь либо недостаточную (статья 48), либо избыточную (статья 49) синхронизацию. В любом случае это способно привести к серьезным ошибкам. Иногда говорится, что пользователи могут сами определить, безопасен ли метод при работе с несколькими потоками, если проверят, присутствует ли модификатор synchronized в документации, генерируемой утилитой Javadoc. Это неверно по нескольким причинам. Хотя в ранних версиях утилита javadoc действительно указывала в создаваемом документе модификатор synchronized, это было ошибкой, ив версии 1.2 она была устранена. Наличие в декларации метода модификатора synchronized - это деталь реализации, а не часть внешнего API. Присутствие модификатора не является надежной гарантией того, что метод безопасен при работе с несколькими потоками. От версии к версии ситуация может меняться. Более того, самоутверждение о том, что наличия ключевого слова synchronized достаточно для того, чтобы говорить о безопасности при работе с несколькими потоками, содержит в себе распространенную микро-концепцию о категоричности этого свойства. На самом деле, класс может иметь несколько уровней безопасности. Чтобы класс можно было безопасно использовать в среде со многими потоками, в документации к нему должно быть четко указано, какой уровень безопасности он поддерживает. В следующем списке приводятся уровни безопасности, которых может придерживаться класс при работе с несколькими потоками. Этот список не претендует на полноту, однако в нем представлены самые распространенные случаи. Используемые здесь названия не являются стандартными, поскольку в этой области нет общепринятых соглашений. Неизменяемый (immutable). Экземпляры такого класса выглядят для своих клиентов как константы. Никакой внешней синхронизации не требуется. Примерами являются String, Integer и Biglnteger (статья 13). С поддержкой многопоточности (thread-safe). Экземпляры такого класса могут изменяться, однако все методы имеют довольно надежную внутреннюю синхронизацию, чтобы эти экземпляры могли параллельно использовать несколько потоков безо всякой внешней синхронизации. Параллельные вызовы будут обрабатываться последовательно внекотором глобально согласованном порядке. Примеры Random и java. util. Тiтeг. С условной поддержкой многопоточности (conditionally thread-safe). тоже, что и с поддержкой многопоточности, за исключением того, что класс (или ассоциированный класс) содержит методы, которые должны вызываться один за другим без взаимного влияния со стороны других потоков. Для исключения возможности такого влияния клиент должен установить соответствующую блокировку на время выполнения этой последовательности. Примеры Hashtable и Vector, чьи итераторы требуют внешней синхронизации. Совместимый с многопоточностью (thread-compatible). Экземпляры такого класса можно безопасно использовать при работе с параллельными потоками, если каждый вызов метода (а в некоторых случаях, каждую последовательность вызовов) окружить внешней синхронизацией. Примерами являются реализации коллекций общего назначения, такие как ArrayList и HashMap. 195 Несовместимый с многопоточностью (thread-hostile). Этот класс небезопасен при параллельной работе с несколькими потоками, даже если вызовы всех методов окружены внешней синхронизацией. Обычно несовместимость связана стем обстоятельством, что эти методы меняют некие статические данные, которые оказывают влияние на другие потоки. К счастью, в библиотеках платформы Java лишь очень немногие классы и методы несовместимы с многопоточностью. Так, метод System.runFinalizersOnExit несовместим с многопоточностью и признан устаревшим. Документированию класса с условной поддержкой многопоточности нужно уделять особое внимание. Вы должны указать, какие последовательности вызовов требуют внешней синхронизации и какую блокировку (в редких случаях, блокировки) необходимо поставить, чтобы исключить одновременный доступ. Обычно это блокировка самого экземпляра, ноне всегда. Если объект является альтернативным представлением какого-либо другого объекта, клиент должен получить блокировку для основного объекта стем, чтобы воспрепятствовать его непосредственной модификации со стороны других потоков. Например, в документации к методу Hashtable.keys нужно сказать примерно следующее Если есть какая-либо опасность того, что хэш-таблица будет изменена из другого потока, то для получения безопасно перечня ее ключей' требуется, чтобы перед вызовом метода keys был блокирован соответствующий экземпляр класса Hashtable и эта блокировка сохранялась до тех пор, пока вы не закончите работу с полученным объектом Enumeration. Описанную схему демонстрирует фрагмент кода Hashtable h = ... ; Synchrоnizеd (h) { for (Enumeration е = h.keys(); е.hаsМогеЕlеmеnts(); ) f(е.пехtЕlеmеnt()); В версии 1.3 нет такого текста в документации к классу Hashtable. Однако будем надеяться, что эта ситуация скоро будет исправлена. Вообще же в библиотеках для платформы Java безопасность работы с потоками можно было' бы документировать получше. Хотя объявление об общедоступном блокировании объекта позволяет клиентам выполнять последовательность вызовов как неделимую. за эту гибкость приходится платить. Клиент, имеющий злой умысел, может предпринять атаку "отказ в обслуживании" (denial-of-service, DOS attack), установив блокировку объекта // атака synchrоnizеd (importantObject) { Тhгеаd.slеер(Iпtеgег,МАХ_VАLUЕ); // ВЫВОДИТ из строя iтрогtапtОЬjесt 196 Если вас беспокоят О - атаки, то используйте для синхронизации операций закрытый объект блокировки (private lock object): // Идиома закрытого объекта блокировки, препятствует атаке private Object lock = new Object(); public void foo() { synchrоnizеd (lock) { Поскольку создаваемая этим объектом блокировка недоступна клиентам, данный объект- контейнер неуязвим для представленной ранее О - атаки. Заметим, что классы с условной поддержкой многопоточности всегда неустойчивы по отношению к такой атаке, в документации к ним должно быть указано, что выполнение последовательности операций атомарным образом требует получения блокировки. Однако классы с поддержкой многопоточности могут быть защищены от О атаки при помощи идиомы закрытого объекта блокировки. Применение внутренних объектов для блокировки особенно подходит классам, предназначенным для наследования (статья 15), таким как класс WorkQueue (статья 49). Если бы суперкласс использовал для блокировки собственные экземпляры, подкласс мог бы непреднамеренно повлиять на их работу. Применяя одну и туже блокировку для разных целей, суперкласс и подкласс стали бы, в конце концов, наступать друг другу на пятки. Подведем итоги. для каждого класса необходимо четко документировать возможность работы с несколькими потоками. Единственная возможность сделать это представить аккуратно составленный текст описания. К описанию того, как класс поддерживает многопоточность, наличие модификатора synchronized отношения не имеет. для классов с условной поддержкой многопоточности важно указывать в документации, какой объект следует заблокировать, чтобы последовательность обращений к методам стала неделимой. Описание того, как класс поддерживает многопоточность, обычно располагается в ос - комментарии ко всему классу. Однако для методов, имеющих особый режим работы с потоками, это должно быть отражено в их собственном ос - комментарии. И з бегайте группировки потоков bПомимо потоков, блокировок и мониторов, система многопоточной обработки предлагает еще одну базовую абстракцию группа потоков (thread гор. Первоначально группировка потоков рассматривалась как механизм изоляции апплетов 1 9 7 в целях безопасности. В действительности своих обязательств они таки не выполнили, а их роль в системе безопасности упала до такой степени, что в работе, где выстраивается модель безопасности для платформы Java 2 (Gong99], они даже не упоминаются. Но если группировка потоков l:Ie несет никакой функциональной нагрузки в системе безопасности, то какие же функции она выполняет В общих словах, она позволяет применять примитивы класса Thread сразу к целой группе потоков. Некоторые из этих примитивов уже устарели, остальные используются нечасто. В итоге группировка потоков не может дать достаточного количества полезной функциональности. По иронии судьбы, API ThreadGroup слаб сточки зрения поддержки многопоточности. Чтобы для некоей группы получить перечень активных потоков, вы должны вызвать метод enumerate. В качестве параметра ему передается массив, достаточно большой, чтобы в него можно было записать все активные потоки. Метод activeCount возвращает количество активных потоков в группе, однако нет никакой' гарантии, что это количество не изменится в то время, пока вы создаете массив и передаете его методу enumerate. Если указанный массив окажется слишком мал, метод enumerate без каких-либо предупреждений игнорирует потоки, не поместившиеся в массив. Точно также некорректен, когда ему передается список подгрупп, входящих в группу потоков. И хотя указанные проблемы можно было решить, добавив в класс ThreadGroup новые методы, этого не было сделано из-за отсутствия реальной потребности. Группировка потоков сильно устарела. Подведем итоги. Группировка потоков практически не имеет сколь-нибудь полезной функциональности, и большинство предоставляемых ею возможностей имеет дефекты. Группировку потоков следует рассматривать как неудачный эксперимента существование групп можно игнорировать. Если вы проектируете класс, который работает с логическими группами потоков, вам нужно записывать ссылки Thread, соответствующие каждой логической группе, в массив или коллекцию. Вы могли заметить, что этот совет вступает в противоречие со статьей 30 "Изучите библиотеки и пользуйтесь ими. Однако в данном случае статья 30 неправа. В большинстве случаев следует игнорировать группировку потоков. Однако есть одно небольшое исключение. Нечто полезное можно найти в интерфейсе класса ThreadGroup. Когда какой-либо поток в группе инициирует исключение, не отлавливаемое в приложении, автоматически вызывается метод ThreadGroup.uncaughtException. Этот метод используется "рабочим окружением" для того, чтобы должным образом реагировать на необработанные исключения. Реализация, предлагаемая по умолчанию, печатает трассировку стека в стандартный поток сообщений об ошибках. Возможно, вы захотите поменять такую реализацию, направив, например, трассировку стека в определенный журнал регистрации. 1 9 8 Глава Сериал из а ц и я В этой главе описывается API сериализации объекта (object serialization), который формирует среду для представления объекта в виде потока байтов и, наоборот, для восстановления объекта из соответствующего потока байтов. Процедура представления объекта в виде потока байтов называется сериализациеu объекта (serializing), обратный процесс называется его десериализациеu (deserializing). Как только объект сериализован, его представление можно передавать с одной работающей виртуальной машины Java на другую или сохранять на диске для последующей десериализации. Сериализация обеспечивает стандартное представление объектов на базовом уровне, которое используется для взаимодействия с удаленными машинами, а также как стандартный формат для сохранения данных при работе с компонентами JavaBeans ТМ. Соблюдайте осторожность при реализации интерфейса Чтобы сделать экземпляры класса сериализуемыми, достаточно добавить в его декларацию слова "implements Serializable". Поскольку это так легко, широкое распространение получило неправильное представление, что сериализация требует от программиста совсем небольших усилий. На самом деле все гораздо сложнее. Значительная доля затратна реализацию интерфейса Serializable связана стем, что уменьшается возможность изменения реализации класса в последующих версиях. Когда класс реализует интерфейс Serializable, соответствующий ему поток байтов (сериализованная форма - serialized Form) становится частью его внешнего API. И как только ваш класс получит широкое распространение, вам придется поддерживать соответствующую сериализованную форму точно также, как вы обязаны поддерживать все остальные части интерфейса, предоставляемого клиентам. Если вы не приложите усилий к построению специальной сериализованноu формы (custom 199 serialized form) , а примете форму, предлагаемую по умолчанию, эта форма окажется навсегда связанной с первоначальным внутренним представлением класса. Иначе говоря, если вы принимаете сериализованную форму, которая предлагается по умолчанию, те экземпляры полей, которые были закрыты или доступны только в пакете, станут частью его внешнего API, и практика минимальной доступности полей (статья 12) потеряет свою эффективность как средство скрытия информации. Если вы принимаете сериализованную форму, предлагаемую по умолчанию, а затем меняете внутреннее представление класса, это может привести к таким изменениям в форме, что она станет несовместима с предыдущими версиями. Клиенты, которые пытаются сериализовать объект с помощью старой версии класса и десериализовать его уже с помощью новой версии, получат сбой программы. Можно поменять внутреннее представление класса, оставив первоначальную сериализованную форму (с помощью методов ObjectOutputSt геат. putFields и ObjectOutputSt геат, readF1elds), но этот механизм довольно сложен и оставляет в исходном коде программы видимые изъяны. Поэтому тщательно выстраивайте качественную сериализованную форму, с которой вы сможете отправиться в долгий путь (статья 55). Эта работа усложняет создание приложения, но дело того стоит. Даже хорошо спроектированная сериализованная форма ограничивает дальнейшее развитие класса, плохо же спроектированная форма может его искалечить. Простым примером того, какие ограничения на изменение класса накладывает сериализация, могут служить уникальные идентификаторы потока (stream иniqиe identifier), более известные как seria/ versio'n и 1 D. С каждым сериализуемым классом связан уникальный идентификационный номер. Если вы не указываете этот идентификатор явно, декларируя поле private stat ic final long с названием serialVersionUID, система генерирует его автоматически, используя для класса сложную схему расчетов. При этом на автоматически генерируемое значение оказывают влияние название класса, названия реализуемых им интерфейсов, а также все открытые и защищенные члены. Если вы каким-то образом поменяете что-либо в этом наборе, например, добавите простой и удобный метод, изменится и автоматически генерируемый serial version UID. Следовательно, если вы не будете явным образом декларировать этот идентификатор, совместимость с предыдущими версиями будет потеряна. Второе неудобство от реализации интерфейса Serializable заключается в том, что повышается вероятность появления ошибок и дыр в защите. Объекты обычно создаются с помощью конструкторов, сериализация же представляет собой механизм создания объектов, который выходит за рамки языка /ava. Принимаете ли вы схему, которая предлагается по умолчанию, или переопределяете ее, десериализация - это "скрытый конструктор, имеющий все те же проблемы, что и остальные конструкторы. Поскольку явного конструктора здесь нет, легко упустить из виду то, что при десериализации вы должны гарантировать сохранение всех инвариантов, устанавливаемых настоящими конструкторами, и исключить возможность получения злоумышленником доступа к внутреннему содержимому создаваемого объекта. Понадеявшись на механизм десериализации, предоставляемый по умолчанию, вы можете получить объекты, которые не препятствуют несанкционированному доступу к внутренним частями разрушению инвариантов (статья 56). 200 Третье неудобство реаливации интерфейса Serializable связано стем, что выпуск новой версии класса сопряжен с большой работой по тестированию. При пересмотре сериализуемого класса важно проверить возможность сериализации объекта в новой версии и последующей его десериализации в старой и наоборот. Таким образом, объем необходимого тестирования прямо пропорционален произведению числа сериализуемых классов и числа имеющихся версий, что может быть большой величиной, К подготовке таких тестов нельзя подходить формально, поскольку, помимо совместимости на бинарном уровне, вы должны проверять совместимость на уровне семантики. Иными словами, необходимо гарантировать .. не только успешность процесса сериализации- десериализации, но и то, что он будет создавать точную копию первоначального объекта. И чем больше изменяется сериализуемый класс, тем сильнее потребность в тестировании. Если при написании класса специальная сериализованная форма была спроектирована тщательно (статья 55), потребность в проверке уменьшается, но полностью не исчезает. Реализация интерфейса Serializable должна быть хорошо продумана. У этого интерфейса есть реальные преимущества его реализация играет важную роль, если класс должен участвовать в какой-либо схеме, которая для передачи или обеспечения живучести объекта использует сериализацию. Более того, это упрощает применение класса как составной части другого класса, который должен реализовать интерфейс Serializable. Однако с реализацией интерфейса Serializable связано и множество неудобств. Реализуя класс, соотносите неудобства с преимуществами. Практическое правило таково классы значений, такие как Date и BigInteger, и большинство классов коллекций обязаны реализовывать этот интерфейс. Классы, представляющие активные сущности, например пул потоков, должны реализовывать интерфейс Serializable крайне редко. Так, в версии 1.4 появился механизм сохранения компонентов JаvаВеап, который использует стандарта потому этим компонентам больше ненужно реализовывать интерфейс Serializable. Классы, предназначенные для наследования (статья 15), редко должны реализовывать Ser1alizable, а интерфейсы - редко его расширять. Нарушение этого правила связано с большими затратами для любого, кто пытается расширить такой класс или реализовать интерфейс. В ряде случаев это правило можно нарушать. Например, если класс или интерфейс создан в первую очередь для использования в некоторой системе, требующей, чтобы все ее участники реализовывали интерфейс Ser1al1zable, то лучше всего, чтобы этот класс (интерфейс) реализовывал (расширял) Ser1a11zable. Нужно сделать одно предупреждение относительно реализации интерфейса Ser1a11zable. Если класс предназначен для наследования и не является сериализуемы, может оказаться, что для него невозможно написать сериализуемый подкласс. В частности, этого нельзя сделать, если у суперкласса нет доступного конструктора без параметров. Следовательно, для несериализуемого класса, который предназначен для наследования, Вы должны рассмотреть возможность создания конструктора без параметров. Часто это не требует особых усилий, поскольку многие классы, предназначенные для наследования, не имеют состояния. Но так бывает не всегда. 201 Самое лучшее _ это создавать объекты, у которых все инварианты уже установлены (статья 13). Если для установки инвариантов необходима информация от клиента, это будет препятствовать использованию конструктора без параметров. Бесхитростное добавление конструктора без параметров и метода инициализации в класс, остальные конструкторы которого устанавливают инварианты, усложняет пространство состояний этого класса и увеличивает вероятность появления ошибки. Приведем вариант добавления конструктора без параметров в несериализуемый расширяемый класс, свободный от этих пороков. Предположим, что в этом классе есть один конструктор public AbstractFoo(int х, int у) { ... } Следующее преобразование добавляет защищенный конструктор без параметров и отдельный метод инициализации. Причем метод инициализации имеет те же параметры и устанавливает те же инварианты, что и обычный конструктор public abstract class AbstractFoo { private int x, y |