В статье речь пойдет о реализации сжатия указателей в Java Virtual Machine 64-bit, которое контролируется опцией UseCompressedOops и включено по-умолчанию для 64 битных систем начиная с Java SE 6u23.
Описание проблемы
В 64 битной JVM указатели занимают в 2 раза больше (сюрприз-сюрприз) места в памяти чем в 32 битной. Это может увеличивать размер данных в 1,5 раза по сравнению с аналогичным кодом для 32 битной архитектуры. При этом в 32 битной архитектуре можно адресовать только 2^32 байт (4 ГБ), что довольно мало в современном мире.
Давайте напишем небольшую программу и посмотрим на то, сколько байт занимают объекты Integer:
import java.util.stream.IntStream;
import java.util.stream.Stream;
class HeapTest {
public static void main(String ... args) throws Exception {
Integer[] x = IntStream.range(0, 1_000_000).boxed().toArray(Integer[]::new);
Thread.sleep(6000000);
Stream.of(x).forEach(System.out::println);
}
}
Здесь мы выделяем миллион объектов класса Integer и надолго засыпаем. Последняя строка нужна для того, чтобы компилятор вдруг не проигнорировал создание массива (хотя на моей машине без этой строки объекты создаются нормально).
Компилируем и запускаем программу с отключенным сжатием указателей:
> javac HeapTest.java
> java -XX:-UseCompressedOops HeapTest
С помощью утилиты jcmd смотрим распределение памяти:
> jps
45236 HeapTest
...
> jcmd 45236 GC.class_histogram
На картинке видно, что общее количество объектов равно 1000128, а размер памяти, который занимают эти объекты 24003072 байт. Т.е. 24 байта на объект (почему именно 24 будет написано ниже).
А вот память той же программы, но с включенным флагом UseCompressedOops:
Теперь каждый объект занимает 16 байт.
Плюсы сжатия очевидны =)
Решение
Как же JVM сжимает указатели? Эта техника называется Compressed Oops. Oop расшифровывается как ordinary object pointer или обычный указатель на объект.
Трюк состоит в том, что в 64 битной системе данные в памяти выровнены по машинному слову, т.е. по 8 байт. И адрес всегда имеет три нулевых бита в конце.
Если при сохранении указателя сдвигать адрес на 3 бита вправо (операция называется encode), а перед использованием сдвигать на 3 бита влево (соответственно decode), то можно уместить в 32-х битах указатели размером в 35 бит, т.е. адресовать до 32 ГБ (2^35 байт).
Если размер кучи для вашей программы больше 32GB, то сжатие перестает работать и все указатели становятся размеров в 8 байт.
Когда опция UseCompressedOops включена, то сжимаются следующие типы указателей:
- Поле класса для каждого объекта
- Объекты поля класса
- Элементы массива объектов.
Объекты самой JVM никогда не сжимаются. При этом сжатие происходит на уровне виртуальной машины, а не байт-кода.
Подробнее про размещение объектов в памяти
А теперь давайте с помощью утилиты jol (Java Object Layout) посмотрим внимательнее на то, сколько памяти занимает наш Integer в разных JVM :
> java -jar jol-cli-0.9-full.jar estimates java.lang.Integer
***** 32-bit VM: **********************************************************
java.lang.Integer object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 8 (object header) N/A
8 4 int Integer.value N/A
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
***** 64-bit VM: **********************************************************
java.lang.Integer object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 16 (object header) N/A
16 4 int Integer.value N/A
20 4 (loss due to the next object alignment)
Instance size: 24 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
***** 64-bit VM, compressed references enabled: ***************************
java.lang.Integer object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 12 (object header) N/A
12 4 int Integer.value N/A
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
***** 64-bit VM, compressed references enabled, 16-byte align: ************
java.lang.Integer object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 12 (object header) N/A
12 4 int Integer.value N/A
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
Разница между "64-bit VM" и "64-bit VM, compressed references enabled" состоит в уменьшении object header (заголовок) на 4 байта. Плюс, в случае без сжатия, появляется необходимость добавить еще 4 байта для выравнивания данных в памяти.
Что такое этот object header? Почему он уменьшился на 4 байта?
На картинке изображен object header, равный 12 байтам, т.е. с включенной опцией UseCompressedOops. Заголовок состоит из некоторых внутренних флагов JVM, а так же указателя на класс данного объекта. Видно, что указатель на класс занимает 32 бита. Без сжатия он занимал бы 64 бита и размер object header был бы уже 16 байт.
Кстати, можно заметить, что есть еще вариант для 16-байтового выравнивания. В таком случае можно увеличить память до 64 ГБ.
Минусы сжатия указателей
У сжатия указателей, конечно же есть очевидный минус — расходы на операции encode и decode при каждом обращении к указателю. Точные цифры будут зависеть от конкретного приложения.
К примеру вот график пауз сборщика мусора для сжатых и не сжатых указателей, взятый отсюда Java GC in Numbers — Compressed OOPs
Видно, что при включенном сжатии, GC паузы длятся дольше. Более подробно об этом можно почитать в самой статье (статья довольно старая — 2013 года).
Ссылки:
Compressed oops in the Hotspot JVM
How does JVM allocate objects
CompressedOops: Introduction to compressed references in Java
Trick behind JVM's compressed Oops
Java HotSpot Virtual Machine Performance Enhancements
Комментарии (20)
dim2r
14.02.2019 11:19Проясните пожалуйста вопрос — как происходит адресация. В С++ например ссылка на данные прямая — тупо хранится адрес и по нему программа ходит за данными. А в джаве, данные объекта перемещаются в памяти, и значит нужно каждый раз вычислять этот адрес при каждом обращении к объекту.
galvanom Автор
14.02.2019 11:49Не уверен, что понял вопрос. Что значит данные объекта перемещаются в памяти? У нас есть объект в памяти, на него есть ссылка, это по факту адрес объекта.
Если вам интересно как происходит процесс сжатия/расжатия, вот, для примера, код JVM (из файла compressedOops.inline.hpp) который делает декодирование адреса.
inline oop decode_not_null(narrowOop v) { assert(!is_null(v), "narrow oop value can never be zero"); address base = Universe::narrow_oop_base(); int shift = Universe::narrow_oop_shift(); oop result = (oop)(void*)((uintptr_t)base + ((uintptr_t)v << shift)); assert(check_obj_alignment(result), "address not aligned: " INTPTR_FORMAT, p2i((void*) result)); return result; }
dim2r
14.02.2019 12:14Сборщик мусора может перемещать объекты. Там есть несколько областей памяти, которые соответствуют короткоживущим и долгоживущим объектам. Если данные переместились, то адрес сменился. То есть логически поразмыслив понятно, что — адрес или надо вычислять каждый раз или менять все ссылки на этот блок после каждой сборки мусора. Вот хотел бы понять, как это происходит
galvanom Автор
14.02.2019 12:30Да, я кажется понял, вы спрашиваете «Что происходит со ссылками когда GC перемещает объект?». Кажется вот здесь описан механизм перемещения объектов в JVM GC:
Specifically, the GC walks the graph of reachable objects within the «from» space, starting from each of the GC roots. Each time it finds a reference to a node (in an instance field, static field, stack frame, etc), it checks the object that the reference points to to see if it has been marked as visited.
* If it is not yet marked, the GC does the following:
It marks the object in the from-space.
It copies the object into the to-space.
It stores the address of the object in to space in the from-space object. (This is like a forwarding address.)
It recursively visits each reference field of the to-space copy of the object.
The result of this the reference to the to-space object.
* If the object has been marked already, the GC looks up the forwarding address, and returns that.
dim2r
14.02.2019 13:16Вот и вопрос — как адресуются данные при таком раскладе. Что происходит, когда надо достать object.field? В С++ просто лезет по адресу в память и получает данные. А как в джаве, если адрес плавает?
tbl
14.02.2019 14:16+1В жвм есть стопзеворлд, когда потоки зависают в определенных строго детерминированных местах на локе и не могут работать с данными, в это время гц перерисовывает ландшафт (в том числе и в стеках потоков), затем наступает новый день, мафия засыпает, рабочие потоки просыпаются и начинают работать с новыми адресами старых объектов, делая вид, что ничего страшного не случилось.
KanuTaH
14.02.2019 18:45+1Там по-моему немножко не так делается, из-под потоков, которые нужно остановить, "выдергиваются" страницы памяти (размапливаются) и gc делает в них все что ему требуется, двигает объекты, правит адреса, и так далее, а потоки останавливаются сами по page fault exception, потом jvm мапит все обратно и потоки продолжают работать как ни в чем не бывало.
dim2r
14.02.2019 19:23A это вроде как только в режиме ядра можно делать — ring0. И страницы по 4кб занимают. Что на каждый объект по 4кб резервируется?
tbl
14.02.2019 20:16+1Вот тут подробное описание. Страница для page fault создается одна на поток, а не на объект. Хандлеры на page fault можно регистрировать и в user space, все современные ядра ос позволяют это.
dim2r
14.02.2019 20:25тема интересная, но надо разбираться.
у меня тоже бродили мысли, что устройство виртуальной памяти очень подходит для сбора мусора, если мусорщику дать возможность манипулировать такими вещами, как dirty bit, page fault, tlb и тп
KanuTaH
14.02.2019 20:18Вот описание одного из вариантов GC, который использует подобную технику:
www.usenix.net/legacy/events/vee05/full_papers/p46-click.pdf
Насколько я знаю есть ряд вариантов таких «pauseless» gc, всякие continuously concurrent gc и так далее.
dim2r
14.02.2019 19:36Вот как раз тут у меня куча вопросов — неужели вся куча перемалывается? Я могу создать 100500 объектов, которые друг на друга ссылаются.
Все адреса хранятся в куче. Если просто пройтись по памяти и заменить байты на новые, то можно зацепить простые данные. Отсюда следует
, что надо как-то разделять простые данные и адреса объектов. Вот интересно, как это сделано и как происходит адресация данных.
PS Все больше ощущаю, что джава программисты не понимают вопросов, которые исходят от программистов С/С++. В понимании С виртуальная память представляет собой простой большой линейный массив и в нем нет регионов, которые декларируются в джаве. А любое разделение сопровождается доп расходами и необходимостью хранить адреса разделов и как-то постоянно складывать базу и смещение чтобы вычислить реальный адресtbl
15.02.2019 00:23вот как gc детектит, ссылка это или примитив:
stackoverflow.com/a/12097214/1961500
И вот, как устроен OopMap:
stackoverflow.com/a/26049445/1961500
Т.е. по сути для интерпретируемого кода, проверяет выравнивание объекта и установленный бит признака объекта, а для нативного кода, сгенеренного jit — при компиляции по инструкциям сохраняет смещения ссылок в стеке рядом с скомпиленным методом.
Vasilyev81
14.02.2019 22:01В джаве нет работы напрямую с адресами в памяти. Вопросы с адресом field, измененным в результате работы GarbageCollector, JVM инкапсулирует.
dim2r
14.02.2019 22:58В самом языке нет, но в конечном счете все работает через ассемблер. Вот и интересно какова стоимость хозяйства по сравнению с прямым доступом к памяти.
Regis
Мне кажется, что показывая график из статьи 2013 года стоило бы особо подчеркнуть, что данные могут быть неактуальны. Особенно для графика пауз GC.
galvanom Автор
На графике в общем-то указана версия Java на которой производились тесты, но я все таки добавил еще одно упоминание о том, что статья старая