Has anyone else also noticed changes in Java's generics in Java 21?
We're using Jdbi for database access and when updating to Java 21 things go wrong because a generic type cannot be determined anymore.
While debugging I noticed that a condition
java.lang.reflect.Type type = ...
if (type instanceof Class) {..}
where type
is an interface evaluates to false
in Java 17 but is true
in Java 21.
Jdbi depends on the Geantyref library for some of its generics. The static method GenericTypeReflector.getTypeParameter(..) returns the generic type in Java 17, but returns null
in Java 21.
Nowhere in the release notes or other announcements I can find anything about that something around generics has changed in Java 21. Nor that there are backwards compatible breaking changes.
I assume some internals might have changed due to record reflection (JEP-440) or the pattern matching for switch (JEP-441), but those features I am already using as 17-preview features without any issue.
Anyone an idea what caused theses changes and why?
I think I have figured it out. I trace the problem back to constructor arguments of a record class. It seems that in Java 21 for a record class somehow the arguments of the default constructor somehow do not have the generic type. So for a record like
record Person(Optional<String> name, Optional<Integer> age) {}
the generic types String
and Integer
are lost in the Person's default constructor.
The default constructor generated by Java can be changed by explicitly adding a canonical constructor with all record properties:
record Person(Optional<String> name, Optional<Integer> age) {
@JdbiConstructor
public Person(Optional<String> aName, Integer anAge) {
this(aName, anAge);
}
}
By adding the @JdbiConstructor
annotation (which Jdbi always requires on a Record) Jdbi is instructed which constructor to use.
Because this constructor now explicitly defines the argument types including the generic type, the reflection util is able to determine the type and I got it to work with Java 21.
The last 2 hours I have been trying to create a bare minimum test case to verify that the generic type is lost in the Record's default constructor arguments, but I have not been able to reproduce it in a test. Even though it fails consistently in our application. I have been comparing type arguments while debugging the application and the test at the same time. The arguments values and types look the same, but the test still succeeds in Java 21.
I'm leaving the test for now since I have to get on with my work and I now do have a working workaround by adding an additional constructor. I'll try to get back to this later. It must be reproducible in a simple test.
Thanks to Holger for creating a small test case proving the issue.
I had 2 things wrong in the 17-Nov update:
I have updated the 17-Nov update accordingly. Thanks for pointing out these mistakes.
I have submitted a bug report with ID 9076247
which is now under review.
The reported bug has been evaluated and assigned Bug ID: JDK-8320575 and is now visible on the url JDK-8320575
I can reproduce your problem after applying some corrections:
The problem does not occur when using
record Person(Optional<String> name, Optional<Integer> age) {}
But since you said, Jdbi always requires the @JdbiConstructor
annotation on a record, I assume that you provided an annotated compact constructor. Then, the problem occurs.
Your fix does not work at all. When swapping the two parameters, both of type Optional
, you get a constructor with the same erasure as the canonical constructor. This is not accepted by a correct compiler and even if a compiler accepted it, it would break at runtime.
It’s not correct that the canonical constructor can’t be fixed. You can create an explicit canonical constructor instead of the compact one.
Using the following test program:
import java.util.Optional;
public class Reproducer {
interface NoConstructorDeclarations {
record Person(Optional<String> name, Optional<Integer> age) {}
}
interface AnnotatedCompactConstructor {
record Person(Optional<String> name, Optional<Integer> age) {
@Deprecated public Person {}
}
}
interface AnotatedExplicitCanonicalConstructor {
record Person(Optional<String> name, Optional<Integer> age) {
@Deprecated
public Person(Optional<String> name, Optional<Integer> age) {
this.name = name;
this.age = age;
}
}
}
public static void main(String args[]) {
for(var approach: Reproducer.class.getDeclaredClasses()) {
Class<?> recordClass = approach.getClasses()[0];
System.out.println(approach.getSimpleName());
var constructor = recordClass.getConstructors()[0];
System.out.println(constructor.isAnnotationPresent(Deprecated.class));
for(var p: constructor.getParameters()) {
System.out.println(p);
}
System.out.println();
}
}
}
and javac
from JDK 21, I get:
AnotatedExplicitCanonicalConstructor
true
java.util.Optional<java.lang.String> name
java.util.Optional<java.lang.Integer> age
AnnotatedCompactConstructor
true
java.util.Optional name
java.util.Optional age
NoConstructorDeclarations
false
java.util.Optional<java.lang.String> name
java.util.Optional<java.lang.Integer> age
which acknowledges the problem and demonstrates a workaround. This problem does not occur with javac
of previous JDKs nor Eclipse’s compiler.
I used @Deprecated
instead of @JdbiConstructor
, to make the example independent from 3rd party libraries.