 | Уровень сложности: средний Крис Сикамп, программист-консультант, IBM Мартин Преслер-Маршалл, аналитик по вопросам производительности ПО, IBM
Эндрю Сайтрон, ведущий программист, IBM
11.04.2008 Cинхронизация доступа к коллекции может привести к проблемам с производительностью. Познакомьтесь с приемом, доступным в Java(TM) 5.0 и более поздних версиях, устраняющим эту проблему.
Недостаток использования общих данных несколькими Java-потоками состоит в том, что доступ к этим данным должен быть синхронизированным, чтобы избежать нарушения целостности отображаемых данных, которое может привести к сбою приложения. Например, методы put() и get() класса Hashtable являются синхронизированными. Синхронизация требуется, для того чтобы одновременные вызовы методов put() и get() имели исключительный доступ к данным во время исполнения, иначе структуры данных приложения могут быть повреждены.
Точки синхронизация вокруг этих методов могут привести к появлению "узких мест": если потоки приложения часто вызывают эти методы, то в конечном итоге потоки могут оказаться заблокированными. Только один поток в момент времени будет получать доступ к содержимому, а другие потоки должны будут ждать своей очереди. Если потоки будут выстраиваться в очередь вместо выполнения действительно полезной работы, производительность и пропускная способность могут пострадать. В случаях, когда анализ производительности показывает, что синхронизированные методы на самом деле приводят к созданию очередей, оптимизация кода будет стоить затраченных усилий.
Для редко обновляемых данных прием, называемый "использование нескольких поколений структур данных", позволяет использовать для организации безопасного доступа к изменяемым данным менее ресурсоемкий модификатор volatile. Когда структуры данных часто считываются, но редко обновляются, такой подход может привести к выигрышу в производительности. Например, можно использовать несинхронизированную структуру данных, такую как HashMap, вместо синхронизированной, такой как Hashtable. В основе этого приема лежат следующие ключевые действия:
- Создать новую копию структуры данных ("новое поколение") при выполнении обновления.
- Полностью заполнить ее.
- Безопасно донести обновления до всех потребителей с помощью
volatile-ссылки.
При использовании этого приема операции get и put никогда не выполняются в одно и тоже время над одним экземпляром структуры данных. Это гарантирует, что два потока не попытаются обновить структуру данных в одно и тоже время, а потоки, считывающие данные, всегда будут видеть стабильную текущую версию данных. (Этот прием работает, даже если данные обновляются часто, но выигрыш в производительности, достигаемый за счет улучшения параллельности, может быть потерян. Частое перезаполнение структуры данных может "съесть" преимущества, полученные за счет отказа от синхронизированных методов для доступа.)
 |
Применимость к парам классов
Класс Hashtable - это один из классов Java, предоставляющий доступ к данным, совместно используемым несколькими потоками. Класс HashMap имеет такую же функциональность, как Hashtable, но он не предназначен для многопоточного использования. Прием, представленный в этой статье, применим и к другим парам схожих классов, отличающихся тем, что в одном методы доступа синхронизированы, а в другом - нет. Например, в классе Vector доступ синхронизирован, а в классе ArrayList - нет. Оба предоставляют схожую функциональность и могут использовать описанный здесь прием.
|
|
Этот прием использует три свойства языка Java:
-
Автоматическая очистка памяти. Когда исчезает последняя ссылка на объект, среда исполнения Java автоматически освобождает память из-под объекта. От приложения не требуется никаких действий, кроме проверки, что на объект не осталось больше ссылок, после того как приложение закончило его использовать. Память из-под объектов старых поколений автоматически освобождается, когда последний клиент завершает работу с ними.
-
Атомарность объектных ссылок. Простая инструкция присваивания, получившая доступ к объекту, уже не может быть прервана. Это значит, что пока поток-потребитель может получать правильные результаты из старой, но при этом полной, копии объекта, необязательно выполнять синхронизацию инструкции присваивания для единственного объекта. Тем не менее, необходимо отметить, что в потоке-производителе необходимо принять меры, чтобы гарантировать, что создание нового объекта завершится до выполнения присваивания. Позже в разделе Обсуждение этой статьи будет показано, что в потоке-производителе необходима синхронизация для гарантированного завершения этого действия до выполнения присваивания. Однако отказ от использования синхронизации в потоках-потребителях позволяет устранить затратные места, приводящие к созданию очередей.
-
Модель памяти Java. В модели памяти Java определена семантика модификаторов
synchronized и volatile. Эти правила определяют, когда совместно используемые объекты видны не только текущему исполняемому потоку, но и остальным потокам.
Этими свойствами языка Java можно воспользоваться в ситуации, когда данные, содержащиеся в структуре, изменяются, храня два отдельных экземпляра этой структуры. После того как одна из структур заполнена, больше она уже не меняется, становясь фактически неизменяемой. Одновременное выполнение операций get и put на одной структуре данных может быть опасным. Представленный здесь прием гарантирует завершение всех операций put до выполнения любой операции get.
Подход
Наш подход иллюстрирует пример кода в листинге 1:
Листинг 1. Код для обновления/считывания данных, не приводящий к образованию очередей
//эта ссылка должна быть volatile, чтобы гарантировать,
//что потребители будут видеть обновленные значения
static volatile Map currentMap = new HashMap();
static Object lockbox = new Object();
// этот код вызывается производителем, когда необходимо обновить данные
public static void buildNewMap() {
//этот код должен быть синхронизирован из-за особенностей модели памяти Java
synchronized (lockbox) {
//в случае, если новые данные основаны на существующих значениях,
//можно использовать объект currentMap как исходную точку
Map newMap = new HashMap(currentMap);
//добавляем или удаляем новые или измененные данные в объект newMap
newMap.put(....);
newMap.put(....);
currentMap = newMap;
}
/*После этого синхронизированного блока все содержимое HashMap видно
за пределами этого потока. Обновленный набор значений доступен
потокам-потребителям.
До тех пор, пока операция присваивания будет завершаться без прерываний
и гарантированно записываться в общую память,
а потребитель сможет временно обходиться
устаревшей информацией, этот прием будет отлично работать. */
}
public static Object getFromCurrentMap(Object key) { // вызывается потоками-потребителями
// блокировка этого объекта не требуется
Map m = currentMap;
Object result = m.get(key); // операция get для HashMap не синхронизируется.
// дополнительные действия, необходимые для окончательной обработки результата.
return(result);
}
|
Здесь приведен разбор листинга 1:
-
Вторая переменная, названная newMap в листинге 1, содержит объект HashMap, заполняемый данными. Эта переменная защищена блоком synchronized и используется в каждый момент времени только одним потоком - потоком-производителем (producer), выполняющим следующие действия:
- Создание нового объекта
HashMap и сохранение его в переменной newMap.
- Выполнение полного набора операций
put над объектом newMap, чтобы все данные, требующиеся потоку-потребителю, оказались в newMap.
- После полного заполнения объекта по ссылке
newMap- присвоение значения ссылки newMap ссылке currentMap.
Поток-производитель может выполняться периодически в результате срабатывания таймера или обработчика событий, активируемого при изменении каких-нибудь внешних данных, например, в базе данных.
- Потоки-потребители, которым нужно работать с содержимым объекта
currentMap, просто получают к нему доступ и выполняют get методы. Отметим, что операция присвоения m = currentMap - это атомарная операция, не требующая синхронизации, даже если другие потоки в этот момент могут обращаться к объекту. Это безопасно, так как доступ к объекту currentMap осуществляется через volatile-ссылку, а сам объект заполняется внутри синхронизированного блока в потоке-производителе. Это значит, что содержимое структуры данных, считанной по ссылке currentMap, будет настолько актуальным, насколько актуальна сама ссылка currentMap.
 |
Обсуждение
После того как значение newMap было присвоено ссылке currentMap, её содержимое никогда не меняется. Фактически этот объект HashMap становится неизменяемым. Это позволяет выполнять несколько методов get параллельно, что может привести к значительному повышению производительности. Согласно разделу 3.5.4 книги Брайана Гетца (Brian Goetz) Java Concurrency in Practice (см. раздел Ресурсы), "доступ к безопасно опубликованным фактически неизменяемым объектам может осуществляться без дополнительной синхронизации". В данном случае безопасный доступ является результатом использования volatile-ссылки.
Единственное, что может поменяться, пока данные считываются - это ссылка currentMap, ведущая к объекту. Поток-производитель может переписать текущее значение новым в тот момент, когда потоки потребители считывают значение. Так как получение ссылки на объект в Java - это атомарная операция, то потребителю не требуется синхронизация в момент доступа к ссылке. Худшее, что может произойти - потребитель получит ссылку на объект currentMap, а затем производитель заменит эту ссылку новой ссылкой, уже на структуру данных с новым содержанием. В этом случае потребитель будет использовать данные, которые только что утратили актуальность, но все равно являются целостными. То же самое произойдет, если потребитель выполнится на секунду раньше, чем сработает поток-производитель. Обычно это не должно вызвать никаких проблем. Главное то, что содержимое currentMap всегда является целостным и неизменяемым, когда оно открывается для доступа.
Когда случается подобная "гонка", потоки-потребители могут получить ссылку на "старую" версию данных. "Новая" ссылка на объект заменит старую, но некоторые потребители все еще будут использовать старую. Когда последний поток-потребитель закончит использование ссылки, ведущей на старый объект, этот объект выйдет из области видимости и будет доступен для очистки памяти. Среда исполнения Java отследит момент, когда это произойдет. Приложению не потребуется явно освобождать память из-под старого объекта, так как это произойдет автоматически.
Новая версия объекта currentMap может создаваться периодически, основываясь на потребностях приложения. Следуя шагам, приведенным выше, можно гарантировать, что эти обновления будут проходить безопасно и последовательно.
Блок synchronized в листинге 1 требуется для того, чтобы два потока-производителя не пытались одновременно обновить currentMap. Это может привести к потере данных, что, в свою очередь, приведет к тому, что потоки-производители увидят неопределенные данные. Блок synchronized защищает оптимизатор от такого решения, по существу превращая создание объекта HashMap в атомарную операцию. Модификатор volatile гарантирует, что потоки-потребители не будут продолжать видеть старое значение переменной currentMap после её изменения. Что еще более важно, это гарантирует, что значения, полученные клиентом по ссылке на объект, будут актуальны настолько, насколько актуальна сама ссылка. Обычная ссылка не обеспечивает таких гарантий.
Если поток-производитель спроектирован как единственный поток этого типа и архитектура приложения гарантирует, что только один поток будет обновлять currentMap, операцию присвоения currentmap = newMap можно вынести за рамки синхронизированного блока. Однако повышение производительности, полученное за счет такого изменения, вряд ли будет значительным. Потеря общности в полученном коде, плюс возможность внесения ошибки говорят против этого дополнительного изменения, даже в случае с единственным потоком производителем.
Побочный эффект от использования блока synchronized и модификатора volatile состоит в том, что поток-потребитель гарантированно видит целостные данные. Поток-производитель упрощается за счет того, что структура данных не изменяется после того, как к ней был открыт доступ. В случае организации доступа к фактически неизменяемому графу объектов все, что нужно - это безопасно организовать доступ к ссылке на корневой объект. Отметим, что можно также синхронизировать доступ потребителя к корневой ссылке, но это может привести к созданию очередей, а это то, чего мы хотим избежать. Брайан Гетц упоминает этот подход как "дешевую" блокировку на чтение/запись ("cheap read-write lock") (см. раздел Ресурсы).
Заключение
Прием, описанный в этой статье, подходит к любой ситуации, когда общие данные меняются нечасто и одновременно используются несколькими потоками. Он применим только в ситуациях, когда использование самых свежих данных не входит в требования приложения.
Результатом является параллельный доступ к общим данным, которые могут со временем меняться. В средах, где требуется высокая параллельность, этот прием помогает избежать появления ненужных источников очередей в приложении.
Важно отметить, что из-за сложностей в модели памяти Java прием, описанный здесь, работает только в Java 5.0 и более поздних версиях. В предыдущих версиях Java клиентские приложения рисковали получить не полностью заполненный объект HashMap или поврежденное, неправильное или противоречивое представление структур данных, находящихся в объекте HashMap.
Благодарности
Авторы хотят поблагодарить Брайана Гетца (Brian Goetz) за техническое рецензирование и предложения, сделавшие эту статью более полной, точной и аккуратной.
Ресурсы Научиться
Получить продукты и технологии
Обсудить
Об авторах  | 
|  |
Крис Сикамп (Chris Seekamp) работает программистом-консультантом в отделе Workplace, Portal and Collaboration Software IBM Software Group. Он работал над различными продуктами, включая Lotus Sametime, Lotus Connections, WebSphere Portal и WebSphere Transcoding Publisher. Он использует приемы ООП-проектирования и разработки уже 15 лет, сначала на C++, затем на языке Java. Крис также интересуется Linux и ПО с открытым кодом. |
 | 
|  |
Мартин Преслер-Маршалл (Martin Presler-Marshall) работает ведущим программистом в исследовательском центре IBM в Рисерч-Трайэнгл Парк, штат Северная Каролина. Он участвует в разработке Web-решений с 1995 года, начиная с разработки первого HTTP сервера IBM. Сейчас он работает экспертом по производительности в группе производительности WPLC в отделе IBM Lotus. Область его деятельности - это повышение производительности IBM WebSphere Portal и IBM Lotus Quickr. Он также является соавтором нескольких рекомендаций W3C и технических отчетов, таких как спецификация P3P. В IBM Мартин работает с 1991 года. В свободное время он любит выезжать на природу, кататься на велосипеде, заниматься работой по дереву и тэквондо. |
 | 
|  |
Энди Сайтрон (Andy Citron) работает в группе по производительности WebSphere Portal в Рисерч-Трайэнгл Парк, штат Северная Каролина. За свою более чем 30-летнюю карьеру в IBM он участвовал в разработке таких продуктов, как Mwave Multimedia Card и системы автоответчика с перенаправлением вызовов, текстовый процессор, операционные системы и беспроводной доступ в Интернет. В конце 80-х Энди работал ведущим архитектором коммуникационного протокола SNA, известного как APPC или LU6.2. Его работа в качестве лидера группы архитекторов SNA привела к нескольким патентам в области двухфазного подтверждения в распределенной обработке. |
Выскажите мнение об этой странице
|  |