Идея
В Java каждый объект - это заголовок и данные. В случае 64 битной машины заголовок равен 16 байтам. Также Java использует выравнивание объектов в памяти по 8 байт, следовательно размер объекта кратен 8 байтам. Поэтому обёртки примитивных типов такие как Integer, Double занимают по 24 байта, что весьма затратно для примитивных типов.
Проблема
Объекты в Java располагаются в куче, ссылки на них лежат в List<?>, в результате для int мы получаем 28 байт (если используется сокращение ссылок) вместо 4 и возможный разброс адресов объектов по памяти.
Для оптимизации JVM заранее инициализирует Boolean, Byte, некоторую часть значений Integer, чтобы свести затраты по памяти до 4 байт на переменную.
Решение
Посмотрим на проблему иначе: большая часть, а зачастую и весь заголовок объекта в случае таких классов не используется, поэтому логично от него избавиться, если нужно сэкономить память.
Создадим массив байт, в который будем сохранять значение примитивных типов (boolean, byte, short, char, int, float, long, double) объекта.
При взятии объекта по индексу будем создавать новый объект и записывать в него значения полей.
Реализация
Полный код доступен на github, в статье приведены сокращённые функции для пояснения принципов работы.
Работать будем через Unsafe, возможно работать через Reflect, но с ним скорость ниже на 20-30%.
Получим экземпляр Unsafe:
private static final Unsafe unsafe;
static {
try {
Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
theUnsafe.setAccessible(true);
unsafe = (Unsafe) theUnsafe.get(null);
} catch (Exception e) {
throw new AssertionError(e);
}
}
Получим информацию о нестатичных полях объекта:
private List<Field> getCorrectFields(Class<?> clazz) {
List<Field> correctFields = new ArrayList<>();
for(Field f : clazz.getDeclaredFields()) {
// игнорируем статичные поля
if((f.getModifiers() & 8) == 0)
correctFields.add(f);
}
return correctFields;
}
Сохраним структуру объекта. В массив fieldOffsets
сохраним offset поля, нужный для работы Unsafe, а в массив fieldTypes
его тип, для оптимизации сборки/разборки объекта.
private final byte[] fieldTypes;
private final long[] fieldOffsets;
private int objectByteSize;
private void initFields(List<Field> correctFields) {
for(int i = 0; i < fieldTypes.length; i++) {
Field f = correctFields.get(i);
fieldOffsets[i] = unsafe.objectFieldOffset(f);
f.setAccessible(true);
if(f.getType() == boolean.class) {
fieldTypes[i] = TYPE_BOOLEAN;
objectByteSize += 1;
}
else if(f.getType() == int.class) {
fieldTypes[i] = TYPE_INT;
objectByteSize += 4;
}
}
}
Здесь всё тривиально, в массив fields записываем сами поля, в fieldTypes их тип (байт от 0 до 7), параллельно считаем размер объекта в байтах.
Для создания пустого объекта в памяти потребуется функция:
private Object buildNewObject() throws Exception {
return unsafe.allocateInstance(clazz);
}
На этом этапе мы можем узнать размер объекта в байтах и знаем число объектов, которые собираемся хранить. Запросим у системы память в конструкторе:
// полный код конструктора
public MemoryEfficientList(Class<?> clazz, int size) {
this.clazz = clazz;
List<Field> correctFields = getCorrectFields(clazz);
fieldTypes = new byte[correctFields.size()];
fieldOffsets = new long[correctFields.size()];
initFields(correctFields);
dataSize = size;
// указатель на память
dataAddress = unsafe.allocateMemory(objectByteSize * size);
try {
// объекты для оптимизаций
firstFast = buildNewObject();
secondFast = buildNewObject();
fastInternal = buildNewObject();
oldValue = buildNewObject();
} catch(Exception ignored) {
}
}
unsafe.allocateMemory
возвращает указатель, поэтому с ним возможны классические операции низкоуровневой работы с памятью, например unsafe.getInt(dataArray + byteOffset)
вернёт 4 байта по адресу dataArray + byteOffset.
Для сохранения объектов нужно преобразовывать его в байты и сохранять по указанному индексу:
private void decomposeObject(int pos, Object object) {
int offset = pos * objectByteSize;
for(int i = 0; i < fieldTypes.length; i++) {
long fo = fieldOffsets[i];
switch(fieldTypes[i]) {
case TYPE_BOOLEAN: {
unsafe.putByte(dataAddress + offset,
(byte) (unsafe.getBoolean(object, fo) ? 1 : 0));
offset += 1;
break;
}
case TYPE_INT: {
unsafe.putInt(dataAddress + offset, unsafe.getInt(object, fo));
offset += 4;
break;
}
}
}
}
Обратная операция, сборка объекта из байт:
private void composeObject(int pos, Object object) {
long offset = pos * objectByteSize;
for(int i = 0; i < fieldTypes.length; i++) {
long fo = fieldOffsets[i];
switch(fieldTypes[i]) {
case TYPE_BOOLEAN: {
unsafe.putBoolean(object, fo,
unsafe.getByte(dataAddress + offset) == 1);
offset += 1;
break;
}
case TYPE_INT: {
unsafe.putInt(object, fo, unsafe.getInt(dataAddress + offset));
offset += 4;
break;
}
}
}
}
Операция добавления объекта в массив:
public boolean add(Object o) {
// добавляем объект в конец массива
decomposeObject(size, o);
size += 1;
return true;
}
Извлечение объекта из массива:
public Object get(int index) {
// в функциях getFast и getFast2 вместо создания
// нового объекта используется заранее созданные
// объекты: firstFast и secondFast
Object result = buildNewObject();
composeObject(index, result);
return result;
}
Удаление объекта по адресу:
public Object remove(int index) {
composeObject(index, oldValue);
final int newSize = size - 1;
if(newSize > index)
unsafe.copyMemory((index + 1) * objectByteSize,
index * objectByteSize,
(newSize - index) * objectByteSize);
size = newSize;
return oldValue;
}
Тесты
Перейдём к тестам производительности.
Конфигурация оборудования:
CPU |
Intel Core i7 7600 |
RAM |
12GB, 2400 MHz |
JVM |
OpenJDK 13.0.2, windows |
Без скобок указано минимальное время теста в секундах, в скобках среднее.
MEL = MemoryEfficientList
Тест 0, добавление N int в массив. Массив создаётся сразу под все элементы. int[]
добавлен для сравнения.
N |
ArrayList |
MEL |
int[] |
10 000 000 |
0.18 (0.27) |
0.15 (0.22) |
0.09 (0.10) |
1 000 000 |
0.019 ( 0.037) |
0.015 (0.023) |
0.01 (0.01) |
В дальнейших тестах массивы не пересоздаются, это значит не тратится время на увеличение размера массива (его размер неизменен после первого прохода, результат которого игнорируется).
Тест 1, заполняем массив из N значений int, затем N раз меняем местами 2 случайных числа, для проверки считаем чек сумму.
Код для ArrayList
for(int i = 0; i < n; i++)
al.add(r.nextInt());
for(int i = 0; i < n; i++) {
int pos1 = r.nextInt(n);
int pos2 = r.nextInt(n);
Integer a = al.get(pos1);
Integer b = al.get(pos2);
al.set(pos1, b);
al.set(pos2, a);
}
N |
ArrayList<Integer> |
MEL |
MEL + getFast |
10 000 000 |
2.51 (2.99) |
1.61 (1.86) |
1.56 (1.73) |
1 000 000 |
0.17 (0.19) |
0.07 (0.09) |
0.06 (0.08) |
Экономия по памяти на объект: 24 байта. 4 / 28 = 14% от потребления памяти ArrayList<Integer>.
Тест 2, решето Эратосфена, поиск простых чисел от 1 до N
Код для ArrayList
for(int i = 0; i < n; i++)
alIs.add(true);
alIs.set(0, false);
alIs.set(1, false);
for(int i = 0; i < n; i++) {
if(alIs.get(i) && i * i > 0) {
for(int j = i * i; j < n; j += i)
alIs.set(j, false);
alResult.add(i);
}
}
N |
ArrayList |
MEL |
MEL + getFast |
10 000 000 |
1.00 (1.05) |
0.40 (0.41) |
0.37 (0.39) |
1 000 000 |
0.035 (0.047) |
0.030 (0.040) |
0.023 (0.036) |
Экономия по памяти: 4 байта (ссылка в ArrayList) против 1 байта в MEL = 25% от исходного размера без учёта разницы в объёме занимаемом массивом результирующих чисел.
Тест 3, аналогичен первому тесту, но вместо Integer будет Vec3{float x, y, z}.
N |
ArrayList |
MEL |
MEL + getFast |
10 000 000 |
2.24 (2.40) |
1.87 (1.91) |
1.67 (1.71) |
1 000 000 |
0.16 (0.18) |
0.15 (0.16) |
0.13 (0.14) |
На этом тесте разница становится менее заметной.
Экономия по памяти: 12 байт против 32 + 4 = 33% от объёма ArrayList.
Тест 4, аналогичен первому, но вместо Integer используется следующий объект:
public class LargeObject {
boolean x, y, z, w;
int i, j, k;
float a, b, c, d, e;
double g, h;
long m, n, o, p;
} // 84 байта
N |
ArrayList |
MEL |
MEL + getFast |
10 000 000 |
2.53 (2.64) |
5.81 (5.94) |
5.09 (5.59) |
1 000 000 |
0.15 (0.16) |
0.53 (0.56) |
0.47 (0.48) |
Здесь MEL проигрывает ArrayList, так как нужно свапать 84 байта, вместо 4.
Экономия по памяти: 84 + 16 + 4 (выравнивание) + 4 / 84 = 77% от размера ArrayList.
Так при регулярном извлечении и вставке объектов размер объекта, на котором будет прирост производительности ограничен ~20 байтами.
Также MEL показывает очень низкую производительность операции toArray, так как по сути создаёт N новых объектов на куче. Эта операция используется в Collections.sort, поэтому для MEL нужно реализовывать свой метод сортировки.
Выводы
Предположение об оптимизации скорости работы с памятью за счёт упорядочивания данных и уменьшения объёма данных оказалось верным. Прирост составляет 20-60% в зависимости от типа объекта и задачи. Если объекты большие (больше 20 байт данных) то разумнее использовать ArrayList.
В текущей реализации есть основные методы работы с коллекцией: добавление, удаление, изменение элемента. На остальные методы поставлены заглушки.
Дальнейшая оптимизация возможна за счёт оптимизации функций compose/decomposeObject. Сейчас используется цикл, вместо него можно генерировать байт код, который развернёт цикл в линейный набор команд, уберёт switch-case и операции вычисления смещений.
Комментарии (37)
poxvuibr
01.01.2022 11:49+2С одной стороны, приятно почитать код, но с другой стороны нет jmh в тестах производительности, что, конечно, всё портит ((.
Было бы ещё прикольно посмотреть, как эти тесты проходит код с Reflection и погонять на разных версиях джавы, вплоть то 17.
Tellamonid
01.01.2022 12:09+4Пожалуй, стоит добавить, что есть библиотеки, в которых поддерживаются коллекции примитивных типов, например:
Trove (больше не поддерживается);
Eclipse collections;
FastUtil;
HPPC;
Koloboke;
Neo4j Primitive Collections;
Наверняка сейчас есть и какие-то ещё.
Правда, я помню случай, про который рассказывал Сергей Куксенко, когда сравнивал ConcurrentHashMap с Trove-мапой, и нашел случай, когда ConcurrentHashMap оказался быстрее, несмотря на то, что там лежали обьектные типы. Это было потому, что для определения того в какую корзину (bucket) положить элемент, в Trove использовался оператор % (остаток от деления), а в ConcurrentHashMap – наложение битовой маски, которое работает, когда длина массива – степень двойки. И в многопоточной среде это было важно, потому операция остаток от деления может исполняться одна на ядре, а наложений битовой маски может быть несколько в параллель. В интернете можно найти таблицы для разных процессоров, у каких операций какого параллелизма можно достичь.
isicju
01.01.2022 16:33+1спасибо за публикацию, но мне кажется что проект валхалла и самописная коллекция с самописными типами это совсем разные вещи. в первом случае мы не будет иметь лишнуюю ссылку на дальную область памяти (потенциально) а хранить данные возле возле указателя. В статье же речь идет немного о другом и если сравнивать с "близостью" данных то в валхалле эта близость почти нулевая а тут близость будет реализована через использование нескольких дополнительных двух трех объектов и пары условий. Но еще раз спасибо за статью!
TheKnight
Вы уверены, что завязываться на Unsafe во времена JDK11 в проде и JDK17 захватывающей прод – хорошая идея?
TimurBaldin
Думаю, что использовать java для задачи где у вас есть строгие рамки по потреблению памяти , плохая идея ...
Если есть такая задача, лучше использовать golang или C++
Hivemaster
Чем же лучше использовать Go? Управление памятью - не его сильная сторона, он в этом даже Java проигрывает.
TimurBaldin
Основная его сильная сторона это многопоточная модель . В целом он обеспечивает более низкое потребление памяти , но цена за это более примитивный инструмент если сравнивать с java
sandersru
Если мне не изменяет склероз, то основная цена облака сейчас в дисках(SSD). Ядра и память на этом фоне просто теряются. Речь конечно не про public cloud типа амазон или гугл.
TimurBaldin
Ну получается, что важно, сколько приложение занимает места на этом "золотом SSD" ...И ещё немаловажный критерий это время запуска ..Golang это не панацея, но для ряда случаев он подходит лучше java...Именно за счёт многопоточной модели и легковесности
sandersru
Не занимает. А пишет. Базы, логи и т.д. там терабайты(и деньги), а не в размере приложений.
Время запуска = graalVM в native mode. Или quarkus.io если хотите. Те же миллисекунды, что у приложений на сях. Виртуальная машина тут это 10 мб. Готовый микросервис с много чем под капотом 60 мб. Что вполне сопоставимо с гоуланг.
Под многопоточностью я так понимаю вы имели ввиду горутины(или как их там в гоу). Ну так в том же кваркус давно реактивное программирование и vertx под капотом. То на то.
Если не путаю, то скоро все это из коробки в jdk будет. В рамках loom по моему пилили
TimurBaldin
Если мы говорим про объемы корпораций где тысячи сервисов работают в проде...то экономия набегает существенная...Даже от такого казалось бы малозначимого факта как размер конкретного приложения)
В native mode в Java пока что достаточно много проблем...Особенно с зависимостями, если интересно на хабре было много статей где это описывалось)
Да, именно горутины)Project loom классный проект, но пока он не вошёл в состав jdk и непонятно когда войдёт и сколько займёт времени пока обновятся основные библиотеки)
Поймите правильно, я не говорю , что go лучше java или наоборот, я говорю о том что есть ряд задач где go может быть полезен и его использование оправдано)
sandersru
Не спорю. Каждому молотку свои гвозди :)
Java рулит экосистемой. Golang - для простых высоконагруженных сервисов. Где эта экосистема не нужна.
>>В native mode в Java пока что достаточно много проблем...Особенно с зависимостями, если интересно на хабре было много статей где это описывалось)
А можно тут подробнее для общего развития? Да, понимаю, что экосистема ещё не доросла до натива, но для опять же простых и высоконагруженных сервисов - по большому счету много чего есть
TimurBaldin
Проблем там несколько , из основных это то что нельзя ничего подгружать динамически...Это частично решает Spring Native (он генерирует специальный файл с статическим указанием необходимых зависимостей), но с ним тоже не все гладко...иногда нужно его править вручную ... Ну и рефлексия (если зависимость ее использует, то придется ее выкинуть)...
да, java+golang это очень хорошая связка)
sandersru
Проблемы спринг - это проблемы спринг. :) Они в свое время завязались на reflection. Теперь отгребают. Я так понимаю, ещё до стадии beta не добрались.
В graalvm просто надо регистрировать классы и методы доступные для reflection.
Кстати вот фишка в graal, которая на прямую позволяет дергать сишные библиотеки(тот же posix) мне отлично зашла. Осталось дождаться приведения API graal и project panama к единому виду, чтобы двойной код не рождать
Посмотрите на quarkus (а он уже около 15% экосистемы занимает по данным из дребрей redhat) там эта проблема изначально решена и много чего в него встроенно.
Некоторые вещи конечно заставляют сильно материться, но скорее либо по незнанию, либо с отсутствием глубокой документации. Ну и community соответственно.
TimurBaldin
Про Graal и quarkus посмотрю)Спасибо!)
Нет, до бета версии они дошли, но с этим инструментом всё ещё не очень удобно работать)
Вам могу посоветовать почитать про golang ...Мне как Java разработчику он зашёл) Работать с ним приятнее чем с python с его динамической типизацией)
sandersru
>> Нет, до бета версии они дошли, но с этим инструментом всё ещё не очень удобно работать)
Как я написал выше - слишком глубокий reflection.
Кваркус на стадии билда все это собирает плагином. + Изначально интеграция с Граалем под капотом.
За golang спасибо, но не надо. В моей практике он присутствует либо когда надо писать под 32 bit mips/arm в качестве домашних поделок. Либо узкие микросервисы на работе.
Да и не программист я давно :)))
TimurBaldin
Я Нео )))
jreznot
RedHat только бы свой фреймворк выгородить, они и 146% нарисуют только дай волю
sandersru
У вас есть какие то данные? Или "я так чувствую", "мне так карты предсказали"?
Цитирую редхат:
Привет. Именно наших данных сходу не нашёл, но в интернетах можно найти всякие Java ecosystem reports, например https://www.jrebel.com/blog/2021-java-technology-report (тут у Quarkus "всего лишь" 6%), https://snyk.io/jvm-ecosystem-report-2021/ (тут 11%, хотя опрос был примерно в то же время). По данным Eclipse Quarkus так вообще использовался 16% разработчиков ещё в середине прошлого года (https://newsroom.eclipse.org/news/announcements/amid-growth-cloud-native-enterprise-java-adoption-eclipse-foundation-announces), но это срез специфичный для Eclipse экосистемы.
То есть Рэдхад ссылается на сторонние данные. Вы же занимаетесь, простите, балаболством.
TimurBaldin
если посмотреть на hh , то там всего 37 вакансий где вообще встречается слово "quarkus" ...Мягко говоря это крайне мало ...
sandersru
Как сказал 3й человек нашей компании(а это больше 10000 человек на борту) если вы знаете майнфреймы, то мы вас сегодня же возьмём на работу :)
Речь не о том, что сейчас, а о динамике.
Тот же front end девелопер как ких то 10-15 лет назад назывался "верстальщик" который рисовал хтмл шаблоны. За совсем другие деньги.
AI/ML и big data ещё 25 лет назад мы учили в институте, только нахрен они были тогда не нужны. Ни за какие деньги.
Тут так же. Просто тэнды.
Мы уже начали уход с спрингбут на кваркус и гоуланг(размер компании выше). Рано или поздно в натив все в java подтянутся
jreznot
Да, пожалуйста
В опросе JetBrains - 3%
https://www.jetbrains.com/lp/devecosystem-2021/java/#Java_what-web-frameworks-do-you-use-if-any
sandersru
А теперь расскажите, как это коррелирует с 146% и что рисует рэдхат в предыдущем посте.
jreznot
15% рынка JVM это очень большая цифра, громадная просто.
jreznot
Важно понимать, что вендор - лицо заинтересованное и его данные надо проверять.
sandersru
Вендор - всегда лицо заинтересованное. Оупенсорс (привет k8s) тоже вендор из Гугла и редхата.
Ну то есть данные от 3 до 15% по "сторонним" рейтингам. Это повод посмотреть, а не искать всемирный заговор
Hivemaster
А у Java не многопоточная модель?
И нет, существенной разницы в потреблении памяти тоже нет, если измерять именно аллокации, а не весь footprint виртуальной машины, да ещё на синтетических тестах. Зато есть разница в издержках на управление ею - не в пользу Go. Рантайм Go использует сборщик мусора типа mark-sweep, такой был в Java с 2002-го года, но уже в 2017-м был объявлен устаревшим. К тому же, в Go сборщик не уплатняющий, что приводит к фрагментации кучи и росту издержек со временем работы программы.
TimurBaldin
В java другая многопоточная модель, которая не подходит для ряда задач
Я видел разницу в Х раз, если у вас есть другие данные , ссылки на статьи в студию)
TheKnight
Иногда хочется чуть-чуть улучшить уже существующее приложение, а не переписывать с нуля его на другом языке.
Тем более, что C++ это сложный язык, а инфраструктура разработки для него в диком мире не так хорошо развита как у Java.
TimurBaldin
Если оптимизация начинается под девизом "хотим чуть-чуть улучшить" это плохая оптимизация...По опыту в 99% случаев оптимизация это :
1)Изменение алгоритмов обработки данных
2)Изменение архитектуры сервиса/сервисов и их взаимодействия
3)Выпиливание лишних/избыточных бибилотеках
В целом, инструмент под задачу подбираем )
TheKnight
В целом, алгоритмически все может быть уже хорошо, архитектурно - тоже, но вот уменьшить потребление памяти, не переписывая код целиком было бы неплохо.
А иногда нет возможности переработать архитектуру системы, разнеся на разные машины разные части. Возможно, из-за latency, возможно - из-за отсутствия железа. А может потому что система должна запускаться на машине конечного пользователя и никак иначе. Вот и приходится ухищряться.
Насколько мне известно, для плюсов и Go тоже бывают необходимы особые ухищрения для повышения производительности или уменьшения расхода памяти.
TimurBaldin
Это обсуждение "сферического коня в вакууме". В целом любой язык высокого уровня имеет в своем арсенале инструменты и простор для различных оптимизаций и если вы применяете их, то все отлично ....но, если начинаете "забивать микроскопом гвозди" ....применяя "черную магию", это может сработать в краткосрочную перпективу ,но в долгосрочную в любом случае придется брать "молоток для гвоздей" )
TheKnight
Мне кажется, вы уже забыли, с чего началась эта ветка обсуждения.
Напомню, что как раз таки с вопроса автору о черной магии Unsafe вместо новых публичных API.
TimurBaldin
Чёрная магия для продакшена это плохо, однозначно)