5 «хаков» для уменьшения накладных расходов при сборке мусора

в 6:07, , рубрики: java, optimization, Серверная оптимизация

5 «хаков» для уменьшения накладных расходов при сборке мусора
В этом посте будут рассмотрены пять путей повышения эффективности кода, помогающие сборщику мусора проводить меньше времени за выделением и освобождением памяти. Долгая процедура сборки мусора может привести к явлению, известному как «Stop the world».

Общие сведения

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

GC в этом случае использует так называемое «молодое поколение» («young generation») — сегмент кучи, где размещаются новые объекты. Каждый объект имеет поле «возраст» («age», находится в заголовке объекта), который определяет сколько сборок мусора он пережил. Как только достигнут определенный возраст, объект копируется в другую область кучи, называемую «старым» («old») поколением.

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

Ниже определены пять путей, которые можно использовать в повседневной разработке для повышения эффективности работы с памятью, при этом не особо тратят наше время и не снижают читаемость кода.

1. Избегайте неявного использования строк

Строки являются неотъемлемой частью практически любой структуры данных. Будучи более требовательными к ресурсам, чем другие примитивные типы, они имеют большее влияние на потребление памяти.

Не стоит забывать, что строки иммутабельны. Они не модифицируются после аллокации. Операторы, такие как "+" при объединении строк в действительности создают новый объект String, содержащий конкатенацию строк. Ко всему прочему, это приводит к неявному созданию объекта StringBuilder, который и проводит саму операцию объединения.

Приведем пример:

a = a + b; // a и b - строки

А вот актуальный код, генерируемый компилятором за кулисами:

StringBuilder temp = new StringBuilder(a).
temp.append(b);
a = temp.toString(); // здесь создается новая строка.
                     //  предыдущая “a” теперь стала мусором.

В реальности все еще хуже.
Рассмотрим следующий пример:

String result = foo() + arg;
result += boo();
System.out.println(“result = “ + result);

Здесь мы имеем 3 StringBuilder'а выделенных неявно — по одному на каждую операцию "+" и две дополнительных строки — одна, как результат второго присвоения, другая передается в метод println. В итоге получили 5 дополнительных объектов в тривиальном коде.

Подумайте, что происходит в реальных программах, вроде генерации веб-страницы, работы с XML или чтения текста из файла. Подобный код внутри цикла приведет к появлению сотен или тысяч неявно выделяемых объектов. VM имеет механизмы борьбы с этим, но все имеет цену — и заплатят ее ваши пользователи.

Решение: одним из путей может быть явное создание StringBuilder. В примере ниже достигается тот же результат, но память выделяется только под один StringBuilder и одну строку под конечный результат.

StringBuilder value = new StringBuilder(“result = “);
value.append(foo()).append(arg).append(boo());
System.out.println(value);

Держа в уме, что в подобных случаях строки и StringBuilder'ы выделяются неявно, вы сможете существенно сократить количество мелких аллокаций памяти в часто выполняющемся коде.

2. Задавайте начальную вместимость списков

Динамически расширяемые коллекции, такие как ArrayList одни из основных структур, предназначенных для содержания данных переменной длины. ArrayList и другие коллекции, например, HashMap, TreeMap реализованы с использованием нижележащего массива Object[]. Так же как строки (которые являются надстройками над массивами символов), размер массива неизменен. Очевидный вопрос — как получается добавлять элементы в коллекцию, при иммутабельном размере нижележащего массива? Ответ не менее очевиден — выделением больших массивов.

Рассмотрим следующий пример:

List<Item> items = new ArrayList<Item>();

for (int i = 0; i < len; i++) {
  Item item = readNextItem();
  items.add(item);
}

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

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

Решение: указать начальную емкость везде, где это возможно:

List<MyObject> items = new ArrayList<MyObject>(len);

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

3. Используйте эффективные коллекции примитивных типов

Текущие версии компиляторов Java поддерживают обычные и ассоциативные массивы с ключами и значениями примитивных типов посредством использования «автобоксинга» — оборачивания примитивного значения стандартным объектом, который может быть выделен и удален GC.

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

Частый случай — сохранить какое-либо отображение между примитивными типами (например, идентификатор) и объектом. Так как HashMap предназначена для хранения объектных типов, это значит, что каждая вставка подразумевает создание еще одного объекта для «упаковки» значения примитивного типа.

Стандартный метод Integer.valueOf() кэширует значения между -128 и 127, но любое число вне данного диапазона приведет к выделению отдельного объекта для каждой пары ключ-значение. Это приводит к тройному GC overhead в каждом ассоциативном массиве. Для тех, кто прибыл из С++, это может стать новостью — благодаря шаблонам в STL эта проблема решена довольно эффективно.

К счастью, над этим работают в новых версиях Java. Ну а пока попробуем как-то повысить эффективность с помощью замечательных сторонних библиотек, предоставляющих деревья примитивных типов, ассоциативные массивы и списки. Я настоятельно рекомендую Trove, с которым проработал довольно много времени и могу подтвердить реальное уменьшение накладных расходов на сборку мусора в критичном коде.

4. Используйте Stream'ы вместо буферов в памяти

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

Обычно чтение данных в память производится с использованием ByteArrayInputStream, ByteBuffe, затем результат передается на десериализацию.

Это может быть плохим подходом, т.к. вы должны сперва выделить, а затем освободить место под данные, но лишь по окончании построения объектов из них. Но как правило размер данных не известен, что приведет, как вы уже догадались, к постоянному перевыделению памяти под массивы Byte[], которые будут расти при превышении вместимости буфера.

Решение крайне просто. Многие библиотеки, такие как нативный сериализатор Java, Protocol Buffers и т.д. способны строить десериализованные объекты, используя данные напрямую из сетевого потока, т.е. не требуют хранения данных в памяти и внутренних массивах. По возможности используйте этот подход — GC скажет спасибо.

5. Иммутабельность не всегда благо

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

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

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

Пример 1. (Неэффективный)

List<Item> items = new ArrayList<Item>();

for (FileData fileData : fileDatas) {
  // Каждый вызов создает новый промежуточный список
  // и, возможно, какие-то внутренние массивы
  items.addAll(readFileItem(fileData));
}

Пример 2.

List<Item> items =  new ArrayList<Item>(
              fileDatas.size() * avgFileDataSize * 1.5);

for (FileData fileData : fileDatas) {
  readFileItem(fileData, items); // заполняем элементами внутри
}

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

Что еще почитать?

1) Про интернирование строк

2) Про эффективные врапперы

3) Про Trove

4) Про Trove на Хабре

Автор: Lucyfer

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js