Размеры объектов в Java, или о чём нам врут heap dump’ы

в 14:47, , рубрики: java, метки:

(пост из серии «будни перформанс-инженеров»)

Привет! Наблюдая достаточно много постов про занимаемое объектами пространство в Java, решил написать пост, срывающий покровы. Для понимания происходящего неплохо было бы ориентироваться в азах устройства Java heap'а, минимальных размерах базовых типов, продвинутых штук, типа сжатых указателей.

Часть первая. Мифы.

Миф 0. Можно раз и навсегда узнать, сколько будет занимать объект в памяти.

Реальность: Зависит как минимум от: а) целевой JVM, будь то HotSpot, JRockit, J9 или ещё что-нибудь; б) битности, как минимум размеры указателей могут различаться, а то и базовые типы могут быть представлены другими размерами (при поддержке семантики языка), в) включённых и случившихся оптимизаций, типа инлайна объектов, скаляризаций, паддингов, г) и ещё тучи всяких штук, по сравнению с которыми фазы Луны куда более предсказуемы.

Миф 1. Для подсчёта размера объекта достаточно сложить размеры полей. Всем известно, сколько занимает поле конкретного примитивного типа, а уж тем более, сколько занимает ссылка.

Реальность: про размеры базовых типов см. Миф 0. Кроме всего прочего конкретные платформы могут требовать выравнивания полей из соображений корректности (некоторые процы вообще не работают с misaligned данными), либо из соображений производительности. Далее, во многих случаях объекты тоже должны быть выровнены, и поэтому за каждым инстансом будет следовать паддинг. Кроме того, есть ещё заголовок объекта…

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

Реальность: у этой информации достаточно маленький «shelf life», т.е. она довольно резво устаревает. Например, в JRockit'е заголовки могут быть 8 байт, а в HotSpot'е — до 16. Но это только пока мы не доберёмся переделать схему блокировок, и в HS не появятся такие же сжатые заголовки. И тогда половина интернетов будет бить себя пяткой в грудь про 16-байтовые заголовки, а вторая — про 8-байтовые.

Миф 3. Любой нормальный инструмент покажет реальный размер объекта.

Реальность: ха-ха, зависит от определения нормальности. «Нормальный» HotSpot'овский HPROF, например, будет считать размеры объекта так, как описано в мифе 1. По этому поводу все тулы, вроде jhat, MAT, и прочих post-mortem тулов обречены на пере/недооценку размера объектов.

Мораль: Нам нужны online-тулы, которые бы давали информацию о размере объектов прямо на месте.

Часть вторая. Реальности.

Возмём для примера обычный такой HashMap, на обычном таком Linux x86_64. Инстанциируем один HashMap, сделаем хипдамп и посмотрим на этот хипдамп разными инструментами. Вот такие размеры HashMap'а в байтах нам рапортуют эти инструменты:

JVM VisualVM Eclipse MAT
HotSpot (7u10) x86 45 48
HotSpot (7u10) x86_64 69 72
HotSpot (7u10) x86_64 (compressed refs) 69 56
JRockit (6u37, R28.2.5) x86 48 48
JRockit (6u37, R28.2.5) x86_64 76 80
JRockit (6u37, R28.2.5) x86_64 (compressed refs) 76 56

Как видно, тулы друг с другом во многом не соглашаются, и на то есть причина: в формате HPROF'а отсутствует информация о расположении полей, поэтому в конце концов приходится гадать о размерах заголовков, размерах полей и заниматься прочей магией.

Однако, у нас есть магия более высокого уровня, когда мы можем напрямую у VM спросить о структуре объектов, но для этого нам придётся пользоваться Unsafe (дьявольский смех), diagnostic MXBeans (дьявольский смех) и делать всё это в онлайне (дьявольский смех). Всё это счастье уже объединено в мой плюшевый проектик на GitHub: https://github.com/shipilev/java-object-layout

Он точно работает для HotSpot и JRockit; может приемлемо работать и для других VM (буду признателен, если кто-нибудь дофиксит это до J9, SAP и прочих, мне по некоторым причинам юридического толка этим заниматься нельзя).

Посмотрим на всё тот же HashMap при помощи нашего волшебного тула:

HotSpot x86:

Running 32-bit HotSpot VM.
Objects are 8 bytes aligned.

java.util.HashMap
 offset  size       type description
      0     8            (assumed to be the object header + first field alignment)
      8     4        Set AbstractMap.keySet
     12     4 Collection AbstractMap.values
     16     4        int HashMap.size
     20     4        int HashMap.threshold
     24     4      float HashMap.loadFactor
     28     4        int HashMap.modCount
     32     4        int HashMap.hashSeed
     36     1    boolean HashMap.useAltHashing
     37     3            (alignment/padding gap)
     40     4    Entry[] HashMap.table
     44     4        Set HashMap.entrySet
     48                  (object boundary, size estimate)
VM reports 48 bytes per instance

Что мы видим в этом дампе?

  • Заголовок занимает 8 байт, что соответствует известным нам описаниям HS — заголовок состоит из mark word'а с флажками состояния и klass word'а, показывающего на метаинформацию класса
  • Объекты выровнены на 8 байт (поспекулирую, что это ради выравнивания long'ов и double'ов)
  • Ссылки занимают по 32 бита, выровнены на 4 внутри объекта, что вкупе с выравниванием объектов даёт выравнивание на 4 в памяти
  • Одинокое boolean-поле индуцирует после себя паддинг, чтобы выровнять следующий указатель; если бы в классе оказалось ещё одно мелкое поле, то можно было бы ожидать, что оно займёт часть паддинга — обратите внимание, что это не увеличит размер объекта ;)

HotSpot x86_64:

Running 64-bit HotSpot VM.
Objects are 8 bytes aligned.

java.util.HashMap
 offset  size       type description
      0    16            (assumed to be the object header + first field alignment)
     16     8        Set AbstractMap.keySet
     24     8 Collection AbstractMap.values
     32     4        int HashMap.size
     36     4        int HashMap.threshold
     40     4      float HashMap.loadFactor
     44     4        int HashMap.modCount
     48     4        int HashMap.hashSeed
     52     1    boolean HashMap.useAltHashing
     53     3            (alignment/padding gap)
     56     8    Entry[] HashMap.table
     64     8        Set HashMap.entrySet
     72                  (object boundary, size estimate)
VM reports 72 bytes per instance

Поанализируем:

  • Заголовок стал занимать 16 байт, потому что mark word и klass word теперь полные 64-битные слова.
  • Объекты по-прежнему выровнены на 8 байт.
  • Ссылки занимают по 64 бита, начали выравниваться по 8 байт.

HotSpot x86_64 (compressed references):

Running 64-bit HotSpot VM.
Using compressed references with 3-bit shift.
Objects are 8 bytes aligned.

java.util.HashMap
 offset  size       type description
      0    12            (assumed to be the object header + first field alignment)
     12     4        Set AbstractMap.keySet
     16     4 Collection AbstractMap.values
     20     4        int HashMap.size
     24     4        int HashMap.threshold
     28     4      float HashMap.loadFactor
     32     4        int HashMap.modCount
     36     4        int HashMap.hashSeed
     40     1    boolean HashMap.useAltHashing
     41     3            (alignment/padding gap)
     44     4    Entry[] HashMap.table
     48     4        Set HashMap.entrySet
     52     4            (loss due to the next object alignment)
     56                  (object boundary, size estimate)
VM reports 56 bytes per instance

Поанализируем:

  • Заголовок стал занимать 12 байт, потому что klass word теперь представлен сжатым указателем.
  • Объекты по-прежнему выровнены на 8 байт.
  • Ссылки занимают по 32 бита, выравниваются на 4 байта, и поэтому мы чуть-чуть потеряли в хвосте.

JRockit x86:

Running 32-bit JRockit VM.
Objects are 8 bytes aligned.

java.util.HashMap
 offset  size       type description
      0     8            (assumed to be the object header + first field alignment)
      8     4        Set AbstractMap.keySet
     12     4 Collection AbstractMap.values
     16     4    Entry[] HashMap.table
     20     4   Object[] HashMap.cache
     24     4        Set HashMap.entrySet
     28     4        int HashMap.cache_bitmask
     32     4        int HashMap.size
     36     4        int HashMap.threshold
     40     4      float HashMap.loadFactor
     44     4        int HashMap.modCount
     48                  (object boundary, size estimate)
VM reports 48 bytes per instance

Поанализируем:

  • Заголовок, так же как и в 32-bit HS, 8 байт, т.е. 2 слова.
  • Объекты выровнены на 8 байт, но с количеством полей у нас подгадано так, что потерь на выравнивание нет.
  • Ссылки занимают по 32 бита, выравниваются на 4 байта.
  • И самое главное: это не тот же самый HashMap, сравните со структурой в HS! (сколько народу погорело на «знании», что JRockit — это «только» VM)

JRockit x86_64:

Running 64-bit JRockit VM.
Objects are 8 bytes aligned.

java.util.HashMap
 offset  size       type description
      0     8            (assumed to be the object header + first field alignment)
      8     8        Set AbstractMap.keySet
     16     8 Collection AbstractMap.values
     24     8    Entry[] HashMap.table
     32     8   Object[] HashMap.cache
     40     8        Set HashMap.entrySet
     48     4        int HashMap.cache_bitmask
     52     4        int HashMap.size
     56     4        int HashMap.threshold
     60     4      float HashMap.loadFactor
     64     4        int HashMap.modCount
     68     4            (loss due to the next object alignment)
     72                  (object boundary, size estimate)
VM reports 72 bytes per instance

Поанализируем:

  • Заголовок, опа-опа, гандам-стайл занимает всего 8 байт, т.е. одно машинное слово! (Клёвая фича, которую сейчас портируют в HS, и которая требует переработки внутренного механизма синхронизации)
  • Объекты выровнены на 8 байт, поэтому потеряли немножко в хвосте
  • Ссылки занимают по 64 бита, выравниваются на 8 байт.

JRockit x86-64, compressed refs:

Running 64-bit JRockit VM.
Using compressed references with 0-bit shift.
Objects are 8 bytes aligned.

java.util.HashMap
 offset  size       type description
      0     8            (assumed to be the object header + first field alignment)
      8     4        Set AbstractMap.keySet
     12     4 Collection AbstractMap.values
     16     4    Entry[] HashMap.table
     20     4   Object[] HashMap.cache
     24     4        Set HashMap.entrySet
     28     4        int HashMap.cache_bitmask
     32     4        int HashMap.size
     36     4        int HashMap.threshold
     40     4      float HashMap.loadFactor
     44     4        int HashMap.modCount
     48                  (object boundary, size estimate)
VM reports 48 bytes per instance

Анализ:

  • Заголовок по-прежнему 8 байт.
  • Ссылки сжались до 32 бит, что в т.ч. дало возможность лихо вписаться в 8-байтовое выравнивание.
  • Обратите внимание, что объект занимает ровно столько же, сколько и на 32-битной платформе, всё из-за заголовка такого же размера.

Таким образом, можно посмотреть на реальные размеры объектов в полной табличке:

JVM VisualVM Eclipse MAT java-object-layout
HotSpot (7u10) x86 45 (врёт) 48 48
HotSpot (7u10) x86_64 69 (врёт) 72 72
HotSpot (7u10) x86_64 (compressed refs) 69 (врёт) 56 56
JRockit (6u37, R28.2.5) x86 48 48 48
JRockit (6u37, R28.2.5) x86_64 76 (врёт) 80 (врёт) 72
JRockit (6u37, R28.2.5) x86_64 (compressed refs) 76 (врёт) 56 (врёт) 48

Как видно, только в тривиальных случаях у нас всё хорошо, но чуть в сторону — враньё.

Часть третья. Извращения.

У волшебного HS есть волшебная опция -XX:ObjectAlignmentInBytes, которая управляет выравниванием объектов (кроме всего прочего, она позволяет использовать сжатые указатели на огромных хипах). Если задать нашему тесту 128-байтовое выравнивание, то мы получим:

Running 64-bit HotSpot VM.
Using compressed references with 7-bit shift.
Objects are 128 bytes aligned.

java.util.HashMap
 offset  size       type description
      0    12            (assumed to be the object header + first field alignment)
     12     4        Set AbstractMap.keySet
     16     4 Collection AbstractMap.values
     20     4        int HashMap.size
     24     4        int HashMap.threshold
     28     4      float HashMap.loadFactor
     32     4        int HashMap.modCount
     36     4        int HashMap.hashSeed
     40     1    boolean HashMap.useAltHashing
     41     3            (alignment/padding gap)
     44     4    Entry[] HashMap.table
     48     4        Set HashMap.entrySet
     52    76            (loss due to the next object alignment)
    128                  (object boundary, size estimate)
VM reports 128 bytes per instance

… в то время как VisualVM радостно отрапортует всё те же 69 байт. Eclipse MAT отрапортует все 128 байт, видимо, пронюхав, что все объекты в хипе выровнены на 128. Но его мы тоже сломаем, когда у нас появится @Contended, и тогда такой безобидный классик:

    public static class Test2 {
        @Contended               private int int1;
                                 private int int2;
    }

… отрапортуется java-object-layout как:

Running 64-bit HotSpot VM.
Using compressed references with 3-bit shift.
Objects are 8 bytes aligned.

Test8003985.Test2
 offset  size type description
      0    12     (assumed to be the object header + first field alignment)
     12     4 int Test2.int2
     16   128     (alignment/padding gap)
    144     4 int Test2.int1
    148   128     (alignment/padding gap)
    276     4     (loss due to the next object alignment)
    280           (object boundary, size estimate)
VM reports 280 bytes per instance

… в то время как, VisualVM и Eclipse MAT отрапортуют примерно по 24 байта, что, конечно, ЛПИП!

Часть четвёртая. Эпилог.

  • Многие offline-тулы врут ввиду проблем с HPROF'ом, и если для HotSpot'а я ещё могу вести бесполезные переговоры, то что делать с остальными VM-ами, непонятно.
  • Немногие online-тулы умеют общаться с VM на равных и извлекать из них нужные знания.
  • Частенько, обладая знанием о внутренней структуре можно добавлять поля, не меняя размер объектов (ninja style!).

Размеры объектов в Java, или о чём нам врут heap dumpы

Автор: TheShade

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js