※本記事は、Ben Evansによる”The Unsafe Class: Unsafe at Any Speed“を翻訳したものです。
ルールを破ることが可能だからといって、ルールを破るべきというわけではないが、しかるべき理由が存在する場合もある
著者:Ben Evans
2020年5月4日
ときには、ルールを破らねばならない場合もあるかもしれません。Javaプラットフォームにおいて、そのような行為は3つの主要メカニズムのいずれかを使って行われるのが一般的です。3つの主要メカニズムとは、リフレクション、クラス・ローディング(関連するバイトコードの変換を含む)、Unsafeです。
Javaのパワー・ユーザーは、たとえ必要に迫られた場合のみに最後の手段として使うものであっても、これら3つのテクニックすべてを理解しておく必要があります。「できるからといって、やるべきというわけではない」という原則は、その他のことと同様、ソフトウェアの設計を選択するうえでも当てはまります。
前述の3つのうちで、潜在的な危険性をもっともはらんでいるのがUnsafeです(それゆえ、もっとも強力です)。他の方法ではできない形で、そしてプラットフォームの確立されたルールを破る形で、特定のことを行う方法を提供するからです。
たとえば、開発者はUnsafeを使って次のようなことを実行できます。
一見して、Java 8のUnsafeクラス(sun.misc.Unsafe)の危険性は明らかです。この点は、クラスの名称だけでなく、クラスが存在するパッケージからもわかります。sun.miscパッケージは、内部用で実装固有の場所です。Javaコードでは、このパッケージに直接触れるべきではありません。Java 9以降のバージョンでは、Unsafeの機能がjdk.unsupportedという名前のモジュールに移動したため、内部向けであるという性質がいっそう明確になっています。
当然ですが、Javaのライブラリがこういった種類の実装の詳細に直接アクセスすることは想定されていません。この立場を強調すべく、Javaのプラットフォームのメンテナンス・チームは、そのようなことをするのは危険であり、ルールを破って内部実装の詳細にリンクしているアプリケーション開発者は自身のリスクでそれを行っていると長い間言い続けてきました。
こういった明確な警告にもかかわらず、具合が悪いことに現実として、Unsafeは至る所で使われています。Javaエコシステムの主要フレームワークでは、ほぼ例外なく、一部の機能においてUnsafeを使っています。最先端のJava開発者が期待するようになったダイナミックさ、柔軟性、パフォーマンスの大部分は、何らかの形でUnsafeを使って実現されていると言っても過言ではありません。
Unsafeの典型的な使用法を確認してみます。例として、「コンペア・アンド・スワップ」(CAS)と呼ばれるハードウェア機能を取り上げます。この機能はほぼすべての最新CPUに搭載されていますが、Javaのメモリ・モデルに含まれていないことはよく知られています。
最初の例では、ルールに従い、Javaのメモリ・モデルの一部である同期機能を使用します。
public final class SynchronizedCounter implements Counter { private int i = 0; @Override public synchronized int increment() { return i = i + 1; } @Override public synchronized int get() { return i; } }
続いて、上記と、Unsafeを用いたAtomicCounterの実装とを比較してみます。リフレクションを使ってUnsafeクラスにアクセスする必要があるため、定型挿入文がかなり多く含まれています。
public final class AtomicCounter implements Counter { private static final Unsafe unsafe; private static final long valueOffset; private volatile int value = 0; static { try { Field f = Unsafe.class.getDeclaredField("theUnsafe"); f.setAccessible(true); unsafe = (Unsafe) f.get(null); valueOffset = unsafe.objectFieldOffset (AtomicCounter.class.getDeclaredField("value")); } catch (Exception ex) { throw new Error(ex); } } @Override public int increment() { return unsafe.getAndAddInt(this, valueOffset, 1) + 1; } @Override public int get() { return value; } }
これら2つでは、パフォーマンスにかなりの差があります。最新のハードウェアでは、Unsafe実装の方がおよそ2倍から3倍高速に実行できます。
重要なポイント:先に述べたように、最新のフレームワークでは、ほぼ例外なく内部的にUnsafeがすでに使われています。アプリケーション開発者は、Unsafeを直接使用すればコードのパフォーマンスが少しでも向上すると考えるべきではありません。フレームワークの開発者はこれまで、実行可能であればいつでも、Unsafeを安全な方法で活用してきています。したがって、アプリケーション開発者は、フレームワークで提供されているものを使うべきです。
AtomicCounterのコードや、そこで使われているJDKのコードを調べれば、Javaでは不可能と思われるいくつかのことがこの実装で行われていることがわかります。
最初に目に付くのは、ポインタのオフセット(AtomicCounterオブジェクトの開始場所と、フィールドvalueが存在する場所との差分)の計算です。JVMバイトコード命令に、この計算を実現できるシーケンスは含まれていません。JVMの内部データ構造に直接アクセスするネイティブ・コードだけがこの計算を行うことができます。
次に、フィールドを使って間接的にアクセスするのではなく、ポインタのオフセットを使ってメモリに直接アクセスしています。さらに、メモリにアクセスする際のモード(この場合はvolatile)を選択しています。通常であれば、このモードは変数の宣言方法によって決まります。
「朝食前の6つの不可能なこと」とまったく同じではありませんが、かなり近いものです。
厳密に言えば、このコードでは前述の内容すべてを行うことで、Java仕様のルールを破っています。このコードでは内部機能が使用されていますが、ユーザーのコードはルールに従うことになっているのに対し、内部機能はルールに従っているとは限らないからです。
しかし現実には、Java仕様に厳格に従い続けるためだけに、パフォーマンスを大幅に犠牲にしてもまったく構わないというJava開発者はいません。このことは誰もがよく知っています。本当の問題は、Unsafeの機能へのアクセスが実に簡単であり、開発者がこの機能を使うことは推奨されていないことです。
それゆえに、この点は根本的な疑問につながります。Unsafeによって実現し、最新のJavaエコシステムが依存している、パフォーマンス面や機能面のメリットを維持しつつ、JavaからUnsafeクラスを削除するためにはどうすればよいのでしょうか。
直接的または間接的に、最新のJavaアプリケーションでUnsafeが使われている範囲を考えれば、選択肢は次のものに限られます。
実際のところ、選択肢はまったくありません。しかるべき通知と検討なしにアクセスが削除されれば、プラットフォームで事実上のフォークが発生して分裂するリスクがあります(Python 2とPython 3のような状態、あるいはそれよりひどくなる可能性もあります)。
Unsafeへのアクセスを単純に遮断すれば、ほぼすべてのJavaアプリケーションがアップグレードできなくなってしまうでしょう。一般的なアプリケーションでは、Unsafeの機能への推移的依存性があるライブラリをほぼ例外なく使用しているからです。
これに関連する(そして過小評価されがちな)点として、Javaプラットフォーム開発チームは、エンドユーザーのJavaアプリケーション(またはそれをサポートするライブラリ)が内部実装の詳細に依存していることを心配せずに、内部詳細を自由に変更できなければならないということが挙げられます。
この状況が、Unsafeの根幹にある別の本質的な問題を浮かび上がらせています。最近のバージョンのJavaは、不完全なカプセル化しか行われていなかったプラットフォームから、さらに強固なモジュール境界を目指して進化しています。その一方で、Unsafeはモジュール・システムを採用するうえでの障害になっています。
これを解決するためには、妥協が必要です。Java 11までに、Unsafe関連メソッドの多くが、jdk.internal.misc.Unsafe(java.base内)とsun.misc.Unsafe(jdk.unsupported内)という2つの異なるクラスに移行されています。
jdk.unsupportedモジュールは、次のように宣言されています。
module jdk.unsupported { exports sun.misc; exports sun.reflect; exports com.sun.nio.file; opens sun.misc; opens sun.reflect; }
この宣言では、unsupportedモジュールに明示的に依存する任意のアプリケーションからのアクセス許可を指定しています。重要なのは同時に、Unsafeを含むパッケージsun.miscに対して、リフレクションによる無制限のアクセスを認めている点です。
これにより、Unsafeはモジュールと親和性の高い形式への移行が促進されているものの、「この妥協の産物はいつまで維持されるべきなのか」と尋ねたくなる方もいるはずです。
実際にUnsafeを削除するためには、リフレクションによるアクセスがもたらす、カプセル化の穴をふさぐ必要があります。そしておそらく、この問題に対処する最適な方法は、リフレクションに関する一般ポリシーの変更です。Java 9の開発が行われていた当時、無制限のリフレクションが広く使用されていたことが、--illegal-access=permitをデフォルトにするという妥協の選択につながりました。
しかし、これはほんの一時的なものとして計画されました。そこで、これら両方の一時的な妥協について、その必然性を再考してみます。
実際にUnsafeから削除されたメソッドもあります。たとえば、次に示すものです。
Java 9では、defineClass()メソッドもMethodHandlesクラスに移動しました。
VarHandle APIの導入も大きな前進です。このAPIは、UnsafeのAPIの一部に代わる安全な手法を提供することを主目的として、Java 9で追加されました。
VarHandleの重要な目的の1つは、CAS機能と、volatileなフィールドまたは配列へのアクセスに代わる機能を提供することです。この実例を確認するため、Unsafeの代わりにVarHandleを使ってアトミック・カウンタを実現するアプローチについて、簡単な例を紹介します。
public class AtomicVHCounter implements Counter { private volatile int value = 0; private static final VarHandle vh; static { try { MethodHandles.Lookup l = MethodHandles.lookup(); vh = l.findVarHandle(AtomicVHCounter.class, "value", int.class); } catch (ReflectiveOperationException e) { throw new Error(e); } } @Override public int increment() { int i; do { i = (int) vh.getVolatile(this); } while (!vh.compareAndSet(this, i, i + 1)); return i; } @Override public int get() { return value; } }
このコードは、Unsafeを使っている先ほどの例と機能的に同等です。しかし、こちらのコードでは完全にサポートされているAPIのみを使っています。主な手順は以下のとおりです。
非常に重要な変更点は、MethodHandles.Lookupを使っている部分です。setAccessible()を使ってプライベート・フィールドにアクセスするリフレクションとは異なり、Lookupオブジェクトには呼出し元のコンテキストと同等のアクセス権しかありません。このコンテキストには、プライベート・フィールドであるvalueへのアクセス権も含まれています。
リフレクションからメソッド・ハンドルやフィールド・ハンドルに移行するということは、Java 8のUnsafeに存在していた多数のメソッドが、未サポートAPIから削除されたことを意味します。たとえば、次のようなものです。
VarHandleには、多くの便利なアクセッサ・メソッドに加え、これらのメソッドと同等の機能が含まれるようになっています。プリミティブタイプやObject用のgetメソッドやputメソッドも存在し、通常アクセス・モードとvolatileアクセス・モードの両方で使用できます。また、効率的な加算を行う、以下のようなメソッドもあります。
VarHandleの別の重要な目的は、JDK 9以降で利用できる新しいメモリ・オーダー・モードへの低レベル・アクセスを許可することです。
総じて言えば、事実上のAPIであるUnsafeの代替機能を作成するという作業は、明確な進展を遂げています。たとえば、VarHandleの他にも、Stack-Walking API(JEP 259)によってUnsafeのgetCallerClass()機能が提供されるようになっています。しかし、やるべきことはまだ残されています。
上記以外で、進展があった主な領域の1つが、JEP 371に記述されているHiddenクラスの実装に向けた作業です。このJEPは、Unsafeで特に多く見られる用途について述べています。その用途とは、他のクラスから直接使用することはできない(ものの、リフレクションを通じて扱うことはできる)クラスをその場で作りたいというものです。
このようなクラスを匿名クラスと呼ぶことがあり、Unsafeのメソッドも、匿名クラスという意味を含むdefineAnonymousClass()という名前になっています。しかし、この用語によって開発者が混乱する可能性があります。通常のJavaアプリケーション・コードでの匿名クラスとは、インタフェースのネストした実装を提供し、静的な型をインタフェースとして宣言する方法を指すからです。たとえば、次のようなものです。
public class Scratch { public void foo() { Runnable r = new Runnable() { @Override public void run() { System.out.println("We had to do it this way before lambdas!"); } }; } }
多くのJavaプログラマーは、このRunnable実装のようなクラスが実際のところ匿名ではないことを知っています。というのも、コンパイラはScratch$1というような名前でクラスを生成し、そのクラスは純粋なJavaクラスとして使用できるからです。このクラスの名前をJavaソース・コードから利用することはできません。しかし、自動生成された名前を使えば、クラスを見つけてリフレクションからアクセスし、他のクラスと同じように利用することができます。
しかし、Unsafeについて話す場合、違う意味になります。Hiddenクラスという用語を使うべきなのはそのためです。
Hiddenクラスも真に匿名なクラスではありません。ClassオブジェクトのgetName()を直接呼び出すことで取得できる名前があるからです。この名前は、診断情報、JVMツール・インタフェース、Java Flight Recorderイベントなど、他のいくつかの場所にも表示される可能性があります。しかし、Hiddenクラスは、クラス・ローダーでも、リフレクション(たとえば、Class.forName())などの、通常のクラスを検出できる仕組みでも検出できません。
Hiddenクラスに名前があるのは、通常のクラスとは異なるネームスペースへと明示的に格納する方法を提供するためです。
最新バージョンのHiddenクラスの実装(OpenJDKプロジェクトで現在も開発が進行中)では、JVMのクラス名に通常は2つの形式があることを利用した命名スキームになっています。1つはバイナリ名(com.acme.Gadget)で、ClassオブジェクトのgetName()を呼び出すことにより、返されるものです。もう1つは、内部形式(com/acme/Gadget)です。
Hiddenクラスの場合、これと同じパターンの命名にはなりません。HiddenクラスのClassオブジェクトのgetName()を呼び出すことにより、com.acme.Gadget/1234というような名前が返されます。これはバイナリ名でも内部形式でもありません。この名前に一致する通常のクラスを作ろうとしても、失敗します。
この命名スキーム(と、このような形でHiddenクラスを差別化すること)のメリットの1つは、JVMのクラス・ローディング・メカニズムで通常行われている、強力で厳密な検査の対象とする必要はない点にあります。これは、Hiddenクラスを使うと想定されているのが、一般的なJavaクラスに課される通常の強固なチェックで検出できないことを必要としているフレームワーク作成者などであるという、全般的な設計に適合しています。
UnsafeについてJEP 371が目指すのは、Lookup APIの一部としてHiddenクラスがサポートされるようになったときにUnsafeのdefineAnonymousClass()メソッドを非推奨とすることであり、ひいては、将来のリリースでこのメソッドを削除可能とすることです。
これは純粋に内部的な変更です。少なくとも最初のうちは、Hiddenクラスの導入によってJavaプログラミング言語に何らかの変化が発生することは想定されていません。しかし、LambdaMetaFactory、StringConcatFactory、LambdaFormなどのクラスの実装は、新しいAPIを使うように更新されるでしょう。
どのユースケースのサポートが必要かについて考えてみます。重要な特殊ケースとして挙げられるのは、モックやプロキシです。このような目的に使う場合、具体的には次の2つの特殊な性質を持つオブジェクトと考えることができます。
最先端のライブラリやフレームワークの多くでは、上記のことを実現するために低レベルのObjenesisプロジェクトの機能を利用しています。Objenesisでは、さまざまなメカニズム(いずれも通常の方法では利用できません)を使ってその機能を実現しています。その1つが、UnsafeのallocateInstance()です。
したがって、Unsafeを完全に削除するためには、このメソッドまたはそれと同等の機能のいずれかをサポート対象のAPIに移行する必要があります。Java開発者に、モックやプロキシを作成する公式な方法が必要な点は変わりません。
これを要約すれば、クラスのコンストラクタを呼び出さずにオブジェクトをインスタンス化する公式な方法が必要だということになります。別の言い方をするなら、領域を割り当ててインスタンスのメタデータをセットアップし、その結果として得られたオブジェクトを元のクラスのインスタンスの代わりに使用できることが必要です。
モック・オブジェクトの場合、ライブラリがモックを作成する際に使う特殊なAPIを提供する新しいjdk.testモジュールを作成して、この問題を解決することもできます。しかし、この方法ではプロキシの問題は解消されません。プロキシは、アプリケーションのコードでテスト時だけでなく実行時にも使用されるものだからです。
この問題は、シリアライズとも密接に関連しています。現在のデシリアライズのメカニズムでは、クラスで宣言されているコンストラクタがバイパスされるからです。そのため、賢いフレームワーク開発者は、シリアライズAPIに便乗してモックやプロキシのインスタンス化を行うことができます。このようにしてオブジェクトを作成することは、Javaの初期の頃からずっと可能でした。
ただし、長期的に見れば、このアプローチがこの先も可能とは限りません。今後、いずれかの時点でシリアライズの仕様が変更され、デシリアライズにコンストラクタが使われるようになることが明言されています。そうなれば、プロキシ用のライブラリが使用している重要なメカニズムが使えなくなることになります。
Unsafeから危険な部分を取り除き、サポート対象の標準APIに置き換える作業は、大きな進展を遂げています。フレームワークやライブラリの作成者は、そのような標準APIを使用できます。しかし、ゴールは見え始めているものの、作業はまだ完了してはいません。一部の機能では、置き換える同等な方法が存在しない、安全でないメソッドを依然として使用しているからです。
うまくいけば、このプロセスは今後数回のJavaリリースで完了し、Javaエコシステムが非標準機能に著しく依存している状況に終止符が打たれることでしょう。
![]() |
Ben EvansBen Evans(@kittylyst):Java Champion。New Relicのプリンシパル・エンジニア。先日発刊された『Optimizing Java』(O'Reilly)を含め、プログラミングに関する5冊の書籍を執筆している。jClarity(Microsoftにより買収)の創業者の1人であり、Java SE/EE Executive Committeeの元メンバーである。 |