Содержание

пятница, 20 июля 2012 г.

Быстрая универсальная сериализация

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

Однако, если посмотреть с другой стороны, то стандартная сериализация очень классная вещь: не нужно описывать схему объекта и нет никаких внешних зависимостей. Например, другое стандартное решение, рекомендуемое Джошуа Блохом, а именно, Externalizable, требует описание схемы каждого объекта. А такое популярное решение, как kryo, тащит за собой в виде зависимости asm, да и для эффективной работы требует указания дополнительной метаинформации о классе. Очень популярный в последнее время protobuf опять же требует подключения библиотеки и хоть и имеет генератор схемы, но все же требует заранее знать все классы, которые вы будете сериализовывать, чтобы еще до деплоймента сгенерировать для них всех схемы.
Описание альтернативного подхода в сериализации
Посмотрим что же есть в арсенале java разработчика, чтобы написать на коленке свою собственную сериализацию, которая будет работать эффективно, не будет требовать внешних зависимостей и предварительного описания схемы. Чтобы получить схему объекта можно использовать рефлекшен. Так как это надо будет сделать всего один раз для каждого типа сериализуемого класса, то никаких проблем в этом не вижу. Таким образом получаем, что для каждого поля сериализуемого объекта у нас есть по объекту java.lang.reflect.Field. Теперь, подключая мой любимый класс sun.misc.Unsafe, мы можем получить сдвиг, с которым записывает значение каждого поля. Делается это с помощью метода
long sun.misc.Unsafe#objectFieldOffset(java.lang.reflect.Field field) 
Если внимательно почитать javadoc к этому методу, то можно обнаружить, что на самом деле это может быть совсем не сдвиг, а некая кука, для рассмотренных ниже методов, так что никаких арифметических операций с адресом по этим сдвигам делать не рекомендуется. Хотя в HotSpot это вроде как самое настоящее смещение в байтах от адреса объекта. Но, поехали дальше. Получив всю эту информацию один раз, дальше мы можем эффективно ее использовать для получения значений полей при сериализации объекта:
long getLong(Object obj, long offset);
double getDouble(Object obj, long offset);
Object getObject(Object obj, long offset);
Для десериализации нам понадобятся методы установки значении полей объекта по сдвигам:
void putLong(Object obj, long offset, long value);
void putDouble(Object obj, long offset, double value);
void putObject(Object obj, long offset, Object value);
Последний штрих - это создание объекта. Его легко создать через рефлекшен, но только если у него есть открытый пустой конструктор. Для общего же случая нас опять выручить класс Unsafe с его методом:
sun.misc.Unsafe#allocateInstance(java.lang.Class aClass)
Частный случай
Теперь я приведу рабочий код как это можно сделать на примере одного простенького класса, а потом опишу как это можно обобщить.
class User{
    long id;
    byte[] key;

    public User(int id, byte[] key) {
        this.id = id;
        this.key = key;
    }

    public String toString() {
        return id + ":" + Arrays.toString(key);
    }
}

class Scheme {

    private static final Unsafe unsafe;
    static {
        try {
            Field field = Unsafe.class.getDeclaredField("theUnsafe");
            field.setAccessible(true);
            unsafe = (Unsafe) field.get(null);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
    
    private long idLongOffset;
    private long keyByteArrayOffset;
    private Class aClass;

    public Scheme(){
        try {
            aClass = User.class;
            idLongOffset = unsafe.objectFieldOffset(User.class.getDeclaredField("id"));
            keyByteArrayOffset = unsafe.objectFieldOffset(User.class.getDeclaredField("key"));
        } catch (NoSuchFieldException e) {
            throw new RuntimeException("Invalid schema");
        }
    }

    public byte[] serialize(Object obj) throws IOException {
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        DataOutputStream os = new DataOutputStream(bos);
        
        os.writeLong(unsafe.getLong(obj, idLongOffset));
        
        byte[] array = (byte[]) unsafe.getObject(obj, keyByteArrayOffset);
        os.writeInt(array.length);
        os.write(array);
        
        return bos.toByteArray();
    }
    
    public Object deserialize(byte[] stream) throws InstantiationException, IOException {
        Object obj = unsafe.allocateInstance(aClass);
        
        DataInputStream is = new DataInputStream(new ByteArrayInputStream(stream));
        
        unsafe.putLong(obj, idLongOffset, is.readLong());
        
        int size = is.readInt();
        byte[] array = new byte[size];
        is.read(array, 0, size);
        unsafe.putObject(obj, keyByteArrayOffset, array);
        
        return obj;
    }

    public static void main(String[] args) throws Exception{
        Scheme scheme = new Scheme();
        Object obj = new User(1, new byte[]{0,1,2});
        byte[] stream = scheme.serialize(obj);
        System.out.println(scheme.deserialize(stream));
    }
}
Обобщение
В данном коде можно увидеть, что методы serialize и deserialize завязаны только на порядок полей, их типы и сдвиги. Вся эта информация как я уже показал выше может быть получена в рантайме, через метод java.lang.Class#getDeclaredFields() и описанные выше методы sun.misc.Unsafe. Получив эту информацию один раз, ее стоит закэшировать. Остается только доработать методы serialize и deserialize, чтобы они понимали все примитивные поля и массивы.

Следующая сложность унификации - это сериализация графа объектов с дубликатами или даже циклами. Поля не являющиеся примитивами можно сериализовать рекурсивно, вытаскивая и кладя их с помощью методов getObject и putObject класса Unsafе, аналогично тому как в примере выше сделано с массовым. С дубликатами и циклами можно справиться с помощью IdentityHashMap.

Более сложная проблема - это вначале десериализации понять какого типа объект мы десериализуем. Полное название класса хранить не вариант, так как оно занимает слишком много места. Если сераилизация и десериализация проходит в одной JVM, то при генерировании схемы для произвольного объекта ему можно назначать уникальный идентификатор, кэшировать его в схеме и писать его в начале сериализованного представления. Однако обычно сериализация используется, чтобы передавать объекты по сети между различными JVM. Унификацию определения типа объекта можно провести специальной накруткой в ремоутинге. Например, идентификатор типа класса получать из его схемы (скажем брать MD5). Если на клиенте такой схемы не находиться, то он отправляет специальный запрос на сервер, чтобы тот прислал ему полное описание класса.

Еще есть интересный момент с определением размера массива, который потребуется, чтобы сериализовать ваш объект. Если вы не используете пул массивов, то динамический массив иметь весьма накладно, поэтому нужен будет специальный визитор, который по схеме обходит объект и вычисляет требуемый размер перед началом реальной сериализации.
Дальнейшая оптимизация
Хочу обратить внимание читателя, что в описанном подходе, как и в случае стандартной сериализации и подхода Externalizable довольно большой оверхед по производительности вносит DataOutputStream. Например, при записи или чтения long выполнятся 8 операций записи или чтения byte в массив:
writeBuffer[0] = (byte)(v >>> 56);
writeBuffer[1] = (byte)(v >>> 48);
writeBuffer[2] = (byte)(v >>> 40);
writeBuffer[3] = (byte)(v >>> 32);
writeBuffer[4] = (byte)(v >>> 24);
writeBuffer[5] = (byte)(v >>> 16);
writeBuffer[6] = (byte)(v >>>  8);
writeBuffer[7] = (byte)(v >>>  0);
out.write(writeBuffer, 0, 8);
С помощью Unsafe же можно выполнить запись или чтение long используя одну машинную инструкцию, за счет наличия слеюующего интринсика, позволяющего делать запись в объект по сдвигу:
sun.misc.Unsafe#putLong(Object array, long offset, long value)
Например, следующий код переведет в байты и положит значение value в массив начиная с позиции position:
private static final long BYTE_ARRAY_OFFSET = unsafe.arrayBaseOffset(byte[].class);
    
public static void putLong(byte[] buffer, long value, int position){
 unsafe.putLong(buffer, BYTE_ARRAY_OFFSET + position, value);
}
Однако с Unsafe нужно быть аккуратным, помня о том, что оно платформозависимо и на архитектурах, где не разрешается запись без выравнивания, такой финт не пройдет. Хотя, конечно, стоит отметить, что на современных популярных архитектурах, таких как x86 и amd64 таких проблем нет. Так же если вы передаете сераилизованный объект по сети, между системами с разным byte order, то надо будет провести конвертацию.

А недавно у меня возникла еще одна мысль об оптимизации описанного подхода, а именно, использовать метод:
sun.misc.Unsafe#copyMemory(Object src, long srcOffset, Object dest, long destOffset, long len)
для копирования данных из полей объекта прямо в массив и обратно, минуя вообще протаскивание данных через стек. К сожалению, данный способ сериализации оказался более медленным, что скорее всего объясняется тем, что копирование произвольного массива байтов работает менее эффективно, чем копирование лонгов и интов. Хотя если объект состоит из больших массивов, то данный подход скорее всего даст выигрыш. Что же касается десериализации, то данный подход не проканал. Метод copyMemory бросает IllegalArgumentException. Прочтение сорсов OpenJDK (метод Unsafe_CopyMemory2), помогло прояснить, что данный метод поддерживает в качестве объекта назначения только массив данных. Связано это со сложностью реализации для произвольного объекта, так это бы требовало card marking, используемого для трекинга модифицированных объектов сборщиком мусора, о котором я уже кратко писал на своей страничке в Google+.
Интеграция с ремоутингом
И еще одна мысль. Если вы используете сериализацию для передачи данных по сети, то имеет смысл интегрировать ее с ремоутингом более тесно и сериализовать объект не в массив байтов, а прямо в ByteBuffer. Фишка в том, что если вы будете использовать direct ByteBuffer, то потом сможете его передать напрямую в канал сокета с помощью метода
java.nio.channels.SocketChannel#write(ByteBuffer src)
тем самым сэкономив на лишнем копировании массива байтов. К тому же, используя direct ByteBuffer, при записи интов и лонгов он за вас будет использовать описанные выше инструкции Unsafe#putLong, тем самым оптимизируя вашу сериализацию. С другой стороны, наверное, не стоит по этому поводу сильно заморачиваться, так как все равно львинную часть latency в ремоутинге у вас вызовет именно сетевой интерфейс, а не сериализация.