Статьи

Java: преимущества встроенных свойств класса, начиная с Java 8

Будем надеяться, что через несколько лет в Java появится функция «встроенного класса», которая решает многие проблемы с текущим состоянием Java. Прочитайте эту статью и узнайте, как использовать Java 8 и более поздние версии, и при этом по-прежнему извлекайте выгоду из некоторых преимуществ предстоящих массивов встроенных объектов, таких как; нет косвенных указателей, устранены накладные расходы на заголовки объектов и улучшена локальность данных.

В этой статье мы узнаем, как мы можем написать короткий класс с именем
InlineArray который поддерживает многие будущие функции встроенного класса. Мы также рассмотрим Speedment HyperStream, существующий инструмент Java, использующий аналогичные средства работы.

Фон

С 1995 года, когда это имело смысл, массив Objects в Java состоит из массива, который, в свою очередь, содержит множество ссылок на другие объекты, которые в конечном итоге распределяются по куче.

Вот как массив с двумя начальными объектами Point размещается сегодня в куче в Java:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
Array
+======+
|Header|
+------+      Point 0
|ref 0 |---> +======+
+------+     |Header|       Point 1
|ref 1 |---- +------+ ---> +======+
+------+     |x     |      |Header|
|null  |     +------+      +------+
+------+     |y     |      |x     |
|null  |     +------+      +------+
+------+                   |y     |
|...   |                   +------+
+------+

Однако со временем конвейер выполнения типичных процессоров претерпел огромные изменения с невероятным увеличением производительности вычислений. С другой стороны, скорость света осталась неизменной, и поэтому задержка загрузки данных из основной памяти, к сожалению, осталась в том же порядке. Баланс между вычислениями и поиском изменился в пользу вычислений.

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

Очевидно, что текущая компоновка массива Object несколько недостатков, таких как:

  • Двойной доступ к памяти (из-за косвенных ссылочных указателей в массиве)
  • Уменьшенная локальность данных (потому что объекты массива расположены в разных местах в куче)
  • Увеличенный объем памяти (поскольку все объекты, на которые есть ссылки в массиве, являются объектами и, следовательно, содержат дополнительную информацию о Class и синхронизации).

Встроенные классы

В сообществе Java сейчас предпринимаются серьезные усилия по внедрению «встроенных классов» (ранее известных как «классы значений»). Текущее состояние этих усилий (на июль 2019 г.) было представлено Брайаном Гетцем.
В этом видео под названием «Project Valhalla Update (выпуск 2019)». Никто не знает, когда эта функция будет доступна в официальном выпуске Java. Мое личное предположение где-то после 2021 года.

Вот как будет размещен массив встроенных объектов Point , как только эта функция станет доступной:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
Array
+======+
|Header|
+------+
|x     |
+------+
|y     |
+------+
|x     |
+------+
|y     |
+------+
|...   |
+------+

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

Эмуляция некоторых встроенных свойств класса

Далее мы реализуем эмуляцию некоторых свойств встроенных классов. Следует отметить, что все приведенные ниже примеры могут быть запущены на стандартной Java 8 и выше уже сейчас.

Предположим, что у нас есть interface Point с геттерами X и Y, как описано здесь:

1
public interface Point { int x(); int y(); }

Затем мы могли бы создать тривиальную реализацию
Point интерфейса, как показано ниже:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
public final class VanillaPoint implements Point {
 
    private final int x, y;
 
    public VanillaPoint(int x, int y) {
        this.x = x;
        this.y = y;
    }
 
    @Override public int x() { return x; }
 
    @Override public int y() { return y; }
 
    // toString(), equals() and hashCode() not shown for brevity
 
}

Далее, предположим, что мы хотим отказаться от свойств объекта / идентичности объектов Point в массивах. Это означает, среди прочего, что мы не можем синхронизировать или выполнять операции идентификации (такие как == и System::identityHashCode )

Идея здесь состоит в том, чтобы создать область памяти, с которой мы можем работать непосредственно на уровне байтов, и сгладить там наши объекты. Эта область памяти может быть инкапсулирована в общий класс с именем InlineArray<T> следующим образом:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
public final class InlineArray<T> {
 
    private final ByteBuffer memoryRegion;
    private final int elementSize;
    private final int length;
    private final BiConsumer<ByteBuffer, T> deconstructor;
    private final Function<ByteBuffer,T> constructor;
    private final BitSet presentFlags;
 
    public InlineArray(
        int elementSize,
        int length,
        BiConsumer<ByteBuffer, T> deconstructor,
        Function<ByteBuffer,T> constructor
    ) {
        this.elementSize = elementSize;
        this.length = length;
        this.deconstructor = requireNonNull(deconstructor);
        this.constructor = requireNonNull(constructor);
        this.memoryRegion = ByteBuffer.allocateDirect(elementSize * length);
        this.presentFlags = new BitSet(length);
    }
 
    public void put(int index, T value) {
        assertIndexBounds(index);
        if (value == null) {
            presentFlags.clear(index);
        } else {
            position(index);
            deconstructor.accept(memoryRegion, value);
            presentFlags.set(index);
        }
    }
 
    public T get(int index) {
        assertIndexBounds(index);
        if (!presentFlags.get(index)) {
            return null;
        }
        position(index);
        return constructor.apply(memoryRegion);
    }
 
    public int length() {
        return length;
    }
 
    private void assertIndexBounds(int index) {
        if (index < 0 || index >= length) {
            throw new IndexOutOfBoundsException("Index [0, " + length + "), was:" + index);
        }
    }
 
    private void position(int index) {
        memoryRegion.position(index * elementSize);
    }
 
}

Обратите внимание, что этот класс может обрабатывать любой тип элемента (типа T ), который можно деконструировать (сериализовать) в байты при условии, что он имеет максимальный размер элемента. Этот класс наиболее эффективен, если все элементы имеют одинаковый размер элемента, как и Point (т. Integer.BYTES * 2 = 8 Всегда Integer.BYTES * 2 = 8 байт). Также обратите внимание, что этот класс не является потокобезопасным, но его можно добавить за счет введения барьера памяти и, в зависимости от решения, использовать отдельные представления ByteBuffer .

Теперь предположим, что мы хотим выделить массив из 10 000 точек. Вооружившись новым классом InlineArray мы можем действовать следующим образом:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Main {
 
    public static void main(String[] args) {
 
        InlineArray<Point> pointArray = new InlineArray<>(
            Integer.BYTES * 2, // The max element size
            10_000,
            (bb, p) -> {bb.putInt(p.x()); bb.putInt(p.y());},
            bb -> new VanillaPoint(bb.getInt(), bb.getInt())
        );
 
        Point p0 = new VanillaPoint(0, 0);
        Point p1 = new VanillaPoint(1, 1);
 
        pointArray.put(0, p0); // Store p0 at index 0
        pointArray.put(1, p1); // Store p1 at index 1
 
        System.out.println(pointArray.get(0)); // Should produce (0, 0)
        System.out.println(pointArray.get(1)); // Should produce (1, 1)
        System.out.println(pointArray.get(2)); // Should produce null
 
    }
 
}

Как и ожидалось, код будет выдавать следующий вывод при запуске:

1
2
3
VanillaPoint{x=0, y=0}
VanillaPoint{x=1, y=1}
null

Обратите внимание, как мы предоставляем деконструктор элемента и конструктор элемента для InlineArray рассказывая ему, как он должен деконструировать и конструировать
Point объекты в линейную память и обратно.

Свойства эмуляции

Приведенная выше эмуляция, вероятно, не даст такого же прироста производительности, как у реальных встроенных классов, но экономия с точки зрения выделения памяти и локальности будет примерно одинаковой. Приведенная выше эмуляция выделяет память вне кучи, поэтому время сбора мусора не будет зависеть от данных элемента, помещенных в InlineArray . Элементы в ByteBuffer расположены так же, как и предлагаемый встроенный массив классов:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
Array
+======+
|Header|
+------+
|x     |
+------+
|y     |
+------+
|x     |
+------+
|y     |
+------+
|...   |
+------+

Потому что мы используем объекты ByteBuffer , которые проиндексированы с
int область резервной памяти становится ограниченной 2 ^ 31 байтом. Это означает, например, что мы можем положить только 2 ^ (31-3) = 2 ^ 28 ≈ 268 миллионов
Point элементы в массиве (поскольку каждая точка занимает 2 ^ 3 = 8 байт), прежде чем мы исчерпаем адресное пространство. Реальные реализации могут преодолеть это ограничение, используя несколько ByteBuffers, Unsafe или библиотек, таких как Chronicle Bytes.

Ленивые Сущности

Учитывая класс InlineArray , довольно легко обеспечить элементы из
InlineArray которые являются ленивыми, в том смысле, что им не нужно десериализовать все поля с нетерпением, когда элемент извлекается из массива. Вот как это можно сделать:

Во-первых, мы создаем другую реализацию интерфейса Point которая берет свои данные из самого ByteBuffer , а не из локальных полей:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public final class LazyPoint implements Point {
 
    private final ByteBuffer byteBuffer;
    private final int position;
 
    public LazyPoint(ByteBuffer byteBuffer) {
        this.byteBuffer = byteBuffer;
        this.position = byteBuffer.position();
    }
 
    @Override
    public int x() {
        return byteBuffer.getInt(position);
    }
 
    @Override
    public int y() {
        return byteBuffer.getInt(position + Integer.BYTES);
    }
 
    // toString(), equals() and hashCode() not shown for brevity
 
}

Затем мы просто заменим десериализатор, помещенный в конструктор
InlineArray как это:

1
2
3
4
5
6
InlineArray pointArray = new InlineArray<>(
    Integer.BYTES * 2,
    10_000,
    (bb, p) -> {bb.putInt(p.x()); bb.putInt(p.y());},
    LazyPoint::new // Use this deserializer instead
);

При использовании в том же основном методе, что и выше, это приведет к следующему выводу:

1
2
3
LazyPoint{x=0, y=0}
LazyPoint{x=1, y=1}
null

Здорово. Это особенно полезно для сущностей с десятками или даже сотнями полей, в которых доступ к ограниченному подмножеству полей всегда доступен для решения рассматриваемой проблемы.

Недостаток этого подхода заключается в том, что если в нашем приложении сохраняется только одна ссылка на LazyPoint , это предотвращает ByteBuffer мусора для всей резервной ByteBuffer . Таким образом, любые ленивые объекты, подобные этим, лучше всего использовать как недолговечные объекты.

Использование больших коллекций данных

Что если мы хотим использовать очень большие наборы данных (например, в терабайтах), возможно, из базы данных или из файлов, и эффективно хранить их в JVM-памяти, а затем иметь возможность работать с этими коллекциями для повышения производительности вычислений? Можем ли мы использовать этот тип технологии?

Speedment HyperStream — это продукт, который использует аналогичную технологию для предоставления данных базы данных в виде стандартных потоков Java и уже некоторое время доступен. HyperStream размещает данные, аналогичные описанным выше, и может хранить терабайты данных в одной виртуальной машине Java с минимальным влиянием сбора мусора или без него, поскольку данные хранятся вне кучи. Он может использовать десериализацию на месте для получения отдельных полей непосредственно из резервной области памяти, что позволяет избежать ненужной полной десериализации объектов. Его стандартные потоки Java имеют детерминированную сверхнизкую задержку, которая в некоторых случаях может создавать и потреблять потоки менее чем за 100 нс.

Вот пример того, как HyperStream (который реализует стандартный поток Java) может использоваться в приложении при просмотре страниц между фильмами.
Переменная Manager films предоставляется Speedment автоматически:

1
2
3
4
5
6
private Stream<Film> getPage(int page, Comparator<Film> comparator) {
    return films.stream()
        .sorted(comparator)
        .skip(page * PAGE_SIZE)
        .limit(PAGE_SIZE)
    }

Даже при том, что фильмов может быть триллион, метод обычно завершается менее чем за микросекунду, поскольку Stream подключен напрямую к RAM и использует индексы в памяти.

Узнайте больше о производительности Speedment HyperStream здесь .

Оцените производительность в ваших собственных приложениях базы данных, загрузив Speedment HyperStream здесь .

Ресурсы

Проект Вальхалла https://openjdk.java.net/projects/valhalla/

Ускорение HyperStream https://www.speedment.com/hyperstream/

Инициализатор ускорения https://www.speedment.com/initializer/

См. Оригинальную статью здесь: Java: преимущества встроенных свойств класса, начиная с Java 8

Мнения, высказанные участниками Java Code Geeks, являются их собственными.