Различное поведение десериализации между Java 8 и Java 11 - PullRequest
12 голосов
/ 12 июня 2019

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

Рассмотрим следующую надуманную реализацию (отношения в реальной задаче немного сложнее и сложнее изменить):

public class Element implements Serializable {
    private static long serialVersionUID = 1L;

    private final int id;
    private final Map<Element, Integer> idFromElement = new HashMap<>();

    public Element(int id) {
        this.id = id;
    }

    public void addAll(Collection<Element> elements) {
        elements.forEach(e -> idFromElement.put(e, e.id));
    }

    public Integer idFrom(Element element) {
        return idFromElement.get(element);
    }

    @Override
    public int hashCode() {
        return id;
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }
        if (!(obj instanceof Element)) {
            return false;
        }
        Element other = (Element) obj;
        return this.id == other.id;
    }
}

Затем я создаю экземпляр, который имеет ссылку на себя, сериализует и десериализует его:

public static void main(String[] args) {
    List<Element> elements = Arrays.asList(new Element(111), new Element(222));
    Element originalElement = elements.get(1);
    originalElement.addAll(elements);

    Storage<Element> storage = new Storage<>();
    storage.serialize(originalElement);
    Element retrievedElement = storage.deserialize();

    if (retrievedElement.idFrom(retrievedElement) == 222) {
        System.out.println("ok");
    }
}

Если я запускаю этот код в Java 8, результат будет «хорошо», если я запускаю его в Java 11, результат будет NullPointerException, потому что retrievedElement.idFrom(retrievedElement) возвращает null.

Я установил точку останова на HashMap.hash() и заметил, что:

  • В Java 8, когда idFromElement десериализуется и к нему добавляется Element(222), его id равен 222, поэтому я могу найти его позже.
  • В Java 11 id не инициализируется (0 для int или ноль, если я сделаю его Integer), поэтому hash() равно 0, когда оно хранится в HashMap. Позже, когда я пытаюсь получить его, id равен 222, поэтому idFromElement.get(element) возвращает null.

Я понимаю, что последовательность здесь десериализуется (Элемент (222)) -> Десериализация (idFromElement) -> помещает незавершенный Элемент (222) в Map. Но по какой-то причине в Java 8 id уже инициализируется, когда мы переходим к последнему шагу, а в Java 11 - нет.

Решение, которое я придумал, состояло в том, чтобы сделать idFromElement кратковременным и написать собственные методы writeObject и readObject, чтобы заставить десериализовать idFromElement после id:

...
transient private Map<Element, Integer> idFromElement = new HashMap<>();
...
private void writeObject(ObjectOutputStream output) throws IOException {
    output.defaultWriteObject();
    output.writeObject(idFromElement);
}

@SuppressWarnings("unchecked")
private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException {
    input.defaultReadObject();
    idFromElement = (HashMap<Element, Integer>) input.readObject();
}

Единственная ссылка, которую мне удалось найти о заказе во время сериализации / десериализации, была такая:

Для сериализуемых классов установлен флаг SC_SERIALIZABLE, количество полей подсчитывает количество сериализуемых полей и сопровождается дескриптором для каждого сериализуемого поля. Дескрипторы написаны в каноническом порядке. Дескрипторы для примитивных типизированных полей пишутся сначала отсортированными по имени поля, затем идут дескрипторы для типизированных полей, отсортированных по имени поля. Имена сортируются с использованием String.compareTo.

Что одинаково в документах Java 8 и Java 11 , и, похоже, подразумевает, что поля с примитивным типом должны быть записаны первыми, поэтому я ожидал, что разницы не будет.


Реализация Storage<T> включена для полноты:

public class Storage<T> {
    private final ByteArrayOutputStream buffer = new ByteArrayOutputStream();

    public void serialize(T object) {
        buffer.reset();
        try (ObjectOutputStream objectOutputStream = new ObjectOutputStream(buffer)) {
            objectOutputStream.writeObject(object);
            objectOutputStream.flush();
        } catch (Exception ioe) {
            ioe.printStackTrace();
        }
    }

    @SuppressWarnings("unchecked")
    public T deserialize() {
        ByteArrayInputStream byteArrayIS = new ByteArrayInputStream(buffer.toByteArray());
        try (ObjectInputStream objectInputStream = new ObjectInputStream(byteArrayIS)) {
            return (T) objectInputStream.readObject();
        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }
        return null;
    }
}

1 Ответ

3 голосов
/ 14 июня 2019

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

Разница заключается в классе ObjectInputStream, в одном из его основных методов. Это соответствующая часть реализации в Java 8:

private void readSerialData(Object obj, ObjectStreamClass desc)
    throws IOException
{
    ObjectStreamClass.ClassDataSlot[] slots = desc.getClassDataLayout();
    for (int i = 0; i < slots.length; i++) {
        ObjectStreamClass slotDesc = slots[i].desc;

        if (slots[i].hasData) {
            if (obj == null || handles.lookupException(passHandle) != null) {
                ...
            } else {
                defaultReadFields(obj, slotDesc);
            }
            ...
        }
    }
}

/**
 * Reads in values of serializable fields declared by given class
 * descriptor.  If obj is non-null, sets field values in obj.  Expects that
 * passHandle is set to obj's handle before this method is called.
 */
private void defaultReadFields(Object obj, ObjectStreamClass desc)
    throws IOException
{
    Class<?> cl = desc.forClass();
    if (cl != null && obj != null && !cl.isInstance(obj)) {
        throw new ClassCastException();
    }

    int primDataSize = desc.getPrimDataSize();
    if (primVals == null || primVals.length < primDataSize) {
        primVals = new byte[primDataSize];
    }
    bin.readFully(primVals, 0, primDataSize, false);
    if (obj != null) {
        desc.setPrimFieldValues(obj, primVals);
    }

    int objHandle = passHandle;
    ObjectStreamField[] fields = desc.getFields(false);
    Object[] objVals = new Object[desc.getNumObjFields()];
    int numPrimFields = fields.length - objVals.length;
    for (int i = 0; i < objVals.length; i++) {
        ObjectStreamField f = fields[numPrimFields + i];
        objVals[i] = readObject0(f.isUnshared());
        if (f.getField() != null) {
            handles.markDependency(objHandle, passHandle);
        }
    }
    if (obj != null) {
        desc.setObjFieldValues(obj, objVals);
    }
    passHandle = objHandle;
}
...

Метод вызывает defaultReadFields, который считывает значения полей. Как упомянуто в цитируемой части спецификации, он сначала обрабатывает дескрипторы полей примитивных полей. Значения, которые считываются для этих полей , устанавливаются сразу после их чтения , с

desc.setPrimFieldValues(obj, primVals);

и, что важно: это происходит за до вызова readObject0 для каждого из не -примитивных полей.

В отличие от этого, вот соответствующая часть реализации Java 11:

private void readSerialData(Object obj, ObjectStreamClass desc)
    throws IOException
{
    ObjectStreamClass.ClassDataSlot[] slots = desc.getClassDataLayout();

    ...

    for (int i = 0; i < slots.length; i++) {
        ObjectStreamClass slotDesc = slots[i].desc;

        if (slots[i].hasData) {
            if (obj == null || handles.lookupException(passHandle) != null) {
                ...
            } else {
                FieldValues vals = defaultReadFields(obj, slotDesc);
                if (slotValues != null) {
                    slotValues[i] = vals;
                } else if (obj != null) {
                    defaultCheckFieldValues(obj, slotDesc, vals);
                    defaultSetFieldValues(obj, slotDesc, vals);
                }
            }
            ...
        }
    }
    ...
}

private class FieldValues {
    final byte[] primValues;
    final Object[] objValues;

    FieldValues(byte[] primValues, Object[] objValues) {
        this.primValues = primValues;
        this.objValues = objValues;
    }
}

/**
 * Reads in values of serializable fields declared by given class
 * descriptor. Expects that passHandle is set to obj's handle before this
 * method is called.
 */
private FieldValues defaultReadFields(Object obj, ObjectStreamClass desc)
    throws IOException
{
    Class<?> cl = desc.forClass();
    if (cl != null && obj != null && !cl.isInstance(obj)) {
        throw new ClassCastException();
    }

    byte[] primVals = null;
    int primDataSize = desc.getPrimDataSize();
    if (primDataSize > 0) {
        primVals = new byte[primDataSize];
        bin.readFully(primVals, 0, primDataSize, false);
    }

    Object[] objVals = null;
    int numObjFields = desc.getNumObjFields();
    if (numObjFields > 0) {
        int objHandle = passHandle;
        ObjectStreamField[] fields = desc.getFields(false);
        objVals = new Object[numObjFields];
        int numPrimFields = fields.length - objVals.length;
        for (int i = 0; i < objVals.length; i++) {
            ObjectStreamField f = fields[numPrimFields + i];
            objVals[i] = readObject0(f.isUnshared());
            if (f.getField() != null) {
                handles.markDependency(objHandle, passHandle);
            }
        }
        passHandle = objHandle;
    }

    return new FieldValues(primVals, objVals);
}

...

Введен внутренний класс, FieldValues. Метод defaultReadFields теперь только читает значения полей и возвращает их как FieldValues объект. После этого возвращенные значения присваиваются полям путем передачи этого объекта FieldValues во вновь введенный метод defaultSetFieldValues, который внутренне выполняет вызов desc.setPrimFieldValues(obj, primValues), который первоначально был выполнен сразу после считывания примитивных значений.

Чтобы еще раз подчеркнуть это: метод defaultReadFields сначала читает значения примитивного поля. Затем он читает значения непримитивного поля. Но это так до значения примитивных полей были установлены!

Этот новый процесс сталкивается с методом десериализации HashMap. Опять же, соответствующая часть показана здесь:

private void readObject(java.io.ObjectInputStream s)
    throws IOException, ClassNotFoundException {

    ...

    int mappings = s.readInt(); // Read number of mappings (size)
    if (mappings < 0)
        throw new InvalidObjectException("Illegal mappings count: " +
                                         mappings);
    else if (mappings > 0) { // (if zero, use defaults)

        ...

        Node<K,V>[] tab = (Node<K,V>[])new Node[cap];
        table = tab;

        // Read the keys and values, and put the mappings in the HashMap
        for (int i = 0; i < mappings; i++) {
            @SuppressWarnings("unchecked")
                K key = (K) s.readObject();
            @SuppressWarnings("unchecked")
                V value = (V) s.readObject();
            putVal(hash(key), key, value, false, false);
        }
    }
}

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

Хольгер уже дал подсказку в комментариях, почему это необходимо: нет гарантии, что хеш-код десериализованных ключей будет таким же, как и до сериализации. Таким образом, вслепую «восстановление исходного массива» может привести к тому, что объекты будут храниться в таблице с неверным хеш-кодом.

Но здесь происходит обратное: ключи (то есть объекты типа Element) десериализованы. Они содержат карту idFromElement, которая в свою очередь содержит объекты Element. Эти элементы помещаются в карту, , в то время как объекты Element все еще находятся в процессе десериализации, используя метод putVal. Но из-за измененного порядка в ObjectInputStream это делается до того, как будет установлено примитивное значение поля id (которое определяет хэш-код). Таким образом, объекты хранятся с использованием хеш-кода 0, а затем присваиваются значения id (например, значение 222), в результате чего объекты оказываются в таблице под хеш-кодом, которого у них больше нет .


Теперь, на более абстрактном уровне, это было уже ясно из наблюдаемого поведения. Поэтому первоначальный вопрос был не «Что здесь происходит ???», а

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

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

Что касается второй части: Что-то лучше может быть в том, чтобы подать отчет об ошибке в Java Bug Database , потому что новое поведение явно нарушено. Может быть трудно указать на спецификацию, которая нарушена, но десериализованная карта определенно несовместима , и это неприемлемо.


(Да, я также мог бы подать отчет об ошибке, но подумал, что может потребоваться больше исследований, чтобы убедиться, что он написан правильно, а не дубликат и т. Д ....)

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...