Теория и практика Java: Оптимизации синхронизаций в Mustang

Escape-анализ может помочь оптимизировать синхронизацию

В этом месяце Брайан представляет некоторые из оптимизаций синхронизации, которые будут включены в Mustang. Обсуждаемые здесь специальные функциональные возможности могут быть или не быть включены в Java SE 6 ("Mustang").

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

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



25.01.2006

Во всех случаях, когда изменяемые переменные доступны для всех потоков, вам необходимо использовать синхронизацию, чтобы убедиться в том, что производимые одним потоком обновления своевременно видимы для других потоков. Основные средства синхронизации - это использование synchronized-блоков, которые предоставляют взаимоисключения и гарантируют видимость. (Другие формы синхронизации включают изменяемые (volatile) переменные, объекты Lock (объекты блокировки) в java.util.concurrent.locks и атомарные переменные. Когда два потока хотят получить доступ к изменяемой переменной общего пользования, они не только должны использовать синхронизацию, но если они используют synchronized-блоки, эти synchronized-блоки должны использовать тот же lock-объект.

На практике блокировка (locking) делится на две категории: в основном состязательную (contended) и в основном несостязательную (uncontended). Contended-блокировки являются "горячими" блокировками в приложении, такими как блокировки, которые защищают общую рабочую очередь от пула потока. Многим потокам постоянно требуются данные, защищенные этими блокировками, и вы можете ожидать, что при запросе такой блокировки вам придется подождать, пока один из потоков не освободит её. Uncontended-блокировки - это те блокировки, которые защищают данные, к которым не так часто обращаются, поэтому большую часть времени, когда поток получает блокировку, ни один из других потоков не может удерживать эту блокировку. Большинство блокировок нечасто являются состязательными, таким образом, повышение производительности uncontended-блокировки может существенно повысить общую производительность при выполнении приложения.

JVM имеет отдельные пути кода для получения contended ("медленный путь") и uncontended ("быстрый путь") захватов блокировки. Было затрачено много усилий на оптимизацию быстрого пути, тогда как Mustang улучшает и быстрый путь, и медленный, а также добавляет несколько оптимизаций, что может полностью устранить некоторые из блокировок.

Пропуск блокировки

Java Memory Model говорит о том, что поток, который выходит из synchronized-блока, происходит до того, как другой поток входит в synchronized-блок, защищенный той же блокировкой. Это значит, что какие бы операции памяти не были видимы потоку А, когда он выходит из synchronized-блока, защищенного блокировкой М, они будут видны потоку В, когда тот входит в synchronized-блок, защищенный M, как показано на Рисунке 1. Для synchronized-блоков, которые используют различные блокировки, мы не можем ничего предполагать об их расположении, как будто вовсе не было никакой синхронизации.

Рисунок 1. Синхронизация и видимость в Java Memory Model
Синхронизация и видимость в Java Memory Model

Очевидно, что если поток затем входит в synchronized-блок, защищенный блокировкой, который ни один из потоков не будет синхронизировать, то эта синхронизация не имеет никакого эффекта и, таким образом, может быть удалена оптимизатором. (Java Language Specification разрешает явно такую оптимизацию.) Такой сценарий может показаться неправдоподобным, но бывают случаи, когда это ясно компилятору. Листинг 1 показывает упрощенный пример локального поточного (thread-local) lock-объекта:

Листинг 1. Использование локального поточного объекта в качестве блокировки
  synchronized (new Object()) {
      doSomething();
  }

Так как ссылка на lock-объект исчезает перед тем, как любой другой поток может его использовать, компилятор может определить, что предыдущая синхронизация может быть удалена, так как два потока не могут синхронизировать одну и ту же блокировку. Так как никто не будет напрямую использовать решение из Листинга 1, данный код очень похож на случай, когда блокировку, связанную с synchronized-блоком, можно подтвердить в качестве локальной поточной переменной (thread-local variable). "Thread-local" не обязательно означает, что она реализуется классом ThreadLocal. Она может быть любой переменной, к которой, как может понять компилятор, не имеет доступа ни один из других потоков. Объекты, на которые ссылаются локальные переменные и которые никогда не выходят из заданных им границ, проходят этот тест - если объект заключен в стек какого-либо из потоков, ни один из потоков не сможет увидеть ссылку на этот объект. (Единственный способ, с помощью которого можно открыть общий доступ к объектам разных потоков - это когда ссылка на него расположена в динамической памяти.)

К счастью или к несчастью, escape-анализ, который мы обсуждали в прошлом месяце, предоставляет компилятор с точной информацией, которая необходима для оптимизации synchronized-блоков, которые используют локальные поточные lock-объекты. Если компилятор может распознать (используя escape-анализ), что объект никогда не располагался в динамической памяти, то это должен быть локальный поточный объект и более того любые synchronized-блоки, использующие этот объект в качестве блокировки, никак не будут влиять на Java Memory Model (JMM) и могут быть устранены. Такая оптимизация называется lock elision (пропуск блокировки) и является еще одним способом JVM-оптимизации, внедренным в Mustang.

Использование блокировки с помощью локального поточного объекта применяется гораздо чаще, чем вы можете думать. Существует много классов, таких как StringBuffer и java.util.Random, которые защищены от потока (thread-safe), потому что могут использоваться сразу большим количеством потоков, но их часто используют как thread-local.

Рассмотрим код в Листинге 2, где используется Vector для создания строкового значения. Метод getStoogeNames() создает Vector, добавляет к нему несколько строк, а затем вызывает toString() для конвертирования его в строку. Каждый из вызовов одного из методов Vector - три вызова add() и один toString() - требует запроса и освобождения блокировки Vector. В то время, как все запросы на блокировку будут uncontended, и, следовательно, быстрыми, компилятор может полностью устранить синхронизацию, используя пропуск блокировки.

Листинг 2. Кандидат на пропуск блокировки
  public String getStoogeNames() {
       Vector v = new Vector();
       v.add("Moe");
       v.add("Larry");
       v.add("Curly");
       return v.toString();
  }

Так как ни одна ссылка на Vector не пропускает метод getStoogeNames(), то необходимый thread-local и, следовательно, любой synchronized-блок, использующий его как блокировку, не будет никак влиять на JMM. Компилятор может подключать вызовы методов add() и toString(), а затем он распознает, что он запрашивает и освобождает блокировку на объект thread-local и может оптимизировать все четыре операции блокировки и отмены блокировки.

Как уже было сказано выше, попытка избежать синхронизации, в общем-то, не лучшая идея. Поступление оптимизаций, таких как lock elision adds (добавление пропуска блокировки), не имеют больше оснований для устранения синхронизации - компилятор может выполнять её автоматически, когда это является безопасным, или оставить её в обратном случае.


Адаптивная блокировка

В добавление к escape-анализу и пропуску блокировки Mustang также имеет и другие оптимизации для выполнения блокировки. Когда два потока претендуют на блокировку, один из них получит блокировку, а другому придется заблокироваться до тех пор, пока блокировка не освободиться. Существует две наглядных методики для реализации блокировки (blocking): операционная система приостанавливает поток до тех пор, пока ему не нужно активироваться или использование spin locks (взаимоблокировок). Взаимоблокировка, в основном, сводится к следующему коду:

  while (lockStillInUse)
      ;

В то время как взаимоблокировки активно загружают центральный процессор и оказываются неэффективными, они могут стать более эффективными, чем приостановка потока и последующая его активация, если рассматриваемая блокировка удерживается очень малое количество времени. При этом возникают значительные издержки, когда поток приостанавливается и происходит его перепланировка, что вовлекает в работу JVM, операционную систему и аппаратное обеспечение. Это представляет проблему для тех, кто работает с JVM: если блокировки удерживаются только на короткий промежуток времени, тогда взаимоблокировка более эффективна. Если же блокировки удерживаются на долгий промежуток времени, то наиболее эффективно удерживание. Без информации о том, для чего нужно распределение времени блокировок для данного приложения, большая часть виртуальных машин JVM ведёт себя консервативно и просто удерживает поток, когда ему не удаётся получить блокировку.

Однако это можно сделать еще лучше. Для каждой блокировки JVM может адаптивно выбирать между взаимоблокировкой (spinning) и удерживанием (suspension), на основе поведения прошлых запросов. Она может попробовать взаимоблокировку и, если это сработало после некоторого промежутка времени, она продолжает следовать этому выбору. Если же, с другой стороны, определенное количество взаимоблокировок не подтверждают соответствующий результат блокировки, она может решить, что эта блокировка удерживается на "большой промежуток времени" и перекомпилирует метод, чтобы использовать только удерживание. Это решение может быть сделано на основе "per-lock" или "per-lock-site".

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


Укрупнение блокировки

Другая оптимизация, которую можно использовать для снижения потребления ресурсов блокировкой является lock coarsening (укрупнение блокировки). Укрупнение блокировки - это процесс, когда соединяются смежные synchronized-блоки, которые используют один и тот же lock-объект. Если компилятор не может устранить блокировку с помощью пропуска блокировки, то он может снизить излишки потребления ресурсов с помощью укрупнения блокировки.

Код, приведенный в Листинге 3, не является обязательным кандидатом на пропуск блокировки (хотя после встраивания addStoogeNames(), виртуальная машина JVM все еще может игнорировать блокировку), но зато действительно может получить выгоду от укрупнения блокировки. Три вызова add() поочередно захватывают блокировку Vector, что-то делают и освобождают блокировку. Компилятор может обнаружить, что есть последовательность смежных блоков, которые оперируют с одной блокировкой, а затем соединить их в один блок.

Листинг 3. Кандидат на укрупнение блокировки
  public void addStooges(Vector v) {
       v.add("Moe");
       v.add("Larry");
       v.add("Curly");
  }

Мало того, что это снижает лишнее потребление ресурсов блокировки, но с помощью объединения блоков три вызова методов объединяются в один большой synchronized-блок, что дает оптимизатору гораздо больший базовый блок, с которым нужно работать. Таким образом, укрупнение блокировки может активировать другие оптимизации, которые не имеют ничего общего с блокировкой.

Даже если есть другие операторы между synchronized-блоками или вызовами методов, компилятор все еще может производить укрупнение блокировки. Компилятору позволяется убирать операторы в synchronized-блок - но не из него. Таким образом, если такие операторы имели место между вызовами add(), компилятор мог объединить все addStooges() в один большой synchronized-блок, где Vector используется, как lock-объект.

Укрупнение блокировки может повлиять на достижение компромисса между производительностью и быстротой реакции. С помощью объединения пар lock-unlock в единую пару lock-unlock, произойдет увеличение производительности благодаря снижению подсчета инструкций и снижению объема трафика синхронизации на шине памяти. Ценой является то, что период времени, на которое задерживается блокировка, может увеличиваться, повышая время, на которое другие потоки могут задерживать блокировку, а также повышая вероятность состояния соревнования за блокировку (lock contention). Однако в любом случае блокировка задерживается на достаточно короткий промежуток времени и компилятор может применить эвристические процедуры, на основе длины кода, защищенного синхронизацией, для создания разумного компромисса. (И в зависимости от того, какие другие оптимизации активированы большим основным блоком, продолжительность, на которую задерживается блокировка, не может больше быть увеличена при укрупнении.)


Заключение

Производительность несоревновательной блокировки увеличилась почти во всех версиях JVM. Mustang продолжает эту тенденцию и повышает непосредственную производительность соревновательной и несоревновательной блокировки, а также представляет оптимизации, которые могут устранить многие операции блокировки.

Ресурсы

Научиться

Обсудить

Комментарии

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=192200
ArticleTitle=Теория и практика Java: Оптимизации синхронизаций в Mustang
publish-date=01252006