A rare but critical bug in java 1.8 compatibility
Let's decompile some java classes into bytecode (javap or recaf)
public static String bar(int a) {
return Integer.toString(a, 16);
}
public static bar(I)Ljava/lang/String;
L0
ILOAD 0
BIPUSH 16
INVOKESTATIC java/lang/Integer.toString (II)Ljava/lang/String;
ARETURN
I'm intentionally removing debug symbols from disassembly.
When we invoke a function from java code, its signature will be stored including its return type.
It's invoked with an INVOKEVIRTUAL
, INVOKESTATIC
, INVOKESPECIAL
or INVOKEINTERFACE
opcode for virtual, static, constructor or interface method.
INVOKEDYNAMIC works differently, we'll work with the other four.
If JVM looks for a function, it will look for a method with similar or compatible signature
Let's look into the following example:
public class Foo {
protected int bar = 0;
public Foo inc() { // allow chaining: inc().inc().inc()
bar++;
return this;
}
// getter
public int getBar() {
return bar;
}
}
public class Baz extends Foo {
@Override
public Foo inc() {
bar += 2;
return this; // The return type is foo, but because Baz extends Foo, returning with Baz is allowed.
}
public Baz dec() {
bar -= 1;
return this;
}
}
Here we have Foo, a simple class, and Baz extending Foo.
This means if we need Foo but we have a Baz, it can work just like Foo would:
Foo foo = new Baz();
foo.inc().inc();
System.out.println(foo.getBar());
// But if we want to use a Baz specific method, it will fail because foo variable has the Foo type
foo.dec(); // ⚡ Compile error
the foo.inc()
will look something like this:
INVOKEVIRTUAL Foo.inc ()LFoo;
Change inc signature when overriding
Because we want to do baz.inc().dec()
we need to change Baz.inc return type to Baz:
@Override
public Baz inc() { // Now return with Baz instead of Foo.
bar += 2;
return this; // You already returned with Baz, you only updated the signature
}
When overriding a method, the return type can implement/extend the original type:
super method: Foo inc()
, overriding method: Baz inc()
When invoking the new Baz Baz.inc()
both signatures are valid:
INVOKEVIRTUAL Foo.inc ()LFoo;
INVOKEVIRTUAL Foo.inc ()LBaz;
Java developers did the same with ByteBuffer.position(int newPosition) when updating to java 9.
Prior to java 9, the JVM compiled ByteBuffer.position(I)
to
INVOKEVIRTUAL java/nio/ByteBuffer.position (I)Ljava/nio/Buffer;
but after the signature change, the new compiler output was
INVOKEVIRTUAL java/nio/ByteBuffer.position (I)Ljava/nio/ByteBuffer;
even if you set targetCompatibility to java 1.8.
Because java accepts signatures with contravariant return type (ByteBuffer instead of Buffer) old programs works fine on java 9 even if those were compiled on java 1.8.
But on java 1.8, there is no java/nio/ByteBuffer.position(I)Ljava/nio/ByteBuffer;
, java 1.8 can't resolve the new signature and throws a NoSuchMethodError: java.nio.ByteBuffer.position(I)Ljava/nio/ByteBuffer;
The easiest and safest workaround is to make sure you use the lowest target JDK to compile your program, in this case use JDK 1.8
compileJava {
options.release.set 8
}
kotlin {
jvmToolchain(8)
}
Setting the compiler target will enforce JDK 1.8, but will fail if it is not found on the machine.
If you want gradle to automatically download the correct JDK, add this to settings.gradle.kts
:
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0"
}
If you can't compile on target JDK, manually casting the variable will force java to use the legacy method:
ByteBuffer byteBuffer = ...;
((Buffer)byteBuffer).position(42);
This will create the following bytecode even if compiled on Java 9+
INVOKEVIRTUAL java/nio/Buffer.position (I)Ljava/nio/Buffer;
what can be safely invoked on any ByteBuffer.
java 1.8 (Class version 52), often called simply java 8
java 9 (Class version 53), this came right after java 1.8, Oracle changed their versioning scheme.
table: https://stackoverflow.com/questions/9170832/list-of-java-class-file-format-major-version-numbers
Function parameters are invariant (you can't change those when overriding), but in a theoretical programming language you may be able to replace parameters with their super types.
Some known methods to watch out:
ByteBuffer
java.nio.ByteBuffer.position(I)Ljava/nio/ByteBuffer;
java.nio.ByteBuffer.limit(I)Ljava/nio/ByteBuffer;
java.nio.ByteBuffer.mark()Ljava/nio/ByteBuffer;
java.nio.ByteBuffer.reset()Ljava/nio/ByteBuffer;
java.nio.ByteBuffer.clear()Ljava/nio/ByteBuffer;
java.nio.ByteBuffer.flip()Ljava/nio/ByteBuffer;
java.nio.ByteBuffer.rewind()Ljava/nio/ByteBuffer;