Как эффективно хранить массивы небольших байт в Java?

Массивами байтов small я называю массивы байтов длиной от 10 до 30.

В магазине я имею в виду сохранение в ОЗУ, а не сериализацию и сохранение файловой системы.

Система macOS 10.12.6, Oracle jdk1.8.0_141 64bit, JVM args -Xmx1g

Пример: Ожидаемое поведение для new byte[200 * 1024 * 1024] составляет ≈200 мб кучного пространства

public static final int TARGET_SIZE = 200 * 1024 * 1024;
public static void main(String[] args) throws InterruptedException {
    byte[] arr = new byte[TARGET_SIZE];
    System.gc();
    System.out.println("Array size: " + arr.length);
    System.out.println("HeapSize: " + Runtime.getRuntime().totalMemory());
    Thread.sleep(60000);
}

jvisualvm общая куча использования кучи для нового байта [200 * 1024 * 1024] образец памяти jvisualvm новый байт [200 * 1024 * 1024]

Однако для меньших массивов математика не так проста

public static final int TARGET_SIZE = 200 * 1024 * 1024;
public static void main(String[] args) throws InterruptedException {
    final int oneArraySize = 20;
    final int numberOfArrays = TARGET_SIZE / oneArraySize;
    byte[][] arrays = new byte[numberOfArrays][];
    for (int i = 0; i < numberOfArrays; i++) {
        arrays[i] = new byte[oneArraySize];
    }
    System.gc();
    System.out.println("Arrays size: " + arrays.length);
    System.out.println("HeapSize: " + Runtime.getRuntime().totalMemory());
    Thread.sleep(60000);
}

jvisualvm общая куча использования кучи для 10 * 1024 * 1024 нового байта [20] образец памяти jvisualvm для 10 * 1024 * 1024 нового байта [20]

И еще хуже

jvisualvm общая куча использования кучи для 20 * 1024 * 1024 нового байта [10] образец памяти jvisualvm для 20 * 1024 * 1024 нового байта [10]

Вопрос

Откуда возникают служебные данные? Как эффективно хранить и работать с малыми массивами (куски данных)?

Обновление 1

для new byte[200*1024*1024][1] он ест jvisualvm общая куча использования кучи для 200 * 1024 * 1024 нового байта [1] образец памяти jvisualvm для 200 * 1024 * 1024 нового байта [1]

Базовая математика говорит, что new byte[1] весит 24 байта.

Обновление 2

В соответствии с Каково потребление памяти объектом в Java? минимальный размер объекта в Java составляет 16 байт. Из моих предыдущих "измерений" 24 байта -4 байта для int length -1 фактический байт моих данных = 3 байта некоторого дополнения other garbage.

Ответы

Ответ 1

Ответ Евгения объясняет причину того, почему вы наблюдаете такое увеличение потребления памяти для большого количества массивов. Вопрос в заголовке "Как эффективно хранить массивы небольших байт в Java?", Может ответить на это: совсем нет. 1

Однако, вероятно, есть способы достижения ваших целей. Как обычно, "лучшее" решение здесь будет зависеть от того, как эти данные будут использоваться. Очень прагматичный подход: Определить interface для вашей структуры данных.

В простейшем случае этот интерфейс может быть просто

interface ByteArray2D 
{
    int getNumRows();
    int getNumColumns();
    byte get(int r, int c);
    void set(int r, int c, byte b);
}

Предлагает базовую абстракцию "массива двумерных байтов". В зависимости от случая применения может быть полезно предложить дополнительные методы здесь. Шаблоны, которые могут быть использованы здесь, часто имеют отношение к библиотекам Matrix, которые обрабатывают "2D-матрицы" (обычно из значений float), и они часто предлагают такие методы:

interface Matrix {
    Vector getRow(int row);
    Vector getColumn(int column);
    ...
}

Однако, когда основной целью здесь является обработка набора массивов byte[], могут быть достаточными методы для доступа к каждому массиву (то есть к каждой строке 2D-массива):

ByteBuffer getRow(int row);

Учитывая этот интерфейс, просто создать различные реализации. Например, вы можете создать простую реализацию, которая просто хранит массив 2D byte[][]:

class SimpleByteArray2D implements ByteArray2D 
{
    private final byte array[][];
    ...
}

В качестве альтернативы вы можете создать реализацию, в которой хранится массив 1D byte[] или аналогично, ByteBuffer внутри:

class CompactByteArray2D implements ByteArray2D
{
    private final ByteBuffer buffer;
    ...
}

Эта реализация затем просто должна вычислить индекс (1D) при вызове одного из методов для доступа к определенной строке/столбцу 2D-массива.

Ниже вы найдете MCVE, в котором показан этот интерфейс и две реализации, основное использование интерфейса и анализ объема памяти с использованием JOL.

Выход этой программы:

For 10 rows and 1000 columns:
Total size for SimpleByteArray2D : 10240
Total size for CompactByteArray2D: 10088

For 100 rows and 100 columns:
Total size for SimpleByteArray2D : 12440
Total size for CompactByteArray2D: 10088

For 1000 rows and 10 columns:
Total size for SimpleByteArray2D : 36040
Total size for CompactByteArray2D: 10088

Показывая, что

  • реализация SimpleByteArray2D, основанная на простом массиве 2D byte[][], требует больше памяти при увеличении количества строк (даже если общий размер массива остается постоянным)

  • потребление памяти CompactByteArray2D не зависит от структуры массива

Вся программа:

package stackoverflow;

import java.nio.ByteBuffer;

import org.openjdk.jol.info.GraphLayout;

public class EfficientByteArrayStorage
{
    public static void main(String[] args)
    {
        showExampleUsage();
        anaylyzeMemoryFootprint();
    }

    private static void anaylyzeMemoryFootprint()
    {
        testMemoryFootprint(10, 1000);
        testMemoryFootprint(100, 100);
        testMemoryFootprint(1000, 10);
    }

    private static void testMemoryFootprint(int rows, int cols)
    {
        System.out.println("For " + rows + " rows and " + cols + " columns:");

        ByteArray2D b0 = new SimpleByteArray2D(rows, cols);
        GraphLayout g0 = GraphLayout.parseInstance(b0);
        System.out.println("Total size for SimpleByteArray2D : " + g0.totalSize());
        //System.out.println(g0.toFootprint());

        ByteArray2D b1 = new CompactByteArray2D(rows, cols);
        GraphLayout g1 = GraphLayout.parseInstance(b1);
        System.out.println("Total size for CompactByteArray2D: " + g1.totalSize());
        //System.out.println(g1.toFootprint());
    }

    // Shows an example of how to use the different implementations
    private static void showExampleUsage()
    {
        System.out.println("Using a SimpleByteArray2D");
        ByteArray2D b0 = new SimpleByteArray2D(10, 10);
        exampleUsage(b0);

        System.out.println("Using a CompactByteArray2D");
        ByteArray2D b1 = new CompactByteArray2D(10, 10);
        exampleUsage(b1);
    }

    private static void exampleUsage(ByteArray2D byteArray2D)
    {
        // Reading elements of the array
        System.out.println(byteArray2D.get(2, 4));

        // Writing elements of the array
        byteArray2D.set(2, 4, (byte)123);
        System.out.println(byteArray2D.get(2, 4));

        // Bulk access to rows
        ByteBuffer row = byteArray2D.getRow(2);
        for (int c = 0; c < row.capacity(); c++)
        {
            System.out.println(row.get(c));
        }

        // (Commented out for this MCVE: Writing one row to a file)
        /*/
        try (FileChannel fileChannel = 
            new FileOutputStream(new File("example.dat")).getChannel())
        {
            fileChannel.write(byteArray2D.getRow(2));
        }
        catch (IOException e)
        {
            e.printStackTrace();
        }
        //*/
    }

}


interface ByteArray2D 
{
    int getNumRows();
    int getNumColumns();
    byte get(int r, int c);
    void set(int r, int c, byte b);

    // Bulk access to rows, for convenience and efficiency
    ByteBuffer getRow(int row);
}

class SimpleByteArray2D implements ByteArray2D 
{
    private final int rows;
    private final int cols;
    private final byte array[][];

    public SimpleByteArray2D(int rows, int cols)
    {
        this.rows = rows;
        this.cols = cols;
        this.array = new byte[rows][cols];
    }

    @Override
    public int getNumRows()
    {
        return rows;
    }

    @Override
    public int getNumColumns()
    {
        return cols;
    }

    @Override
    public byte get(int r, int c)
    {
        return array[r][c];
    }

    @Override
    public void set(int r, int c, byte b)
    {
        array[r][c] = b;
    }

    @Override
    public ByteBuffer getRow(int row)
    {
        return ByteBuffer.wrap(array[row]);
    }
}

class CompactByteArray2D implements ByteArray2D
{
    private final int rows;
    private final int cols;
    private final ByteBuffer buffer;

    public CompactByteArray2D(int rows, int cols)
    {
        this.rows = rows;
        this.cols = cols;
        this.buffer = ByteBuffer.allocate(rows * cols);
    }

    @Override
    public int getNumRows()
    {
        return rows;
    }

    @Override
    public int getNumColumns()
    {
        return cols;
    }

    @Override
    public byte get(int r, int c)
    {
        return buffer.get(r * cols + c);
    }

    @Override
    public void set(int r, int c, byte b)
    {
        buffer.put(r * cols + c, b);
    }

    @Override
    public ByteBuffer getRow(int row)
    {
        ByteBuffer r = buffer.slice();
        r.position(row * cols);
        r.limit(row * cols + cols);
        return r.slice();
    }
}

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


1 Примечание:

Проблема служебных данных памяти аналогична на других языках. Например, в C/С++ структура, которая наиболее близко напоминает "2D-массив Java", представляет собой массив выделенных вручную указателей:

char** array;
array = new (char*)[numRows];
array[0] = new char[numCols];
...

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

Ответ 2

ОК, поэтому, если я правильно понял (спросите, не ответите - постарайтесь ответить), здесь есть пара вещей. Во-первых, вам нужен правильный инструмент для измерений, и JOL является единственным, кому я доверяю.

Давайте начнем просто:

byte[] two = new byte[1];
System.out.println(GraphLayout.parseInstance(one).toFootprint()); 

Это будет отображаться 24 bytes (12 для mark и class слов - или заголовков объектов + 4 байта заполнения), 1 byte для фактического значения и 7 bytes for padding (память выровнена на 8 байт).

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

byte[] eight = new byte[8];
System.out.println(GraphLayout.parseInstance(eight).toFootprint()); // 24 bytes

byte[] nine = new byte[9];
System.out.println(GraphLayout.parseInstance(nine).toFootprint()); // 32 bytes

Теперь перейдем к двумерным массивам:

byte[][] ninenine = new byte[9][9];    
System.out.println(GraphLayout.parseInstance(ninenine).toFootprint()); // 344 bytes

System.out.println(ClassLayout.parseInstance(ninenine).toPrintable());

Так как java не имеет истинных двухмерных массивов; каждый вложенный массив сам является объектом (byte[]), который имеет заголовки и содержимое. Таким образом, у одного byte[9] есть 32 bytes (12 headers + 4 padding) и 16 bytes для содержимого (9 bytes для фактического содержимого + 7 bytes дополнений).

Объект ninenine имеет 56 байтов всего: 16 заголовки + 36 для хранения ссылок на девять объектов + 4 bytes для заполнения.


Посмотрите на полученный образец здесь:

byte[][] left = new byte[10000][10];
System.out.println(GraphLayout.parseInstance(left).toFootprint()); // 360016 bytes

byte[][] right = new byte[10][10000];
System.out.println(GraphLayout.parseInstance(right).toFootprint()); // 100216 bytes

Увеличение 260%; поэтому, просто переключаясь на работу, вы можете сэкономить много места.

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