Хранение массивов в структурах Java и C#: поля-массивы, куча и управление памятью в 2026 году | AdminWiki

Хранение массивов в структурах Java и C#: поля-массивы, куча и управление памятью в 2026 году

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

В Java и C# массив это ссылочный тип. Поле, объявленное как int[] data, хранит только ссылку, а сами элементы лежат в управляемой куче отдельным объектом с собственным заголовком и полем длины. Размер объекта-владельца от длины массива не зависит: int[4] и int[100000] занимают в поле одинаковые 4 байта в JVM и 8 байт в .NET на x64.

Порядок цифр для расчёта такой. В JVM с compressed oops массив new int[100] занимает 16 байт заголовка плюс 400 байт данных, итого 416 байт. В .NET на x64 тот же массив занимает 24 байта заголовка плюс 400 байт, итого 424 байта. Заголовок есть у любого массива, даже у byte[1], поэтому массив из одного элемента стоит десятки байт.

Практических следствий три. Размер объекта с полем-массивом считают как заголовок класса плюс размер ссылки, а не как заголовок плюс данные. Каждый массив в поле это отдельная аллокация и вклад в allocation rate. Большие массивы попадают в специальные области кучи: LOH в .NET и humongous-регионы в G1 на JVM, где сборка дороже.

Пороги и поведение сборщика зависят от версии рантайма, режима GC и параметров запуска. Все числа ниже проверяйте на своём стенде: снимайте гистограмму классов и трейс аллокаций до и после правок.

Что происходит с массивом, когда он становится полем класса

Массив состоит из заголовка и непрерывного блока элементов, которые идут сразу за заголовком. Адрес элемента считается как base + i * size, где size фиксирован для типа, поэтому доступ по индексу это одно умножение и одно сложение без обхода цепочки. Модель памяти и вычисление адреса по индексу разобраны в статье элементы массива в памяти и вычисление адреса по индексу.

Заголовок объекта и длина массива: сколько байт реально съедает поле

Заголовок отличается в двух платформах, и разница накапливается на каждом массиве.

Часть заголовкаJVM, compressed oops.NET, x64
Служебное словоmark word, 8 байтobject header (sync block index), 8 байт
Указатель на типklass pointer, 4 байтаmethod table pointer, 8 байт
Длина массива4 байта4 байта плюс 4 байта выравнивания
Итого16 байт24 байта

В mark word лежат хеш идентичности, возраст объекта для GC и состояние блокировки. klass pointer ведёт к метаданным класса. В .NET в object header хранится индекс sync block, method table pointer указывает на таблицу методов, дальше идёт длина. Оба рантайма выравнивают объект по границе 8 байт, поэтому расчётный размер округляется вверх.

Тип массиваJava, длина 1Java, длина 100.NET x64, длина 1.NET x64, длина 100
byte[]2412032128
int[]2441632424
long[]2481632824
object[] и массивы ссылок2441632824

Значения указаны в байтах. В JVM при куче больше 32 ГБ compressed oops отключается, klass pointer становится 8 байт, заголовок вырастает до 24 байт, а ссылка в поле до 8 байт. Массивы примитивов хранят значения без упаковки: int[] это 400 байт данных на 100 элементов. Массив ссылочного типа хранит только ссылки, а объекты по ним лежат отдельно, поэтому object[100] в .NET занимает 824 байта под ссылки плюс память под сами объекты.

java -jar jol-cli.jar sizes java.lang.Object
java -jar jol-cli.jar internals "int[]"

В коде JOL даёт два разных ответа: ClassLayout.parseInstance(obj).instanceSize() возвращает размер самого объекта, GraphLayout.parseInstance(obj).totalSize() считает объект вместе с достижимыми из него массивами. В .NET те же данные снимают через dotnet-dump analyze и команды dumpheap -stat, dumpobj и objsize.

Почему struct в C# с полем-массивом не хранит данные внутри себя

struct в C# это value type, но поле-массив внутри него остаётся ссылкой. Размер struct S { public int[] Data; } на x64 равен 8 байтам, а не длине массива, умноженной на 4. Добавьте поле int Length, и размер станет 16 байт: 8 байт ссылки, 4 байта значения и 4 байта выравнивания.

Дальше всё зависит от того, где лежит экземпляр. Локальная переменная метода может оказаться на стеке, и на стеке будет только ссылка, а массив всё равно в куче. Если struct лежит полем в классе или элементом массива, ссылка лежит в куче, а данные массива в другом месте. При упаковке struct в object копируются поля, но не массив: обе копии смотрят на один и тот же массив.

Отсюда частая ошибка ожиданий: struct с массивом не экономит память и не даёт непрерывности данных. Для плотного размещения внутри метода используют Span<T> или stackalloc, для передачи набора без владения ReadOnlySpan<T> или Memory<T>. В Java пользовательских value-типов в мейнстриме нет, там любое поле вида int[] это ссылка, и вопрос сводится к полю класса. Общие критерии выбора разобраны в статье принципы и критерии выбора подхода к хранению массива в структуре.

Влияние полей-массивов на сборку мусора и размер объекта

Поле-массив превращает одну логическую сущность в несколько объектов: сам владелец, массив и, для вложенных структур, подмассивы. Каждый из них аллоцируется в куче, учитывается в allocation rate и проходит через поколения сборщика. Массив, переживший несколько молодых сборок, продвигается в old generation или gen2, и его сборка обходится дороже.

Large Object Heap и humongous regions: когда массив становится дорогим

В .NET объект размером от 85 000 байт попадает в Large Object Heap. Порог переводится в элементы просто: int[21250] это 24 байта заголовка плюс 85 000 байт данных, то есть 85 024 байта, уже LOH. LOH собирается вместе с gen2, по умолчанию не компактируется и со временем фрагментируется. Разово уплотнить его можно через GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce перед сборкой.

В G1 объект уходит в humongous, если он больше половины региона. Размер региона сборщик выводит из размера кучи: примерно heap / 2048, округлённое до степени двойки в диапазоне 1-32 МБ. При куче 4 ГБ регион получается 2 МБ, а порог humongous 1 МБ. Такие объекты размещаются в отдельных регионах, провоцируют дополнительные сборки и оставляют фрагментацию.

Платформа и сборщикПорог крупного объектаПоведение
JVM, G1больше половины региона, обычно 1-16 МБhumongous-регионы, риск фрагментации и лишних сборок
JVM, generational ZGCразмерные классы small, medium, largeобработка конкурентная, паузы почти не растут с размером
JVM, Shenandoahрегиональная схема с крупными объектамиконкурентное уплотнение, низкие паузы
.NET CLRот 85 000 байтLOH, сборка вместе с gen2, компакция только по запросу

Как поля-массивы увеличивают allocation rate и паузы

Allocation rate считается прямо: объекты в секунду умножить на размер объекта. Сервис обрабатывает 10 000 запросов в секунду, на каждом создаётся объект с полем int[256]. Размер массива 16 байт заголовка плюс 1024 байта данных, около 1040 байт. Только массивы дают около 10,4 МБ в секунду. При молодом поколении 256 МБ minor GC случается примерно раз в 25 секунд, при 1 ГБ в секунду каждые 0,25 секунды.

Долгоживущий массив меняет картину иначе. Кэш в статическом поле, который постепенно растёт, продвигает данные в old gen, и каждая сборка старого поколения становится длиннее. Здесь помогает не уменьшение числа аллокаций, а ограничение размера кэша и вынос данных в off-heap.

Мерить нужно до правок: события JFR jdk.ObjectAllocationInNewTLAB и jdk.ObjectAllocationOutsideTLAB показывают, где именно рождаются объекты, а в .NET счётчик alloc-rate из dotnet-counters вместе со счётчиками gen0, gen1, gen2 даёт ту же картину. Для серверных нагрузок выбирайте Server GC: он даёт отдельную кучу на ядро, выше пропускную способность и больше памяти. На десктопе или в контейнере с жёстким лимитом обычно лучше Workstation GC.

Массив, List или специализированная коллекция в роли поля: матрица выбора

Поле типа T[] даёт минимум памяти и максимум скорости, но фиксированную длину. Всё остальное это компромисс: дополнительный объект-обёртка, уровень косвенности или копирование при изменении.

Тип поляДополнительный объектРазмер поляРостМожет быть полем классаКогда брать
T[]нетссылка 4-8 байтнетдадлина фиксирована, важны память и скорость
List<T> в .NETда, около 32 байтссылкада, capacity удваиваетсядадлина меняется, нужны Add и Remove
ArrayList или List в Javaда, около 24 байтссылкада, capacity растёт с запасомдадинамический размер; сырой ArrayList упаковывает примитивы
Span<T>нет, это ref struct16 байтнетнетразбор буфера внутри метода
Memory<T>нет, это struct16 байтнетдахранить срез в поле без копирования
ArraySegment<T>нет, это struct16 байтнетдаокно offset и count, старый API
ImmutableArray<T>нет, это struct8 байттолько новым массивомданеизменяемый набор
ReadOnlyCollection<T>дассылканетдазапретить изменения, добавив уровень косвенности

Внутри List<T> лежит тот же T[], поэтому доступ по индексу отличается от массива только лишним разыменованием. Цена это объект-обёртка: около 32 байт в .NET и около 24 байт в Java на ArrayList. Ещё одна особенность List<T> в том, что он отдаёт изменяемое содержимое по ссылке. Если наружу нужно отдать набор только для чтения, берите IReadOnlyList, ImmutableArray<T> или ReadOnlyCollection<T>.

Когда List<T> лучше массива, а когда хуже

List<T> выигрывает, когда длина поля меняется во время работы: Add и Remove не требуют ручного копирования, а внутренний массив растёт сам. Проигрывает он там, где длина известна и не меняется: лишний объект, лишний уровень косвенности и возможность случайного изменения содержимого из любого места кода.

Если длина известна заранее, но нужен API списка, задавайте capacity в конструкторе: new List<int>(1000). Тогда рост не вызовет ни одной промежуточной копии. Без capacity добавление миллиона элементов создаёт около 20 промежуточных массивов, и каждый старый массив становится мусором.

В Java List это интерфейс, а ArrayList один из классов, которые его реализуют. List.of() создаёт неизменяемый список, Arrays.asList() возвращает представление поверх массива: изменение элемента массива видно в списке, а добавление элементов через такое представление запрещено.

Span<T>, Memory<T> и ArraySegment<T>: когда массив не нужен как поле

Span<T> это ref struct: он живёт только на стеке, не может быть полем класса и не может пересекать await. Взамен он не требует выделения памяти и не копирует данные при Slice. Для методов парсинга и обработки буферов это основная форма.

Memory<T> можно хранить в поле класса. Он не владеет памятью, а держит ссылку на владельца вместе с offset и длиной. Если объект получает часть чужого массива и не должен копировать его, поле типа Memory<T> снимает аллокацию.

ArraySegment<T> решает ту же задачу, но считается устаревшим: у него нет методов работы как у Span, и в современном коде его вытесняет Memory<T>. Оба варианта полезны при разборе протоколов, когда кадр это окно в общем буфере чтения.

В Java прямого аналога Span нет. Для байтов работает ByteBuffer с position, limit и slice, но он не обобщённый: для int[] приходится держать offset и длину отдельными полями. Сравнение с C++, где std::array хранит данные внутри класса, а std::vector держит указатель на кучу, разобрано в статье std::array или std::vector в поле класса C++. В Java и C# inline-массив внутри поля недоступен.

Многомерные массивы: как не плодить аллокации

В Java настоящих многомерных массивов нет: int[][] это массив ссылок на одномерные массивы. Каждая строка отдельный объект со своим заголовком. Для матрицы 100 на 100 получается 101 объект: внешний массив 16 байт заголовка плюс 400 байт ссылок, итого 416 байт, и 100 внутренних по 416 байт, итого 42 016 байт.

В C# вариантов два. Rectangular массив int[,] это один объект непрерывного блока: 24 байта заголовка плюс 40 000 байт данных, итого 40 024 байта. Jagged массив int[][] это 101 объект: внешний 24 плюс 800 байт ссылок и 100 подмассивов по 424 байта, итого 43 224 байта.

ФормаОбъектов на 100x100ПамятьДоступРазная длина строк
Java int[][]101около 42 016 байтбыстрый, две проверки границда
C# int[,]1около 40 024 байтмедленнее: умножение индексов и проверка границнет
C# int[][]101около 43 224 байтбыстрый при обходеда
Одномерный с индексом i * cols + j1около 40 024 байтсамый быстрыйнет

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

ArrayPool и stackalloc: переиспользование буферов вместо new

В .NET ArrayPool<T>.Shared.Rent(minLength) возвращает массив не короче запрошенного, а фактический размер смотрят через array.Length. Буфер возвращают методом Return в блоке finally. Данные в буфере не очищаются, поэтому нулевой инициализации ждать не нужно, а иногда её не нужно и получать: для очистки есть перегрузка Return(array, clearArray).

var buffer = ArrayPool<byte>.Shared.Rent(4096);
try
{
    // работа с buffer[0 .. n]
}
finally
{
    ArrayPool<byte>.Shared.Return(buffer);
}

Пул потокобезопасен и раскладывает буферы по корзинам размеров, поэтому аллокации и давление на GC падают. MemoryPool<T> решает ту же задачу там, где нужен IMemoryOwner<T>.

stackalloc выделяет память на стеке и не создаёт объектов: Span<byte> buffer = stackalloc byte[256]. Держите размер в пределах килобайта или нескольких килобайт, ориентируясь на размер стека потока, и не пытайтесь вернуть такой буфер наружу или пережить await.

В JVM встроенного пула массивов нет. Для сетевых буферов обычно берут Netty с PooledByteBufAllocator, для низкоуровневых задач Agrona, для простых случаев держат переиспользуемый буфер в ThreadLocal. Учтите, что в пуле потоков привязка буфера к потоку закрепляет память за ним.

Сериализация и клонирование объектов с массивами: типичные ошибки

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

Поверхностное и глубокое копирование: как не потерять данные

Object.clone() в Java и MemberwiseClone() в C# делают поверхностную копию. Новый объект получает ту же ссылку на массив, два объекта начинают делить данные, и правка в одном видна в другом.

class Holder implements Cloneable
{
    int[] data;

    public Holder clone() throws CloneNotSupportedException
    {
        Holder copy = (Holder) super.clone();
        copy.data = Arrays.copyOf(data, data.length);
        return copy;
    }
}

Глубокую копию делают явно: Arrays.copyOf и System.arraycopy в Java, (int[])data.Clone() или Array.Copy в C#. Для вложенных массивов нужна рекурсия: clone у int[][] копирует внешний массив ссылок, а подмассивы остаются общими.

Записи с полем-массивом автоматической глубокой копии не дают. В Java record equals для компонента-массива сравнивает ссылки, в C# то же самое происходит при сравнении структур. Если нужно сравнение по содержимому, реализуйте его вручную. Интерфейс ICloneable в .NET не говорит, глубокая копия или поверхностная, поэтому как публичный контракт он неудобен.

Сериализация массивов: transient, NonSerialized и современные форматы

В Java transient исключает поле из стандартной сериализации. После десериализации поле окажется null или нулями, поэтому так помечают кэши, которые можно пересчитать. В Jackson поле скрывают аннотацией JsonIgnore или тем же transient.

В C# атрибут NonSerialized работает только с BinaryFormatter, который объявлен устаревшим и не годится для новых проектов. В System.Text.Json поле исключают через JsonIgnore. Пометьте кэш массива, чтобы он не попадал в вывод.

Размер имеет значение. int[1000] в JSON это примерно 5 КБ текста против 4 КБ бинарных данных, а byte[] в JSON обычно кодируют base64, что даёт рост около 33% относительно исходных байт. Для больших массивов берите бинарные форматы: Protobuf или MessagePack для обмена, Parquet для аналитики. Порядок байтов, выравнивание и готовые примеры записи разобраны в статье сериализация и персистентность массивов на диске.

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

Профилирование памяти в JVM и .NET: команды и инструменты

Проверка занимает два замера: до правок и после. Без baseline любое изменение размера кучи выглядит как улучшение.

Как найти виновника: гистограмма классов и трейсинг аллокаций

jcmd 12345 GC.heap_info
jcmd 12345 GC.class_histogram
jmap -histo:live 12345
jmap -dump:live,format=b,file=heap.hprof 12345
jcmd 12345 JFR.start name=alloc settings=profile duration=60s filename=alloc.jfr

В гистограмме JVM ищите типы [I для int[], [B для byte[], [C для char[], [J для long[] и [Ljava.lang.Object; для массивов ссылок. Столбец с байтами показывает, что именно занимает память. Верх списка часто занимают byte[] из сетевых буферов и char[] из строк.

dotnet-counters monitor --process-id 12345 --counters System.Runtime
dotnet-gcdump collect -p 12345
dotnet-trace collect -p 12345 --providers Microsoft-Windows-DotNETRuntime
dotnet-dump analyze core_20260930

В dotnet-dump analyze команда dumpheap -stat печатает топ типов: System.Byte[], System.Int32[], System.Char[]. Для конкретного объекта dumpobj показывает размер, gcroot показывает, кто его держит, а dumpheap -live отсекает мусор. Сравнение двух дампов по одним и тем же типам даёт ответ, помогли ли правки. Методика расчёта размера массива с заголовками и выравниванием и сверка расчёта с фактом разобраны в статье как измерить память под массивы в рантайме.

Бенчмарки: JMH и BenchmarkDotNet для проверки гипотез

JMH в Java с профилем gc показывает gc.alloc.rate.norm, то есть байты на одну операцию. BenchmarkDotNet в .NET с атрибутом MemoryDiagnoser добавляет столбец Allocated. Сравнивайте одинаковые сценарии: new int[1000] в цикле против ArrayPool<int>.Shared.Rent(1000) с возвратом в finally.

Два правила, без которых замер врёт. Прогревайте код: без warmup в результат попадает интерпретация и JIT. Возвращайте результат наружу или складывайте в чёрный ящик, иначе компилятор выбросит вычисление вместе с аллокацией. Если после перехода на пул allocated на операцию падает с 4 КБ до нуля, а счётчики gen0 растут медленнее, гипотеза подтверждена.

Что изменилось в 2026 году: JVM и .NET

Модель памяти массивов за последние годы не изменилась: заголовок, длина, ссылка в поле. Изменилось окружение, и это влияет на выбор приёмов.

  • Java 21 LTS дала виртуальные потоки и generational ZGC, Java 25 LTS вышла в сентябре 2025 года. Виртуальные потоки меняют профиль аллокаций: тысячи одновременных задач создают больше буферов на единицу времени, и пулинг становится заметнее.
  • Project Panama (FFM API, классы Arena и MemorySegment) даёт доступ к off-heap памяти без JNI. Для больших долгоживущих буферов это способ обойти humongous-регионы и снять нагрузку с кучи.
  • Valhalla с value classes к 2026 году в мейнстрим не вышла. Планировать на исчезновение заголовков у массивов пока нельзя.
  • .NET 8 LTS принёс FrozenCollections, SearchValues и динамический PGO по умолчанию, в 2026 году актуальна LTS .NET 10 (ноябрь 2025). NativeAOT сокращает стартовую память, но заголовки объектов и порог LOH остаются на месте.
  • Настройки GC решают больше, чем микронастройки кода: Server GC и DOTNET_gcServer=1 для серверной нагрузки, DATAS и GCHeapHardLimit для контейнеров с жёстким лимитом, разовая компакция LOH через GCSettings.

Сверяйте пороги со своим рантаймом: поведение сборщиков меняется от версии к версии, а параметры вида размера региона G1 выводятся из фактического размера кучи.

Чек-лист: как уменьшить давление массивов на память

  1. Снимите baseline: jcmd GC.class_histogram и dotnet-counters со счётчиком alloc-rate.
  2. Замените List<T> на T[], если длина поля фиксирована.
  3. Задайте capacity в конструкторе списка, если рост всё же нужен.
  4. Берите буферы из ArrayPool<T>.Shared и возвращайте их в finally.
  5. Для мелких временных буферов используйте stackalloc или ThreadLocal.
  6. Держите долгоживущие массивы ниже 85 000 байт и ниже половины региона G1.
  7. Плотные матрицы храните одномерным массивом с индексом i * cols + j.
  8. Клонируйте массивы явно: Arrays.copyOf, System.arraycopy, Array.Copy.
  9. Помечайте кэши массивов через transient в Java и JsonIgnore в .NET.
  10. Повторите замер тем же инструментом и сравните дампы до и после.

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

Поделиться: