Почему размер элементов не равен потреблению памяти
Расчёт по формуле «элементы умножить на размер элемента» почти всегда расходится с фактом. Итоговый объём массива складывается из размера элементов, заголовка объекта, выравнивания, служебных полей аллокатора и фрагментации кучи.
Рабочий порядок действий даёт доказуемые цифры: рассчитать теоретический объём, измерить фактический в рантайме, посчитать расхождение и объяснить его конкретными статьями расходов. После этого в отчёте появляется не «серверу не хватает памяти», а «буфер держит 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, и в измерение памяти такого массива входят добавленные байты.
Как рассчитать теоретический объём массива
Расчёт выполняют до запуска приложения, он служит базой для сравнения с фактом. Последовательность из шести шагов:
- Определите размер одного элемента (itemsize) и единицу, в которой он измеряется.
- Умножьте размер элемента на планируемое число элементов.
- Добавьте заголовок объекта для своего рантайма.
- Округлите результат вверх до границы 8 байт.
- Добавьте оверхед аллокатора: 4 или 8 байт служебных данных на блок, минимум 16 байт на 32-битных системах и 24 или 32 байта на 64-битных.
- Для многомерных массивов сложите заголовок внешнего массива и заголовки вложенных, затем прибавьте выравнивание на каждом уровне.
| Рантайм и структура | Служебная часть | Что добавить к расчёту |
|---|---|---|
| Java, int[] | 8 байт mark + 4/8 байт klass + 4 байта длины | выравнивание до 8 байт |
| Java, ArrayList | около 24 байт | внутренний Object[] по capacity, а не по size |
| Python, list | 56 байт | 8 байт на указатель и объекты элементов |
| Python, array.array | небольшая служебная часть | только сырые значения фиксированной ширины |
| Python, numpy.ndarray | заголовок объекта ndarray | буфер данных, точный размер в nbytes |
| Go, slice | 24 байта | массив под cap, а не под len |
| C, malloc | 4 или 8 байт служебных данных | округление до минимального блока (16/24/32 байта) |
| C++, std::vector | 24 байта | буфер по 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.
Чтобы связать картину по процессам с состоянием узла, используйте порядок проверки из материала о практической оптимизации производительности систем на сервере.
Как сравнить расчётный объём с реальным потреблением
Расхождение не случайно, у него есть статья расходов. Сравнение превращает догадки в вывод.
Пошаговая методика сравнения
- Зафиксируйте сценарий: версия рантайма, флаги запуска, объём данных, режим прогрева.
- Посчитайте теоретический объём по формуле из раздела выше.
- Сделайте замер до создания массива и после него: на разнице виден чистый вклад.
- Выполните полный GC и снимите показатель повторно, чтобы убрать мусор.
- Вычислите расхождение в байтах и в процентах.
- Задокументируйте причину: заголовок, выравнивание, аллокатор, фрагментация, отложенная сборка.
Пример: 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 int | 56 + 8 000 000 байт по указателям | около 36 МБ | объекты int по 28 байт |
| array.array из 1 000 000 int | около 4 МБ | около 4 МБ | сырой буфер без объектов |
| Go, []int64 на 1 000 000 | 24 + 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.
Источники и подтверждающие материалы
Числовые ориентиры по заголовкам объектов, выравниванию и оверхеду аллокатора, использованные в расчётах выше, опираются на следующие публикации:
- Размеры объектов и заголовков в HotSpot, включая mark word, ссылку на класс, поле длины массива и выравнивание на 8 байт: Демистификация размеров объектов в Java.
- Сжатые указатели (compressed oops), порог 32 ГБ и отключение сжатых ссылок выше него: Compressed OOPs in the JVM.
- Накладные расходы malloc в glibc, минимальный размер блока и malloc_usable_size: исходный код malloc в glibc.
- Размер int (28 байт), накладные расходы list (56 байт), 8-байтовые указатели и кэш малых целых -5...256: Understand How Much Memory Your Python Objects Use.
- Заголовок среза в Go (24 байта: указатель, длина, ёмкость) и размер массива фиксированной длины: Slices in Go и материал о заголовке среза.
Значения заголовков, выравнивания и оверхеда аллокатора зависят от версии рантайма, флагов запуска, архитектуры и библиотеки аллокатора. Числа в примерах приведены для 64-битных сборок HotSpot со сжатыми ссылками и 64-битного CPython. Проверяйте свою конфигурацию перед тем, как закладывать цифры в план по памяти.
Начните с одной структуры, которая вызывает вопросы: посчитайте её объём, снимите замер до и после создания, сравните и запишите причину расхождения. Через несколько таких замеров появится калиброванная формула для вашего рантайма, и планирование памяти перестанет опираться на догадки.