Идея

В 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)


  1. TheKnight
    01.01.2022 00:16
    +12

    Вы уверены, что завязываться на Unsafe во времена JDK11 в проде и JDK17 захватывающей прод – хорошая идея?


    1. TimurBaldin
      01.01.2022 14:50
      -3

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

      Если есть такая задача, лучше использовать golang или C++


      1. Hivemaster
        01.01.2022 15:51
        +2

        Чем же лучше использовать Go? Управление памятью - не его сильная сторона, он в этом даже Java проигрывает.


        1. TimurBaldin
          01.01.2022 16:09

          Основная его сильная сторона это многопоточная модель . В целом он обеспечивает более низкое потребление памяти , но цена за это более примитивный инструмент если сравнивать с java


          1. sandersru
            01.01.2022 16:34

            Если мне не изменяет склероз, то основная цена облака сейчас в дисках(SSD). Ядра и память на этом фоне просто теряются. Речь конечно не про public cloud типа амазон или гугл.


            1. TimurBaldin
              01.01.2022 16:45

              Ну получается, что важно, сколько приложение занимает места на этом "золотом SSD" ...И ещё немаловажный критерий это время запуска ..Golang это не панацея, но для ряда случаев он подходит лучше java...Именно за счёт многопоточной модели и легковесности


              1. sandersru
                01.01.2022 16:52
                +2

                Не занимает. А пишет. Базы, логи и т.д. там терабайты(и деньги), а не в размере приложений.

                Время запуска = graalVM в native mode. Или quarkus.io если хотите. Те же миллисекунды, что у приложений на сях. Виртуальная машина тут это 10 мб. Готовый микросервис с много чем под капотом 60 мб. Что вполне сопоставимо с гоуланг.

                Под многопоточностью я так понимаю вы имели ввиду горутины(или как их там в гоу). Ну так в том же кваркус давно реактивное программирование и vertx под капотом. То на то.

                Если не путаю, то скоро все это из коробки в jdk будет. В рамках loom по моему пилили


                1. TimurBaldin
                  01.01.2022 17:09
                  -2

                  Если мы говорим про объемы корпораций где тысячи сервисов работают в проде...то экономия набегает существенная...Даже от такого казалось бы малозначимого факта как размер конкретного приложения)

                  В native mode в Java пока что достаточно много проблем...Особенно с зависимостями, если интересно на хабре было много статей где это описывалось)

                  Да, именно горутины)Project loom классный проект, но пока он не вошёл в состав jdk и непонятно когда войдёт и сколько займёт времени пока обновятся основные библиотеки)

                  Поймите правильно, я не говорю , что go лучше java или наоборот, я говорю о том что есть ряд задач где go может быть полезен и его использование оправдано)


                  1. sandersru
                    01.01.2022 17:15

                    Не спорю. Каждому молотку свои гвозди :)

                    Java рулит экосистемой. Golang - для простых высоконагруженных сервисов. Где эта экосистема не нужна.

                    >>В native mode в Java пока что достаточно много проблем...Особенно с зависимостями, если интересно на хабре было много статей где это описывалось)

                    А можно тут подробнее для общего развития? Да, понимаю, что экосистема ещё не доросла до натива, но для опять же простых и высоконагруженных сервисов - по большому счету много чего есть


                    1. TimurBaldin
                      01.01.2022 17:31
                      +1

                      Проблем там несколько , из основных это то что нельзя ничего подгружать динамически...Это частично решает Spring Native (он генерирует специальный файл с статическим указанием необходимых зависимостей), но с ним тоже не все гладко...иногда нужно его править вручную ... Ну и рефлексия (если зависимость ее использует, то придется ее выкинуть)...

                      Golang - для простых высоконагруженных сервисов.

                      да, java+golang это очень хорошая связка)


                      1. sandersru
                        01.01.2022 17:50
                        +1

                        Проблемы спринг - это проблемы спринг. :) Они в свое время завязались на reflection. Теперь отгребают. Я так понимаю, ещё до стадии beta не добрались.

                        В graalvm просто надо регистрировать классы и методы доступные для reflection.

                        Кстати вот фишка в graal, которая на прямую позволяет дергать сишные библиотеки(тот же posix) мне отлично зашла. Осталось дождаться приведения API graal и project panama к единому виду, чтобы двойной код не рождать

                        Посмотрите на quarkus (а он уже около 15% экосистемы занимает по данным из дребрей redhat) там эта проблема изначально решена и много чего в него встроенно.

                        Некоторые вещи конечно заставляют сильно материться, но скорее либо по незнанию, либо с отсутствием глубокой документации. Ну и community соответственно.


                      1. TimurBaldin
                        01.01.2022 18:16

                        Про Graal и quarkus посмотрю)Спасибо!)

                        Нет, до бета версии они дошли, но с этим инструментом всё ещё не очень удобно работать)

                        Вам могу посоветовать почитать про golang ...Мне как Java разработчику он зашёл) Работать с ним приятнее чем с python с его динамической типизацией)


                      1. sandersru
                        01.01.2022 18:23

                        >> Нет, до бета версии они дошли, но с этим инструментом всё ещё не очень удобно работать)

                        Как я написал выше - слишком глубокий reflection.

                        Кваркус на стадии билда все это собирает плагином. + Изначально интеграция с Граалем под капотом.

                        За golang спасибо, но не надо. В моей практике он присутствует либо когда надо писать под 32 bit mips/arm в качестве домашних поделок. Либо узкие микросервисы на работе.

                        Да и не программист я давно :)))


                      1. TimurBaldin
                        01.01.2022 18:30

                        а и не программист я давно :)))

                        Я Нео )))


                      1. jreznot
                        01.01.2022 18:38

                        RedHat только бы свой фреймворк выгородить, они и 146% нарисуют только дай волю


                      1. sandersru
                        01.01.2022 18:56

                        У вас есть какие то данные? Или "я так чувствую", "мне так карты предсказали"?

                        Цитирую редхат:

                        Привет. Именно наших данных сходу не нашёл, но в интернетах можно найти всякие 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 экосистемы.

                        То есть Рэдхад ссылается на сторонние данные. Вы же занимаетесь, простите, балаболством.


                      1. TimurBaldin
                        01.01.2022 20:38

                        если посмотреть на hh , то там всего 37 вакансий где вообще встречается слово "quarkus" ...Мягко говоря это крайне мало ...


                      1. sandersru
                        01.01.2022 20:46
                        -2

                        Как сказал 3й человек нашей компании(а это больше 10000 человек на борту) если вы знаете майнфреймы, то мы вас сегодня же возьмём на работу :)

                        Речь не о том, что сейчас, а о динамике.

                        Тот же front end девелопер как ких то 10-15 лет назад назывался "верстальщик" который рисовал хтмл шаблоны. За совсем другие деньги.

                        AI/ML и big data ещё 25 лет назад мы учили в институте, только нахрен они были тогда не нужны. Ни за какие деньги.

                        Тут так же. Просто тэнды.

                        Мы уже начали уход с спрингбут на кваркус и гоуланг(размер компании выше). Рано или поздно в натив все в java подтянутся


                      1. jreznot
                        01.01.2022 20:58

                      1. sandersru
                        01.01.2022 21:01

                        А теперь расскажите, как это коррелирует с 146% и что рисует рэдхат в предыдущем посте.


                      1. jreznot
                        01.01.2022 21:16

                        15% рынка JVM это очень большая цифра, громадная просто.


                      1. jreznot
                        01.01.2022 21:02

                        Важно понимать, что вендор - лицо заинтересованное и его данные надо проверять.


                      1. sandersru
                        01.01.2022 21:05

                        Вендор - всегда лицо заинтересованное. Оупенсорс (привет k8s) тоже вендор из Гугла и редхата.

                        Ну то есть данные от 3 до 15% по "сторонним" рейтингам. Это повод посмотреть, а не искать всемирный заговор


          1. Hivemaster
            02.01.2022 17:22

            А у Java не многопоточная модель?

            И нет, существенной разницы в потреблении памяти тоже нет, если измерять именно аллокации, а не весь footprint виртуальной машины, да ещё на синтетических тестах. Зато есть разница в издержках на управление ею - не в пользу Go. Рантайм Go использует сборщик мусора типа mark-sweep, такой был в Java с 2002-го года, но уже в 2017-м был объявлен устаревшим. К тому же, в Go сборщик не уплатняющий, что приводит к фрагментации кучи и росту издержек со временем работы программы.


            1. TimurBaldin
              02.01.2022 17:35

              В java другая многопоточная модель, которая не подходит для ряда задач

              Я видел разницу в Х раз, если у вас есть другие данные , ссылки на статьи в студию)


      1. TheKnight
        02.01.2022 13:46

        Иногда хочется чуть-чуть улучшить уже существующее приложение, а не переписывать с нуля его на другом языке.

        Тем более, что C++ это сложный язык, а инфраструктура разработки для него в диком мире не так хорошо развита как у Java.


        1. TimurBaldin
          02.01.2022 14:54

          Если оптимизация начинается под девизом "хотим чуть-чуть улучшить" это плохая оптимизация...По опыту в 99% случаев оптимизация это :

          1)Изменение алгоритмов обработки данных

          2)Изменение архитектуры сервиса/сервисов и их взаимодействия

          3)Выпиливание лишних/избыточных бибилотеках

          В целом, инструмент под задачу подбираем )


          1. TheKnight
            03.01.2022 03:08

            В целом, алгоритмически все может быть уже хорошо, архитектурно - тоже, но вот уменьшить потребление памяти, не переписывая код целиком было бы неплохо.

            А иногда нет возможности переработать архитектуру системы, разнеся на разные машины разные части. Возможно, из-за latency, возможно - из-за отсутствия железа. А может потому что система должна запускаться на машине конечного пользователя и никак иначе. Вот и приходится ухищряться.

            Насколько мне известно, для плюсов и Go тоже бывают необходимы особые ухищрения для повышения производительности или уменьшения расхода памяти.


            1. TimurBaldin
              03.01.2022 10:08

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


              1. TheKnight
                03.01.2022 12:12

                Мне кажется, вы уже забыли, с чего началась эта ветка обсуждения.

                Напомню, что как раз таки с вопроса автору о черной магии Unsafe вместо новых публичных API.


                1. TimurBaldin
                  03.01.2022 12:38

                  Чёрная магия для продакшена это плохо, однозначно)


  1. Valle
    01.01.2022 00:38

    Интересно было бы еще сравнить с fastutil.


  1. TimurBaldin
    01.01.2022 03:23
    +4

    Статья интересная , но вопрос...зачем ?


  1. poxvuibr
    01.01.2022 11:49
    +2

    С одной стороны, приятно почитать код, но с другой стороны нет jmh в тестах производительности, что, конечно, всё портит ((.


    Было бы ещё прикольно посмотреть, как эти тесты проходит код с Reflection и погонять на разных версиях джавы, вплоть то 17.


  1. Tellamonid
    01.01.2022 12:09
    +4

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

    • Trove (больше не поддерживается);

    • Eclipse collections;

    • FastUtil;

    • HPPC;

    • Koloboke;

    • Neo4j Primitive Collections;

    Наверняка сейчас есть и какие-то ещё.

    Правда, я помню случай, про который рассказывал Сергей Куксенко, когда сравнивал ConcurrentHashMap с Trove-мапой, и нашел случай, когда ConcurrentHashMap оказался быстрее, несмотря на то, что там лежали обьектные типы. Это было потому, что для определения того в какую корзину (bucket) положить элемент, в Trove использовался оператор % (остаток от деления), а в ConcurrentHashMap – наложение битовой маски, которое работает, когда длина массива – степень двойки. И в многопоточной среде это было важно, потому операция остаток от деления может исполняться одна на ядре, а наложений битовой маски может быть несколько в параллель. В интернете можно найти таблицы для разных процессоров, у каких операций какого параллелизма можно достичь.


  1. sandersru
    01.01.2022 15:41
    +2

    Мне кажется или вы пытаетесь придумать ByteBuffer?


  1. isicju
    01.01.2022 16:33
    +1

    спасибо за публикацию, но мне кажется что проект валхалла и самописная коллекция с самописными типами это совсем разные вещи. в первом случае мы не будет иметь лишнуюю ссылку на дальную область памяти (потенциально) а хранить данные возле возле указателя. В статье же речь идет немного о другом и если сравнивать с "близостью" данных то в валхалле эта близость почти нулевая а тут близость будет реализована через использование нескольких дополнительных двух трех объектов и пары условий. Но еще раз спасибо за статью!