ByteBufferの使用がバイナリ互換性を破壊するケース

ClojureコードをコンパイルするとJVMバイトコードが生成される。一般的には、使用するClojureのパージョンさえ固定すれば、 どのような環境でコンパイルしてもコンパイル結果として得られるバイトコードは基本的に同じはずだ。

しかし、「どのバージョンのJava上でコンパイルしたバイトコードか」が問題になる特殊なケースがあるらしい(実際に遭遇した)ので、記録のためにメモしておく。

たとえば、次のようなコードがあったとする:

(import 'java.nio.ByteBuffer)

(defn set-pos! [^ByteBuffer b ^long pos]
  (.position b pos))

これをJava 8上でコンパイルすると、コンパイル結果のバイトコードは以下のようになる:

 0: aload_0
 1: checkcast     #15                 // class java/nio/Buffer
 4: lload_1
 5: invokestatic  #21                 // Method clojure/lang/RT.intCast:(J)I
 8: invokevirtual #25                 // Method java/nio/Buffer.position:(I)Ljava/nio/Buffer;
11: areturn

一方で、同じコードをJava 9以上でコンパイルすると次のようなバイトコードが出力される:

 0: aload_0
 1: checkcast     #15                 // class java/nio/ByteBuffer
 4: lload_1
 5: invokestatic  #21                 // Method clojure/lang/RT.intCast:(J)I
 8: invokevirtual #25                 // Method java/nio/ByteBuffer.position:(I)Ljava/nio/ByteBuffer;
11: areturn                                        

バイトコードのインストラクションだけ見ていると気づきにくいが、下から2行目 8: invokevirtual #25 から始まる行の呼び出している メソッドの型が違っているのが分かるだろうか。

Java 8以前では ByteBuffer に対して position(int) メソッドを呼び出すと、Buffer#position(int) が呼ばれ、戻り値の型は Bufferになる。Java 9以降では、 Buffer#positon(int) は残されつつも ByteBuffer に独自のオーバーライド ByteBuffer#position(int) が追加され、その戻り値の型は ByteBuffer だ。 ByteBuffer 自体は Buffer クラスを 継承しているので型の面で特に何かが問題になるということはない。

問題なのは、バイトコード上に解決されるメソッドだ。 ByteBuffer#position(int) はJava 9で追加されたメソッドなので、 Java 8以前の環境に持っていくと当然そんなメソッドはないとエラーで怒られる:

;; Clojure 1.9.0
;; Java HotSpot(TM) 64-Bit Server VM 1.8.0_181-b13
user=> (set-pos! (ByteBuffer/allocate 32) 0)

NoSuchMethodError java.nio.ByteBuffer.position(I)Ljava/nio/ByteBuffer;  bytebuffer-repro.core/set-pos! (core.clj:5)
user=>

つまり、 ByteBuffer#position を使っているコードをJava 9以降の環境でコンパイルすると、生成されたバイトコードはJava 8以前の環境では動かなくなる。 同じ問題は ByteBuffer#position の他に ByteBuffer#flipByteBuffer#limitByteBuffer#clear にもあるようだ。

調べてみると、AkkaやMongoDBのJavaドライバーなどのOSSもこの問題に遭遇している。

この問題の解決自体は難しくない。Clojureでは、型ヒントをつけて position メソッドのレシーバが Buffer だと明示してやることで、 メソッドを Buffer#position に解決させることができる:

(import '[java.nio Buffer ByteBuffer])

(defn set-pos! [^ByteBuffer b ^long pos]
  (.position ^Buffer b pos))

この例だと、元の b の型ヒントを Buffer に変更してやるのでも問題ない。

もしくは、レシーバの型ヒントを完全に落としてしまって、実行時にリフレクションで解決してもらう手もあるが、わざわざ型ヒントをつけていたのを 落とすのが現実的な解決策になるケースはあまり多くないと思う:

(defn set-pos! [b ^long pos]
  (.position b pos))

なお、この話はあくまでAOTコンパイルをする場合の問題ということに注意。AOTコンパイルしないのであれば、コンパイル環境と実行環境が 食い違うということもないのでそもそも上のような問題は発生しない。AOTコンパイルはしないで済むならしない方が吉。

updatedupdated2018-12-122018-12-12