Перейти к основному содержимому

Расположение объектов в памяти в Java

· 12 мин. чтения

1. Обзор

В этом руководстве мы увидим, как JVM размещает объекты и массивы в куче.

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

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

Мы также можем взаимозаменяемо использовать термины JVM и HotSpot JVM.

2. Обычные указатели объектов (ООП)

HotSpot JVM использует структуру данных, называемую Ordinary Object Pointers ( OOPS ), для представления указателей на объекты. Все указатели (как объекты, так и массивы) в JVM основаны на специальной структуре данных, называемой oopDesc . Каждый oopDesc описывает указатель со следующей информацией:

Меточное слово описывает заголовок объекта. HotSpot JVM использует это слово для хранения хэш-кода идентификации, предвзятого шаблона блокировки, информации о блокировке и метаданных сборщика мусора.

Более того, состояние слова метки содержит только uintptr_t , поэтому его размер варьируется от 4 до 8 байт в 32-битной и 64-битной архитектурах соответственно. Кроме того, знаковое слово для смещенных и обычных объектов отличается. Однако мы будем рассматривать только обычные объекты, так как в Java 15 блокировка с предубеждением будет объявлена устаревшей .

Кроме того, слово klass инкапсулирует информацию о классе на уровне языка, такую как имя класса, его модификаторы, информацию о суперклассе и т. д.

Для обычных объектов в Java, представленных как instanceOop , заголовок объекта состоит из слов mark и klass, а также возможных дополнений для выравнивания . После заголовка объекта может быть ноль или более ссылок на поля экземпляра. Таким образом, это как минимум 16 байтов в 64-битных архитектурах из-за 8 байтов метки, 4 байтов класса и еще 4 байтов для заполнения.

Для массивов, представленных как arrayOop , заголовок объекта содержит 4-байтовую длину массива в дополнение к метке, классу и отступам. Опять же, это будет не менее 16 байтов из-за 8 байтов метки, 4 байтов класса и еще 4 байта для длины массива.

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

3. Настройка JOL

Чтобы проверить расположение объектов в памяти в JVM, мы будем широко использовать Java Object Layout ( JOL ). Поэтому нам нужно добавить зависимость jol-core :

<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
</dependency>

4. Примеры размещения памяти

Давайте начнем с рассмотрения общих деталей виртуальной машины:

System.out.println(VM.current().details());

Это напечатает:

# Running 64-bit HotSpot VM.
# Objects are 8 bytes aligned.
# Field sizes by type: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
# Array element sizes: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]

Это означает, что ссылки занимают 4 байта, boolean и byte занимают 1 байт, короткие s и char занимают 2 байта, int и float занимают 4 байта и, наконец, long s и double занимают 8 байтов. Интересно, что они потребляют одинаковое количество памяти, если мы используем их как элементы массива.

Также, если мы отключим сжатые ссылки через -XX:-UseCompressedOops, изменится только размер ссылки на 8 байт:

# Field sizes by type: 8, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
# Array element sizes: 8, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]

4.1. Базовый

Рассмотрим класс SimpleInt :

public class SimpleInt {
private int state;
}

Если мы напечатаем его макет класса:

System.out.println(ClassLayout.parseClass(SimpleInt.class).toPrintable());

Мы бы увидели что-то вроде:

SimpleInt object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 12 (object header) N/A
12 4 int SimpleInt.state N/A
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

Как показано выше, заголовок объекта составляет 12 байт, включая 8 байтов метки и 4 байта класса. После этого у нас есть 4 байта для состояния int . Всего любой объект из этого класса будет занимать 16 байт.

Кроме того, нет значения для заголовка объекта и состояния, потому что мы анализируем макет класса, а не макет экземпляра.

4.2. Идентификационный хэш-код

hashCode() является одним из общих методов для всех объектов Java. Если мы не объявляем метод hashCode() для класса, Java будет использовать для него идентификационный хэш-код.

Идентификационный хэш-код не изменится для объекта в течение его жизни. Таким образом, JVM HotSpot сохраняет это значение в слове метки после его вычисления.

Давайте посмотрим на структуру памяти для экземпляра объекта:

SimpleInt instance = new SimpleInt();
System.out.println(ClassLayout.parseInstance(instance).toPrintable());

HotSpot JVM лениво вычисляет хэш-код идентификации:

SimpleInt object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) # mark
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) # mark
8 4 (object header) 9b 1b 01 f8 (10011011 00011011 00000001 11111000) (-134145125) # klass
12 4 int SimpleInt.state 0
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

Как показано выше, в настоящее время слово метки не хранит ничего существенного.

Однако это изменится, если мы вызовем System.identityHashCode() или даже Object.hashCode() для экземпляра объекта:

System.out.println("The identity hash code is " + System.identityHashCode(instance));
System.out.println(ClassLayout.parseInstance(instance).toPrintable());

Теперь мы можем определить хэш-код идентификации как часть слова метки:

The identity hash code is 1702146597
SimpleInt object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 25 b2 74 (00000001 00100101 10110010 01110100) (1957831937)
4 4 (object header) 65 00 00 00 (01100101 00000000 00000000 00000000) (101)
8 4 (object header) 9b 1b 01 f8 (10011011 00011011 00000001 11111000) (-134145125)
12 4 int SimpleInt.state 0

HotSpot JVM сохраняет хэш-код идентификации как «25 b2 74 65» в слове метки. Самый значащий байт — 65, так как JVM хранит это значение в формате с прямым порядком байтов. Следовательно, чтобы восстановить значение хэш-кода в десятичном виде (1702146597), мы должны прочитать последовательность байтов «25 b2 74 65» в обратном порядке:

65 74 b2 25 = 01100101 01110100 10110010 00100101 = 1702146597

4.3. Выравнивание

По умолчанию JVM добавляет к объекту достаточно отступов, чтобы сделать его размер кратным 8.

Например, рассмотрим класс SimpleLong :

public class SimpleLong {
private long state;
}

Если мы разберем макет класса:

System.out.println(ClassLayout.parseClass(SimpleLong.class).toPrintable());

Затем JOL напечатает макет памяти:

SimpleLong object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 12 (object header) N/A
12 4 (alignment/padding gap)
16 8 long SimpleLong.state N/A
Instance size: 24 bytes
Space losses: 4 bytes internal + 0 bytes external = 4 bytes total

Как показано выше, заголовок объекта и длинное состояние занимают всего 20 байт. Чтобы сделать этот размер кратным 8 байтам, JVM добавляет 4 байта заполнения.

Мы также можем изменить размер выравнивания по умолчанию с помощью флага настройки -XX:ObjectAlignmentInBytes . Например, для того же класса расположение памяти с -XX:ObjectAlignmentInBytes=16 будет таким:

SimpleLong object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 12 (object header) N/A
12 4 (alignment/padding gap)
16 8 long SimpleLong.state N/A
24 8 (loss due to the next object alignment)
Instance size: 32 bytes
Space losses: 4 bytes internal + 8 bytes external = 12 bytes total

Заголовок объекта и переменная long по- прежнему занимают в общей сложности 20 байт. Итак, мы должны добавить еще 12 байтов, чтобы сделать его кратным 16.

Как показано выше, он добавляет 4 внутренних байта заполнения, чтобы начать переменную long со смещения 16 (обеспечивая более согласованный доступ). Затем он добавляет оставшиеся 8 байтов после длинной переменной.

4.4. Полевая упаковка

Когда класс имеет несколько полей, JVM может распределить эти поля таким образом, чтобы свести к минимуму потери заполнения. Например, рассмотрим класс FieldsArrangement :

public class FieldsArrangement {
private boolean first;
private char second;
private double third;
private int fourth;
private boolean fifth;
}

Порядок объявления полей и их порядок в макете памяти различаются:

OFFSET  SIZE      TYPE DESCRIPTION                               VALUE
0 12 (object header) N/A
12 4 int FieldsArrangement.fourth N/A
16 8 double FieldsArrangement.third N/A
24 2 char FieldsArrangement.second N/A
26 1 boolean FieldsArrangement.first N/A
27 1 boolean FieldsArrangement.fifth N/A
28 4 (loss due to the next object alignment)

Основной мотивацией этого является минимизация отходов заполнения.

4.5. Блокировка

JVM также поддерживает информацию о блокировке внутри слова метки. Давайте посмотрим на это в действии:

public class Lock {}

Если мы создадим экземпляр этого класса , расположение памяти для него будет таким:

Lock object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00
4 4 (object header) 00 00 00 00
8 4 (object header) 85 23 02 f8
12 4 (loss due to the next object alignment)
Instance size: 16 bytes

Однако, если мы синхронизируем на этом экземпляре:

synchronized (lock) {
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
}

Схема памяти меняется на:

Lock object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) f0 78 12 03
4 4 (object header) 00 70 00 00
8 4 (object header) 85 23 02 f8
12 4 (loss due to the next object alignment)

Как показано выше, битовый шаблон для слова метки изменяется, когда мы удерживаем блокировку монитора.

4.6. Возраст и стаж

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

Чтобы имитировать незначительные сборщики мусора, мы создадим много мусора, назначив объект volatile - переменной. Таким образом, мы можем предотвратить возможное устранение мертвого кода JIT-компилятором:

volatile Object consumer;
Object instance = new Object();
long lastAddr = VM.current().addressOf(instance);
ClassLayout layout = ClassLayout.parseInstance(instance);

for (int i = 0; i < 10_000; i++) {
long currentAddr = VM.current().addressOf(instance);
if (currentAddr != lastAddr) {
System.out.println(layout.toPrintable());
}

for (int j = 0; j < 10_000; j++) {
consumer = new Object();
}

lastAddr = currentAddr;
}

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

Вот как со временем меняются первые 4 байта слова метки:

09 00 00 00 (00001001 00000000 00000000 00000000)
^^^^
11 00 00 00 (00010001 00000000 00000000 00000000)
^^^^
19 00 00 00 (00011001 00000000 00000000 00000000)
^^^^
21 00 00 00 (00100001 00000000 00000000 00000000)
^^^^
29 00 00 00 (00101001 00000000 00000000 00000000)
^^^^
31 00 00 00 (00110001 00000000 00000000 00000000)
^^^^
31 00 00 00 (00110001 00000000 00000000 00000000)
^^^^

4.7. Ложный обмен и @Contended

Аннотация jdk.internal.vm.annotation.Contended ( или sun.misc.Contended в Java 8) — это подсказка для JVM, чтобы изолировать аннотированные поля, чтобы избежать ложного совместного использования .

Проще говоря, аннотация Contended добавляет некоторые отступы вокруг каждого аннотируемого поля, чтобы изолировать каждое поле в отдельной строке кэша. Следовательно, это повлияет на структуру памяти.

Чтобы лучше понять это, давайте рассмотрим пример:

public class Isolated {

@Contended
private int v1;

@Contended
private long v2;
}

Если мы проверим структуру памяти этого класса, мы увидим что-то вроде:

Isolated object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 12 (object header) N/A
12 128 (alignment/padding gap)
140 4 int Isolated.i N/A
144 128 (alignment/padding gap)
272 8 long Isolated.l N/A
Instance size: 280 bytes
Space losses: 256 bytes internal + 0 bytes external = 256 bytes total

Как показано выше, JVM добавляет 128 байт заполнения вокруг каждого аннотированного поля. Размер строки кэша на большинстве современных машин составляет около 64/128 байт, отсюда и 128-байтовое заполнение. Конечно, мы можем контролировать размер Contended padding с помощью флага настройки -XX:ContendedPaddingWidth .

Обратите внимание, что аннотация Contended является внутренней для JDK, поэтому нам следует избегать ее использования.

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

4.8. Массивы

Как мы упоминали ранее, длина массива также является частью массива oop. Например, для логического массива, содержащего 3 элемента:

boolean[] booleans = new boolean[3];
System.out.println(ClassLayout.parseInstance(booleans).toPrintable());

Схема памяти выглядит так:

[Z object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 # mark
4 4 (object header) 00 00 00 00 # mark
8 4 (object header) 05 00 00 f8 # klass
12 4 (object header) 03 00 00 00 # array length
16 3 boolean [Z.<elements> N/A
19 5 (loss due to the next object alignment)
Instance size: 24 bytes
Space losses: 0 bytes internal + 5 bytes external = 5 bytes total

Здесь у нас есть 16 байтов заголовка объекта, содержащего 8 байтов слова метки, 4 байта слова класса и 4 байта длины. Сразу после заголовка объекта у нас есть 3 байта для логического массива с 3 элементами.

4.9. Сжатые ссылки

До сих пор наши примеры выполнялись в 64-битной архитектуре с включенным сжатием ссылок.

При выравнивании по 8 байтам мы можем использовать до 32 ГБ кучи со сжатыми ссылками. Если мы превысим это ограничение или даже отключим сжатые ссылки вручную, то слово класса будет занимать 8 байтов вместо 4.

Давайте посмотрим на структуру памяти для того же примера массива, когда сжатые операторы отключены с помощью флага настройки -XX:-UseCompressedOops :

[Z object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 # mark
4 4 (object header) 00 00 00 00 # mark
8 4 (object header) 28 60 d2 11 # klass
12 4 (object header) 01 00 00 00 # klass
16 4 (object header) 03 00 00 00 # length
20 4 (alignment/padding gap)
24 3 boolean [Z.<elements> N/A
27 5 (loss due to the next object alignment)

Как и было обещано, теперь есть еще 4 байта для слова klass.

5. Вывод

В этом руководстве мы увидели, как JVM размещает объекты и массивы в куче.

Для более подробного изучения настоятельно рекомендуется просмотреть раздел oops исходного кода JVM. Также у Алексея Шипилева есть гораздо более глубокая статья на эту тему.

Более того, дополнительные примеры JOL доступны как часть исходного кода проекта.

Как обычно, все примеры доступны на GitHub .