Replacing Finalizers with Cleaners

原文はこちら。
The original article was written by Roger Riggs (Principal Member of Technical Staff at Oracle).
https://rogerriggs.wordpress.com/2022/05/03/replacing-finalizers-with-cleaners/
https://inside.java/2022/05/25/clean-cleaner/

CleanerはJDK 9で導入された、セキュリティ上重要な内容を持つ、あるいは非ヒープリソースをカプセル化したヒープオブジェクトのクリーンアップ関数を呼び出すメカニズムです。

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

JEP 421では、ファイナライザを置き換える根拠と、開発者のための代替手段を説明しています。

JEP 421: Deprecate Finalization for Removal
https://openjdk.java.net/jeps/421

JEP 421が進むにつれて、ファイナライザに代わる便利な方法としてCleanerの採用が開発者の関心を集めています。ファイナライザを置き換えるためにクリーンアップ関数を使用するいくつかの方法を例示してご紹介します。

これまでは、クリーンアップはクラスのfinalizeメソッドで行われていました。オブジェクトが到達不可能であったとしても、finalizeメソッドを呼び出すと、オブジェクトのすべてのフィールドにアクセスできるようになり、フィールドを変更できるようになります。

クリーンアップ関数はファイナライザと同様に、オブジェクトがどのクラスやスレッドからも到達不可能であることが判明した際に実行されますが、ファイナライザとは異なり、クリーンアップ関数はクリーンアップに必要なstateをオブジェクトとは別に保持します。これは、オブジェクトが到達不能になった時点ですぐに再利用されるようにしたいからです。クリーンアップ関数はオブジェクトから独立して動作できなければなりません。もし、クリーンアップ関数からオブジェクトへの参照があれば、まだ到達可能ということなので再利用できません。クリーンアップに必要なstateはすべて、クリーンアップ関数にカプセル化されていなければなりません。

どれが何か追跡しやすくするため、オブジェクトO、クリーンアップ関数FCleanerCleanableを含むインスタンスへの参照にイタリック体を使うことにします。

オブジェクトOとそれに対応するクリーンアップ関数FCleanerに登録します。登録は通常オブジェクトOのコンストラクタ内で実行します。CleanerはオブジェクトOとそのクリーンアップ関数Fへの参照を保持するCleanableを返します。Cleanable.clean()を呼び出すと、クリーンアップ関数が最大1回実行されます。Cleanerは登録済みオブジェクトが到達不能になるのを待ち、対応するクリーンアップ関数Fを実行するスレッドを持ちます。Cleanerとそのスレッドはかなり重いものなので、可能であればアプリケーション、パッケージ、またはクラス内で共有されるべきです。

オブジェクトO、クリーンアップ関数FCleanerの間には密接な関係があるため、注意すべき潜在的なコー ディング上の落とし穴がいくつか存在します。具体的な例として、ガベージコレクタがオブジェクトを回収する際に、クリーンアップ関数をどの ように書けばよいかを説明します。

SensitiveDataクラスは、使われなくなったら消去されるべきデータを保持しています。このデータは、クリーンアップ関数(最初はラムダを使用)で内部のchar配列をクリアすることで消去されます。この関数は、インスタンスが到達不能になったときに、closeメソッドまたはCleanerによって呼び出されます。クリーンアップができる限り早く起こるべきだという考えを促進するため、SensitiveDataAutoCloseableを実装して、try-with-resources内で使用することを奨励しています。

Interface AutoCloseable
https://docs.oracle.com/en/java/javase/18/docs/api/java.base/java/lang/AutoCloseable.html
https://docs.oracle.com/javase/jp/18/docs/api/java.base/java/lang/AutoCloseable.html

構造化されていないコンテキストでは、try-with-resourcesが適切でない場合、closeメソッドを直接呼び出す必要があります。Cleanerは、SensitiveDataオブジェクトがもはや参照されず、closeメソッドがクリーンアップを呼び出さなかった場合のフォールバックとして機能します。

以下の例は、クリーンアップ関数がラムダまたはSensitiveCleanableクラスを使用して実装されています。クリーンアップに必要なすべてのstateは、クリーンアップ関数にカプセル化されています。

import java.lang.ref.Cleaner;
import java.util.Arrays;
import java.util.Optional;

public class SensitiveData implements AutoCloseable {
  // A cleaner
  private static final Cleaner cleaner = Cleaner.create();

  // The sensitive data
  private char[] sensitiveData;
    
  // The result of registering with the cleaner
  private final Cleaner.Cleanable cleanable;

  /**
   * Construct an object to hold sensitive data.
   */
  public SensitiveData(char[] sensitiveData) {
    final char[] chars = sensitiveData.clone();
    final Runnable F   // Pick one 
      = () -> Arrays.fill(chars, (char) 0);// A lambda      
      // = new SensitiveCleanable(chars);  // A record 
      // = clearChars(chars);              // A static lambda
    this.sensitiveData = chars;
    this.cleanable = cleaner.register(this, F);
  }

  /**
   * Return an Optional of a copy of the char array.
   */
  public Optional<char[]> sensitiveData() {
    return Optional.ofNullable(sensitiveData == null
          ? null : sensitiveData.clone());
  }

  /**
   * Close and cleanup the sensitive data storage.
   */
  public void close() {
    sensitiveData = null;   // Data not available after close
    cleanable.clean();
  }

  /*
   * Return a lambda to do the clearing.
   */
  private static Runnable clearChars(char[] chars) {
    return () -> Arrays.fill(chars, (char)0);
  }

  /*
   * Nested record class to perform the cleanup of an array.
   */
  private record SensitiveCleanable(char[] sensitiveData) 
                 implements Runnable {
    public void run() {
      Arrays.fill(sensitiveData, (char)0);
    }
  }
}

こちらは、SensitiveDatatry-with-resourcesとともに使用し、一時的な配列をクリアして、機密データがプロセスメモリで見える時間を最小限に抑える簡単な例です。

public class Main {
  // Encapsulate a string for printing
  public static void main(String[] args) {
    for (String s : args) {
      char[] chars = s.toCharArray();
      try (var sd = new SensitiveData(chars)) {
        Arrays.fill(chars, (char) 0);
        print(sd);
      }
    }
  }

  // Print the sensitive data and clear
  private static void print(SensitiveData sd) {
    char[] chars = sd.sensitiveData().get();
    System.out.println(chars);
    Arrays.fill(chars, (char) 0);
  }
}

Coding the Cleanup Function

では、様々なクリーンアップ関数のコーディングの選択肢の長所と短所を詳しく見てみましょう。これらはそれぞれ、自身が保持する値のみを使用してクリーンアップを実行する、引数なしのFunctionalInterfaceメソッドを公開します。

Annotation Interface FunctionalInterface
https://docs.oracle.com/en/java/javase/18/docs/api/java.base/java/lang/FunctionalInterface.html
https://docs.oracle.com/javase/jp/18/docs/api/java.base/java/lang/FunctionalInterface.html

A lambda cleanup function

class Foo {
  private final char[] data;
    
  Foo(char[] chars) {
    final char[] array = chars.clone();
    cleaner.register(this, 
          () -> Arrays.fill(array, (char)0));
    this.data = array;
  }
}

シンプルなクリーンアップであれば、ラムダを使うと簡潔で、コンストラクタ内にインラインでコーディングできます。コーディングは簡単ですが、間違いを見つけたり、期待通りに動作しているかどうかを検証したりするのは難しいかもしれません。例えば、charsがフィールドの場合、ラムダはthis.charsを参照し、不用意にこれをキャプチャしてしまう可能性があります。これがクリアされたことを確認するテストが書かれていない限り、オブジェクトが収集されず、クリーンアップが行われないことに気づかない可能性があります。

this.charsを捕捉しないようにする一つの方法は、staticメソッドでラムダを作成することです。そのスコープにはthisがないので、誤ってキャプチャされることはありません。

private static Runnable clearChars(char[] chars) {
  return () -> Arrays.fill(chars, (char)0);
}

A cleanup function as record, nested, or top level class

class Foo {
  // Record class to clear an array.
  private record Cleanup(char[] array)
          implements Runnable {
    public void run() {
      Arrays.fill(array, (char) 0);
    }
  }

  private char[] data;

  // Copy the array and register the cleanup.
  Foo(char[] chars) {
    final char[] array = chars.clone();
    cleaner.register(this, new Cleanup(array));
    this.data = array;
  }
}

recordクラスやstaticなnestedクラス、トップレベルクラスは、良好なクリーンアップ関数を記述するのに最も堅牢な方法です。これは、オブジェクトから独立したstateの分離の見通しがよくなり、クリーンアップを文書化する場所を提供するためです。

クリーンアップ関数を内部クラスにすると、暗黙のうちにこの内部クラスを参照することになってクリーンアップが実行されなくなるので注意してください。

Cleanup Using Mutable State

ほとんどの場合、recordクラスやnestedクラスのフィールドはimmutableですが、クリーンアップに必要なstateがオブジェクトOの通常の使用中に変更されるため、結果としてmutableでなければならないユースケースがあります。オブジェクトOとクリーンアップ関数Fの両方からアクセス可能なオブジェクトにmutableなstateを保持するというのが、このユースケースを扱う簡単な方法です。複数のスレッドがstateを変更する場合、何らかの同期が必要です。Cleanerが異なる用途の間で共有され、stateの同期が必要な場合、クリーン アップ関数がCleanerのスレッドをブロックする可能性があります。どのような同期でもそうですが、同期がデッドロックやクリーンアップの遅延につながらないか、注意深くチェックしてください。たとえば、クリーンアップにネットワーク接続のクローズが含まれる場合、Cleanerスレッドから独立したタスクを分岐することが賢明でしょう。

Wrap up

細部に注意を払うなら、最も軽量なクリーンアップ関数は、ラムダです。ラムダでは、暗黙的もしくは明示的にthisを参照してはいけません。そしてラムダ本体では、コンストラクタのフィールドを参照できません。これは、final値や実質的にfinalな値を使用する通常の許容範囲よりも厳しい制限です。

ファイナライザからクリーンアップ関数に変換する場合、stateを分離するためにちょっとしたリファクタリングが必要になる可能性があります。このリファクタリングによって、クラスの構造と信頼性を向上させることができます。


種々の選択肢に関する完全な例は、SensitiveData.java にあります。

コメントを残す

以下に詳細を記入するか、アイコンをクリックしてログインしてください。

WordPress.com ロゴ

WordPress.com アカウントを使ってコメントしています。 ログアウト /  変更 )

Facebook の写真

Facebook アカウントを使ってコメントしています。 ログアウト /  変更 )

%s と連携中