Testing Clean Cleaner Cleanup

原文はこちら。
The original article was written by Roger Riggs (Principal Member of Technical Staff at Oracle).
https://rogerriggs.wordpress.com/2022/05/14/testing-clean-cleaner-cleanup/
https://inside.java/2022/05/27/testing-clean-cleaner-cleanup/

前エントリ(Replacing Finalizers with Cleaners)では、ヒープオブジェクトにカプセル化されたリソースのクリーンアップを、try-with-resourcesとCleanerという 2 つの補完的なメカニズムを使ってアレンジしています。何らかのクリーンアップが必要なリソースには、セキュリティ上重要なデータや、ファイルディスクリプタ、ネイティブハンドルのようなオフヒープリソースが含まれます。クリーンアップは常に行われる必要があり、リソースがアクティブでなくなったらできるだけ早く行われるべきです。

Replacing Finalizers with Cleaners
https://rogerriggs.wordpress.com/2022/05/03/replacing-finalizers-with-cleaners/
https://inside.java/2022/05/25/clean-cleaner/
https://logico-jp.io/2022/05/29/replacing-finalizers-with-cleaners/
Class Cleaner
https://docs.oracle.com/en/java/javase/18/docs/api/java.base/java/lang/ref/Cleaner.html
https://docs.oracle.com/javase/jp/18/docs/api/java.base/java/lang/ref/Cleaner.html

Cleanerがファイナライザを置き換えるときに生じる疑問の1つに、クリーンアップが機能していることをどのようにテストするかというものがあります。try-with-resourcesでは、テストはとても簡単です。確認する必要があるstateは、テストがアクセスできるオブジェクトにあり、いつそれが完了したか(closeの後)を確認するかは明らかです。

  1. try-with-resources文の中で、オブジェクトへの参照を作成する
  2. クリーンアップ対象のカプセル化されたstateデータへの参照を取り出す
  3. try-with-resources文を終了し、暗黙のうちにcloseを呼び出す
  4. クリーンアップが行われたことを確認する
  public void testAutoClose() {
    char[] origChars = "myPrivateData".toCharArray();
    char[] implChars;
    try (SensitiveData data = new SensitiveData(origChars)) {
      // Save the sensitiveData char array
      implChars = (char[]) getField(SensitiveData.class,
                       "sensitiveData", data);
    }
    // After close, was it cleared?
    char[] zeroChars = new char[implChars.length];
    assertEquals(implChars, zeroChars,
                 "SensitiveData chars not zero: ");
  }

以下の例はかなりわかりやすいものですが、以下のgetFieldユーティリティメソッドを使用して、SensitiveData.sensitiveDataからクリアされるプライベートなchar配列を取得しています。

  import java.lang.reflect.Field;

  /**
   * Get an object from a named field.
   */
  static Object getField(Class<?> clazz, 
    String fieldName, Object instance) {
    try {
      Field field = clazz.getDeclaredField(fieldName);
      field.setAccessible(true);
      return field.get(instance);
    } catch (NoSuchFieldException | IllegalAccessException ex) {
      throw new RuntimeException("field unknown or not accessible");
    }
  }

Testing the Cleaner of SensitiveData

到達不可能な場合のクリーンアップの検証は、もう少し興味深いものです。ガベージコレクタがオブジェクトを到達不可能と判断した後、しばらくしないとクリーンアップ関数は実行されません。セットアップは同じで、配列がクリアされたかどうかのチェックも行います。try-with-resourcesは、SensitiveDataへの参照をクリアすることに置き換えられ、ガベージコレクションができるようになります。

  1. クリーンアップ対象のオブジェクトへの参照を作成し保持する
  2. クリーンアップ対象のカプセル化されたデータへの参照を取り出す
  3. オブジェクトへの参照を削除する
  4. ガベージコレクションの実行を要求する
  5. 配列をポーリングし、クリーンアップが完了したことを確認する
  public void testCharArray() {
    final char[] origChars = "myPrivateData".toCharArray();
    SensitiveData data = new SensitiveData(origChars);

    // A reference to sensitiveData char array
    char[] implChars = (char[]) getField(SensitiveData.class, 
                         "sensitiveData", data);

    data = null;  // Remove reference

    char[] zeroChars = new char[implChars.length];
    for (int i = 10; i > 0; i--) {
      System.gc();
      try {
        Thread.sleep(10L);
      } catch (InterruptedException ie) { }

      if (Arrays.equals(implChars, zeroChars))
        break;    // break as soon as cleared
    }
    // Check and report any errors
    assertEquals(implChars, zeroChars, 
                 "After GC, chars not zero");
  }

上記のAutoCloseableの場合と同様に、sensitiveDataの内部char配列はテストによって保存されます。SensitiveDataオブジェクトへの参照をnullにした後、System.gc()を使ってガベージコレクタを起動してから、配列に0があるかどうかをチェックします。ガベージコレクタは並列で実行され、System.gc()を起動してから、ガベージコレクタがオブジェクトが参照されていないと判断し、Cleanerがクリーンアップ関数を呼び出すCleanable.clean()が通知されるまで、時間がかかることがあります。

このテストコードでは、配列がクリアされたことを直接確認します。しかし、テストでstateが見える場合にはうまくいきますが、他のケースやテストされるクラスでは、stateが常に見えるとは限りませんし、 アクセスできるとも限りません。

An Alternative Test for Off-heap Resources

リソースのクリーンアップの種類によっては、 クリーンアップが行われたかどうかをテストが直接観測することができないものもあります。例えば、リソースがネイティブメモリアドレスやハンドルの場合、クリーンアップ関数は直接リソースを処理し、テストではリソースの解放やクリアを確認することができない可能性があります。

次善の策は、クリーンアップ関数が呼び出され、完了したことを確認することです。それがどのように動作するかを見るために、Cleanerがクリーンアップ関数を呼び出すタイミングを決定する方法について、少し理解する必要があるでしょう。

Cleanerは、登録されたオブジェクトに到達できなくなったという通知を待ち、その後対応するクリーンアップ関数を呼び出すスレッドです。クリーンアップ関数がCleanerに登録されると、オブジェクトとそのクリーンアップ関数はCleanableを作成し返すことで紐付けられます。通常、SensitiveDataの例で行われているように、Cleanableオブジェクトはオブジェクトに保存され、closeメソッドがCleanable.cleanを呼び出してクリーンアップ関数を起動し、登録を削除できるようにします。

Class Cleaner
https://docs.oracle.com/en/java/javase/18/docs/api/java.base/java/lang/ref/Cleaner.html
https://docs.oracle.com/javase/jp/18/docs/api/java.base/java/lang/ref/Cleaner.html
Interface Cleaner.Cleanable
https://docs.oracle.com/en/java/javase/18/docs/api/java.base/java/lang/ref/Cleaner.Cleanable.html
https://docs.oracle.com/javase/jp/18/docs/api/java.base/java/lang/ref/Cleaner.Cleanable.html

CleanableはオブジェクトへのPhantomReferenceとして実装されます。

Class PhantomReference
https://docs.oracle.com/en/java/javase/18/docs/api/java.base/java/lang/ref/PhantomReference.html
https://docs.oracle.com/javase/jp/18/docs/api/java.base/java/lang/ref/PhantomReference.html

PhantomReferenceはオブジェクトを生かしたままにしないので、オブジェクトがまだ生きているかどうかを知るために問い合わせることができます。通常のガベージコレクション処理の間、オブジェクトが到達不可能になると、CleanableCleanerスレッドによる処理のためにキューに入れられます。クリーンアップされるまで、CleanerCleanableを参照するため、ガベージコレクションされることはありません。クリーンアップ関数が呼ばれた後、Cleanable自身が解放され、ガベージコレクションの対象になります。

クリーンアップを起動するために使用されるのと同じ参照ベースのテクニックを使用して、テストがCleanableを監視し、Cleanableが参照されなくなったときに、クリーンアップが完了したことを知ることができます。テストではSensitiveData.cleanableフィールドからCleanableを取得し、独自のPhantomReferenceを作成し、独自のReferenceQueueポーリング・ユーティリティを使用して監視します。

  1. クリーンアップ対象のオブジェクトへの参照を作成し、保持する
  2. クリーンアップ関数を保持するCleanableへの参照を取得する
  3. オブジェクトへの参照を解放してはじめて、クリーンアップが発生することを確認する
  4. オブジェクトへの参照を削除する
  5. Cleanableが参照されなくなるのを待つ
  import java.lang.ref.PhantomReference;
  import java.lang.ref.Reference;
  import java.lang.ref.ReferenceQueue;
  
  public void testCleanable() {
      final char[] origChars = "myPrivateData".toCharArray();
      SensitiveData data = new SensitiveData(origChars);

      // Extract a reference to the Cleanable
      Cleanable cleanable = (Cleaner.Cleanable)
               getField(SensitiveData.class, "cleanable", data);

      ReferenceQueue<Object> queue = new ReferenceQueue<>();
      PhantomReference<Object> ref = 
               new PhantomReference<>(cleanable, queue);
      cleanable = null;   
      // Only the Cleaner will have a strong 
      // reference to the Cleanable

      // Check that the cleanup does not happen 
      // before the reference is cleared.
      assertNull(waitForReference(queue), 
                 "SensitiveData cleaned prematurely");

      data = null;    // Remove the reference 

      assertEquals(waitForReference(queue), ref,
                   "After GC, not cleaned");
  }

このテストでは、waitForReferenceユーティリティメソッドを使ってガベージコレクションを呼び出し、参照がキューに入るのを待ちます。呼び出し側は、キューに入った参照が期待されるオブジェクトへのPhantomReferenceであるかどうかをチェックします。

  /**
   * Wait for a Reference to be enqueued.
   * Returns null if no reference is queued within 0.1 seconds
   */
  static Reference<?> waitForReference(ReferenceQueue<Object> queue) {
    Objects.requireNonNull(queue);
    for (int i = 10; i > 0; i--) {
      System.gc();
      try {
        var r = queue.remove(10L);
        if (r != null) {
          return r;
        }
      } catch (InterruptedException ie) {
        // ignore, the loop will try again
      }
    };
    return null;
  }

クリーンアップ関数をテストするこのテクニックは、SensitiveDataクラスの実装とCleanerが管理するCleanableオブジェクトの使用に関する知識に依存しています。

オブジェクト参照がnullに設定される前にCleanableの初期セットアップをテストすることで、 クリーンアップが早期に呼び出されないことを検証します。もしクリーンアップが早期に呼び出される場合には、テストもしくは実装にバグがある可能性があります。

Cleanableが呼び出され、到達できなくなるのを待つこのテクニックは、クリーンアップ関数が単純なラムダか、明示的なrecordクラス、nestedクラス、トップレベルクラスであるかに関係なく有効です。クリーンアップを直接検証するわけではありませんが、クリーンアップ関数が呼び出され、完了したことを検証できます。

これらはテストの書き方のほんの一部で、テスト対象のクラスとの協調性を利用したものは他にもたくさんあります。stateをリファクタリングしたり、テストがクラスのカプセル化を解除したりすることで、 クリーンアップ関数とテストの両方で、クリーンアップが発生し、期待通りにクリーンアップが発生することを確認するために必要な可視性を持つことができます。


サンプルコードSensitiveDataとテストコードSensistiveDataTestSensitiveDataのGistで公開されています。テストはテストアサーションにTestNGを使用しています。

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください