Теория и практика Java: Сборка мусора и производительность

Подсказки, советы и мифы, связанные с разработкой классов, легко поддающихся сборке мусора

Два последних выпуска Теории и практики Java обсуждали различные методы сборки мусора и основы работы сборщиков мусора в JDK 1.4.1. В этом месяце ведущий рубрики Брайан Гетц рассмотрит влияние выбора сборщика мусора на производительность, на то, как различные методы кодирования взаимодействуют со сборщиком мусора, и как распределение, а также другие связанные с этим затраты в виртуальных машинах Java изменились за последние несколько лет.

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

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



06.03.2007

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

Во что обходится распределение?

В пакетах JDK 1.0 и 1.1 использовались маркирующе-зачищающие сборщики, которые выполняли уплотнение при некоторых (но не всех) сборках мусора, что означало, что динамическая память может быть фрагментирована после сборки мусора. Соответственно, затраты на распределение памяти в виртуальных машинах пакетов 1.0 и 1.1 были сравнимы с затратами в языке C или C++, где программа распределения ресурсов использует эвристику вида "first-first" или "best-fit" для управления свободным пространством динамической памяти. Затраты на освобождение памяти были также высоки, поскольку маркирующе-зачищающий сборщик мусора должен был производить зачистку всей динамической памяти при каждой сборке мусора. Не удивительно, что нам советовали быть осторожнее с программой распределения ресурсов.

В виртуальных машинах HotSpot (Sun JDK 1.2 и более поздних) всё стало намного лучше - пакеты Sun JDK перешли на сборщик по поколениям. Так как для молодого поколения используется копирующий сборщик мусора, свободное пространство в динамической памяти всегда непрерывно, так что размещение нового объекта из динамической памяти может быть выполнено простым добавлением указателя, как показано в Листинге 1. Это делает размещение объектов в приложениях Java значительно менее затратным, чем в языке C, - возможность, которую многие разработчики сначала с трудом могли себе представить. Аналогично, поскольку копирующие сборщики мусора не посещают мёртвые объекты, динамическая память с большим количеством временных объектов (что довольно типично в приложениях Java) требует очень небольших затрат при сборке мусора; просто отслеживаются и копируются живые объекты на свободное место и вся динамическая память освобождается одним махом. Нет ни списков свободной памяти, ни слипания блоков, ни уплотнения - просто затирается динамическая память и всё начинается сначала. Поэтому затраты на размещение и удаление объектов в пакете JDK 1.2 значительно сократились.

Листинг 1. Быстрое распределение ресурсов в непрерывной динамической памяти
void *malloc(int n) { 
  synchronized (heapLock) {
    if (heapTop - heapStart > n)
      doGarbageCollection();

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

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

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

Но подождите, всё улучшается

JIT-компилятор может производить дополнительные оптимизации, которые могут снизить стоимость размещения объектов до нуля. Рассмотрим код Листинга 2, где метод getPosition() создает временный объект, чтобы держать координаты точки, и вызывающий метод использует объект Point в течение ограниченного времени, а затем выбрасывает его. JIT, вероятно, вставит вызов getPosition() и, используя метод, носящий название escape analysis (анализ на выходе), сможет распознать, что на выходе из метода doSomething() не остаётся ни одной ссылки на объект Point. Зная это, JIT может затем разместить объект в стеке вместо динамической памяти или (что ещё лучше) полностью оптимизировать размещение, так чтобы его не было вовсе, и просто поднять поля Point в регистры. И хотя сегодняшние виртуальные машины Sun ещё не производят эту оптимизацию, будущие виртуальные машины, вероятно, будут её делать. Тот факт, что размещение объектов может стать в будущем ещё дешевле и без внесения изменений в ваш код - ещё одна причина не приносить корректность и удобство в эксплуатации вашей программы в жертву ради того, чтобы избежать небольшого количества дополнительных размещений объектов.

Листинг 2. Метод Escape analysis может полностью ликвидировать множество временных размещений
void doSomething() { 
  Point p = someObject.getPosition();
  System.out.println("Object is at (" + p.x, + ", " + p.y + ")");
}

...

Point getPosition() { 
  return new Point(myX, myY);
}

Является ли программа распределения ресурсов (аллокатор) узким местом для масштабируемости?

Листинг 1 показывает, что, хотя размещение объектов само по себе происходит быстро, доступ к структуре динамической памяти должен быть синхронизирован между потоками. А разве это не превращает аллокатор в угрозу для масштабируемости? Существует несколько умных трюков, которые виртуальные машины используют, чтобы значительно уменьшить эту цену. Виртуальные машины IBM используют технику, называемую внутрипотоковая динамическая память (thread-local heaps), с помощью его каждый поток запрашивает маленький блок памяти (порядка 1K) у аллокатора, и размещение малых объектов происходит в рамках этого блока. Если программа запрашивает блок больше по размеру, чем может быть предоставлен из небольшой внутрипотоковой динамической памяти, то используется глобальный аллокатор, чтобы либо удовлетворить непосредственно запрос, либо выделить новый блок внутрипотоковой динамической памяти. С помощью данной техники большой процент запросов на размещение может быть удовлетворен без борьбы за блокировку разделяемой динамической памяти. (В виртуальных машинах Sun используется похожая техника, именуемая термином "Local Allocation Blocks.")


Финализаторы вам не друзья

Объекты с финализаторами (те, что имеют нетривиальный метод finalize()) несут значительные накладные расходы по сравнению с объектами без финализаторов и должны использоваться ограниченно. Объекты, подлежащие финализации, медленно выделяются и также медленно удаляются сборщиком мусора. Во время размещения объектов виртуальная машина должна регистрировать любые финализируемые объекты в сборщике мусора, и (по крайней мере в виртуальной машине HotSpot JVM) финализируемые объекты должны следовать более медленным путём распределения, чем большинство других объектов. Аналогично, финализируемые объекты также медленнее других обрабатываются сборщиком мусора. Требуется, по крайней мере, два цикла сборки мусора (в лучшем случае), пока финализируемый объект сможет быть собран, и сборщик мусора вынужден делать дополнительную работу, чтобы активизировать финализатор. В результате больше времени тратится на размещение объектов и их удаление сборщиком мусора, больше нагрузка на сборщик мусора, потому что память, используемая недоступными финализируемыми объектами, удерживается дольше. Объедините это с тем фактом, что для финализаторов не гарантируется работа в каких-либо предсказуемых временных рамках, или даже совсем не гарантируется работа, и вы увидите, что существует относительно мало ситуаций, в которых финализация является верным инструментом.

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


Псевдопомощь сборщику мусора

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

Группировка объектов

Группировка объектов - довольно простое понятие: создаётся пул из часто используемых объектов и всякий раз, когда необходимо, объект берётся из этого пула, а не создаётся новый. Теоретически, при группировке объектов в пул затраты на размещение объектов распределяются на множество случаев их использования. Когда затраты на создание объекта высоки, как в случаях с подключениями к базам данных или потоками, или же, когда группируемый объект представляет из себя ограниченный либо дорогостоящий ресурс, как, например, подключения к базам данных, тогда это имеет смысл. Однако, количество ситуаций, где эти условия выполняются, довольно мало.

Вдобавок к этому, группировка объектов имеет некоторые серьёзные оборотные стороны. Так как пул объектов обычно является разделяемым между всеми потоками, размещение объекта в пуле может стать узким местом для синхронизации. Группировка также вынуждает вас в явном виде управлять освобождением памяти, что ведёт опять к риску возникновения висячих ссылок. Также размер пула должен быть правильно настроен, чтобы получить желаемый выигрыш в производительности. Если он слишком мал, то размещения объектов не будут исключены; а если он слишком большой, ресурсы, которые могли бы быть возвращены, вместо этого будут простаивать, находясь в пуле. Связывая память, которая могла бы быть освобождена, использование пула объектов создаёт дополнительную нагрузку на сборщик мусора. Написать эффективную программную реализация группировки не так просто.

В своём выступлении "Разоблачение мифов о производительности" на JavaOne 2003 (см. Ресурсы) Др. Клифф Клик (Cliff Click)предложил конкретные данные тестов производительности, демонстрирующие, что на современных виртуальных машинах Java группировка объектов приводит к потере производительности для всех объектов, кроме наиболее тяжеловесных. Добавьте к этому повторяющийся риск размещения объектов и возникновения висячих ссылок и станет ясно, что группировки объектов нужно избегать во всех случаях, кроме самых экстремальных.

Принудительное обнуление

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

Есть один случай, когда использование принудительного обнуления не только полезно, но и фактически обязательно, это когда ссылка на объект обладает большей областью действия чем та, в которой она фактически используется или считается действительной в спецификации программы. Это включает такие случаи, как использование вместо локальной переменной статического поля или поля экземпляра объекта для хранения ссылки на временный буффер (см. в Ресурсах ссылку на статью "Ссылки на объекты: следите за производительностью", где есть пример такого случая), или использование массива для хранения ссылок, которые могут оставаться доступными для среды выполнения, но не для неявной семантики программы. Рассмотрите класс в Листинге 3, который является реализацией простейшего ограниченного стека, хранящегося в массиве. Когда в примере pop() вызывается без принудительного обнуления, этот класс может вызвать утечку памяти (более правильно называемую "непреднамеренным удержанием объекта," или иногда "зависанием объекта"), потому что ссылка, хранящаяся в stack[top+1] больше недоступна программе, но все ещё считается доступной сборщиком мусора.

Листинг 3. Как избежать зависания объекта при реализации стека
public class SimpleBoundedStack {
  private static final int MAXLEN = 100;
  private Object stack[] = new Object[MAXLEN];
  private int top = -1;

  public void push(Object p) { stack [++top] = p;}

  public Object pop() {
    Object p = stack [top];
    stack [top-] = null;  // explicit null
    return p;
  }
}

В сентябрьской колонке "Java Developer Connection Tech Tips" за 1997 год (см. Ресурсы) компания Sun предупреждала об этом риске и объясняла, как необходимо принудительное обнуление в случаях, подобных приведенному выше примеру с pop(). К сожалению, программисты зачастую понимают этот совет слишком буквально, используя принудительное обнуление в надежде помочь сборщику мусора. Но в большинстве случаев это никак не помогает сборщику мусора, а в некоторых случаях даже наносит вред производительности программы.

Рассмотрите код Листинга 4, который объединяет несколько действительно плохих идей. Этот листинг является реализацией связанного списка, который использует финализатор для перемещения по списку и обнуления всех ссылок вперёд. Мы уже обсуждали, чем плохо использование финализаторов. Этот случай ещё хуже, потому что теперь класс делает дополнительную работу, якобы помогая сборщику мусора, но на самом деле не помогает, а может даже нанести вред. Перемещение по списку потребляет машинные циклы процессора и приведёт к посещению всех мертвых объектов и затаскиванию их в кэш - той работе, которую сборщик мусора мог бы полностью избежать, так как копирующие сборщики совсем не посещают мертвых объектов. Обнуление ссылок совершенно не помогает трассирующему сборщику мусора; если заголовок списка недоступен, остальная часть списка не будет трассироваться.

Листинг 4. Комбинация финализаторов и принудительного обнуления, приводящая к полной катастрофе для производительности: не делайте этого!
public class LinkedList {

  private static class ListElement {
    private ListElement nextElement;
    private Object value;
  }

  private ListElement head;

  ...

  public void finalize() { 
    try {
      ListElement p = head;
      while (p != null) {
        p.value = null;
        ListElement q = p.nextElement;
        p.nextElement = null;
        p = q;
      }
      head = null;
    }
    finally {
      super.finalize();
    }
  }
}

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

Принудительная сборка мусора

Третий случай, когда разработчики зачастую ошибочно думают, что помогают сборщику мусора, связан с использованием System.gc(), что запускает сборку мусора (на самом деле, просто указывает, что пришло время сборки мусора). К сожалению, System.gc() запускает полную сборку мусора, включающую в себя трассировку всех живых объектов в динамической памяти, а также зачистку и сжатие старого поколения. Это может быть огромной работой. В общем случае, лучше позволить системе самой решить, когда динамической памяти нужна обработка сборщиком мусора и нужна ли полная сборка мусора. В большинстве случаев неполной сборки мусора вполне достаточно. Хуже того, вызовы System.gc() часто довольно глубоко скрыты там, где разработчики могут не знать об их присутствии, и где они могут запускаться гораздо чаще, чем это необходимо. Если вы обеспокоены тем, что ваше приложение имеет скрытые в библиотеках запросы System.gc(), вы можете активизировать виртуальную машину Java с опцией -XX:+DisableExplicitGC, чтобы запретить вызовы System.gc() и запуск сборки мусора.

И опять о неизменяемости

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

Многие объекты функционируют как контейнеры для ссылок на другие объекты. Когда объект, на который ссылаются, должен смениться, у нас есть два выбора: обновить ссылку (как мы сделали бы в классе изменяемых контейнеров) или заново создать контейнер, чтобы держать в нём новую ссылку (как в классе неизменяемых контейнеров). Листинг 5 показывает два способа реализации простейшего класса-держателя. Если исходить из предположения, что объект, содержащийся в контейнере мал, как это часто и бывает (как, например, элемент Map.Entry в Map или элемент связанного списка), размещение нового неизменяемого объекта имеет некоторые скрытые преимущества в производительности, которые вытекают из методики работы поколенческого сборщика мусора, учитывающего относительный возраст объектов.

Листинг 5. Изменяемые и неизменяемые держатели объектов
public class MutableHolder {
  private Object value;
  public Object getValue() { return value; }
  public void setValue(Object o) { value = o; }
}

public class ImmutableHolder {
  private final Object value;
  public ImmutableHolder(Object o) { value = o; }
  public Object getValue() { return value; }
}

В большинстве случаев, когда объект-держатель обновляется, чтобы ссылаться на другой объект, этот объект является молодым. Если мы обновим MutableHolder вызвав setValue(), мы создадим ситуацию, когда более старый объект ссылается на более молодой. С другой стороны, если создать вместо этого новый объект ImmutableHolder, более молодой объект будет ссылаться на более старый. Последняя ситуация, когда большинство объектов ссылается на более старые объекты, является гораздо более лёгкой для поколенческого сборщика мусора. Если MutableHolder, который живёт в старом поколении, изменяется, все объекты на карте, содержащие MutableHolder, должны быть просканированы на наличие ссылок со старых объектов на новые при следующей малой сборке мусора. Использование изменяемых ссылок для долгоживущих объектов-контейнеров увеличивает объём работы, выполняемой, чтобы отследить ссылки со старых объектов на новые во время сборки мусора. (См. статью прошлого месяца и Ресурсы из статьи этого месяца, которые объясняют алгоритм маркировки карт, используемый для реализации барьера записи в поколенческом сборщике мусора, используемом в текущих версиях виртуальных машин Sun).


Когда хорошие советы по повышению производительности становится плохими

Заглавная статья в июльском номере 2003 года журнала Java Developer's Journal иллюстрирует, насколько легко хороший совет по повышению производительности может превратиться в плохой совет только из-за того, что ошибочно определены условия, при которых он должен применяться, или проблема, которую он призван решать. Хотя статья и содержит полезный анализ, она, скорее всего, нанесёт больше вреда, чем пользы (и, к сожалению, этим же грешит подавляющее большинство советов о повышении производительности).

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

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

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


Вывод

Сборка мусора прошла большой путь развития в последние несколько лет. Современные виртуальные машины предлагают быстрое размещение объектов и довольно быстро сами делают свою работу, с меньшими паузами на сборку мусора, чем в предыдущих виртуальных машинах. Уловки, такие как группировка объектов и принудительное обнуление, которые когда-то казались разумными приёмами для улучшения производительности, больше не нужны и не приносят пользы (и даже могут быть вредными), так как затраты на размещение объектов и сборку мусора значительно снизились.

Ресурсы

Комментарии

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=200232
ArticleTitle=Теория и практика Java: Сборка мусора и производительность
publish-date=03062007