Статьи

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

Если вы когда-либо выделяли большие кучи Java, вы знаете, что в какой-то момент, обычно начиная с 4 ГБ, у вас начнутся проблемы с паузами при сборке мусора.

Я не буду вдаваться в подробности о том, почему в JVM происходят паузы, но вкратце это происходит, когда JVM выполняет полные сборы, а у вас большая куча. По мере увеличения кучи эти коллекции могут стать длиннее.

Самый простой способ преодолеть это — настроить параметры сборки мусора JVM, чтобы они соответствовали распределению памяти и поведению освобождения вашего конкретного приложения. Это немного темное искусство и требует тщательных измерений, но возможно иметь очень большие кучи, избегая в основном сборок мусора старого поколения. Если вы хотите узнать больше о настройке сборки мусора, ознакомьтесь с этим руководством по настройке JVM GC . Если вы действительно заинтересовались GC в целом, это отличная книга: Сборник мусора .

Существуют реализации JVM, которые гарантируют намного меньшее время паузы, чем Sun VM, такие как Zing JVM, но обычно с другими затратами в вашей системе, такими как увеличение использования памяти и однопоточная производительность. Простота настройки и низкие гарантии gc по-прежнему очень привлекательны. В этой статье я буду использовать пример кэша в памяти или хранилища на Java, главным образом потому, что в прошлом я создавал пару, используя некоторые из этих методов.

Предположим, у нас есть базовое определение интерфейса кеша, например:

1
2
3
4
5
6
import java.io.Externalizable;
 
public interface Cache<K extends Externalizable, V extends Externalizable> {
    public void put(K key, V value);
    public V get(K key);
}

Мы требуем, чтобы ключи и значения были Externalizable только для этого простого примера, а не для этого IRL.

Мы покажем, как иметь разные реализации этого кэша, которые по-разному хранят данные в памяти. Самый простой способ реализовать этот кеш — использовать коллекции Java:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
import java.io.Externalizable;
import java.util.HashMap;
import java.util.Map;
 
public class CollectionCache<K extends Externalizable, V extends Externalizable> implements Cache<K, V> {
    private final Map<K, V> backingMap = new HashMap<K, V>();
 
    public void put(K key, V value) {
        backingMap.put(key, value);
    }
 
    public V get(K key) {
        return backingMap.get(key);
    }
}

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

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

  • Используйте отдельный процесс для хранения данных . Может быть что-то вроде экземпляра Redis или Memcached, который вы подключаете через сокеты или сокеты unix. Это довольно просто реализовать.
  • Выгрузите данные на диск, используя файлы, отображенные в памяти . Операционная система — ваш друг, и она проделает большую тяжелую работу, предсказывая, что вы будете читать дальше из файла, и ваш интерфейс к нему будет похож на большой массив данных.
  • Используйте собственный код и получите доступ к нему через JNI или JNA . Вы получите лучшую производительность с JNI и простоту использования с JNA. Требует от вас написать собственный код.
  • Используйте прямые выделенные буферы из пакета NIO.
  • Используйте специальный небезопасный класс Sun для доступа к памяти прямо из вашего кода Java.

Я сосредоточусь на решениях, которые используют исключительно Java для этой статьи, прямые выделенные буферы и класс Unsafe.

Прямые Выделенные Буферы

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

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

1
2
int numBytes = 1000;
ByteBuffer buffer = ByteBuffer.allocateDirect(numBytes);

После создания нового буфера вы можете манипулировать буфером несколькими различными способами. Если вы никогда не использовали буферы Java NIO, вам определенно стоит взглянуть на них, потому что они действительно крутые.

Помимо способов заполнения, опустошения и маркировки разных точек в буфере, вы можете выбрать другое представление буфера вместо ByteBuffer — например, buffer.asLongBuffer() дает вам представление ByteBuffer, где вы манипулируете элементами как longs.

Итак, как их можно использовать в нашем примере с Cache? Есть несколько способов, наиболее простым способом было бы сохранить сериализованную / внешнюю форму записи значения в большом массиве вместе с картой ключей для смещений и размеров записи в этом массиве.

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

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
import java.io.Externalizable;
import java.nio.ByteBuffer;
import java.util.HashMap;
import java.util.Map;
 
public class DirectAllocatedCache<K extends Externalizable, V extends Externalizable> implements Cache<K,V> {
    private final ByteBuffer backingMap;
    private final Map<K, Integer> keyToOffset;
    private final int recordSize;
 
    public DirectAllocatedCache(int recordSize, int maxRecords) {
        this.recordSize = recordSize;
        this.backingMap = ByteBuffer.allocateDirect(recordSize * maxRecords);
        this.keyToOffset = new HashMap<K, Integer>();
    }
 
    public void put(K key, V value) {
        if(backingMap.position() + recordSize < backingMap.capacity()) {
            keyToOffset.put(key, backingMap.position());
            store(value);
        }  
    }
 
    public V get(K key) {
        int offset = keyToOffset.get(key);
        if(offset >= 0)
            return retrieve(offset);
 
        throw new KeyNotFoundException();
    }
 
    public V retrieve(int offset) {
        byte[] record = new byte[recordSize];
        int oldPosition = backingMap.position();
        backingMap.position(offset);
        backingMap.get(record);
        backingMap.position(oldPosition);
 
        //implementation left as an exercise
        return internalize(record);
    }
 
    public void store(V value) {
        byte[] record = externalize(value);
        backingMap.put(record);
    }
}

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

Имейте в виду, что JVM накладывает ограничение на объем памяти, используемой буферами с прямым выделением. Вы можете настроить это с опцией -XX: MaxDirectMemorySize. Проверьте ByteBuffer Javadocs

небезопасный

Еще один способ управления памятью непосредственно из Java — использование скрытого класса Unsafe. Технически мы не должны использовать это, и это зависит от конкретной реализации, поскольку оно существует в пакете Sun, но предлагаемые возможности безграничны.
Что небезопасно дает нам возможность распределять, освобождать и управлять памятью непосредственно из кода Java. Мы также можем получить фактические указатели и передать их между собственным и Java-кодом взаимозаменяемо.

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

1
2
3
4
5
6
7
8
private Unsafe getUnsafeBackingMap() {
    try {
        Field f = Unsafe.class.getDeclaredField('theUnsafe');
        f.setAccessible(true);
        return (Unsafe) f.get(null);
    } catch (Exception e) { }
    return null;
}

Как только мы обнаружим небезопасное, мы можем применить это к нашему предыдущему примеру Cache:

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
59
60
61
62
63
64
65
66
67
68
69
import java.io.Externalizable;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;
 
import sun.misc.Unsafe;
 
public class UnsafeCache<K extends Externalizable, V extends Externalizable> implements Cache<K, V> {
    private final int recordSize;
    private final Unsafe backingMap;
    private final Map<K, Integer> keyToOffset;
    private long address;
    private int capacity;
    private int currentOffset;
 
    public UnsafeCache(int recordSize, int maxRecords) {
        this.recordSize = recordSize;
        this.backingMap = getUnsafeBackingMap();
        this.capacity = recordSize * maxRecords;
        this.address = backingMap.allocateMemory(capacity);
        this.keyToOffset = new HashMap<K, Integer>();
    }
 
    public void put(K key, V value) {
        if(currentOffset + recordSize < capacity) {
            store(currentOffset, value);
            keyToOffset.put(key, currentOffset);
            currentOffset += recordSize;
        }
    }
 
    public V get(K key) {
        int offset = keyToOffset.get(key);
        if(offset >= 0)
            return retrieve(offset);
 
        throw new KeyNotFoundException();
    }
 
    public V retrieve(int offset) {
        byte[] record = new byte[recordSize];
 
        //Inefficient
        for(int i=0; i<record.length; i++) {
            record[i] = backingMap.getByte(address + offset + i);
        }
 
        //implementation left as an exercise
        return internalize(record);
    }
 
    public void store(int offset, V value) {
        byte[] record = externalize(value);
 
        //Inefficient
        for(int i=0; i<record.length; i++) {
            backingMap.putByte(address + offset + i, record[i]);
        }
    }
 
    private Unsafe getUnsafeBackingMap() {
        try {
            Field f = Unsafe.class.getDeclaredField('theUnsafe');
            f.setAccessible(true);
            return (Unsafe) f.get(null);
        } catch (Exception e) { }
        return null;
    }
}

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

Проверьте Javadocs для небезопасных

Вывод

Есть несколько способов избежать использования кучи в Java и, таким образом, использовать намного больше памяти. Вам не нужно этого делать, и я лично видел правильно настроенные JVM с 20GiB-30GiB, работающими без длительных пауз сборки мусора, но это довольно интересно.

Если вы хотите проверить, как некоторые проекты используют это для основного (и честно непроверенного, почти написанного на салфетке) кеш-кода, который я написал здесь, взгляните на EHCache BigMemory или Apache Cassandra, который также использует Unsafe для этого типа подхода.

Ссылка: Выход из кучи JVM для приложений, интенсивно использующих память, от нашего партнера по JCG Рубена Бадаро из блога Java Advent Calendar .