GeekFactory

int128.hatenablog.com

@DelegateでJavaバージョンの差異が顕在化するケース

JDK 8でビルドしたクラスファイルをJDK 7で実行するとClassNotFoundExceptionが発生する事象でハマったのでメモ。結論からいうと、ソースコードJava 8に固有のクラスを直接参照していなくても、@Delegateによって間接的に参照されるケースがあります。

前提

JDK 8でのコンパイル時にsource, targetのバージョンを指定しているものとします。 そうしないと、JDK 7でクラスファイルを読み込んだ時に以下のエラーが発生します。

Exception in thread "main" java.lang.UnsupportedClassVersionError: Main : Unsupported major.minor version 52.0

Gradleでは以下の2行を書くだけでOKです。

sourceCompatibility = JavaVersion.VERSION_1_7
targetCompatibility = JavaVersion.VERSION_1_7

再現方法

例えば、以下のように@Delegateを使ったクラスがあるとします。

class NamedMap<T> {
    @Delegate
    private final Map<String, T> map = [:]
}

このクラスをJDK 8でコンパイルします。

そして、クラスファイルをJDK 7で読み込むと以下のエラーが発生します。

Exception in thread "main" java.lang.NoClassDefFoundError: java/util/function/BiFunction

原因

先ほどのクラスをJDK 7でコンパイルした場合、Java 7のMapクラスに対するデリゲートメソッドが生成されます。 一方、JDK 8でコンパイルした場合、Java 8のMapクラスに対するデリゲートメソッドが生成されます。 Java 8のMapクラスでは新しいメソッドが追加されているため、Java 7の実行環境では存在しないクラスを参照することになります。

例えば、Java 8で新たに追加されたcomputeIfPresent(K, BiFunction<K, V, V>)メソッドのデリゲートは以下のようになります。

class NamedMap<T> {
    @Delegate
    private final Map<String, T> map = [:]

    // コンパイル時にデリゲートメソッドが追加される
    T computeIfPresent(String key, BiFunction<K, V, V> func) {
        map.computeIfPresent(key, func)
    }
}

このクラスファイルをJDK 7で読み込もうとすると、Java 8に固有のクラスBiFunctionが見つからないため、ClassNotFoundExceptionが発生します。

ソースコードJava 8に固有のクラスを直接参照していなくても、@Delegateによって間接的に参照されるケースがあります。

対策

ロスコンパイルを行う場合、古いバージョンのJDKコンパイルするか、-bootclasspathを指定するようにしましょう。 また、Travis CIなどを活用して複数バージョンのJDKで自動テストを実行するようにしましょう。

余談

ソースコードを見てもClassNotFoundExceptionの原因がつかめない場合、クラスファイルを逆コンパイルすると解決の糸口が見えることがあります。 以下のようにjavapコマンドを使います。

javap -classpath build/classes/main com.example.Main

特にオプションを付けなければメソッド一覧が表示されます。

Compiled from "Main.groovy"
public class com.example.Main {
  /* 中略 */
  public java.lang.Object super$2$computeIfPresent(java.lang.String, java.util.function.BiFunction);
}

-cオプションを付ければメソッドの中身が逆コンパイルされます。

今回はjavapコマンドによる逆コンパイルで原因を見つけました。