Skip to content

Instantly share code, notes, and snippets.

@KosmX
Last active July 29, 2023 22:43
Show Gist options
  • Save KosmX/e8792213a0e7920a849351c2693a257f to your computer and use it in GitHub Desktop.
Save KosmX/e8792213a0e7920a849351c2693a257f to your computer and use it in GitHub Desktop.
Java 1.8 ByteBuffer signature changes and compatibility errors

NoSuchMethodError: java.nio.ByteBuffer.position(I)Ljava/nio/ByteBuffer;

A rare but critical bug in java 1.8 compatibility

JVM method representation

Let's decompile some java classes into bytecode (javap or recaf)

Simple function

    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

Compatible signatures

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/nio/Buffer.position(I)Ljava/nio/Buffer;

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.

Java 9+ works fine.

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;

Workarounds

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

Compiling on specified JDK

Gradle

Java (with Groovy DSL)

compileJava {
    options.release.set 8
}

Kotlin

kotlin {
    jvmToolchain(8)
}

Auto JDK resolver

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"
}

manual checking

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.

Footnote

Be careful, this is not the only method affected by this bug.

Java versioning

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

Parameter variance

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.

Feel free to share, copy and edit this document.

@KosmX
Copy link
Author

KosmX commented Jul 9, 2023

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;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment