I'm workig (as a part of a team) on an Enterprise Java application that has been in active development for 15+ years now. We are looking to build a REST API that would call some of the EJBs of the 'core' application. Currently the 'core' is running on Java 8 (Oracle) and we'd like to use Java 21 or Java 11 for developing the REST API.
While working on the POC we've noticed that the return value of a specific EJB call cannot be deserialised by newer Java versions.
I have managed to create a simplified reproducer that contains only the required classes simplified as much as possible to reproduce the issue. That reproducer can be found overhere.
If a specific object graph is constructed as in the reproducer and it's serialised to a file when the Airport object is the root then deserialization fails with the below NPE:
java.lang.NullPointerException
at edu.gozke.BaseCodeObject.hashCode(BaseCodeObject.java:57)
at java.base/java.util.HashMap.hash(HashMap.java:340)
at java.base/java.util.HashMap.put(HashMap.java:608)
at java.base/java.util.HashSet.readObject(HashSet.java:343)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.base/java.lang.reflect.Method.invoke(Method.java:566)
at java.base/java.io.ObjectStreamClass.invokeReadObject(ObjectStreamClass.java:1046)
at java.base/java.io.ObjectInputStream.readSerialData(ObjectInputStream.java:2357)
at java.base/java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:2228)
at java.base/java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1687)
at java.base/java.io.ObjectInputStream.defaultReadFields(ObjectInputStream.java:2496)
at java.base/java.io.ObjectInputStream.readSerialData(ObjectInputStream.java:2390)
at java.base/java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:2228)
at java.base/java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1687)
at java.base/java.io.ObjectInputStream.defaultReadFields(ObjectInputStream.java:2496)
at java.base/java.io.ObjectInputStream.readSerialData(ObjectInputStream.java:2390)
at java.base/java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:2228)
at java.base/java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1687)
at java.base/java.io.ObjectInputStream.defaultReadFields(ObjectInputStream.java:2496)
at java.base/java.io.ObjectInputStream.readSerialData(ObjectInputStream.java:2390)
at java.base/java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:2228)
at java.base/java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1687)
at java.base/java.io.ObjectInputStream.readObject(ObjectInputStream.java:489)
at java.base/java.io.ObjectInputStream.readObject(ObjectInputStream.java:447)
at edu.gozke.SerializationTest.readObject(SerializationTest.java:47)
at edu.gozke.SerializationTest.deserializationFailsIfAirportIsTheRoot(SerializationTest.java:26)
The same code works fine with Java 8 (does not throw an NPE). Staring with java 9 the above rror occurs. See the linked repo for other tried JDK versions.
My questions about the problem are:
Edit: As suggested I'll include the source below. (now without raw types and useless assert
statements)
public class City extends BaseCodeObject {
private final Set<Airport> airports = new HashSet<>();
City(String iataCityCode) {
super(iataCityCode);
}
public void addAirport(Airport airport) {
airports.add(airport);
}
}
public abstract class BaseCodeObject implements Serializable, Comparable<BaseCodeObject> {
protected String code;
protected int hash;
protected BaseCodeObject(String code) {
assert (code != null);
this.code = code;
}
public final String getCode() {
return code;
}
@Override
public int compareTo(BaseCodeObject obj) {
return code.compareTo(obj.code);
}
public boolean equals(Object otherObject) {
if (otherObject == this) {
return true;
}
if (otherObject == null) {
return false;
}
if (!(otherObject instanceof BaseCodeObject)) {
return false;
}
BaseCodeObject otherBaseObject = (BaseCodeObject) otherObject;
return code.equals(otherBaseObject.code);
}
@Override
public int hashCode() {
if (hash == 0) {
hash = code.hashCode();
}
return hash;
}
}
public class Airport extends CodedBaseDataContainer {
Airport(String iataAirportCode) {
super(iataAirportCode);
}
void addAirportData(AirportData data) {
addBaseData(data);
}
}
public abstract class CodedBaseDataContainer extends BaseCodeObject {
private final static PeriodComparator COMPARATOR = new PeriodComparator();
protected final TreeMap<String, CodedPeriodBaseData> dataMap = new TreeMap<>(COMPARATOR);
protected CodedBaseDataContainer(String code) {
super(code);
}
protected final void addBaseData(CodedPeriodBaseData baseData) {
dataMap.put(baseData.getEffectivePeriod(), baseData);
}
private static class PeriodComparator implements Comparator<String>, Serializable {
public int compare(String period1, String period2) {
return period1.compareTo(period2);
}
}
}
public class AirportData extends CodedPeriodBaseData {
private final City city;
AirportData(Airport airport, City city, String effectivePeriod) {
super(airport, effectivePeriod);
this.city = city;
airport.addAirportData(this);
}
}
public abstract class CodedPeriodBaseData extends BaseCodeObject {
private final String effectivePeriod;
protected final CodedBaseDataContainer container;
protected CodedPeriodBaseData(CodedBaseDataContainer container, String effectivePeriod) {
super(container.getCode());
this.container = container;
this.effectivePeriod = effectivePeriod;
}
public String getEffectivePeriod() {
return effectivePeriod;
}
@Override
public boolean equals(Object o) {
if (o == this) {
return true;
}
if (o == null) {
return false;
}
if (!(o instanceof CodedPeriodBaseData)) {
return false;
}
CodedPeriodBaseData data = (CodedPeriodBaseData) o;
return code.equals(data.code) && effectivePeriod.equals(data.effectivePeriod);
}
@Override
public int hashCode() {
if (hash == 0) {
hash = 17;
hash = 37 * hash + code.hashCode();
hash = 37 * hash + effectivePeriod.hashCode();
}
return hash;
}
@Override
public int compareTo(BaseCodeObject obj) {
CodedPeriodBaseData data = (CodedPeriodBaseData) obj;
int cmpCode = code.compareTo(data.code);
if (cmpCode != 0) {
return cmpCode;
}
return effectivePeriod.compareTo(data.effectivePeriod);
}
}
And the problematic reproducer method:
public void deserializationFailsIfAirportIsTheRoot() throws ClassNotFoundException, IOException {
City city = new City("BB");
Airport airport = new Airport("BB");
city.addAirport(airport);
new AirportData(airport, city, "dummyKey");
writeToFile(airport, "airport");
Object object = readObject("airport");
System.out.println(object.hashCode());
}
private static final Object readObject(String fileName) throws IOException, ClassNotFoundException {
try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(fileName))) {
return ois.readObject();
}
}
private static void writeToFile(Object obj, String fileName) throws FileNotFoundException, IOException {
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(fileName))) {
oos.writeObject(obj);
}
}
So based on forty-two's answer I started exploring the other StackOverflow question and it lead me down a rabbit hole to find the commit in the openjdk repo that introduced this behavior.
This commit right here is where the relevant change was introduced. Judging from the related JBS ticket's description the author was unaware of this side effect.
I couldn't find any pre-built binaries to prove that this commit 'broke' deserialization so I've decided to build two versions locally. One with the breaking commit (e11aec59a2705854ded7eaf2103d9cde57d81652)
(A) and one from a version just before that commit (a1e2230a4045afb7930f95dca6a351339403f41d)
(B).
The reproducer throws an NPE with build A but works fine with build B.
During the research my team mate found that the problem has already been reported at least twice in the JBS, but haven't been tocuhed for a while. These issues are:
Based on the noted workaround in JDK-8199664 if I extend the BaseCodeObject
class with the below method then deserialization works fine.
private void readObject(final ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
}
With this my original question has been anwered.