Статьи

Положите свои жирные коллекции на диету!

Фон

В каждой Java-программе используются какие-то структуры данных, будь то простой массив, куча Фибоначчи или даже что-то более экзотическое, о чем знает только поиск Google. В большинстве случаев разработчики не пишут свои собственные реализации этих структур, а используют либо те, которые предоставляются базовыми API-интерфейсами Java, либо некоторые сторонние библиотеки, такие как Apache Collections или Google Guava. За мои 10 с лишним лет разработки Java не прошло и дня, чтобы я не использовал некоторую структуру данных из API Java Collection. Эти списки, наборы и карты настолько естественны для меня, что я не колеблясь ни секунды, прежде чем писать 

Map<Integer, String> = new HashMap<Integer, String>(); 

И все было хорошо до недавнего времени …

Один из классов внутри нашего Java-агента Plumbr должен хранить целое число целых чисел как одно из его полей. Полуформальные требования заключаются в следующем:

  • Нам нужна структура данных для хранения целых чисел.
  • Без дубликатов
  • Заказ не важен
  • Нам нужно добавить в эту структуру новые элементы
  • Нам нужно посмотреть, если какой-то элемент существует в этой структуре
  • Количество различных элементов ограничено до пары сотен максимум
  • Потребление памяти важнее скорости
  • Тем не менее, производительность должна быть достойной, поэтому файлы MemoryMapped, базы данных и т. Д. Не обсуждаются.

Естественным выбором для этого требования является, по крайней мере, учитывая мой опыт, java.util.HashSet <Integer> . Итак, недолго думая, я попробовал. Это была катастрофа!

эксперимент

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

  1. Напишите класс Java с методом main , который содержит необходимую структуру данных в качестве локальной переменной.
  2. Добавьте бесконечный цикл в конец этого основного метода, чтобы этот поток не умер слишком быстро.
  3. Используя Eclipse Memory Analyzer, возьмите дамп памяти и выясните размер оставшейся кучи для интересующей локальной переменной.

В качестве основы я использовал тот факт, что в Java целое число занимает 4 байта. Итак, для  числа COUNT целых чисел нам нужно 4 * байта COUNT . Затем мы можем рассчитать накладные расходы данной структуры данных следующим образом:

Overhead = Structure's retained heap/(4*COUNT)

Обратите внимание, что Java различает примитивы и объекты, а API-интерфейс Collection работает только с объектами. Это означает, что общие накладные расходы состоят из накладных расходов данной структуры данных коллекции и накладных расходов на использование объектов Integer, а не примитивов.

Полученные результаты


 Итак, решив это, давайте измерим, насколько велики
java.util.HashSet s. Для этого я использовал следующий код:

Set<Integer> set = new HashSet<Integer>();
int COUNT = 10;
for (int i = 0; i < COUNT; i++) {
  set.add(i);
}

Давайте посмотрим на результаты:

COUNT Оставшаяся куча накладные расходы
10 720 18
100 6 960 17,4
1000 85 424 21,36
1000000 88 774 256 22,19

Вау! Просто вау! Хранение целых чисел в java.util.HashSet занимает примерно в 20 (!) Раз больше памяти, чем информация, которую мы храним. Это ОГРОМНЫЕ накладные расходы, на мой взгляд. Принимая во внимание нашу потребность работать в действительно ограниченных условиях памяти, это было недопустимо. Мы должны были найти другой путь. Мы начали с рассмотрения наших требований: что нам действительно нужно? Оказывается, старый добрый Java-массив вполне соответствует нашим требованиям. Изменение моего кода на это:

int COUNT = 10;
Integer[] array = new Integer[COUNT];
for (int i = 0; i < COUNT; i++) {
    array[i] = i;
} 

дал следующие результаты:
COUNT Оставшаяся куча накладные расходы
10 344 8,6
100 3 224 8,06
1000 32 024 8,006
1000000 32 000 024 8.000006

Все идет нормально. Это сократило накладные расходы в 2-3 раза по сравнению с
HashSet . Но он все еще слишком большой на мой вкус. Поэтому я просто заменил Integer [] на int []:

int COUNT = 10; 
int[] array = new int[COUNT];
for (int i = 0; i < COUNT; i++) {
    array[i] = i;
}

и получил:

COUNT Оставшаяся куча накладные расходы
10 64 1,6
100 424 1,06
1000 4 024 1,006
1000000 4 000 024 1.000006

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

Вывод

The first conclusion is quite obvious: array of primitives is the most compact data structure. That is not surprising ? What was really a big surprise for me, was the magnitude of overhead that Java Collection API has. I hope to keep that in mind next time I choose the structure for my data.
Of course, Java Collection API will not become deprecated as a result of this post ? And I certainly will use it again and again, as it provides a very easy-to-use API. But in those rare cases when every byte really matters it is much better to be aware of discovered overhead and design your piece of software accordingly.
Another case where this overhead may be important, is storing large amount of data in e.g. a 
HashSet. I mean, in case of a couple of millions of elements this overhead, in absolute numbers, grows to a couple of hundreds of megabytes. So, the overhead alone requires a significant increase in your heap size.