Hibernateのlazy関連用Proxyについての注意点

JPAの仕様では@ManyToOneなど関連先が1個になる関連はデフォルトでeager関連となります。特に、JPAのプロバイダーとしてHibernateを利用する場合、eager関連はデフォルトでOUTER JOINされてしまうため、多くの関連を定義すると一気に大量のJOINが行われてしまい、効率が悪くなります。Hibernate3のもともとの設定ファイルでは関連は一律lazyというのがデフォルトですし、Java Persistence with Hibernate: Revised Edition of Hibernate in Actionでも、関連はできるだけlazyとしておき、ユースケースごとに必要に応じてJPQLで個別にフェッチJOINするというのがベストプラクティスと書かれていたと思います。実際、少なくともJPAの標準機能としては、eager関連を個別にlazy関連として扱う手段がないため、アノテーションの設定上は関連をできる限りlazyにしておくというのは妥当でしょう。
従来、lazyな関連を使うと有名なLazyInitializationExceptionの発生で苦労するということもありますが、Seamの場合、会話の期間中永続コンテキストを継続できるため、この例外は原理的に発生せず、したがってlazy関連が簡単に扱えるとされています。
ただし、Hibernateのデフォルト設定では、lazy関連はProxyクラスの生成によって実装されているため、LazyInitializationException以外にもいろいろな問題を引き起こす可能性があります。lazy関連Proxyの問題については、今まで私自身いろいろな局面で苦労させられてきているため、ここで問題点についてまとめておこうと思います。

lazy関連の実装方式について

lazyな関連の実装方式については、通常

の2通りが考えられます。前者の方式はバイトコードで永続フィールドにアクセスする箇所にnullチェックするロジックを埋め込むことでlazy関連の初期化を行う方式で、Hibernate以外のほとんどのJPA実装(EclipselinkやOpen JPAなど)で採用されている方式です。後者の方式はjavassistやcglibといったライブラリーを用いて実行時に関連クラスのサブクラスを動的に生成し、メソッドの呼び出し時に実際の関連先を読み込むロジックを実装するという方法です。後者の方法ではビルド時やクラスロード時にバイトコードを変換する処理が不要となるということが最大のメリットですが、以下のような制約に注意が必要となります。

問題点1 lazy関連Proxyに対してgetClass()でクラスを取得する操作が透過的でない

lazy関連Proxyに対して普通にgetClass()として型クラスを取得すると、宣言されている型ではなく、cglibなどによって生成されたサブクラスの型クラスが返されます。したがって、ロジックの中で型クラスの同一性を判断するようなことをやっていると、期待したとおりに動作しません。lazy関連に対して実際の型クラスを取得するには以下のメソッドを呼び出す必要があります。

  • Hibernate.getProxy()メソッド
  • HibernateProxyHelper.getClassWithoutInitializingProxy()

なお、動的に生成されたクラスは、Proxyが委譲する先の実体クラスのサブクラスなので、instanceofによる比較は(後述の多態関連のケースを除き)可能です。

問題点2 多態関連を透過的にダウンキャストできない

継承ツリーの親クラスに対する関連に対して、instanceofで実行時の型を判定し、特定のサブクラスにダウンキャストするということがlazyなProxyを使うとできません。これはProxyの原理を理解していれば当然の制約で、生成されるProxyは宣言されている親クラスに対する直接のサブクラスとなるためです。Java Persistence with Hibernate: Revised Edition of Hibernate in Actionでは、そもそもダウンキャストが必要なのは多態性をうまく扱っていないためであり、オブジェクト指向設計がよくないのがいけないというような記述が書かれていますが、JPAではインタフェーフェースがうまく扱えないこともあり、エンティティのダウンキャストは時々どうしても必要になります。この場合の対処としては問題点1で記述した方法か、何らかの型コードなどの値から実際のサブクラスの型を推測し、EntityManagerからサブクラス指定でgetReference()を呼び出して参照を取得するという手順が必要になります。そもそも関連を初期化してよいのであれば、以下のようなヘルパーメソッドを呼び出して、Proxyの委譲先を先に取り出してしまうのもありです。

public static <T> T deproxy(T maybeProxy, Class<T> baseClass) throws ClassCastException {
   if (maybeProxy instanceof HibernateProxy)
      return baseClass.cast(((HibernateProxy) maybeProxy).getHibernateLazyInitializer().getImplementation());
   else
      return maybeProxy;
}

問題点3 Proxyのフィールドに直接アクセスできない

lazy初期化Proxyはサブクラス化によって処理を埋め込むため、getterなどのメソッドの中に初期化ロジックが埋め込まれます。よってフィールドを直接参照するようなコードが正しく動作しません。通常エンティティのフィールドはprivateであるため、問題となることは少ないですが、特にequalsメソッド内で自分のクラスの別インスタンスの中身を比較するようなケースで注意する必要があります。

問題点4 双方向の1対1関連でinverse側(mappedBy属性を指定している外部キーカラムのない側)がlazyにならない

これはテーブル設計によっては非常に問題となりそうなポイントですが、Proxy方式の制約により、FetchType.LAZYを指定しているにもかかわらず、双方向のOneToOne関連ではlazy指定が無視されるような動作となります。(さらに、悪いことになぜか2次キャッシュも効きません。)JPA仕様的にはlazy指定はヒントであり、無視してもよいということなので、一応バグではないということですが、実際テーブルを細かく分けているとN+1問題が容易に発生してしまい、性能上のボトルネックとなるケースが多いです。原理は簡単でProxyはnull値を透過的に表現できないため、inverse側の関連を初期化する際には、そもそもProxyを生成するかしないかを判定するために関連先の存在チェックが必要となっていまい結局DBを見に行くという動作となってしまうためです。(http://community.jboss.org/wiki/Someexplanationsonlazyloadingone-to-one)
HibernateでProxy方式を使った双方向の1対1関連でlazyが期待通り動作するのは

  • @PrimaryKeyJoinColumnを使った主キー結合の場合
  • optional=falseとして関連先が必須として指定できる場合

の両方を満たすケースに限られるようです。

lazy関連にProxy方式を利用しない方法

このように、Proxy方式はビルドやクラスロード時のバイトコード変換が必要ないということはメリットですが、Java5以降でこれらが一般化した現在においては、やや時代遅れの方式のようにも思われます。ただし、あまり知られていないことですが、@LazyToOne(LazyToOneOption.NO_PROXY) というアノテーションを指定することで、特定のlazy関連をProxy方式ではなくすことができるため、上記のいずれかの問題で悩んだら試してみるのもよいと思います。さらに、以下の記事では@LazyToOne(LazyToOneOption.NO_PROXY)を指定しつつ、かつ手動で初期化することでバイトコード変換をせずにlazy関連を実現するテクニックが紹介されています。(永続フィールドではなく永続プロパティを利用することが前提ですが)
http://justonjava.blogspot.com/2010/09/lazy-one-to-one-and-one-to-many.html