Неверные результаты теста ArrayList и HashSet

Я был вдохновлен этой темой: Сравнение распределения производительности и памяти между List и Set, чтобы фактически запустить некоторые тесты и измерить разницу в производительности между ArrayList и HashSet.

Самый верный ответ в упомянутой теме меня очень заинтриговал (ссылка):

HashSet потребляет в 5,5 раз больше памяти, чем ArrayList для того же количества элементов

С помощью ScalaMeter Я хотел убедиться в этом.

Я сделал два простых теста, добавив от 10000 до 100000 элементов как к ArrayList, так и к HashSet. Установка начального размера до максимума не изменила результаты. Я тестировал эти коллекции с двумя типами:

  • Int (ввод последовательных чисел от 0 до 100000)
  • String (помещение случайной строки с помощью Apache RandomStringUtils)

Код доступен в моем репозитории здесь.

И запустив те, дали мне следующие результаты:

  • X-axis - размер → размер коллекции
  • Y-axis - значение → количество используемого kB

Для коллекций, содержащих Int: Целочисленные результаты

Для коллекций, содержащих String размера 10: Строковые результаты размером 10

Для коллекций, содержащих String размера 50: Строковые результаты размером 50

Вопрос:

Что случилось с теорией, упомянутой в цитируемом ответе? Это ложь? Или, вероятно, на моей стороне какая-то ошибка?

Спасибо:)!

Обновление после ответа @andrzej Я еще раз обновил код (и репозиторий). Результаты улучшаются, но результаты не отличаются в 5,5 раз. Теперь я проверяю что-то большее.

Ответ 1

Что случилось с теорией, упомянутой в цитируемом ответе? Это ложь?

Мы можем сделать некоторые вычисления, чтобы получить оценку:

Посмотрим на источник OpenJDK для ArrayList и HashMap (поскольку HashSet - это всего лишь оболочка вокруг HashMap) для подсказок.

Предположим, что у вас есть элементы n для хранения.

ArrayList

Элементы сохраняются в поле transient Object[] elementData;. Поэтому длина elementData должна быть не менее n.
Предположим, вы создали экземпляр списка с new ArrayList<>(n), и поэтому elementData.length - это точно n. Тогда размер вашего списка равен n*c bytes (где c - размер ссылки на объект). Здесь я проигнорировал поле size и заголовок объекта в списке.

HashMap

HashMap хранит элементы в transient Node<K,V>[] table;, где node имеет поля

final int hash;
final K key;
V value;
Node<K,V> next;

Затем для хранения элементов n вам нужны n узлы или n*(3*c + 4) байты i.e каждый node имеет 3 ссылки на объекты - 3*c bytes - и int - 4 байта.
Согласно HashMap javadoc:

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

Исходя из этого, я буду оценивать, что table.length == 2*n.
Для суммирования hashmap требуется n*2*c + n*(3*c + 4) = n*5*c + n*4 байт.

Резюме

Теперь предположим, что у вас есть 64-битная JVM, а размер ссылки на объект - 8 байтов (т.е. c = 8) (пусть воспламеняется такие вещи, как сжатые oops). Тогда n*5*c + n*4 = n*5*8 + n*4 = n*44 и n*c = n*8.
Наконец n*44 / n*8 = 5.5

Итак, оригинальная теория, что HashSet потребляет в 5,5 раз больше памяти, чем ArrayList, кажется вполне правдоподобной, и кажется, что с вашими измерениями что-то не так.

Ответ 2

Пожалуйста, добавьте объект измерения в качестве возвращаемого значения.

measure method "Int" in {
  using(sizes) curve listS in { i =>
    val c = new util.ArrayList[Int](i)
    (0 until i).map(t => c.add(t))
    c // return c
  }

  using(sizes) curve setS in { i =>
    val c = new util.HashSet[Int]()
    (0 until i).map(t => c.add(t))
    c // return c
  }
}

Ответ 3

Думаю, здесь есть две проблемы:

  • Как отметил Анджей, вы не возвращаете свои коллекции из эталонных фрагментов. Scalameter измеряет отпечаток, выполняя GC до и после эталонного исполнения (найдите здесь здесь). Если вы не вернете коллекцию, она просто удаляется из памяти GC после тестирования, и результаты теста бесполезны. Это объясняет, почему следы памяти в тестах остаются небольшими (около четырех байт на объект) и не отличаются друг от друга. Но это не объясняет, почему след увеличивается, когда размер коллекции растет, и здесь возникает вторая проблема.

  • Некоторые сборщики мусора (особенно CMS и G1) не гарантируют, что после выполнения сборки мусора все мертвые объекты удаляются из памяти. Если ваша JVM выбирает один из этих коллекционеров (или если вы укажете его вручную), это объяснит восходящий тренд памяти. Вы можете проверить, какой коллекционер используется, предоставив -XX:+PrintFlagsFinal вариант вашего теста и найти значения флагов UseG1GC и UseConcMarkSweepGC.