Теория и практика Java: Еще раз о городских легендах о производительности

Аллокация гораздо быстрее, чем вы думаете, и все больше ускоряется

Язык Java™ зачастую критикуют из-за производительности. И хотя некоторые из них могут быть вполне заслуженными, обзор разного рода конференций и групп новостей, касающихся предмета изучения, показывает, что во всем это присутствует большая доля недопонимания того, как в действительности работает Java Virtual Machine (JVM). В этой статье серии Теория и практика Java Брайан Гетц развенчивает некоторые часто повторяемые мифы о производительности, касающийся медленной аллокации в JVM.

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

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



08.02.2007

Скажите без подготовки: Какой язык может похвастаться более быстрой непосредственной производительностью аллокации, Java или C/C++? Ответ может показаться вам неожиданным - аллокация в современных JVM происходит гораздо быстрее, чем реализации malloc с наилучшей производительностью. Обычный путь кода для new Object() в HotSpot 1.4.2 и более поздних версиях составляет приблизительно 10 машинных команд (данные предоставлены компанией Sun; см. Ресурсы), тогда как реализации malloc с наилучшей производительностью в C требуют в среднем от 60 до 100 команд на запрос (Detlefs и др.; см. Ресурсы). Производительность аллокации не является простым компонентом общей производительности - тесты показывают, что многие программы C и C++ для реальных задач, такие как Perl и Ghostscript, тратят от 20 до 30 процентов своего общего времени выполнения на malloc и free - это гораздо больше, чем издержки аллокации и сбора мусора работоспособного Java-приложения (Zorn; см. Ресурсы).

Двигайтесь вперед, заваривайте кашу

Вам не придется много рыться в разных блогах или публикациях Slashdot, чтобы найти утверждения о том, что "Сбор мусора никогда не будет таким эффективным, как прямое управление памятью." И до некоторой степени подобные утверждения верны - динамическое управление памятью не такое же быстрое - зачастую оно гораздо быстрее. При подходе malloc/free мы имеем дело с блоками памяти, по одному за раз, тогда как при сборе мусора мы касаемся управления памятью в больших пакетах, что дает больше возможностей для оптимизации (за счет некоторых потерь в прогнозируемости).

Этот "правдоподобный аргумент" - что легче убрать беспорядок в одной большом пакете данных, чем подбирать пылинки в течение всего дня, - опровергается фактами. В одном исследовании (Zorn; см. Ресурсы) также измерялись влияния замены malloc на консервативный сборщик мусора Boehm-Demers-Weiser (BDW) в ряде распространенных приложений C++, и в результате многие из этих программ показали увеличение быстродействия при работе со сборщиком мусора вместо традиционного аллокатора. (При том, что BDW является консервативным, стационарным сборщиком мусора, который во многом ограничен в своей возможности оптимизировать аллокацию и утилизацию и улучшить локальность памяти; точно настраивающие сборщики такие, которые используются в JVM, могут работать гораздо быстрее.)

Аллокация в JVM не всегда была такой быстрой - ранние JVM, конечно, имели низкую производительность аллокации и сбора мусора, из-за чего почти наверняка и начал распространяться этот миф. В давние времена мы слышали много мнений о том, что "аллокация - это медленно" - потому что так оно и было, как и почти все остальное в ранних JVM - и гуру производительности пропагандировали различные уловки, позволяющие избежать аллокации, как, например, объединение объектов. (Объявление службы общественной информации: объединение объектов сейчас ведет к серьезным потерям производительности для всех объектов, за исключением самых тяжеловесных, и даже при этом это весьма сложно правильно осуществить без получения конфликтов параллельной обработки.) Однако уже много воды утекло со времен JDK 1.0; введение генерационных сборщиков мусора (generational collectors) в JDK 1.2 позволило применить гораздо более простой подход к аллокации, значительно улучшающий производительность.

Генерационная сборка мусора

Генерационный сборщик мусора разделяет неупорядоченный массив (heap) данных на несколько поколений; большинство JVM используют два поколения, поколение "молодое" и "старое". Объекты размещаются в молодом поколении; если они выживают после нескольких сборок мусора, они считаются "долгожителями" и переходят в старое поколение.

Хотя HotSpot предлагает на выбор три сборщика молодого поколения (серийный копирующий, параллельный копирующий и параллельный чистящий (scavenge)), все они являются видами "копирующих" сборщиков и имеют несколько общих важных характеристик. Копирующий коллектор делит пространство памяти пополам и использует одновременно только одну половину. Сначала используемая половина формирует один большой блок доступной памяти; аллокатор удовлетворяет запросы аллокации, выдавая первые N байтов той части, которая не используется, передвигая указатель, который разделяет "используемую" часть от "свободной" части, как показано в псевдокоде в Листинге 1. Когда используемая половина заполняется, сборщик мусора копирует все активные объекты (которые не являются мусором) в основание другой половины (сжимая массив), и вместо этого начинает аллокацию из другой половины.

Листинг 1. Поведение аллокатора в присутствии копирующего сборщика
void *malloc(int n) {
    if (heapTop - heapStart < n)
        doGarbageCollection();

    void *wasStart = heapStart;
    heapStart += n;
    return wasStart;
}

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

Как насчет деаллокации?

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

Оказывается, подавляющее большинство объектов типичных объектно-ориентированных программ (от 92 до 98 процентов, согласно различным исследованиям) "умирают молодыми," а это означает, что они становятся мусором вскоре после аллокации, зачастую до следующего сбора мусора. (Эта характерная особенность называется генерационной гипотезой, она была проверена эмпирическим методом и оказалась верной для многих объектно-ориентированных языков.) Поэтому не только аллокация оказывается быстрой, но для большинства объектов деаллокация является свободной.

Аллокация локального потока

Если бы аллокатор был действительно реализован, как показано в Листинге 1, в общем поле heapStart быстро возник бы конфликт в результате параллельной обработки, поскольку каждая аллокация требовала бы блокировки данного поля. Для предотвращения данной проблемы большинство JVM используют блоки аллокации локального потока, где каждый поток локализует больший фрагмент памяти из массива и обрабатывает небольшие запросы аллокации соответственно из этого блока локального потока. В итоге количество раз, за которое поток должен запросить замок общего массива, значительно сокращается, улучшая параллельность. (Гораздо сложнее и затратнее решать данную проблему в контексте традиционной реализации malloc; встраивание и поддержки потока, и сборки мусора в платформу облегчает такую совместную деятельность, как эта.)


Стековая аллокация

C++ предлагает программистам на выбор аллокацию объектов в массиве или в стеке. Стековая аллокация является эффективной: аллокация недорога, затраты на деаллокацию почти равны нулю, а язык помогает демаркировать жизненные циклы объекта, снижая риск того, что объект забудут освободить. С другой стороны, в C++ вам нужно быть очень осторожными при публикации или совместном использовании ссылок на стековые объекты, потому что стековые объекты автоматически освобождаются, когда стековый фрейм раскрывается, приводя к появлению неработающих ссылок.

Другим преимуществом стековой аллокации является то, что она гораздо более удобна для кэша. В современных процессорах цена кэш-промахов весьма значительна, следовательно, если язык и рабочий цикл могут помочь вашей программе достичь лучшей локализации данных, то производительность улучшится. Верхушка стека почти всегда "горячая" в кэше, тогда как верхушка массива почти всегда "холодная" (потому что наверняка прошло достаточно времени с тех пор, как использовалась эта память). В результате аллокация объекта в массиве, скорее всего, повлечет за собой больше кэш-промахов, чем аллокация объекта в стеке.

Плохо то, что кэш-промах при аллокации объекта в массиве имеет особенно опасное взаимодействие с памятью. При аллокации памяти из массива содержимое данной памяти является мусором - любые биты, оставшиеся после того, как память использовалась в последний раз. Если вы размещаете блок памяти в массиве, который еще не находится в кэше, при выполнении возможны зависания, пока содержимое данной памяти будет переноситься в кэш. Затем вы немедленно перепишете эти значения, которые вы затратили на то, чтобы перенести в кэш с нулями или другими исходными данными, а это приводит к большим потерям работы памяти. (Некоторые процессоры, такие как Azul's Vega, включают аппаратную поддержку для ускорения аллокации массива).

Escape-анализ

Язык Java не предлагает никакого способа точно расположить объект в стеке, но этот факт не мешает JVM использовать стековую аллокацию, где это уместно. JVM может использовать технологию, именуемую escape-анализ, с помощью которой можно сказать, что определенные объекты удерживаются в одном потоке в течение всего жизненного цикла, а он связан со временем существования данного стекового фрейма. Такие объекты можно безопасно располагать в стеке вместо массива. Что даже лучше для маленьких объектов, JVM может полностью оптимизировать аллокацию и просто поднять поля объекта в списки.

В Листинге 2 показан пример, когда escape-анализ может использоваться для оптимизации аллокации массива. Метод Component.getLocation() делает защитную копию расположения компонента, так что вызывающий оператор не сможет случайно изменить фактическое расположение компонента. Вызов getDistanceFrom() сначала получает расположение другого компонента, который включает аллокацию объекта, а затем использует поля x и yPoint, выдаваемые getLocation() для подсчета расстояния между двумя компонентами.

Листинг 2. Типичный метод защитного копирования для получения составного значения
public class Point {
  private int x, y;
  public Point(int x, int y) {
    this.x = x; this.y = y;
  }
  public Point(Point p) { this(p.x, p.y); }
  public int getX() { return x; }
  public int getY() { return y; }
}

public class Component {
  private Point location;
  public Point getLocation() { return new Point(location); }

  public double getDistanceFrom(Component other) {
    Point otherLocation = other.getLocation();
    int deltaX = otherLocation.getX() - location.getX();
    int deltaY = otherLocation.getY() - location.getY();
    return Math.sqrt(deltaX*deltaX + deltaY*deltaY);
  }
}

Методу getLocation() не известно, что его вызывающий оператор собирается делать с полученным Point; оно может сохранить ссылку на него, как, например, внесение в совокупность данных, так что getLocation() кодируется для защиты. Тем не менее, в этом примере, getDistanceFrom() не сделает этого; он просто будет использовать Point в течение некоторого времени и затем удалит его, что кажется пустой тратой замечательного объекта.

Умная JVM может видеть, что происходит, и оптимизировать аллокацию защитной копии. Сначала будет подключен вызов getLocation(), а также вызовы getX() и getY(), и тогда вы получите getDistanceFrom(), ведущий себя как показано в Листинге 3.

Листинг 3. Псевдокод, описывающий результат применения оптимизаций, подключенных к getDistanceFrom()
  public double getDistanceFrom(Component other) {
    Point otherLocation = new Point(other.x, other.y);
    int deltaX = otherLocation.x - location.x;
    int deltaY = otherLocation.y - location.y;
    return Math.sqrt(deltaX*deltaX + deltaY*deltaY);
  }

На данном этапе escape-анализ может показать, что объект расположенный в первой строке никогда не сбрасывается из его основного блока, и что getDistanceFrom() никогда не модифицирует состояние компонента other. (Под escape (сбросом) мы подразумеваем, что ссылка на него не хранится в массиве и не передается неизвестному коду, который может сохранить копию.) При условии что Point является действительно местным потоком и известно и что его жизненный цикл ограничен основным блоком, в котором он расположен, он может быть или расположенным в стеке или полностью удален в результате оптимизации, как показано в Листинге 4.

Листинг 4. Псевдокод, описывающий результат оптимизации аллокации в getDistanceFrom()
  public double getDistanceFrom(Component other) {
    int tempX = other.x, tempY = other.y;
    int deltaX = tempX - location.x;
    int deltaY = tempY - location.y;
    return Math.sqrt(deltaX*deltaX + deltaY*deltaY);
  }

В итоге мы получаем точно такую же производительность, как если бы все поля были общедоступными, при сохранении безопасности, которую дают нам инкапсуляция и защитное копирование (как технологии безопасного кодирования).

Escape-анализ в Mustang

Escape-анализ является оптимизацией, о которой уже давно говорилось, и наконец-то вот она - текущие сборки Mustang (Java SE 6) могут осуществлять escape-анализ и конвертировать аллокацию массива в стековую аллокацию (или без аллокации) там, где это уместно. Использование escape-анализа для устранения некоторых результатов аллокаций с более быстрым средним временем аллокации, уменьшенным потреблением ОЗУ и меньшим количеством кэш-промахов. Кроме этого, оптимизирование некоторых аллокаций уменьшает давление на сборщик мусора и позволяет реже запускать сборку.

Escape-анализ может найти возможности для стековой аллокации даже тогда, когда она может быть нецелесообразной в исходном коде, даже если язык позволяет данную опцию, потому что то, оптимизируется ли определенная аллокация, определяется тем, как в действительности используется результат возвращающего объект метода для данного пути кода. Point, возвращённый getLocation(), может оказаться неподходящим для стековой аллокации во всех случаях, но как только JVM подключает (inlines) getLocation(), она может свободно оптимизировать каждый вызов отдельно, объединяя преимущества двух областей: оптимальную производительность при меньших затратах времени на создание решений по настройкам производительности низкого уровня.


Заключение

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

Ресурсы

Научиться

Получить продукты и технологии

Обсудить

Комментарии

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=194638
ArticleTitle=Теория и практика Java: Еще раз о городских легендах о производительности
publish-date=02082007