Идея
В 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 и операции вычисления смещений.
ссылка на оригинал статьи https://habr.com/ru/post/599003/
Добавить комментарий