Измерение памяти под массивы: профилирование и оценка объёма | AdminWiki

Измерение памяти под массивы: профилирование и оценка объёма

22 сентября 2026 14 мин. чтения
Содержание статьи

Почему размер элементов не равен потреблению памяти

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

Рабочий порядок действий даёт доказуемые цифры: рассчитать теоретический объём, измерить фактический в рантайме, посчитать расхождение и объяснить его конкретными статьями расходов. После этого в отчёте появляется не «серверу не хватает памяти», а «буфер держит 480 МБ, из которых 400 МБ не используется».

Запас RAM под пиковую нагрузку удобнее планировать от измеренных значений, а не от оценок на глаз. Общий подход к расчёту запаса мощности и поиску узких мест описан в материале о подготовке системы к росту нагрузки.

Из чего складывается объём массива

Итоговое потребление формируют пять компонентов.

  • Размер элемента. Четыре байта под int, восемь под указатель, один под char, восемь под double. Берите itemsize из документации рантайма, а не на глаз.
  • Заголовок объекта (object header). В 64-битной HotSpot заголовок включает 8 байт mark word и ссылку на метаданные класса: обычно 4 байта при сжатых указателях классов, иначе 8 байт. Для массивов дополнительно присутствует 32-битное поле длины. Обычный объект обходится без поля длины. В Go заголовок среза занимает 24 байта (указатель, длина, ёмкость). В C++ std::vector хранит три указателя, те же 24 байта. Объект Python list занимает 56 байт плюс место под указатели.
  • Выравнивание (padding). Рантайм добивает размер до границы 8 байт, чтобы адреса оставались кратными машинному слову. На это уходит от 0 до 7 байт.
  • Накладные расходы аллокатора. В glibc каждый выделенный через malloc блок несёт скрытое служебное слово с размером и статусом: минимум 4 или 8 байт. Минимальный выделяемый размер — 16 байт на большинстве 32-битных систем и 24 или 32 байта на 64-битных (включая служебные данные). Для мелких массивов такая надбавка заметна.
  • Фрагментация и арены. Аллокатор удерживает освобождённые участки в аренах потока, поэтому RSS процесса может не вернуться к исходному значению после освобождения массива.

Формула для практической оценки: объём = заголовок + число элементов × размер элемента + выравнивание + накладные расходы аллокатора. Фрагментация в формулу не входит, её добавляют отдельным запасом.

Плотность упаковки показывает, какая доля выделенной памяти занята полезными данными. У типизированного буфера, например numpy.ndarray, она близка к единице. У списка Python на каждый элемент приходится указатель плюс отдельный объект, поэтому плотность падает в разы.

Выравнивание памяти массивов и его влияние

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

Пример: массив из 1001 int в HotSpot занимает 16 байт заголовка плюс 4004 байта данных, итого 4020 байт, после выравнивания до границы 8 байт - 4024 байта. Четыре байта ушли в padding и не хранят данные. Массив из 1000 int даёт 16 + 4000 = 4016 байт, и эта сумма уже кратна восьми, поэтому дополнительного выравнивания не требуется.

В C и C++ выравнивание проявляется в структурах: struct с полями char и int занимает 8 байт вместо 5. Если такие структуры лежат в массиве, порядок полей меняет объём до 30-40%.

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

Как рассчитать теоретический объём массива

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

  1. Определите размер одного элемента (itemsize) и единицу, в которой он измеряется.
  2. Умножьте размер элемента на планируемое число элементов.
  3. Добавьте заголовок объекта для своего рантайма.
  4. Округлите результат вверх до границы 8 байт.
  5. Добавьте оверхед аллокатора: 4 или 8 байт служебных данных на блок, минимум 16 байт на 32-битных системах и 24 или 32 байта на 64-битных.
  6. Для многомерных массивов сложите заголовок внешнего массива и заголовки вложенных, затем прибавьте выравнивание на каждом уровне.
Рантайм и структураСлужебная частьЧто добавить к расчёту
Java, int[]8 байт mark + 4/8 байт klass + 4 байта длинывыравнивание до 8 байт
Java, ArrayListоколо 24 байтвнутренний Object[] по capacity, а не по size
Python, list56 байт8 байт на указатель и объекты элементов
Python, array.arrayнебольшая служебная частьтолько сырые значения фиксированной ширины
Python, numpy.ndarrayзаголовок объекта ndarrayбуфер данных, точный размер в nbytes
Go, slice24 байтамассив под cap, а не под len
C, malloc4 или 8 байт служебных данныхокругление до минимального блока (16/24/32 байта)
C++, std::vector24 байтабуфер по capacity

Расчёт для Java: int[], String[], ArrayList

int[1000]: 8 байт mark word + 4 байта ссылки на класс + 4 байта длины + 4000 байт данных = 4016 байт, выравнивание не требуется. int[1001] даёт 4024 байта после округления до границы 8 байт.

String[1000]: заголовок массива плюс 4000 байт ссылок при сжатых ссылках (compressed oops, они включены по умолчанию при объёме кучи до 32 ГБ). Сами строки считают отдельно: объект String, массив байтов внутри и padding. Для строк длиной 20 латинских символов это порядка 48-56 байт на строку, то есть ещё около 50 КБ на тысячу.

ArrayList: объект около 24 байт (заголовок, size, ссылка на массив, modCount) плюс внутренний Object[] размером capacity. Ёмкость растёт ступенями: 10 при первом добавлении, дальше примерно в 1,5 раза. Список из 1000 элементов может держать массив на 1234 позиции, то есть 16 + 4936 = 4952 байта вместо 4016.

Проверка в рантайме: jcmd <pid> GC.heap_info показывает размер кучи по регионам и занятую часть.

Расчёт для Python: list, array, numpy

list из 1 000 000 различных int: 56 байт заголовка + 8 000 000 байт указателей + 28 000 000 байт объектов int, итого около 36 МБ. Если значения попадают в кэш малых целых (-5...256), новые объекты не создаются и расход падает до размера массива указателей. Сам кэш предварительно выделяет 266 × 28 = 7448 байт.

array.array('i') хранит сырые четырёхбайтовые значения: миллион элементов занимает около 4 МБ, объекты элементов не создаются вовсе.

numpy.ndarray держит заголовок объекта и непрерывный буфер. Точный размер буфера возвращает arr.nbytes, служебную часть объекта показывает sys.getsizeof. Для трассировки распределений используют tracemalloc.

Расчёт для Go и C/C++

Срез Go несёт 24 байта заголовка независимо от длины: указатель, длину и ёмкость. Срез из миллиона int64 занимает 24 байта заголовка плюс 8 МБ данных. Массив фиксированной длины заголовка не имеет: только элементы, например [5]int занимает 40 байт. Ёмкость среза после append может вдвое превышать длину, поэтому для оценки берите cap.

В C malloc отдаёт блок со служебной частью. Функция malloc_usable_size позволяет узнать, сколько байт можно использовать, не перезаписывая другие выделенные объекты; она полезна для отладки и проверок, но не гарантирует, что это ровно запрошенный размер. Массив из 10 байт реально занимает не менее 16-32 байт из-за минимального размера блока. std::vector в C++ хранит три указателя и буфер по capacity, лишний запас убирает shrink_to_fit.

Escape analysis в Go решает, попадёт массив в кучу или останется на стеке. Для памяти под нагрузкой важен кучевой вариант, и он виден в профиле.

Инструменты профилирования памяти для разных языков

Выбор инструмента привязан к рантайму. Счётчики уровня процесса дают общую картину, гистограммы классов и дампы кучи показывают конкретный массив.

Профилирование памяти Java: jcmd, jmap, MAT

  • jcmd <pid> GC.heap_info - размер кучи, занятое и свободное место по регионам, без остановки приложения.
  • jmap -histo:live <pid> - гистограмма классов: число экземпляров и суммарные байты. Опция :live запускает полный GC и убирает мусор из отчёта, при этом приложение приостанавливается.
  • jmap -dump:live,format=b,file=heap.hprof <pid> - снимок кучи (heap dump) для анализа в Eclipse MAT или VisualVM.
  • jstat -gc <pid> 1000 - динамика сборок мусора раз в секунду.

В MAT строка int[] в dominator tree показывает retained heap: сколько памяти освободится, если удалить массив и всё, что он удерживает. Для массива примитивов retained heap близок к размеру самого массива, для массива строк он включает все строки.

Профилирование памяти Python: tracemalloc, memory_profiler

tracemalloc запускают до создания массивов, иначе аллокации не попадут в статистику: tracemalloc.start(), затем take_snapshot() и statistics("lineno") дают топ строк по удержанной памяти. Разница двух снимков показывает, что выросло между контрольными точками.

memory_profiler даёт построчный отчёт: запуск python -m memory_profiler script.py или декоратор profile над функцией. Расход по строкам виден в MiB.

objgraph.show_most_common_types() показывает, каких объектов больше всего, и помогает найти массивы, удерживаемые ссылками.

Для numpy есть отдельный счётчик: arr.nbytes возвращает точный размер буфера без служебных структур объекта.

Профилирование памяти Go и C/C++

Go: профиль кучи снимается через net/http/pprof, например go tool pprof http://localhost:6060/debug/pprof/heap. runtime.ReadMemStats отдаёт HeapAlloc и HeapInuse. Разница между inuse и allocated показывает, сколько памяти удерживают живые срезы.

C/C++: valgrind --tool=massif ./app записывает график потребления памяти, ms_print massif.out строит диаграмму по времени. heaptrack ./app трассирует аллокации и показывает стек вызовов для самых крупных.

Системный уровень: pmap -x <pid> даёт карту памяти процесса, smem -t -p делит расход на PSS и USS, файл /proc/<pid>/status содержит VmRSS. RSS включает разделяемые библиотеки, поэтому для честного сравнения контейнеров берите PSS.

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

Как сравнить расчётный объём с реальным потреблением

Расхождение не случайно, у него есть статья расходов. Сравнение превращает догадки в вывод.

Пошаговая методика сравнения

  1. Зафиксируйте сценарий: версия рантайма, флаги запуска, объём данных, режим прогрева.
  2. Посчитайте теоретический объём по формуле из раздела выше.
  3. Сделайте замер до создания массива и после него: на разнице виден чистый вклад.
  4. Выполните полный GC и снимите показатель повторно, чтобы убрать мусор.
  5. Вычислите расхождение в байтах и в процентах.
  6. Задокументируйте причину: заголовок, выравнивание, аллокатор, фрагментация, отложенная сборка.

Пример: int[1 000 000] даёт расчёт 4 000 016 байт, и гистограмма jmap показывает ту же цифру, потому что сумма уже кратна восьми. Если те же данные создавать блоками по 1000 элементов, к каждому добавится 16 байт заголовка, и суммарный расход вырастет на 16 КБ.

СтруктураРасчётный объёмФактическое потреблениеПричина расхождения
Java, int[1000000]4 000 016 байтоколо 4 000 016 байтвыравнивание не потребовалось
Python, list из 1 000 000 int56 + 8 000 000 байт по указателямоколо 36 МБобъекты int по 28 байт
array.array из 1 000 000 intоколо 4 МБоколо 4 МБсырой буфер без объектов
Go, []int64 на 1 000 00024 + 8 000 000 байтоколо 8 МБзаголовок 24 байта мал на фоне данных

Типичные причины расхождений

  • Заголовки объектов: mark word 8 байт плюс ссылка на класс и поле длины у массива в HotSpot, 24 байта на срез в Go, 56 байт на список в CPython.
  • Выравнивание: до 7 байт на объект плюс округление вложенных структур.
  • Оверхед аллокатора: 4 или 8 байт служебных данных на блок, минимальный блок 16-32 байта для мелких аллокаций.
  • Фрагментация: освобождённые участки остаются в аренах и видны в RSS.
  • Отложенная сборка мусора: до полного цикла GC старые массивы занимают место.
  • Сжатые ссылки в JVM: они включены по умолчанию при объёме кучи до 32 ГБ, а при максимальном размере кучи более 32 ГБ JVM автоматически отключает сжатые указатели, и массивы ссылок дорожают.
  • Кэш малых целых в CPython: значения в диапазоне -5...256 не создают новых объектов, поэтому список из одинаковых мелких чисел расходует только память под указатели.
  • Escape analysis в Go: массив, оставшийся на стеке, в профиле кучи не появится.

Поиск утечек и раздутых буферов, связанных с массивами

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

Как отличить утечку от нормального роста

Нормальный рост: под нагрузкой RSS и heap увеличиваются, после снятия нагрузки и полного GC возвращаются к базовому уровню. Утечка: после десяти циклов нагрузки RSS не возвращается к стартовому значению, а retained heap после полного GC не уменьшается.

Практический критерий: снимите снимок кучи, выполните серию операций, вызовите полный GC, снимите второй снимок и сравните распределение по классам. Массивы, чей размер вырос, и есть кандидаты. В MAT путь до GC root показывает, кто удерживает массив: статическое поле, долгоживущий кэш или очередь задач.

Раздутые буферы: capacity vs size

Частая причина перерасхода: массив выделен с большим запасом. ArrayList, созданный с capacity 1 000 000, но хранящий 10 элементов, держит около 4 МБ. Срез Go после append может иметь cap вдвое больше len. Динамический список Python после удаления половины элементов сохраняет прежнюю ёмкость.

Механика роста ёмкости, амортизированная стоимость push и приёмы вроде reserve и shrink_to_fit разобраны в статье о динамических массивах; для измерения важно, что замер по size даёт заниженную цифру.

Что искать в отчётах: строка int[] или byte[] с десятками экземпляров и суммарным размером в сотни мегабайт; список, где capacity превышает size более чем на порядок; буферы, выделенные под максимальный размер пакета, который никогда не приходит.

Лечение по рантаймам: trimToSize() у ArrayList, срез с ограничением ёмкости в Go (slice = slice[:len:len]), copy в новый массив меньшего размера, отказ от глобальных буферов в пользу локальных.

Практические команды и примеры для быстрой диагностики

Набор команд для первой проверки. Подставьте PID процесса вместо <pid>.

Команды для Java

  • jcmd <pid> GC.heap_info - занято, свободно и размер регионов.
  • jmap -histo:live <pid> - таблица классов; первые строки показывают, где больше всего байт. Пример строки: [I (int array) с числом экземпляров и суммарным размером в байтах.
  • jmap -dump:live,format=b,file=heap.hprof <pid> - дамп для анализа.
  • jstat -gc <pid> 1000 - частота и длительность сборок мусора.

Команды jmap с опцией :live приостанавливают JVM, а на большом heap работают минуты. Запускайте их в окно обслуживания или на снятой с балансировки реплике.

Команды для Python

  • В коде: tracemalloc.start() в начале сценария, затем take_snapshot() и statistics("lineno") - топ строк по памяти.
  • python -m memory_profiler script.py - построчный отчёт.
  • objgraph.show_most_common_types() - распределение объектов по типам.
  • numpy: arr.nbytes - точный размер буфера.

tracemalloc включают до создания массивов. Для сравнения снимков применяют snapshot1.compare_to(snapshot2, "lineno").

Команды для Go и C/C++

  • go tool pprof http://localhost:6060/debug/pprof/heap - интерактивный разбор профиля кучи.
  • runtime.ReadMemStats - HeapAlloc, HeapInuse и другие счётчики из кода.
  • valgrind --tool=massif ./app и ms_print massif.out - график потребления по времени.
  • heaptrack ./app - трассировка аллокаций с привязкой к стеку.
  • pmap -x <pid> и smem -t -p - карта памяти и разбивка на PSS и USS.

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

Типичные ошибки при измерении памяти под массивы

Ошибки в расчёте

  • Учитывают только размер элементов: int[1000] считают как 4000 байт вместо 4016.
  • Забывают выравнивание: результат не округляют до границы 8 байт.
  • Игнорируют оверхед аллокатора на мелких массивах, где служебные байты дают заметную долю сверху.
  • Считают capacity равным size: внутренний массив динамической структуры держит запас.
  • Не учитывают вложенные заголовки в многомерных массивах и массивах объектов.
  • Оценивают массив ссылок без учёта размера самих объектов.
  • Получают переполнение счётчика при умножении большого числа элементов на размер элемента в 32-битной арифметике.

Ошибки в измерении

  • Берут RSS вместо heap и считают разделяемые библиотеки памятью приложения.
  • Измеряют до прогрева JVM: классы и JIT ещё не загружены, цифры занижены, а недооценка выглядит как экономия.
  • Делают замер без полного GC, поэтому мусор попадает в результат.
  • Запускают jmap без опции :live и получают размер вместе с недостижимыми объектами.
  • Сравнивают расчёт и факт на разных версиях рантайма, разных флагах или разном объёме данных.
  • Измеряют в контейнере и забывают про лимит cgroup: кэш файловой системы тоже учтён в счётчике памяти группы.
  • Ограничиваются одним запуском, тогда как фрагментация даёт разброс между прогонами.
  • Принимают за утечку рост кэша: ложные срабатывания появляются при сравнении пиков без учёта влияния GC.

Источники и подтверждающие материалы

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

Значения заголовков, выравнивания и оверхеда аллокатора зависят от версии рантайма, флагов запуска, архитектуры и библиотеки аллокатора. Числа в примерах приведены для 64-битных сборок HotSpot со сжатыми ссылками и 64-битного CPython. Проверяйте свою конфигурацию перед тем, как закладывать цифры в план по памяти.

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

Поделиться:
Сохранить гайд? В закладки браузера