JDK 14のShenandoah GC パート1:自己修正バリア

Red Hat で Solution Architect として OpenJDK を担当している伊藤ちひろ(@chiroito)です。

この記事は、Red Hat Developerのブログ記事、Shenandoah GC in JDK 14, Part 1: Self-fixing barriers | Red Hat Developer の翻訳記事です。


https://developers.redhat.com/sites/default/files/styles/article_feature/public/blog/2019/08/2019_Email_Hero_Design_JDK-copy.png?itok=C5bgEDTF

次期JDK 14に搭載されるShenandoahガベージコレクタ(以下GC)の開発では、大幅な改良が行われています。ここで取り上げている最初のもの(自己修正バリア)は、局所的な遅延を減らすことを目的としています。これらはバリアの中間のパスとスローパスに費やされます。2つ目は、並行ルート処理と並行クラスアンロードを取り上げます。

自己修正バリア

自己修正バリアの改善は、JDK 13に搭載されたロードリファレンスバリアをベースにしています。ロードリファレンスバリアは、参照フィールドや配列要素からのロード後、ロードされたオブジェクトがアプリケーションコードの残りの部分に与えられる前に用いられます。疑似コードでは、バリアは次のようになります。

T load_reference_barrier(T* addr) {
  T obj = *addr;

  // Fast-path: スレッドローカルフラグへのアクセス
  if (!is_gc_active()) return obj;

  // Mid-path 1: 小さなビットマップへのアクセス(領域ごとのバイト、数KB程度?)
  if (!is_in_collection_set(obj)) return obj;

  // Mid-path 2: オブジェクト(ヒープ全体?)内の転送ポインタにアクセスします。
  T fwd = resolve_forwardee(obj);
  if (obj != fwd) return fwd;

  // Slow-path: ランタイムへの呼び出し、転送されていないオブジェクトのロケーションごとに1回
  return load_reference_barrier_slowpath(obj);
}

その結果、GCがアクティブなときにオブジェクトをロードすると、そのオブジェクトがコレクションセットへの参照である場合、オブジェクトの解決に突入し、実際の避難を行うためにランタイムの遅い部分に突入する可能性があります。可能性としては、オブジェクトはGC自体によって退避され、バリアはMid-path 2のチェックでこれを発見し、そこでこのメソッドから戻るでしょう。最悪の場合、バリアはランタイムを呼び出して全ての作業を行います。

この話のパフォーマンスに敏感な部分は、GCサイクルが終わってGCが関心のある参照を更新するまでの間、再配置されたオブジェクトをMid-path 2でずっとチェックしてしまうことです。ホットループでアクセスを行うと、GCが実行されている間、常にバリアの奥まで達することになります。また、Mid-path 2チェックは幅広く遠くまで届くため、コストがかかります。

自己修正バリアの考え方は、オブジェクトを解決し、転送されたコピーを発見したときに、その場で位置を更新できるというものです。コレクションセットにないオブジェクトのコピーへの参照を更新しているので、次のバリアの呼び出しでは、Mid-path 1でこのメソッドから戻ることになります。

このような変更を加えたバリアは、このようになっています。

T load_reference_barrier(T* addr) {
  T obj = *addr;

  // Fast-path: スレッドローカルフラグへのアクセス
  if (!is_gc_active()) return obj;

  // Mid-path 1: 小さなビットマップへのアクセス(領域ごとのバイト、数KB程度?)
  if (!is_in_collection_set(obj)) return obj;

  // Mid-path 2: オブジェクト(ヒープ全体?)内の転送ポインタにアクセスします。
  T fwd = resolve_forwardee(obj);

  if (obj != fwd) {
    // ここでアップデートが可能
    CAS(addr, fwd, obj);
    return fwd;
  }

  // Slow-path: ランタイムへの呼び出し、転送されていないオブジェクトのロケーションごとに1回
  fwd = load_reference_barrier_slowpath(obj);
  // ここでアップデートが可能
  CAS(addr, fwd, obj);
  return fwd;
}

言い換えれば、転送されるものを取得したらすぐにスローパスに入り、新しいコピーへの参照を元のアドレスに置き換えて戻せるのです。これは、上書きしてはいけない別のJavaスレッドによる同じフィールドの更新が競合する可能性を避けるために、比較とセットの操作を使って行います。

ここで、Mid-path 2のチェックに失敗するのは、更新されていない場所ごとに1回だけであることに注意してください。このチェックに失敗しても、それを修正すれば、二度とバリアのダークな部分を訪れることはありません。このことを念頭に置いて、更新全体をスローパス自体に移動させることで、バリアを単純化できます。

T load_reference_barrier(T* addr) {
  T obj = *addr;

  // Fast-path: スレッドローカルフラグへのアクセス
  if (!is_gc_active()) return obj;

  // Mid-path 1: 小さなビットマップへのアクセス(領域ごとのバイト、数KB程度?)
  if (!is_in_collection_set(obj)) return obj;

  // Slow-path: ランタイムへの呼び出し、更新されていないロケーションごとに1回
  return load_reference_barrier_slowpath(obj, addr); // アップデートは実際にここで
}

これで、よりシンプルなミューテータ側のバリアができました。 複雑なのは、スローパスにaddrを渡すことで、インタプリタ、C1、C2をいじらなければなりません。それが、最初からこの方法で行われない理由です。また、注意点にも注目してください。更新されていないロケーションの場合、以前はMid-path 2のチェックから早めに終了していました。現在は、それらのためにランタイムに入ります。この動作はコード上では悪く見えますが、ホットオブジェクトに対して多くのMid-path 2チェックを行っています。これらのオブジェクトはコレクションセットに含まれていますが、ロケーションごとに1回、修正のためのランタイムに入るよりもはるかにコストがかかります。

また、これらの仕組みにより、後に行われる参照の更新フェーズにおいて、GCワーカーが行うべき作業が少なくなります。

まとめ

JDK 14のShenandoah GCリリースに搭載されている自己修正バリアが、バリアの中間のパスとスローパスの局所的な遅延の低減にどのように役立つか、お分かりいただけたかと思います。次回の記事では、並行ルート処理と並行クラスアンロードを取り上げます。これらの機能を合わせると、GC作業を停止フェーズから並行フェーズに移行することで、GCの一時停止時間を短縮し、その結果、全体的な遅延を削減できます。

* 各記事は著者の見解によるものでありその所属組織を代表する公式なものではありません。その内容については非公式見解を含みます。