Lights on

blog > Java method references are dangerous

Java method references are dangerous [txt]#

06 Oct 2018

This post is the result of me having to figure out a rather strange problem. May the same not happen to you.

Let's say you have a class TheClass with a method theMethod that does something important.

In a later version, you noticed that theMethod can be used elsewhere too so you move it to BaseClass and make TheClass extend BaseClass.

$ cat versiona/TheClass.java
class TheClass {
        void theMethod() {
                System.out.println("hi!");
        }
}

$ cat versionb/TheClass.java
class BaseClass {
        void theMethod() {
                System.out.println("hi!");
        }
}

class TheClass extends BaseClass {
}

TheClass.java for both versions.

Now, what if TheClass is part of a library and a user wants to use it? They can invoke theMethod in a few different ways.

$ cat User.java
public class User {
        public static void main(String[] args) {
                TheClass i = new TheClass();
                i.theMethod();
                ((Runnable) () -> i.theMethod()).run();
                ((Runnable) i::theMethod).run();
        }
}

Usage of theMethod in other code.

Let's try compiling and running User.java using TheClass from version A.

$ javac versiona/TheClass.java

$ javac -cp versiona/ User.java

$ java -cp ".;versiona/" User
hi!
hi!
hi!

Using version A.

Let's do the same for version B.

$ javac versionb/TheClass.java

$ javac -cp versionb/ User.java

$ java -cp ".;versionb/" User
hi!
hi!
hi!

Using version B.

All great, everything works as expected.

But what happens if there's a different version of the library containing TheClass on the running classpath? Let's see.

$ javac -cp versiona/ User.java

$ java -cp ".;versionb/" User
hi!
hi!
hi!

Compiled against version A, but running using version B.

All good!

$ javac -cp versionb/ User.java

$ java -cp ".;versiona/" User
hi!
hi!
Exception in thread "main" java.lang.BootstrapMethodError: java.lang.NoClassDefFoundError: BaseClass
        at User.main(User.java:6)
Caused by: java.lang.NoClassDefFoundError: BaseClass
        ... 1 more
Caused by: java.lang.ClassNotFoundException: BaseClass
        at java.net.URLClassLoader.findClass(Unknown Source)
        at java.lang.ClassLoader.loadClass(Unknown Source)
        at sun.misc.Launcher$AppClassLoader.loadClass(Unknown Source)
        at java.lang.ClassLoader.loadClass(Unknown Source)
        ... 1 more

Compiled against version B, but running using version A.

Uh-oh! The normal invocation and lambda are working fine, but when it's trying to call someMethod using a method reference, things suddenly explode.

This can be quite confusing for the user of the library (me, yesterday). We're getting a ClassNotFoundException for BaseClass on line 6, but we don't even know where BaseClass came from. Only TheClass is used and there's no BaseClass to be found in the library that we compiled against.

Let's see what there is to be found under the hood in the bytecode.

// (omitted)

 aload1
 invokevirtual TheClass.theMethod()V

// (omitted)

Normal invocation.

Nothing special for the normal invocation.

// (omitted)

 aload1
 invokedynamic java/lang/invoke/LambdaMetafactory.metafactory(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite; : run(LTheClass;)Ljava/lang/Runnable; ()V User.lambda$main$0(LTheClass;)V (6) ()V
 invokeinterface java/lang/Runnable.run()V

// (omitted)

 private static synthetic lambda$main$0(TheClass arg0) { //(LTheClass;)V
  L1
   aload0
   invokevirtual TheClass.theMethod()V
   return
 }

Lambda + invocation.

Lots of more code for the lamba invocation, but in the end it's adding a method that calls TheClass.theMethod like a normal invocation.

 aload1
 dup
 invokevirtual java/lang/Object.getClass()Ljava/lang/Class;
 pop
 invokedynamic java/lang/invoke/LambdaMetafactory.metafactory(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite; : run(LTheClass;)Ljava/lang/Runnable; ()V BaseClass.theMethod()V (5) ()V
 invokeinterface java/lang/Runnable.run()V

Method reference + invocation.

Well hello there. Inside that big mess there's BaseClass.theMethod()V. Of course this won't work when having version A on the classpath.

So what happens when it's compiled against version A?

 aload1
 dup
 invokevirtual java/lang/Object.getClass()Ljava/lang/Class;
 pop
 invokedynamic java/lang/invoke/LambdaMetafactory.metafactory(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite; : run(LTheClass;)Ljava/lang/Runnable; ()V TheClass.theMethod()V (5) ()V
 invokeinterface java/lang/Runnable.run()V

Method reference + invocation when compiled using version A.

No real surprises here actually, now it just uses TheClass.theMethod. But this is also fine when running it while version B is on the classpath.

A real world situation can be that you're writing an extension or plugin for a host application. The code hierarchy in the host application may change in newer versions, but the changes might not be directly visible (imagine theMethod being deeper down in the structure). Of course you'd want your plugin to still work for previous versions. It compiles fine when using the latest version so you'd think it's all good, but then problems like this could appear when a user uses an older version of the host application (that used to work with your extension/plugin).

In this situation, if one still wants a single binary for different versions of the host application, one can either not use method references where they cause issues like this or just compile using an older version.