Статьи

5 советов по снижению затрат на сборку мусора в Java

Каковы некоторые из наиболее полезных советов по снижению накладных расходов на сборщик мусора?

В грядущем выпуске Java 9 с отложенной задержкой сборщик мусора G1 («Сначала мусор») станет сборщиком по умолчанию для HotSpot JVM. От последовательного сборщика мусора вплоть до сборщика CMS, JVM видела много реализаций GC в течение своего срока службы, и сборщик G1 следующий в очереди.

По мере развития сборщиков мусора каждое поколение (без каламбура) приносит на стол улучшения и улучшения по сравнению с предыдущими. Параллельный сборщик мусора, последовавший за последовательным сборщиком, сделал сборку мусора многопоточным, используя вычислительные возможности многоядерных машин. Последовавший коллектор CMS («Concurrent Mark-Sweep») разделил сбор на несколько этапов, позволяя выполнять большую часть работы по сбору одновременно во время работы потоков приложения, что приводит к гораздо реже паузам «остановка мира» , G1 добавляет лучшую производительность в JVM с очень большими кучами и имеет гораздо более предсказуемые и равномерные паузы.

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

Совет № 1: Предсказать возможности сбора

Все стандартные коллекции Java, а также большинство пользовательских и расширенных реализаций (таких как Trove и Google’s Guava ) используют базовые массивы (на основе примитивов или объектов). Поскольку массивы неизменны по размеру после выделения, добавление элементов в коллекцию во многих случаях может привести к удалению старого базового массива в пользу большего вновь выделенного массива.

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

Давайте возьмем следующий код в качестве простого примера:

01
02
03
04
05
06
07
08
09
10
public static List reverse(List<? extends T> list) {
 
    List result = new ArrayList();
 
    for (int i = list.size() - 1; i >= 0; i--) {
        result.add(list.get(i));
    }
 
    return result;
}

Этот метод выделяет новый массив, а затем заполняет его элементами из другого списка, только в обратном порядке.

Точка, которая может быть болезненной и может быть оптимизирована, — это строка, которая добавляет элементы в новый список. При каждом добавлении в список необходимо убедиться, что в его базовом массиве достаточно свободных слотов для размещения нового элемента. Если это так, он просто сохраняет новый предмет в следующем свободном слоте. Если нет, он выделяет новый базовый массив, копирует содержимое старого массива в новый массив, а затем добавляет новый элемент. Это приводит к множественному распределению массивов, которые остаются там для сбора GC.

Мы можем избежать этих избыточных распределений, сообщая массиву, сколько элементов он должен содержать, создавая его:

01
02
03
04
05
06
07
08
09
10
11
public static List reverse(List<? extends T> list) {
 
    List result = new ArrayList(list.size());
 
    for (int i = list.size() - 1; i >= 0; i--) {
        result.add(list.get(i));
    }
 
    return result;
 
}

Это делает начальное выделение, выполняемое конструктором ArrayList, достаточно большим, чтобы содержать элементы list.size (), что означает, что ему не нужно перераспределять память во время итерации.

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

1
2
List result = Lists.newArrayListWithCapacity(list.size());
List result = Lists.newArrayListWithExpectedSize(list.size());

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

Совет № 2: Обработка потоков напрямую

Например, при обработке потоков данных, таких как данные, считанные из файлов, или данные, загруженные по сети, очень часто можно увидеть что-то вроде:

1
byte[] fileData = readFileToByteArray(new File("myfile.txt"));

Полученный байтовый массив можно затем проанализировать в XML-документе, объекте JSON или в сообщении «Буфер протокола», чтобы назвать несколько популярных опций.

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

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

Лучший способ подойти к этому — использовать соответствующий InputStream (в данном случае FileInputStream) и передавать его непосредственно в анализатор без предварительного чтения всего этого в байтовый массив. Все основные библиотеки предоставляют API для прямого анализа потоков, например:

1
2
FileInputStream fis = new FileInputStream(fileName);
MyProtoBufMessage msg = MyProtoBufMessage.parseFrom(fis);

Совет № 3: Используйте неизменяемые объекты

Неизменность имеет много, много преимуществ. Даже не заводи меня. Тем не менее, одно преимущество, которое редко уделяется должному вниманию, — это его влияние на сборку мусора.

Неизменяемый объект — это объект, чьи поля (и, в частности, не примитивные поля в нашем случае) не могут быть изменены после того, как объект был построен. Например:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
public class ObjectPair {
 
    private final Object first;
    private final Object second;
 
    public ObjectPair(Object first, Object second) {
        this.first = first;
        this.second = second;
    }
 
    public Object getFirst() {
        return first;
    }
 
    public Object getSecond() {
        return second;
    }
 
}

Создание этого класса приводит к неизменяемости объекта — все его поля помечены как окончательные и не могут быть изменены после создания.

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

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

Совет № 4: Остерегайтесь конкатенации строк

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

Проблема, очевидно, не в литеральных строках, поскольку они встроены и интернированы, а скорее в строках, которые выделяются и создаются во время выполнения. Давайте посмотрим на быстрый пример построения динамической строки:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
public static String toString(T[] array) {
 
    String result = "[";
 
    for (int i = 0; i < array.length; i++) {
        result += (array[i] == array ? "this" : array[i]);
        if (i < array.length - 1) {
            result += ", ";
        }
    }
 
    result += "]";
 
    return result;
}

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

Трудно увидеть весь этот синтаксический сахар, но то, что на самом деле происходит за кулисами, таково:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static String toString(T[] array) {
 
    String result = "[";
 
    for (int i = 0; i < array.length; i++) {
 
        StringBuilder sb1 = new StringBuilder(result);
        sb1.append(array[i] == array ? "this" : array[i]);
        result = sb1.toString();
 
        if (i < array.length - 1) {
            StringBuilder sb2 = new StringBuilder(result);
            sb2.append(", ");
            result = sb2.toString();
        }
    }
 
    StringBuilder sb3 = new StringBuilder(result);
    sb3.append("]");
    result = sb3.toString();
 
    return result;
}

Строки являются неизменяемыми, что означает, что они сами по себе не изменяются, когда происходит конкатенация, а скорее новые строки распределяются по очереди. Кроме того, компилятор использует стандартный класс StringBuilder для фактического выполнения этих объединений. Это приводит к двойным проблемам, так как на каждой итерации цикла мы получаем (1) неявное распределение промежуточных строк и (2) неявное распределение промежуточных объектов StringBuilder, чтобы помочь нам построить конечный результат.

Лучший способ избежать этого — явное использование StringBuilder и непосредственное добавление к нему вместо использования несколько наивного оператора конкатенации («+»). Вот как это может выглядеть:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
public static String toString(T[] array) {
 
    StringBuilder sb = new StringBuilder("[");
 
    for (int i = 0; i < array.length; i++) {
        sb.append(array[i] == array ? "this" : array[i]);
        if (i < array.length - 1) {
            sb.append(", ");
        }
    }
 
    sb.append("]");
    return sb.toString();
}

Здесь только один StringBuilder выделен нами в начале метода. С этого момента все строки и элементы списка добавляются к этому единственному StringBuilder, который в конечном итоге преобразуется только один раз в строку, используя метод toString, и возвращается.

Совет № 5: Используйте специализированные примитивные коллекции

Стандартная библиотека коллекций Java удобна и универсальна, что позволяет нам использовать коллекции с полустатическим связыванием типов. Это замечательно, если мы хотим использовать, например, набор строк (Set <String>) или карту между парой и списком строк (Map <Pair, List <String >>).

Настоящая проблема начинается, когда мы хотим сохранить список целых или карту со значениями типа double. Так как универсальные типы нельзя использовать с примитивами, альтернативой является использование типов в штучной упаковке, поэтому вместо List <int> нам нужно использовать List <Integer>.

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

Чтобы решить эту проблему, мы в Takipi используем отличную библиотеку коллекции Trove. Trove отказывается от некоторых (но не всех) обобщений в пользу специализированных примитивных коллекций с эффективным использованием памяти. Например, вместо расточительного Map <Integer, Double>, существует специализированная альтернатива в виде TIntDoubleMap:

1
2
3
4
TIntDoubleMap map = new TIntDoubleHashMap();
map.put(5, 7.0);
map.put(-1, 9.999);
...

Базовая реализация Trove использует массивы примитивов, поэтому при манипулировании коллекциями не происходит никакого бокса (int -> Integer) или распаковки (Integer -> int), и вместо примитивов не хранятся никакие объекты.

Последние мысли

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

Ссылка: 5 советов по снижению затрат на сборку мусора Java от нашего партнера по JCG Нива Штейнгартена из блога Takipi .