Mastodon

@JvmDefault: More Useful Than You Think

@JvmDefault is an annotation + compiler flag in Kotlin to enable using Java 8 default interface methods. It does more than it leads on though! This post explores some other bytecode optimizations you can gain with it as well as some hidden behaviors you should be aware of.

@JvmDefault: More Useful Than You Think

@JvmDefault is an annotation + compiler flag in Kotlin to enable using Java 8 default interface methods. It does more than it leads on though! This post explores some other bytecode optimizations you can gain with it as well as some hidden behaviors you should be aware of.

Note: For the purposes of simplicity, "Java" code blocks below really mean Java "equivalent" of the bytecode generated by the Kotlin compiler.

If you understand the basics of how @JvmDefault and DefaultImpls work, you can skip to the Going Deeper section.

Refresher

A basic interface using @JvmDefault could look like this:

// Kotlin
interface Taco {
  fun load()
}

In bytecode, this ends up like so:

// Java
interface Taco {
  void load();
}

Fairly simple! Now let's add a default body implementation.

// Kotlin
interface Taco {
  fun load() { }
}

The bytecode ends up something like this:

// Java
interface Taco {
  void load();
  
  final class DefaultImpls {
    public static void load(Taco instance) { }
  }
}

DefaultImpls is a compiler-generated construct for holding default implementations. In this case - the default load() implementation. It's implemented as a static method by the same name, return type, an instance parameter (`this` references in the function body are modified to refer to the instance parameter), and finally any other parameters.

DefaultImpls can hold other things too, such as defaults methods for functions with default parameter values. This is how Kotlin supports default methods natively, and why it can work even on Java 6. Some fun facts

If an implementing class doesn't define load(), then the compiler synthetic generates one just pointing to this static method.

// Kotlin
class TacoImpl : Taco
// Java
public final class TacoImpl implements Taco {
   public void load() {
      Taco.DefaultImpls.load(this);
   }
}

If we extend the interface from another, it will generate its own DefaultImpls class + load() method that just delegates to the original definition. These are duplicated each time into every extending interface.

// Kotlin
interface SpicyTaco : Taco
// Java
public interface SpicyTaco extends Taco {
   void load();

   final class DefaultImpls {
      public static void load(SpicyTaco instance) {
         Taco.DefaultImpls.load(instance);
      }
   }
}

Now let's bring in @JvmDefault. For simplicity, we'll start with -Xjvm-default=enable.

// Kotlin
interface Taco {
  @JvmDefault fun load()
}

Now in bytecode, we'll see it uses the native default interface method support and no longer uses DefaultImpls.

// Java
public interface Taco {
   @JvmDefault default void load() { }
}

If we use -Xjvm-default=compatibility, it will use both the native default support while also still generating the DefaultImpls API. This is important for backward binary compatibility, but not necessary if the interface method is new. Note however that now DefaultImpls#load() just defers back to instance's implementation.

// Java
interface Taco {
  @JvmDefault default void load() { }
  
  final class DefaultImpls {
    @JvmDefault public static void load(Taco instance) {
       instance.load();
    }
  }
}

Extending interfaces will also delegate now.

// Java
public interface SpicyTaco extends Taco {
   void load();

   final class DefaultImpls {
      public static void load(SpicyTaco instance) {
         instance.load(instance);
      }
   }
}

In both enable and compatibility cases, implementing classes compiling against this interface will just behave like a normal Java 8 class and not define the method at all.

// Java
public final class TacoImpl implements Taco { }

Whew! You made it 👏


Going Deeper

On paper and in documentation, this is the end of the story for @JvmDefault. But let's dig in some more. Consider this curious case:

// Kotlin
interface Taco {
  @JvmDefault fun load()
}

load() has no default method body, yet @JvmDefault is still allowed on it. That's weird 🤔. What's it look like in bytecode?

// Java
public interface Taco {
  @JvmDefault void load();
}

Weirder still. No default modifier. What's the point of this?

To explain, we need to briefly cover the other mentioned use of DefaultImpls: holding default values methods. A deep dive of this is better saved for another blog post, but in short if you have a method like this:

// Kotlin
fun load(seasoning: String = "")

The Kotlin compiler will generate a synthetic method like so:

// Java
void load$default(String seasoning, int mask, DefaultConstructorMarker marker);

Any calls to load() that omits any parameter that has a default will be modified at the call site by the compiler to actually call this load$default() method and pass a mask that indicates which parameters are defined.

In a normal class - this defaults method is generated as just another method in it. In an interface however, it can't do this. This is where DefaultImpls comes back in to play. The Kotlin compiler will put this defaults method in DefaultImpls with much of the same patterns we saw before.

// Kotlin
interface Taco {
  fun load(input: String = "")
}

Becomes

// Java
public interface Taco {
   void load(String seasoning);

   final class DefaultImpls {
      // $FF: synthetic method
      public static void load$default(Taco instance, String seasoning, int mask, Object var3) {
         // ...<reassigning seasoning to its default if not defined>
         instance.load(seasoning);
      }
   }
}

It also exhibits the same behavior in extending interfaces as far as being duplicated in each type.

This is really wasteful on Java 8!

Every interface is now coming with this extra class just to hold its static defaults methods. Plus - it's being duplicated in every extending interface! If we're running in a Java 8 environment, there should be no need to need to do this pattern. The interface that defines the method should just be able to have the static load$default method added directly to the interface itself since the runtime supports it. You also wouldn't need to duplicate it in interface subtypes either.

This is where that initial body-less default function comes in to play. Let's take the same example and annotate it with @JvmDefault and its compiler flag set to enable:

// Kotlin
interface Taco {
  @JvmDefault fun load(input: String = "")
}

Again - there is no function body here. By all accounts in the documentation, this annotation should be a no-op. But yet if we look at the bytecode now...

// Java
public interface Taco {
   @JvmDefault void load(String seasoning);

   // $FF: synthetic method
   static void load$default(Taco instance, String seasoning, int mask, Object var3) {
      // ...<reassigning seasoning to its default if not defined>
      instance.load(seasoning);
   }
}

Boom! It's actually no longer generating the DefaultImpls class and generating the static load$default method directly onto the interface like we wanted! If we set the compiler flag to compatibility, we get this:

// Java
public interface Taco {
   @JvmDefault void load(String seasoning);

   // $FF: synthetic method
   static void load$default(Taco instance, String seasoning, int mask, Object var3) {
      // ...<reassigning seasoning to its default if not defined>
      instance.load(seasoning);
   }

   final class DefaultImpls {
      // $FF: synthetic method
      public static void load$default(Taco instance, String seasoning, int mask, Object var3) {
         Taco.load$default(instance, seasoning, mask, var3);
      }
   }
}

So this even works with compatibility mode!

This also applies to methods with default bodies.

// Kotlin
interface Taco {
  @JvmDefault fun load(input: String = "") { }
}
// Java
public interface Taco {
   @JvmDefault default void load(String input) { }

   // $FF: synthetic method
   static void load$default(Taco instance, String seasoning, int mask, Object var3) {
      // ...<reassigning seasoning to its default if not defined>
      instance.load(seasoning);
   }
}

But why though?

Edit: The issue for this has been updated with information from Jetbrains confirming this behavior is intended, but agree the semantic scope of @JvmDefault could be broadened.

It's easy to assume the compiler would always use DefaultImpls for synthetic defaults methods. As far as the documentation is concerned, @JvmDefault should have zero bearing on this. And yet here we are. There's even an open issue proposing modifying the compiler to use this leaner format on JVM target 1.8 and above.

I found this out by accident. I was going to look into whether or not this desired static interface method approach could be contributed via PR, but was befuddled to find no special logic to always force defaults to a DefaultImpls class in interfaces. This appears entirely gated on where the compiler tells the defaults method generator to generate into, and it seems, in this case, using @JvmDefault tricks the compiler into telling it to generate into the interface class and the generator blindly complies as if it's DefaultImpls class. When compatibility is set, then the interface method is copied into DefaultImpls and just modified to delegate to the implementation in the interface. Maybe. This is a lot of speculation from just eyeballing the compiler source code.

TL;DR: I think it might be working by coincidence and have commented on the issue raising this.

This is pretty neat in terms of generated bytecode savings. It's also potentially risky. Aside from @JvmDefault on empty bodies not being documented behavior, as shown above this also happens on default functions that do have bodies. That means that these are part of your binary API and have to be considered for compatibility. Considering how much care and attention was put into the development @JvmDefault to ensure binary compatibility, this under-the-hood behavior could catch an unsuspecting developer off guard.

That said, the fact that it's effectively guarded by the same opt-in controls as @JvmDefault, I think it's pretty safe from what I can see. The more desirable outcome would be to rebrand the -Xjvm-default compiler flag as something more general like -Xjvm-invokespecial or something similar. And ideally - enabled by default.