原文はこちら。
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の後)を確認するかは明らかです。
- try-with-resources文の中で、オブジェクトへの参照を作成する
- クリーンアップ対象のカプセル化されたstateデータへの参照を取り出す
- try-with-resources文を終了し、暗黙のうちにcloseを呼び出す
- クリーンアップが行われたことを確認する
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
への参照をクリアすることに置き換えられ、ガベージコレクションができるようになります。
- クリーンアップ対象のオブジェクトへの参照を作成し保持する
- クリーンアップ対象のカプセル化されたデータへの参照を取り出す
- オブジェクトへの参照を削除する
- ガベージコレクションの実行を要求する
- 配列をポーリングし、クリーンアップが完了したことを確認する
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
はオブジェクトを生かしたままにしないので、オブジェクトがまだ生きているかどうかを知るために問い合わせることができます。通常のガベージコレクション処理の間、オブジェクトが到達不可能になると、Cleanable
はCleaner
スレッドによる処理のためにキューに入れられます。クリーンアップされるまで、Cleaner
はCleanable
を参照するため、ガベージコレクションされることはありません。クリーンアップ関数が呼ばれた後、Cleanable
自身が解放され、ガベージコレクションの対象になります。
クリーンアップを起動するために使用されるのと同じ参照ベースのテクニックを使用して、テストがCleanable
を監視し、Cleanable
が参照されなくなったときに、クリーンアップが完了したことを知ることができます。テストではSensitiveData.cleanable
フィールドからCleanableを取得し、独自のPhantomReference
を作成し、独自のReferenceQueue
ポーリング・ユーティリティを使用して監視します。
- クリーンアップ対象のオブジェクトへの参照を作成し、保持する
- クリーンアップ関数を保持する
Cleanable
への参照を取得する - オブジェクトへの参照を解放してはじめて、クリーンアップが発生することを確認する
- オブジェクトへの参照を削除する
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
とテストコードSensistiveDataTest
はSensitiveData
のGistで公開されています。テストはテストアサーションにTestNG
を使用しています。