Рубрики
Без рубрики

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

Узнайте, как JVM размещает объекты и массивы в куче

Автор оригинала: Ali Dehghani.

1. Обзор

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

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

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

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

2. Обычные указатели На Объекты (УПС)

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

Слово mark описывает заголовок объекта. JVM HotSpot использует это слово для хранения identityhashcode, предвзятого шаблона блокировки, информации о блокировке и GCmetadata.

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

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

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

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

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

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

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


    org.openjdk.jol
    jol-core
    0.10

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 s и byte s занимают 1 байт, short s и char s занимают 2 байта, int s и float s занимают 4 байта и, наконец, long s и double s занимают 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. Основные

Давайте рассмотрим класс Simple Int :

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());

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

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

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

65 74 b2 25 = 01100101 01110100 10110010 00100101 = 1702146597

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

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

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

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 tuning. Например, для того же класса компоновка памяти с -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 байт после переменной long .

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

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

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. Возраст и обеспечение

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

Чтобы смоделировать незначительные GCS, мы создадим много мусора, назначив объект переменной 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. Ложный обмен и @Оспариваемый

То То аннотация (или аннотация (или на 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 tuning.

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

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

4.8. Массивы

Как мы уже упоминали ранее, длина массива также является частью ооп массива. Например, для логического массива, содержащего 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.                             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 байта для массива boolean с 3 элементами.

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

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

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

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

[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.                             N/A
     27     5           (loss due to the next object alignment)

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

5. Заключение

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

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

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

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