14

I have a problem with deserialization in Java 11 that results in a HashMap with a key that can't be found. I would appreciate if anyone with more knowledge about the issue could say if my proposed workaround looks ok, or if there is something better I could do.

Consider the following contrived implementation (the relationships in the real problem are a bit more complex and hard to change):

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

Then I create an instance that has a reference to itself and serialize and deserialize it:

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

If I run this code in Java 8 the result is "ok", if I run it in Java 11 the result is a NullPointerException because retrievedElement.idFrom(retrievedElement) returns null.

I put a breakpoint at HashMap.hash() and noticed that:

  • In Java 8, when idFromElement is being deserialized and Element(222) is being added to it, its id is 222, so I am able to find it later.
  • In Java 11, the id is not initialized (0 for int or null if I make it an Integer), so hash() is 0 when it's stored in the HashMap. Later, when I try to retrieve it, the id is 222, so idFromElement.get(element) returns null.

I understand that the sequence here is deserialize(Element(222)) -> deserialize(idFromElement) -> put unfinished Element(222) into Map. But, for some reason, in Java 8 id is already initialized when we get to the last step, while in Java 11 it is not.

The solution I came up with was to make idFromElement transient and write custom writeObject and readObject methods to force idFromElement to be deserialized after 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();
}

The only reference I was able to find about the order during serialization/deserialization was this:

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.

Which is the same in both Java 8 and Java 11 docs, and seems to imply that primitive typed fields should be written first, so I expected there would be no difference.


Implementation of Storage<T> included for completeness:

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;
    }
}
Anderson Vieira
  • 8,919
  • 2
  • 37
  • 48
  • 2
    After comparing the `ObjectInputStream` implementations of 8 and 11, I'm tempted to consider this as a bug: In 8, it *reads* the primitive field values and then *sets* them, and then it *reads* the object field values and then *sets* them. In 11, it *reads* the primitive field values, then *reads* the object field values, and then *sets* the primitive field values, and then *sets* the object field values. I'd be going out on a limb when suggesting that it really **is** wrong (and thus a bug), but if you you think it's helpful, I'd carve out the relevant code parts into an answer. – Marco13 Jun 13 '19 at 11:43
  • 1
    The cited part only says in which order descriptors are written. It does not say anything about the order in which field values are read. – Holger Jun 13 '19 at 12:16
  • @Holger : After a quick look at https://docs.oracle.com/en/java/javase/11/docs/specs/serialization/protocol.html , the grammar seems to say that the class data is written with *"fields in order of class descriptor"*, but I'd have to re-read the spec to verify that this has the meaning that I suspect now (namely, that the quoted section indeed *is* relevant). Beyond that, I think that the behavior in 11 is flawed, although it's hard to say whether this is solely caused by the changes in `OIS`, or by `HashMap#readObject`, which uses `putVal` instead of plainly deserializing the table array... – Marco13 Jun 13 '19 at 12:37
  • 2
    @Marco13 Serialization is flawed in general. There are plenty of problems with such circular object graphs. `HashMap`’s strategy is not entirely wrong, as there is no guaranty that an object’s hash code is the same after deserialization. Think of all objects not having overridden `Object.hashCode()`. It could postpone the re-hashing via registering an `ObjectInputValidation`, but even that has problems. The protocol says that values are *written* in the order of the descriptors, still, this doesn’t say that they are read resp. restored in that order. – Holger Jun 13 '19 at 12:43
  • 1
    @Marco13 just to prevent misunderstandings: even without an explicit definition, I agree that the previous behavior should be kept, unless there’s a strong reason for the change. There’s no point in adding another problem to the pile. If they deprecate and remove it, fine. Otherwise, it should stay in a compatibility maintenance mode. – Holger Jun 13 '19 at 12:49
  • @Holger I haven't used serialization extensively, and can imagine that it's difficult to handle circular references, but they are already covered as far as possible. The point is also not that the hash code changes. The point here is only that a primitive field value (which is incidentally *used* as the hash code) is set "too late" in the deserialization process. This *specific* setting may be a corner case that is hard to cover via the spec, but *in general*, it worked with 8, and the state of the map in 11 is simply inconsistent. IMHO, this should be fixed either in `OIS` or in `HashMap`. – Marco13 Jun 13 '19 at 12:49
  • (Our last comments overlapped, but I think we agree in general) – Marco13 Jun 13 '19 at 12:50
  • 1
    @Marco13 exactly. `HashMap`’s strategy hasn’t changed, as the code is prepared to handle changing hash codes (which is not the case here), but due to the old field restoration order, this particular use case worked and unless the change fixes an even bigger problem, it should not be made. The fundamental flaws can’t be fixed anyway. – Holger Jun 13 '19 at 12:53
  • 2
    Note that JDK 8’s behavior was established with JDK 1.4, [similarly breaking compatibility](https://bugs.openjdk.java.net/browse/JDK-6208166). The suggested work-around at that time looks pretty much like the work-around in this question. It also [has been recognized](https://bugs.openjdk.java.net/browse/JDK-8201131) that JDK 9 made compatibility breaking changes, though the bug report is a slightly different case. – Holger Jun 13 '19 at 13:24
  • @Marco13, thank you. If you could write an answer as you suggested in your first comment, it would surely be helpful. – Anderson Vieira Jun 14 '19 at 13:44
  • @Holger, thank you for the discussion and the links. – Anderson Vieira Jun 14 '19 at 13:45

2 Answers2

6

As mentioned in the comments and encouraged by the asker, here are the parts of the code that changed between version 8 and version 11 that I assume to be the reason for the different behavior (based on reading and debugging).

The difference is in the ObjectInputStream class, in one of its core methods. This is the relevant part of the implementation in 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;
}
...

The method calls defaultReadFields, which reads the values of the fields. As mentioned in the quoted part of the specification, it first handles the field descriptors of primitive fields. The values that are read for these fields are set immediately after reading them, with

desc.setPrimFieldValues(obj, primVals);

and importantly: This happens before it calls readObject0 for each of the non-primitive fields.

In contrast to that, here is the relevant part of the implementation of 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);
}

...

An inner class, FieldValues, has been introduced. The defaultReadFields method now only reads the field values, and returns them as a FieldValuesobject. Afterwards, the returned values are assigned to the fields, by passing this FieldValues object to a newly introduced defaultSetFieldValues method, which internally does the desc.setPrimFieldValues(obj, primValues) call that originally was done immediately after the primitive values had been read.

To emphasize this again: The defaultReadFields method first reads the primitive field values. Then it reads the non-primitive field values. But it does so before the primitive field values have been set!

This new process interferes with the deserialization method of HashMap. Again, the relevant part is shown here:

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

It reads the key- and value objects, one by one, and puts them into the table, by computing the hash of the key and using the internal putVal method. This is the same method that is used when manually populating the map (i.e. when it is filled programmatically, and not deserialized).

Holger already gave a hint in the comments why this is necessary: There is no guarantee that the hash code of the deserialized keys will be the same as before the serialization. So blindly "restoring the original array" could basically lead to objects being stored in the table under a wrong hash code.

But here, the opposite happens: The keys (i.e. the objects of type Element) are deserialized. They contain the idFromElement map, which in turn contains the Element objects. These elements are put into the map, while the Element objects are still in the process of being deserialized, using the putVal method. But due to the changed order in ObjectInputStream, this is done before the primitive value of the id field (which determines the hash code) has been set. So the objects are stored using hash code 0, and later, the id values is assigned (e.g. the value 222), causing the objects to end up in the table under a hash code that they actually no longer have.


Now, on a more abstract level, this was already clear from the observed behavior. Therefore, the original question was not "What is going on here???", but

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

I think that the workaround could be OK, but would hesitate to say that nothing could go wrong there. It's complicated.

As of the second part: Something better could be to file a bug report at the Java Bug Database, because the new behavior is clearly broken. It may be hard to point out a specification that is violated, but the deserialized map is certainly inconsistent, and this is not acceptable.


(Yes, I could also file a bug report, but think that more research might be necessary in order to make sure it is written properly, not a duplicate, etc....)

Marco13
  • 53,703
  • 9
  • 80
  • 159
0

I want to add one possible solution to the excellent answers above:

Instead of making idFromElement transient and forcing the HashMap to be deserialized after the id, you could also make id not final and deserialize it before calling defaultReadObject().

This makes the solution more scalable, since there could be other classes / objects using the hashCode and equals methods or the id leading to similar cycles as you described.

It might also lead to a more generic solution of the problem, although this is not yet completely thought out: All the information that is used in the deserialization of other objects needs to be deserialized before defaultReadObject() is called. That might be the id, but also other fields that your class exposes.