javagenericsjdbijava-21

Incompatible changes in generics in Java 21


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?

Update 17-Nov-2023

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.

Update 20-Nov-2023

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.

Update 22-Nov-2023

The reported bug has been evaluated and assigned Bug ID: JDK-8320575 and is now visible on the url JDK-8320575


Solution

  • I can reproduce your problem after applying some corrections:

    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.