测试清理器清理

用清理器替换终结器 中,堆对象封装的资源清理使用两种互补机制来安排:try-with-resources 和 清理器。需要某种清理的资源包括安全敏感数据和堆外资源,例如文件描述符或本机句柄。清理应该始终发生,并且应该在资源不再活动时尽快发生。

清理器取代终结器后出现的一个问题是如何测试清理是否正常工作。对于 try-with-resources,测试非常简单,我们需要验证的状态在一个对象中,该对象可以访问或可以被测试访问,并且何时检查它已完成很清楚(在关闭之后)。

  1. try-with-resources 语句中,创建一个对 对象 的引用
  2. 提取对要清理的封装状态数据的引用
  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 中获取要清除的私有字符数组。

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");
  }
}


测试 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 字符数组由测试保存。在对 SensitiveData 对象的引用设置为 null 之后,System.gc() 用于启动垃圾收集器,然后检查数组是否为零。垃圾收集器并行运行,在调用 System.gc() 之后可能需要一些时间,垃圾收集器才能确定 对象 不再被引用,并且 清理器 被通知调用 Cleanable.clean(),从而调用 清理函数

此测试代码直接确认数组已清除。只要要清理的状态对测试可见,它就能很好地工作,但对于其他正在测试的案例和类,状态可能并不总是可见或可访问的。


堆外资源的替代测试

对于某些类型的资源清理,测试无法直接观察到清理是否已发生。例如,如果资源是本机内存地址或句柄,清理函数 可能直接处理资源,并且测试可能无法观察到资源是否已释放或清除。

最好的办法是观察 清理函数 是否已被调用并已完成。要了解它是如何工作的,我们需要了解 清理器 如何确定何时调用 清理函数

一个 清理器 是一个线程,它等待通知注册的 对象 不再可达,然后调用相应的 清理函数。当 清理函数清理器 注册时,对象及其 清理函数 通过创建和返回 Cleanable 来链接。通常,Cleanable 对象保存在 对象 中,就像在 SensitiveData 示例中一样,以便 close 方法可以调用 Cleanable.clean 来调用 清理函数 并删除注册。

Cleanable 的实现是 对象PhantomReferencePhantomReference 不会使对象保持活动状态,并且可以查询以了解对象是否仍然活动。在正常的垃圾回收过程中,当 对象 变得不可达时,Cleanable 会被排队等待 清理器 线程处理。在它被清理之前,Cleanable 会被 清理器 引用,并且不会被垃圾回收。在它的 清理函数 被调用之后,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 类及其使用 清理器 管理的 Cleanable 对象的实现的了解。

测试 Cleanable 的初始设置(在对象引用设置为 null 之前)验证清理不会过早地调用,如果这样,这很可能是测试或实现中的错误。

这种技术(等待 Cleanable 被调用并变得不可达)对于 清理函数 是一个简单的 lambda 还是一个显式的记录类、嵌套类或顶级类是有效的。虽然它没有直接验证清理,但它确实验证了 清理函数 已被调用并已完成。

这些只是编写测试的几种可能方法,还有许多其他方法利用了与正在测试的类的协作。通过重构状态或允许测试破坏类的封装,清理函数 和测试都可以具有确认清理发生以及在预期时间发生所需的可见性。

SensitiveData 示例代码和 SensistiveDataTest 测试代码可在 SensitiveData Gist 中找到。测试使用 `TestNG` 进行测试断言。

~

最初发布在 这里