【问题标题】:Different deserialization behavior between Java 8 and Java 11Java 8 和 Java 11 之间不同的反序列化行为
【发布时间】:2019-10-27 08:15:40
【问题描述】:

我在 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) 返回 null

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

  • 在Java 8中,当idFromElement被反序列化并添加Element(222)时,它的id是222,所以我以后可以找到它。
  • 在 Java 11 中,id 未初始化(int 为 0,如果我将其设为 Integer,则为 null),因此当它存储在 HashMap 中时,hash() 为 0。后来,当我尝试检索它时,id 是 222,所以idFromElement.get(element) 返回null

我知道这里的顺序是反序列化(元素(222))->反序列化(idFromElement)->将未完成的元素(222)放入Map中。但是,由于某种原因,在 Java 8 中,id 在我们进行到最后一步时已经初始化,而在 Java 11 中则没有。

我想出的解决方案是使idFromElement 瞬态并编写自定义writeObjectreadObject 方法来强制idFromElementid 之后反序列化:

...
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 8Java 11 文档中是相同的,并且似乎暗示应该首先编写原始类型字段,所以我预计不会有任何区别。


为了完整起见,包含了Storage&lt;T&gt; 的实现:

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 的 ObjectInputStream 实现之后,我很想将其视为一个错误:在 8 中,它读取原始字段值,然后 设置它们,然后它读取对象字段值,然后设置它们。在11中,它读取原始字段值,然后读取对象字段值,然后设置原始字段值,然后设置对象字段值。当我建议它确实错误(因此是一个错误)时,我会四处走动,但如果你认为它有帮助,我会将相关的代码部分分割成一个答案.
  • 引用部分仅说明了描述符的写入顺序。它没有说明读取字段值的顺序。
  • @Marco13 序列化通常是有缺陷的。这种圆形对象图有很多问题。 HashMap 的策略并非完全错误,因为无法保证反序列化后对象的哈希码相同。想想所有没有被覆盖的对象Object.hashCode()。它可以通过注册ObjectInputValidation 来推迟重新散列,但即使这样也有问题。协议说值是按描述符的顺序写入的,但这并没有说它们是分别读取的。按此顺序恢复。
  • @Marco13 只是为了防止误解:即使没有明确的定义,我同意应该保留以前的行为,除非有充分的理由进行更改。再增加一个问题是没有意义的。如果他们弃用并删除它,那很好。否则,它应该保持在兼容性维护模式。
  • 请注意,JDK 8 的行为是在 JDK 1.4 中建立的,similarly breaking compatibility。当时建议的解决方法看起来很像这个问题中的解决方法。 has been recognized JDK 9 也做出了兼容性破坏性更改,尽管错误报告的情况略有不同。

标签: java serialization java-8 deserialization java-11


【解决方案1】:

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

除了使idFromElement 瞬态并强制在id 之后 反序列化HashMap,您还可以使id 不是最终的并反序列化它之前打电话给defaultReadObject()

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

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

【讨论】:

    【解决方案2】:

    正如 cmets 中所述并受到提问者的鼓励,以下是我假设在版本 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,它读取字段的值。如规范中引用的部分所述,它首先处理 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);
    }
    
    ...
    

    引入了内部类FieldValuesdefaultReadFields 方法现在只读取字段值,并将它们作为FieldValuesobject 返回。之后,通过将此 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 方法,一个一个地读取键和值对象,并将它们放入表中。这与手动填充地图时使用的方法相同(即,当它以编程方式填充,而不是反序列化时)。

    Holger 已经在 cmets 中给出了为什么需要这样做的提示:无法保证反序列化密钥的哈希码与序列化之前相同。所以盲目地“恢复原始数组”基本上会导致对象以错误的哈希码存储在表中。

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


    现在,在更抽象的层面上,从观察到的行为中已经很清楚了。因此,最初的问题是不是“这是怎么回事???”,而是

    如果我建议的解决方法看起来不错,或者如果有更好的方法我可以做。

    我认为解决方法可能没问题,但会犹豫说那里没有任何问题。情况很复杂。

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


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

    【讨论】:

      猜你喜欢
      • 2021-05-25
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2020-07-26
      • 2020-09-05
      • 2018-08-14
      • 1970-01-01
      • 2021-07-25
      相关资源
      最近更新 更多