Java 8 和 Java 11 之间的不同反序列化行为

Different deserialization behavior between Java 8 and Java 11

我在 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 中,结果是 "ok",如果我 运行 它在 Java 11 中,结果是 NullPointerException 因为 retrievedElement.idFrom(retrievedElement) returns null.

我在 HashMap.hash() 处设置了一个断点并注意到:

我理解这里的顺序是反序列化(Element(222)) -> 反序列化(idFromElement) -> 将未完成的Element(222) 放入Map。但是,出于某种原因,在 Java 8 中,当我们到达最后一步时,id 已经初始化,而在 Java 11 中,它不是。

我想出的解决方案是使 idFromElement 成为瞬态并编写自定义 writeObjectreadObject 方法来强制 idFromElement 在 [=23= 之后反序列化]:

...
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();
}

我在 serialization/deserialization 期间找到的关于订单的唯一参考是:

For serializable classes, the SC_SERIALIZABLE flag is set, the number of fields counts the number of serializable fields and is followed by a descriptor for each serializable field. The descriptors are written in canonical order. The descriptors for primitive typed fields are written first sorted by field name followed by descriptors for the object typed fields sorted by field name. The names are sorted using String.compareTo.

这在两个 Java 8 and 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;
    }
}

正如评论中提到的和提问者的鼓励,这里是版本 8 和版本 11 之间更改的代码部分,我 假设 是不同的行为(基于阅读和调试)。

区别在于ObjectInputStreamclass,在它的核心方法之一。这是 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,它读取字段的值。如规范引用部分所述,它首先处理 primitive 字段的字段描述符。为这些字段读取的值 在读取它们后立即设置

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);
}

...

已引入内部 class、FieldValuesdefaultReadFields 方法现在仅 读取 字段值,并且 returns 它们作为 FieldValues 对象。之后,通过将此 FieldValues 对象传递给新引入的 defaultSetFieldValues 方法,将返回值分配给字段,该方法在内部执行最初在原始值之后立即完成的 desc.setPrimFieldValues(obj, primValues) 调用已阅读。

再次强调:defaultReadFields方法首先读取原始字段值。然后它 读取 non-primitive 字段值。但它会在 设置原始字段值之前这样做!

这个新进程干扰了 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 方法,一个一个地读取键和值对象,并将它们放入 table。这与手动填充地图时使用的方法相同(即以编程方式填充地图,而不是反序列化)。

Holger 已经在评论中给出了为什么这是必要的提示:不能保证反序列化密钥的哈希码与序列化之前相同。所以盲目 "restoring the original array" 基本上会导致对象在错误的哈希码下存储在 table 中。

但在这里,相反的情况发生了:键(即 Element 类型的对象)被反序列化。它们包含 idFromElement 地图,而地图又包含 Element 对象。这些元素被放入映射中, Element 对象仍在反序列化过程中,使用putVal 方法。但是由于 ObjectInputStream 中的更改顺序,这是在 设置 id 字段的原始值(确定哈希码)之前完成的。因此对象使用散列码 0 存储,然后分配 id 值(例如值 222),导致对象最终出现在 table在他们实际上不再拥有的哈希码下。


现在,在更抽象的层面上,从观察到的行为中已经很清楚了。因此,原题是不是"What is going on here???",而是

if my proposed workaround looks ok, or if there is something better I could do.

我认为解决方法 可能 没问题,但会犹豫地说那里不会出错。情况很复杂。

从第二部分开始:更好的办法是在 Java Bug Database 提交错误报告,因为新行为显然已被破坏。可能很难指出违反的规范,但反序列化的映射肯定不一致,这不是acceptable。


(是的,我也可以提交错误报告,但我认为可能需要进行更多研究以确保其编写正确,而不是重复等....)

我想为上面的优秀答案添加一个可能的解决方案:

除了使 idFromElement 成为瞬态并强制 HashMap 在 id 之后被反序列化 ,您还可以使 id 不是最终的并在 调用 defaultReadObject().

之前对其进行反序列化

这使得解决方案更具可扩展性,因为可能有其他 classes/对象使用 hashCodeequals 方法或导致与您描述的类似循环的 id。

它也可能导致问题的更通用的解决方案,尽管这还没有完全考虑清楚:other 对象的反序列化中使用的所有信息都需要被反序列化 before defaultReadObject() 被调用。这可能是 id,但也可能是您的 class 公开的其他字段。