В 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, длина 1 | Java, длина 100 | .NET x64, длина 1 | .NET x64, длина 100 |
|---|---|---|---|---|
| byte[] | 24 | 120 | 32 | 128 |
| int[] | 24 | 416 | 32 | 424 |
| long[] | 24 | 816 | 32 | 824 |
| object[] и массивы ссылок | 24 | 416 | 32 | 824 |
Значения указаны в байтах. В 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 struct | 16 байт | нет | нет | разбор буфера внутри метода |
| Memory<T> | нет, это struct | 16 байт | нет | да | хранить срез в поле без копирования |
| ArraySegment<T> | нет, это struct | 16 байт | нет | да | окно offset и count, старый API |
| ImmutableArray<T> | нет, это struct | 8 байт | только новым массивом | да | неизменяемый набор |
| 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 + j | 1 | около 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 выводятся из фактического размера кучи.
Чек-лист: как уменьшить давление массивов на память
- Снимите baseline: jcmd GC.class_histogram и dotnet-counters со счётчиком alloc-rate.
- Замените List<T> на T[], если длина поля фиксирована.
- Задайте capacity в конструкторе списка, если рост всё же нужен.
- Берите буферы из ArrayPool<T>.Shared и возвращайте их в finally.
- Для мелких временных буферов используйте stackalloc или ThreadLocal.
- Держите долгоживущие массивы ниже 85 000 байт и ниже половины региона G1.
- Плотные матрицы храните одномерным массивом с индексом i * cols + j.
- Клонируйте массивы явно: Arrays.copyOf, System.arraycopy, Array.Copy.
- Помечайте кэши массивов через transient в Java и JsonIgnore в .NET.
- Повторите замер тем же инструментом и сравните дампы до и после.
Начните с первого пункта и меняйте по одному приёму за прогон: так вы увидите, какой именно шаг дал эффект на вашей нагрузке, и не будете гадать по цифрам кучи.