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

Измерение размеров объектов в JVM

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

1. Обзор

В этом руководстве мы увидим, сколько места занимает каждый объект в куче Java.

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

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

В этом руководстве мы сосредоточимся на одной конкретной реализации JVM: HotSpot JVM.

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

2. Мелкие, оставшиеся и глубокие размеры объектов

Для анализа размеров объектов мы можем использовать три разных показателя: мелкие, сохраненные и глубокие размеры.

При вычислении мелкого размера объекта мы учитываем только сам объект. То есть, если объект имеет ссылки на другие объекты, мы учитываем только размер ссылки на целевые объекты, а не их фактический размер объекта. Например:

./d6ff3935178ed0d4811af72d9dfdd6c0.png

Как показано выше, неглубокий размер экземпляра Triple представляет собой всего лишь сумму трех ссылок. Мы исключаем из этого размера реальный размер упомянутых объектов, а именно A1, B1 и C1 .

Напротив, глубокий размер объекта включает в себя размер всех упомянутых объектов в дополнение к мелкому размеру:

./2a3a49eeb737547cacfe0536b2646891.png

Здесь глубокий размер экземпляра Triple содержит три ссылки плюс фактический размер A1, B1 и C1. Поэтому глубокие размеры носят рекурсивный характер.

Когда сборщик мусора освобождает память, занятую объектом, он освобождает определенный объем памяти. Эта сумма является сохраненным размером этого объекта:

./3dc967e18cda94aeeb1cd9cb21964b85.png

Сохраненный размер экземпляра Triple включает только A1 и C1 в дополнение к самому экземпляру Triple . С другой стороны, этот сохраненный размер не включает B1, поскольку экземпляр Pair также имеет ссылку на B1.

Иногда эти дополнительные ссылки косвенно создаются самой JVM. Поэтому вычисление оставшегося размера может быть сложной задачей.

Чтобы лучше понять сохраняемый размер, мы должны думать о сборке мусора. Сбор экземпляра Triple делает A1 и C1 недоступными, но B1 по-прежнему доступен через другой объект. В зависимости от ситуации сохраняемый размер может быть где угодно между поверхностным и глубоким размером.

3. Зависимость

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

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

4. Простые типы данных

Чтобы лучше понять размер более сложных объектов, мы должны сначала узнать, сколько места занимает каждый простой тип данных. Для этого мы можем попросить Java Memory Layout или JOL распечатать информацию о виртуальной машине:

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

Приведенный выше код будет печатать размеры простых типов данных следующим образом:

# Running 64-bit HotSpot VM.
# Using compressed oop with 3-bit shift.
# Using compressed klass with 3-bit shift.
# 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]

Итак, вот требования к пространству для каждого простого типа данных в JVM:

  • Ссылки на объекты занимают 4 байта
  • логические и байтовые значения занимают 1 байт
  • значения short и char занимают 2 байта
  • значения int и float занимают 4 байта
  • значения long и double занимают 8 байт

Это справедливо как для 32-битных архитектур, так и для 64-битных архитектур с действующими сжатыми ссылками .

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

4.1. Несжатые ссылки

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

# Objects are 8 bytes aligned.
# 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]

Теперь ссылки на объекты будут занимать 8 байтов вместо 4 байтов. Остальные типы данных по-прежнему потребляют тот же объем памяти.

Кроме того, HotSpot JVM также не может использовать сжатые ссылки, когда размер кучи превышает 32 ГБ ( если только мы не изменим выравнивание объектов ).

Суть в том, что если мы явно отключим сжатые ссылки или размер кучи больше 32 ГБ, ссылки на объекты будут занимать 8 байт.

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

5. Сложные объекты

Чтобы рассчитать размер сложных объектов, давайте рассмотрим типичное отношение профессора к курсу:

public class Course {

private String name;

// constructor
}

Каждый Профессор, помимо личных данных, может иметь список Курсов :

public class Professor {

private String name;
private boolean tenured;
private List<Course> courses = new ArrayList<>();
private int level;
private LocalDate birthDay;
private double lastEvaluation;

// constructor
}

5.1. Мелкий размер: класс курса

Небольшой размер экземпляров класса Course должен включать 4-байтовую ссылку на объект (для поля имени ) плюс некоторые накладные расходы на объект. Мы можем проверить это предположение, используя JOL:

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

Это напечатает следующее:

Course object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 12 (object header) N/A
12 4 java.lang.String Course.name N/A
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

Как показано выше, неглубокий размер составляет 16 байтов, включая 4-байтовую ссылку на объект для поля имени и заголовок объекта.

5.2. Небольшой размер: класс профессора

Если мы запустим тот же код для класса Professor :

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

Затем JOL напечатает потребление памяти для класса Professor следующим образом:

Professor object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 12 (object header) N/A
12 4 int Professor.level N/A
16 8 double Professor.lastEvaluation N/A
24 1 boolean Professor.tenured N/A
25 3 (alignment/padding gap)
28 4 java.lang.String Professor.name N/A
32 4 java.util.List Professor.courses N/A
36 4 java.time.LocalDate Professor.birthDay N/A
Instance size: 40 bytes
Space losses: 3 bytes internal + 0 bytes external = 3 bytes total

Как мы, вероятно, и ожидали, инкапсулированные поля занимают 25 байт:

  • Три ссылки на объекты, каждая из которых занимает 4 байта. Всего 12 байт для ссылки на другие объекты.
  • Один int , который занимает 4 байта
  • Одно логическое значение , занимающее 1 байт
  • Один двойной , который потребляет 8 байт

Если добавить 12 байтов служебных данных заголовка объекта и 3 байта заполнения выравнивания, то неглубокий размер составит 40 байтов.

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

5.3. Мелкий размер: экземпляр

Метод sizeOf() в JOL предоставляет гораздо более простой способ вычисления мелкого размера экземпляра объекта. Если мы запустим следующий фрагмент:

String ds = "Data Structures";
Course course = new Course(ds);

System.out.println("The shallow size is: " + VM.current().sizeOf(course));

Он напечатает мелкий размер следующим образом:

The shallow size is: 16

5.4. Размер без сжатия

Если мы отключим сжатые ссылки или используем более 32 ГБ кучи, размер мелкой части увеличится:

Professor object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 16 (object header) N/A
16 8 double Professor.lastEvaluation N/A
24 4 int Professor.level N/A
28 1 boolean Professor.tenured N/A
29 3 (alignment/padding gap)
32 8 java.lang.String Professor.name N/A
40 8 java.util.List Professor.courses N/A
48 8 java.time.LocalDate Professor.birthDay N/A
Instance size: 56 bytes
Space losses: 3 bytes internal + 0 bytes external = 3 bytes total

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

5.5. Глубокий размер

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

String ds = "Data Structures";
Course course = new Course(ds);

Глубокий размер экземпляра Course равен малому размеру самого экземпляра Course плюс глубокий размер этого конкретного экземпляра String .

С учетом сказанного давайте посмотрим, сколько места занимает этот экземпляр String :

System.out.println(ClassLayout.parseInstance(ds).toPrintable());

Каждый экземпляр String инкапсулирует char[] (подробнее об этом позже) и хэш -код int :

java.lang.String 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) da 02 00 f8
12 4 char[] String.value [D, a, t, a, , S, t, r, u, c, t, u, r, e, s]
16 4 int String.hash 0
20 4 (loss due to the next object alignment)
Instance size: 24 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

Неглубокий размер этого экземпляра String составляет 24 байта, включая 4 байта кэшированного хеш-кода, 4 байта ссылки char[] и другие типичные служебные данные объекта.

Чтобы увидеть фактический размер char[], мы также можем проанализировать макет его класса:

System.out.println(ClassLayout.parseInstance(ds.toCharArray()).toPrintable());

Макет char[] выглядит следующим образом:

[C 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) 41 00 00 f8
12 4 (object header) 0f 00 00 00
16 30 char [C.<elements> N/A
46 2 (loss due to the next object alignment)
Instance size: 48 bytes
Space losses: 0 bytes internal + 2 bytes external = 2 bytes total

Итак, у нас есть 16 байтов для экземпляра Course , 24 байта для экземпляра String и, наконец, 48 байтов для char[]. В общей сложности глубокий размер этого экземпляра Course составляет 88 байт.

С введением компактных строк в Java 9 класс String внутренне использует byte[] для хранения символов:

java.lang.String object internals:
OFFSET SIZE TYPE DESCRIPTION
0 4 (object header)
4 4 (object header)
8 4 (object header)
12 4 byte[] String.value # the byte array
16 4 int String.hash
20 1 byte String.coder # encodig
21 3 (loss due to the next object alignment)

Таким образом, в Java 9+ общий размер экземпляра Course будет составлять 72 байта вместо 88 байт.

5.6. Макет графа объектов

Вместо того, чтобы анализировать расположение классов каждого объекта в графе объектов отдельно, мы можем использовать метод GraphLayout. С GraphLayot мы просто передаем начальную точку графа объектов, и он сообщает о расположении всех доступных объектов из этой начальной точки. Таким образом, мы можем вычислить глубокий размер начальной точки графика.

Например, мы можем увидеть общую площадь экземпляра Course следующим образом:

System.out.println(GraphLayout.parseInstance(course).toFootprint());

Что печатает следующее резюме:

Course@67b6d4aed footprint:
COUNT AVG SUM DESCRIPTION
1 48 48 [C
1 16 16 com.foreach.objectsize.Course
1 24 24 java.lang.String
3 88 (total)

Всего 88 байт. Метод totalSize() возвращает общую площадь объекта, которая составляет 88 байт:

System.out.println(GraphLayout.parseInstance(course).totalSize());

6. Инструменты

Чтобы вычислить неглубокий размер объекта, мы также можем использовать инструментальный пакет Java и агенты Java. Во-первых, мы должны создать класс с методом premain() :

public class ObjectSizeCalculator {

private static Instrumentation instrumentation;

public static void premain(String args, Instrumentation inst) {
instrumentation = inst;
}

public static long sizeOf(Object o) {
return instrumentation.getObjectSize(o);
}
}

Как показано выше, мы будем использовать метод getObjectSize() для определения мелкого размера объекта. Нам также нужен файл манифеста:

Premain-Class: com.foreach.objectsize.ObjectSizeCalculator

Затем, используя этот файл MANIFEST.MF , мы можем создать файл JAR и использовать его в качестве агента Java:

$ jar cmf MANIFEST.MF agent.jar *.class

Наконец, если мы запустим какой-либо код с аргументом -javaagent:/path/to/agent.jar , то сможем использовать метод sizeOf() :

String ds = "Data Structures";
Course course = new Course(ds);

System.out.println(ObjectSizeCalculator.sizeOf(course));

Это напечатает 16 как неглубокий размер экземпляра Course .

7. Статистика класса

Чтобы увидеть небольшой размер объектов в уже запущенном приложении, мы можем взглянуть на статистику класса с помощью jcmd:

$ jcmd <pid> GC.class_stats [output_columns]

Например, мы можем видеть размер каждого экземпляра и количество всех экземпляров Course :

$ jcmd 63984 GC.class_stats InstSize,InstCount,InstBytes | grep Course 
63984:
InstSize InstCount InstBytes ClassName
16 1 16 com.foreach.objectsize.Course

Опять же, это сообщает о мелком размере каждого экземпляра Course как 16 байт.

Чтобы увидеть статистику класса, мы должны запустить приложение с флагом настройки -XX:+UnlockDiagnosticVMOptions .

8. Дамп кучи

Использование дампов кучи — еще один способ проверки размеров экземпляров в запущенных приложениях. Таким образом, мы можем видеть сохраненный размер для каждого экземпляра. Чтобы получить дамп кучи, мы можем использовать jcmd следующим образом:

$ jcmd <pid> GC.heap_dump [options] /path/to/dump/file

Например:

$ jcmd 63984 GC.heap_dump -all ~/dump.hpro

Это создаст дамп кучи в указанном месте. Кроме того, с параметром -all в дампе кучи будут присутствовать все достижимые и недоступные объекты. Без этой опции JVM выполнит полную сборку мусора перед созданием дампа кучи.

Получив дамп кучи, мы можем импортировать его в такие инструменты, как Visual VM:

./5acfe1fb8c959d5118744d56a2a159cb.png

Как показано выше, сохраняемый размер единственного экземпляра Course составляет 24 байта. Как упоминалось ранее, сохраняемый размер может быть где угодно между мелкими (16 байт) и глубокими размерами (88 байт).

Также стоит упомянуть, что Visual VM была частью дистрибутивов Oracle и Open JDK до Java 9. Однако с Java 9 это уже не так, и мы должны загрузить Visual VM с ее веб- сайта отдельно.

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

В этом руководстве мы познакомились с различными метриками для измерения размеров объектов в среде выполнения JVM. После этого мы действительно измеряли размеры экземпляров с помощью различных инструментов, таких как JOL, агенты Java и утилита командной строки jcmd .

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